Skip to content

Commit 64b80b5

Browse files
mchwangclaude
andcommitted
E4: address review round 2 on planning leftovers
- PlanningStorage types planning's leftovers apart from Ask's, so an Ask-policy RetainedStorage cannot be passed to the planning provider. - removePlanningRoot deletes only planning roots under the temporary directory, and makes the read-only input directory writable first, also on retries. - A failed request reports its own error followed by the cleanup failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent eb915fc commit 64b80b5

3 files changed

Lines changed: 73 additions & 32 deletions

File tree

‎runner/planning-provider.ts‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { randomUUID } from 'node:crypto';
22
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
4-
import { join } from 'node:path';
4+
import { basename, dirname, join } from 'node:path';
55
import type { InvocationContext, InvocationInput, InvocationResult, StopReason } from '../agents/contract.ts';
66
import type { TaskFilesystems, TaskStorageLimits } from '../agents/container/storage.ts';
77
import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts';
@@ -26,21 +26,37 @@ export interface PlanningProviderOptions {
2626
readonly deps: PlanningDependencies;
2727
/** Shared across providers in one process, so the image is built once. */
2828
readonly image?: { id?: string };
29-
/** What earlier requests left behind, from `planningLeftovers()`. Invocations stay refused while any remains. */
30-
readonly retained?: RetainedStorage;
29+
/** What earlier requests left behind. Invocations stay refused while any of it remains. */
30+
readonly retained?: PlanningStorage;
3131
}
3232

3333
/**
3434
* Planning's leftovers: lane D allocations, allocations lane D returned no handle for, and host copies of the planned
3535
* code. Use one instance for planning and never share it with Ask, so a planning failure never turns Ask off.
3636
*/
3737
export const PLANNING_LEFTOVERS: LeftoverPolicy = Object.freeze({
38-
removePath: (path: string) => rmSync(path, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }),
38+
removePath: removePlanningRoot,
3939
paths: (paths: readonly string[]) => `A copy of the planned code from an earlier request could not be deleted (${paths.join(', ')}). Planning stays off until it is deleted.`,
4040
untracked: () => 'A planning agent\'s setup or cleanup failed and was not confirmed, so codeboost cannot tell which Docker resources were left. Planning is off until codeboost restarts.',
4141
retained: (count: number) => `Planning storage from an earlier request could not be removed (${allocations(count)}). Planning stays off until Docker removes it. Check that Docker is running, then retry.`,
4242
});
43-
export const planningLeftovers = () => new RetainedStorage(PLANNING_LEFTOVERS);
43+
/** Planning's own leftovers. Typed apart from Ask's, so an instance with Ask's wording and removal cannot be passed in. */
44+
export class PlanningStorage extends RetainedStorage {
45+
readonly feature = 'planning';
46+
constructor() { super(PLANNING_LEFTOVERS); }
47+
}
48+
49+
const ROOT_PREFIX = 'codeboost-planning-';
50+
/**
51+
* Delete a planning root: a direct child of the temporary directory named with planning's prefix, and nothing else.
52+
* Its input directory is made writable first, since the request left it read-only.
53+
*/
54+
export function removePlanningRoot(root: string): void {
55+
if (dirname(root) !== tmpdir() || !basename(root).startsWith(ROOT_PREFIX))
56+
throw new Error(`Refusing to remove a path that is not a planning root (${root}).`);
57+
try { chmodSync(join(root, 'input'), 0o700); } catch { /* never created, or already removed */ }
58+
rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
59+
}
4460

