wave autoplan-e97beb7bf577: merge #3762 (0d53a5f041)

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 73feb6de3e
5 files changed
+92 -11

No files matched your search

+8 -2
View File
@@ -17,6 +17,10 @@ type Event = ClaudeParentPublicEvent;
type Use = NativePublicToolEvent & { order: number };
/** Claude Code's reply to a Read of an unchanged file it already returned in full. */
export const DEDUP_REPLY = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.';
/** Claude Code appends exactly this one line to a Bash result when the command's `cd` left the session cwd (#3651). */
const SHELL_CWD_RESET = /\nShell cwd was reset to [^\n]+$/;
/** A Bash result's JSON, without that one host footer; anything else must still be strict JSON. */
export function bashJson(text: string): any { return JSON.parse(text.replace(SHELL_CWD_RESET, '')); }
/** The longest Read input or Bash command the index keeps; a literal init is far shorter. */
const INDEX_INPUT_MAX = 4096;
@@ -69,11 +73,13 @@ function select(tests: GuardJournalTests, currentToolUseId: string) {
const replies = (resultsOf.get(init.use.toolUseId) ?? []).flatMap(r => r.events)
.filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order);
let reply: unknown;
try { reply = replies.length === 1 ? JSON.parse(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; }
try { reply = replies.length === 1 ? bashJson(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; }
// A failed or unreadable init binds nothing and is skipped, as invocation() does.
if (!object(reply)) continue;
// A reused:true init keeps the earlier binding, as invocation() does.
if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan &&
bound.restorePath === reply.restorePath) continue;
chainStart = init.record; bound = object(reply) ? reply : undefined;
chainStart = init.record; bound = reply;
}
let start = 0;
for (let i = chainStart ?? records.length - 1; i >= 0; i--)
+12 -9
View File
@@ -10,7 +10,7 @@ import { extractImplementationPlan, checkPhaseImplementation, acceptedBlocks, ca
import { autoplanPhaseCompletions } from '../../lib/autoplan-phase-publication';
import { nativePathSpelling, ownedNativePath, sameNativePath, type ClaudeParentPublicEvent } from '../../lib/claude-journal-records';
import { ownedRecordLimit, ownedRetainedLimit, type JournalPrefix, type OwnedRead, type OwnedReadMeasure } from '../../lib/claude-owned-journal';
import { readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
import { bashJson, readGuardJournal, textResult, DEDUP_REPLY } from './guard-journal';
import { REASONS, reasonCode, reasonText, type Detail, type ReasonCode } from './guard-reasons';
import { logGuardDecision } from './guard-log';
import { approveOwnedRead, renderSectionBase, userRenderRoot } from './owned-read';
@@ -227,7 +227,7 @@ function checkpointResult(result: Event, tools: Tools, init: Invocation): { phas
if (use?.name !== 'Bash' || use.order >= result.order) return;
const text = textResult(result);
if (text === undefined) return;
const output = JSON.parse(text);
const output = bashJson(text);
if (!object(output) || !['ceo', 'design', 'dx', 'eng'].includes(output.phase) ||
!ownPath(output.snapshotPath) || typeof output.nativePrompt !== 'string' || !object(output.baselineEdits)) return;
const { nativePrompt, baselineEdits, ...identity } = output;
@@ -260,6 +260,7 @@ function initArguments(command: unknown, root: string): string[] | undefined {
const INIT_MISMATCH_CAUSE = 'Snapshot init returned paths that do not match the canonical form of its own command arguments';
const INIT_FAILED_CAUSE = 'The most recent snapshot init call failed; re-run the same init command with the same three paths';
const INIT_UNPARSEABLE_CAUSE = 'The most recent snapshot init printed something other than its JSON result; re-run the same init command with the same three paths, without a leading cd';
/** Diagnostic only, never binds: a Bash call that ran snapshot init in a shape the guard cannot bind (#3045). */
const UNBINDABLE_INIT = /(?:gstack-autoplan-snapshot|SNAPSHOT_TOOL|\$\{?[A-Z_]+\}?"?)\S*\s+init\s/;
@@ -268,8 +269,8 @@ function invocation(events: Event[], root: string): Invocation {
let bound: Invocation | undefined;
let chosen: Record<string, any> | undefined;
let unbindable = false;
// True while the most recent bindable init use errored (a later success clears it).
let lastErrored = false;
// The cause while the most recent bindable init use failed (a later success clears it).
let lastFailure: string | undefined;
const tools = byTool(events);
for (const use of events) {
if (use.kind !== 'use' || use.name !== 'Bash') continue;
@@ -278,10 +279,12 @@ function invocation(events: Event[], root: string): Invocation {
const results = tools.results(use.toolUseId).filter(x => x.order > use.order);
if (results.length !== 1) fail('init_mismatch');
// An errored init published nothing; a later correct init may still bind.
if (results[0]!.isError === true) { lastErrored = true; continue; }
if (results[0]!.isError === true) { lastFailure = INIT_FAILED_CAUSE; continue; }
const text = textResult(results[0]!);
if (text === undefined) fail('init_failed');
const result = JSON.parse(text);
if (text === undefined) { lastFailure = INIT_FAILED_CAUSE; continue; }
let result: any;
// Output that is not the init's JSON bound nothing; a later correct init may still bind.
try { result = bashJson(text); } catch { lastFailure = INIT_UNPARSEABLE_CAUSE; continue; }
// init reports canonical destinations (macOS /tmp is /private/tmp); canonicalize the argv the same way.
let active: string, restore: string;
try { active = canonicalDestination(args[1]!); restore = canonicalDestination(args[2]!); }
@@ -289,13 +292,13 @@ function invocation(events: Event[], root: string): Invocation {
if (!object(result) || !samePath(result.sourcePlan, fs.realpathSync(args[0]!)) || !samePath(result.activePlan, active) ||
!samePath(result.restorePath, restore) || typeof result.reused !== 'boolean' || !positive(result.originalBytes) ||
!/^[a-f0-9]{64}$/.test(result.originalSha256)) fail('init_mismatch', { cause: INIT_MISMATCH_CAUSE });
lastErrored = false;
lastFailure = undefined;
if (result.reused && bound && bound.activePlan === result.activePlan && bound.restorePath === result.restorePath) continue;
chosen = result;
bound = { activePlan: result.activePlan, restorePath: result.restorePath,
originalSha256: result.originalSha256, start: results[0]!.order };
}
if (lastErrored) fail('init_failed', { cause: INIT_FAILED_CAUSE });
if (lastFailure) fail('init_failed', { cause: lastFailure });
if ((!chosen || !bound) && unbindable) fail('init_unbindable');
if (!chosen || !bound) fail('init_required');
const restore = read(bound.restorePath, true), active = read(bound.activePlan);
@@ -0,0 +1,2 @@
### Fixed
- **`/autoplan` no longer locks a Claude Code session after a snapshot init run as `cd <dir> && …` (#3651).** When that `cd` leaves the session's folder, Claude Code adds a `Shell cwd was reset to …` line to the command's output. The guard now removes exactly that one line before reading the init or checkpoint JSON; any other extra text still fails. An init whose output still can't be read now counts as a failed init with a "re-run" hint, so a later clean init in the same session works again. Before, every later phase read was denied with code `evidence` until a new session.
+7
View File
@@ -257,6 +257,13 @@ the same three paths. An errored init is skipped, so a later successful init
still binds. This denial applies when no init ever succeeded, or when an
errored init follows a good one.
An init whose output is not its JSON result (extra text, or no single text
block) also counts as failed, and the message adds "without a leading cd" to
the re-run hint. The one exception is the `Shell cwd was reset to <dir>` line
Claude Code adds as the last line of a Bash result when the command's `cd`
left the session's folder: the guard removes exactly that line before reading
an init or checkpoint result (#3651).
<a id="init-unbindable"></a>
### `init_unbindable`
Init ran through a shell variable, substitution, chaining, a pipe or a
+63
View File
@@ -206,6 +206,16 @@ describe('Autoplan parent publication guard', () => {
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') });
});
test('#3651: a rerun checkpoint whose create result carries the cwd-reset footer still retires the old close', () => {
const { f, tasks } = completedCycle(), create = checkpointWriter(f);
const oldClose = f.events.find(e => e.kind === 'use' && e.toolUseId === 'eng-close')!;
create('eng', 'new-generation');
(f.events.at(-1) as any).content += '\nShell cwd was reset to /home/user/project';
f.read('old-close-replayed', (oldClose as any).input.file_path as string); f.message('Phase 3 complete.');
f.input.tool_input = { file_path: tasks }; f.current();
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('earlier checkpoint') });
});
for (const status of ['partial', 'failed', 'pending'] as const)
test(`a ${status} rerun close cannot borrow the first cycle's completed report`, () => {
const { f, create, tasks } = completedCycle(), rerun = create('eng', 'rerun');
@@ -1596,3 +1606,56 @@ describe('PAX-3638: symlinked plan directory and superseded failed inits', () =>
expect(f.evaluate()).toMatchObject({ allow: false, reason: expect.stringContaining('(code mutation_pending,') });
});
});
describe('#3651: Claude Code cwd-reset footer on a Bash result', () => {
const FOOTER = '\nShell cwd was reset to /home/user/project';
function withInitTail(tail: string) {
const f = fixture();
(f.events.find(e => e.kind === 'result' && e.toolUseId === 'init') as any).content += tail;
return f;
}
const ready = (f: ReturnType<typeof fixture>) => { f.message(); f.current(); f.reorder(); return f.evaluate(); };
test('an init result carrying the footer still binds', () => {
expect(ready(withInitTail(FOOTER))).toEqual({ allow: true });
});
for (const [name, tail] of [['other trailing text', '\nunexpected trailing output'], ['two footers', `${FOOTER}${FOOTER}`], ['a footer that is not the last line', `${FOOTER}\n`]] as const)
test(`${name} after an init result is init_failed with the re-run hint, not the generic evidence denial`, () => {
const decision = ready(withInitTail(tail)) as any;
expect(decision.allow).toBe(false);
expect(decision.reason).toContain('(code init_failed,');
expect(decision.reason).toContain('re-run');
});
test('#3651: the hook path keeps the first binding when an init is re-run after a typed user message', async () => {
const footer = '\nShell cwd was reset to /home/user/project';
const run = async (tail: string, failed?: unknown) => {
const f = fixture(); (f.events[1] as any).content += tail; f.message();
if (failed !== undefined) { f.use('bad-init', 'Bash', (f.events[0] as any).input); f.result('bad-init', { content: failed }); }
f.use('re-init', 'Bash', (f.events[0] as any).input); f.result('re-init', { content: JSON.stringify({ ...f.init, reused: true }) + tail });
f.current();
const { rows, record } = f.journal();
const at = rows.findIndex(r => Array.isArray(r.message.content) && r.message.content.some((b: any) => b.type === 'tool_use' && ['bad-init', 're-init'].includes(b.id)));
(rows[at - 1]!.message as any).stop_reason = 'end_turn';
rows.splice(at, 0, record('user', 'Looks good, keep going.', { origin: { kind: 'human' }, promptSource: 'typed', promptId: randomUUID() }));
rows.forEach((r, i) => { r.parentUuid = i ? rows[i - 1]!.uuid : null; });
fs.writeFileSync(f.input.transcript_path, rows.map(x => JSON.stringify(x)).join('\n') + '\n');
return withNativeProjectDirectory(f.cwd, () => runPublicationHook(f.input, ROOT));
};
expect(await run('')).toEqual(OWNED_READ_APPROVAL);
expect(await run(footer)).toEqual(OWNED_READ_APPROVAL);
expect(await run('', 'unexpected output')).toEqual(OWNED_READ_APPROVAL);
expect(await run('', [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toEqual(OWNED_READ_APPROVAL);
});
for (const [name, content] of [['did not parse', undefined], ['had no single text block', [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]]] as const)
test(`a later clean init recovers the session after an init whose output ${name}`, () => {
const f = withInitTail('\nunexpected trailing output');
if (content) (f.events[1] as any).content = content;
f.events.splice(2, 0, { ...(structuredClone(f.events[0]!) as any), toolUseId: 'again' },
{ ...(structuredClone(f.events[1]!) as any), toolUseId: 'again', content: JSON.stringify({ ...f.init, reused: true }) });
expect(ready(f)).toEqual({ allow: true });
});
});