4561
// Planning reads the code; it needs no room to write. The same ceilings as Ask.
4662
export const PLANNING_STORAGE: TaskStorageLimits = Object.freeze({
@@ -93,7 +109,7 @@ export interface PlanningProvider extends AuthorProvider {
93109
invoke(request: AuthorRequest, signal: AbortSignal): Promise<string>;
94110
}
95111
export function createPlanningProvider(options: PlanningProviderOptions): PlanningProvider {
96-
const { vendor, deps } = options, image = options.image ?? {}, retained = options.retained ?? planningLeftovers();
112+
const { vendor, deps } = options, image = options.image ?? {}, retained = options.retained ?? new PlanningStorage();
97113
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98114
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Planning timeout must be a positive integer.');
99115
return { async invoke(request: AuthorRequest, signal: AbortSignal): Promise<string> {
@@ -109,9 +125,9 @@ export function createPlanningProvider(options: PlanningProviderOptions): Planni
109125
const credential = planningCredential(vendor, deps.env);
110126
retained.release(deps.removeFilesystems);
111127
image.id ??= deps.buildImage(remaining());
112-
const root = mkdtempSync(join(tmpdir(), 'codeboost-planning-'));
128+
const root = mkdtempSync(join(tmpdir(), ROOT_PREFIX));
113129
const staging = join(root, 'staging'), input = join(root, 'input');
114-
let filesystems: TaskFilesystems | undefined, failed = false;
130+
let filesystems: TaskFilesystems | undefined, failure: { error: unknown } | undefined;
115131
try {
116132
mkdirSync(staging); mkdirSync(input);
117133
writeFileSync(join(input, 'schema.json'), request.schemaText, { mode: 0o444 });
@@ -147,20 +163,24 @@ export function createPlanningProvider(options: PlanningProviderOptions): Planni
147163
if (result.unreleased !== undefined) retained.markUntracked();
148164
return planningOutput(result, invocation);
149165
} catch (error) {
150-
failed = true;
166+
failure = { error };
151167
throw error;
152168
} finally {
153169
const failures: unknown[] = [];
154170
if (filesystems) try { deps.removeFilesystems(filesystems); } catch (error) { retained.retain(filesystems); failures.push(error); }
155-
try { chmodSync(input, 0o700); } catch { /* input was never created */ }
156-
try { PLANNING_LEFTOVERS.removePath(root); }
171+
try { removePlanningRoot(root); }
157172
catch (error) {
158173
retained.retainPath(root);
159174
failures.push(new Error(`A copy of the planned code could not be deleted (${root}).`, { cause: error }));
160175
}
161-
// A failed request keeps its own error: what cleanup left is retained above, and the next request names it. A
162-
// successful one is refused rather than returned, as Ask does, so a plan is never kept from an unsettled run.
163-
if (failures.length && !failed) throw new AggregateError(failures, 'Planning container cleanup did not settle.');
176+
// What cleanup left is retained above, and the next request names it. A successful request is refused rather than
177+
// returned, as Ask does, so a plan is never kept from an unsettled run. A failed one keeps its own message first.
178+
if (failures.length) {
179+
const text = (error: unknown) => error instanceof Error ? error.message : String(error);
180+
throw failure
181+
? new AggregateError([failure.error, ...failures], `${text(failure.error)} Cleanup also did not settle: ${failures.map(text).join('; ')}`)
182+
: new AggregateError(failures, 'Planning container cleanup did not settle.');
183+
}
164184
}
165185
} };
166186
}

‎scripts/record-planning.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { captureInvocation } from '../agents/contract.ts';
1717
import { AGENT_IMAGE, buildAgentImage, CLAUDE_VERSION } from '../agents/container/image.ts';
1818
import { prepareTaskFilesystems, removeTaskFilesystems } from '../agents/container/run.ts';
1919
import { createTaskClone } from '../git/clone.ts';
20-
import { createPlanningProvider, planningCredential, planningLeftovers, type PlanningDependencies } from '../runner/planning-provider.ts';
20+
import { createPlanningProvider, planningCredential, PlanningStorage, type PlanningDependencies } from '../runner/planning-provider.ts';
2121
import { credentialEnvironment, measureGitRepository } from '../runner/question-container.ts';
2222
import { planningCommandSha256, prepareRecording, RECORDING_FILES, RECORDING_KIND, recordingFilesSha256, sha256,
2323
type PlanningRecording } from '../test/fixtures/planning/recording-inputs.ts';
@@ -49,7 +49,7 @@ const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core
4949
{ cwd, env: gitEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
5050

5151
const root = mkdtempSync(join(tmpdir(), 'codeboost-recording-'));
52-
const retained = planningLeftovers(), runnerOwner = randomBytes(16).toString('hex');
52+
const retained = new PlanningStorage(), runnerOwner = randomBytes(16).toString('hex');
5353
let failed = false;
5454
try {
5555
const repository = join(root, 'repository');

‎test/planning-provider.test.ts‎

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
1+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { afterEach, expect, it } from 'vitest';
55
import type { InvocationHandle, InvocationInput, InvocationResult, StopReason } from '../agents/contract.ts';
66
import type { AgentAdapterRequest } from '../agents/adapters/types.ts';
77
import type { TaskFilesystems } from '../agents/container/storage.ts';
88
import { SuggestionCoordinator } from '../core/planning-suggestions.ts';
9-
import { CODEX_PLANNING_REFUSED, createPlanningProvider, planningLeftovers, type PlanningDependencies } from '../runner/planning-provider.ts';
9+
import { CODEX_PLANNING_REFUSED, createPlanningProvider, PlanningStorage, removePlanningRoot, type PlanningDependencies } from '../runner/planning-provider.ts';
1010
import { RetainedStorage, StopError } from '../runner/question-container.ts';
1111
import { Store } from '../runner/store.ts';
1212
import { prepareRecording, recordingContext, recordingInput, recordingPreviousPlan } from './fixtures/planning/recording-inputs.ts';
@@ -47,7 +47,7 @@ function fakeDeps(result: Partial<InvocationResult> | 'hold' = {}, env: Record<s
4747
};
4848
return { deps, events, captured, started, cancels, settle: (value: Partial<InvocationResult>) => settle(value) };
4949
}
50-
const provider = (deps: PlanningDependencies, extra: { vendor?: 'claude' | 'codex'; retained?: RetainedStorage } = {}) =>
50+
const provider = (deps: PlanningDependencies, extra: { vendor?: 'claude' | 'codex'; retained?: PlanningStorage } = {}) =>
5151
createPlanningProvider({ vendor: extra.vendor ?? 'claude', repository: '/repo', head: HEAD, snapshotId: 'snapshot-1',
5252
runnerOwner: OWNER, deps, retained: extra.retained });
5353
const draftRequest = (requestId = 'b3c1f3b2-6a55-4d7e-9a47-0c7c8f6f1a01') => prepareRecording('draft', HEAD, requestId).request;
@@ -127,7 +127,7 @@ it.each([
127127
});
128128

129129
it('refuses later requests after lane D reports resources it could not release', async () => {
130-
const retained = planningLeftovers(), fake = fakeDeps({ unreleased: [], stopReason: 'capture-failure', exitCode: null });
130+
const retained = new PlanningStorage(), fake = fakeDeps({ unreleased: [], stopReason: 'capture-failure', exitCode: null });
131131
await expect(provider(fake.deps, { retained }).invoke(draftRequest(), new AbortController().signal)).rejects.toThrow();
132132
const next = fakeDeps();
133133
await expect(provider(next.deps, { retained }).invoke(draftRequest('c4d2e4c3-7b66-4e8f-8b58-1d8d907f2b12'), new AbortController().signal))
@@ -136,7 +136,7 @@ it('refuses later requests after lane D reports resources it could not release',
136136
});
137137

138138
it('marks an allocation lane D could not clean up as untracked, and refuses later requests', async () => {
139-
const retained = planningLeftovers(), fake = fakeDeps();
139+
const retained = new PlanningStorage(), fake = fakeDeps();
140140
fake.deps.prepareFilesystems = () => { throw new AggregateError([new Error('docker rm failed')], 'setup cleanup'); };
141141
await expect(provider(fake.deps, { retained }).invoke(draftRequest(), new AbortController().signal)).rejects.toThrow('setup cleanup');
142142
expect(retained.untracked).toBe(1);
@@ -146,14 +146,14 @@ it('marks an allocation lane D could not clean up as untracked, and refuses late
146146
});
147147

148148
it('does not mark an ordinary setup failure as untracked', async () => {
149-
const retained = planningLeftovers(), fake = fakeDeps();
149+
const retained = new PlanningStorage(), fake = fakeDeps();
150150
fake.deps.prepareFilesystems = () => { throw new Error('no space'); };
151151
await expect(provider(fake.deps, { retained }).invoke(draftRequest(), new AbortController().signal)).rejects.toThrow('no space');
152152
expect(retained.untracked).toBe(0);
153153
});
154154

155155
it('keeps storage Docker did not remove, refuses until it is removed, then runs again', async () => {
156-
const retained = planningLeftovers(), fake = fakeDeps({ stdout: '{"plan":true}' });
156+
const retained = new PlanningStorage(), fake = fakeDeps({ stdout: '{"plan":true}' });
157157
let removable = false;
158158
fake.deps.removeFilesystems = () => { if (!removable) throw new Error('docker volume rm failed'); };
159159
await expect(provider(fake.deps, { retained }).invoke(draftRequest(), new AbortController().signal))
@@ -168,31 +168,52 @@ it('keeps storage Docker did not remove, refuses until it is removed, then runs
168168
});
169169

170170
it('keeps a failed request\'s own error when its cleanup also fails, and refuses the next request', async () => {
171-
const retained = planningLeftovers(), fake = fakeDeps({ exitCode: 1, stdout: 'Invalid API key' });
171+
const retained = new PlanningStorage(), fake = fakeDeps({ exitCode: 1, stdout: 'Invalid API key' });
172172
fake.deps.removeFilesystems = () => { throw new Error('docker volume rm failed'); };
173173
await expect(provider(fake.deps, { retained }).invoke(draftRequest(), new AbortController().signal))
174-
.rejects.toThrow('Claude could not write the plan.');
174+
.rejects.toThrow(/^Claude could not write the plan\..* Cleanup also did not settle: docker volume rm failed$/);
175175
expect(retained.size).toBe(1);
176176
await expect(provider(fake.deps, { retained }).invoke(draftRequest('a8b6c8a7-bfaa-4c23-8f9c-5bc1d4b26f56'), new AbortController().signal))
177177
.rejects.toThrow(/Planning storage from an earlier request could not be removed/);
178178
});
179179

180+
/** A real planning root that rmSync cannot empty: its child directory sits in a read-only parent. */
181+
function lockedPlanningRoot(): string {
182+
const root = mkdtempSync(join(tmpdir(), 'codeboost-planning-'));
183+
mkdirSync(join(root, 'staging', 'child'), { recursive: true }); chmodSync(join(root, 'staging'), 0o500);
184+
cleanups.push(() => chmodSync(join(root, 'staging'), 0o700)); roots.push(root);
185+
return root;
186+
}
180187
const refusal = (retained: RetainedStorage) => {
181188
try { retained.release(() => { throw new Error('still there'); }); } catch (error) { return (error as Error).message; }
182189
throw new Error('release did not refuse');
183190
};
184191
it.each([
185192
['an allocation Docker did not remove', (r: RetainedStorage) => r.retain({} as TaskFilesystems), /^Planning storage from an earlier request could not be removed \(1 allocation\)/],
186193
['an allocation lane D returned no handle for', (r: RetainedStorage) => r.markUntracked(), /^A planning agent's setup or cleanup failed.*Planning is off until codeboost restarts\.$/],
187-
['a host copy that cannot be deleted', (r: RetainedStorage) => {
188-
const parent = mkdtempSync(join(tmpdir(), 'planning-locked-')), root = join(parent, 'root');
189-
mkdirSync(join(root, 'child'), { recursive: true }); chmodSync(root, 0o500);
190-
roots.push(parent); cleanups.push(() => chmodSync(root, 0o700));
191-
r.retainPath(join(root, 'child'));
192-
}, /^A copy of the planned code from an earlier request could not be deleted \(.*child\)\. Planning stays off/],
194+
['a host copy that cannot be deleted', (r: RetainedStorage) => r.retainPath(lockedPlanningRoot()),
195+
/^A copy of the planned code from an earlier request could not be deleted \(.*codeboost-planning-.*\)\. Planning stays off/],
193196
] as const)('words the refusal for %s as planning, never as Ask', (_, leave, message) => {
194-
const retained = planningLeftovers(); leave(retained);
197+
const retained = new PlanningStorage(); leave(retained);
195198
const text = refusal(retained);
196199
expect(text).toMatch(message);
197200
expect(text).not.toMatch(/Ask|question/);
198201
});
202+
203+
it('removes only planning roots, and makes a read-only input directory writable first', () => {
204+
const foreign = mkdtempSync(join(tmpdir(), 'not-planning-')); roots.push(foreign);
205+
expect(() => removePlanningRoot(foreign)).toThrow(/not a planning root/);
206+
expect(existsSync(foreign)).toBe(true);
207+
const nested = mkdtempSync(join(foreign, 'codeboost-planning-'));
208+
expect(() => removePlanningRoot(nested)).toThrow(/not a planning root/);
209+
const root = mkdtempSync(join(tmpdir(), 'codeboost-planning-')); roots.push(root);
210+
mkdirSync(join(root, 'input')); writeFileSync(join(root, 'input', 'schema.json'), '{}'); chmodSync(join(root, 'input'), 0o555);
211+
removePlanningRoot(root);
212+
expect(existsSync(root)).toBe(false);
213+
});
214+
215+
it('accepts only planning storage, never Ask\'s', () => {
216+
const fake = fakeDeps();
217+
// @ts-expect-error Ask's RetainedStorage words refusals for Ask and cannot remove planning roots.
218+
createPlanningProvider({ vendor: 'claude', repository: '/repo', head: HEAD, snapshotId: 's', runnerOwner: OWNER, deps: fake.deps, retained: new RetainedStorage() });
219+
});

0 commit comments

Comments
 (0)