From aaadab444536cc2101bbbec574fb7a334b8acfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 01:49:04 +0000 Subject: [PATCH] fix: decouple session resume from isFirstVisit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When frontmatter validation fails, the step is never written to CAS, so isFirstVisit remains true on the next run. Both agent-claude-code and agent-hermes gated session cache lookup behind !isFirstVisit, which caused them to start a fresh session (and a new worktree) instead of resuming the one that already has all the work done. Changes: - Remove the isFirstVisit guard from both adapters so they always check the session cache. - When isFirstVisit + cache hit (frontmatter-only failure), send a minimal correction prompt via buildFrontmatterRetryPrompt() instead of re-sending the full initial prompt — the session already has full context, we just need the agent to re-output correctly formatted frontmatter. - Add buildFrontmatterRetryPrompt to util-agent with tests. Fixes #139 --- packages/agent-claude-code/src/claude-code.ts | 20 +++++++++--- packages/agent-hermes/src/hermes.ts | 32 +++++++++++++------ .../frontmatter-retry-prompt.test.ts | 23 +++++++++++++ .../src/frontmatter-retry-prompt.ts | 21 ++++++++++++ packages/util-agent/src/index.ts | 1 + 5 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 packages/util-agent/__tests__/frontmatter-retry-prompt.test.ts create mode 100644 packages/util-agent/src/frontmatter-retry-prompt.ts diff --git a/packages/agent-claude-code/src/claude-code.ts b/packages/agent-claude-code/src/claude-code.ts index 72be905..f370471 100644 --- a/packages/agent-claude-code/src/claude-code.ts +++ b/packages/agent-claude-code/src/claude-code.ts @@ -6,6 +6,7 @@ import { type AgentContext, type AgentRunResult, buildContinuationPrompt, + buildFrontmatterRetryPrompt, buildRolePrompt, buildThreadProgress, createAgent, @@ -176,8 +177,12 @@ async function runClaudeCode(ctx: AgentContext, model: string | null): Promise { - if (ctx.isFirstVisit || resumeDisabled) { + if (resumeDisabled) { await client.connect(cwd); - return { useContinuation: false, resumed: false }; + return { useContinuation: false, resumed: false, frontmatterRetry: false }; } + // Check session cache regardless of isFirstVisit. A previous run may + // have completed and cached its session but failed frontmatter + // validation — the step never got written to CAS so isFirstVisit is + // still true, yet we should resume the existing session. const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role, ctx.storageRoot); if (cachedSessionId === null) { log("6RWK3N8Q", `no cached session for ${ctx.threadId}:${ctx.role}, starting new session`); await client.connect(cwd); - return { useContinuation: false, resumed: false }; + return { useContinuation: false, resumed: false, frontmatterRetry: false }; } try { await client.resume(cachedSessionId, cwd); log("9MHT4V2P", `resumed hermes session ${cachedSessionId} for ${ctx.threadId}:${ctx.role}`); - return { useContinuation: true, resumed: true }; + return { + useContinuation: !ctx.isFirstVisit, + resumed: true, + frontmatterRetry: ctx.isFirstVisit, + }; } catch (error) { const message = error instanceof Error ? error.message : String(error); log("3XPN7K4W", `session resume failed, falling back to new session: ${message}`); await client.close(); await client.connect(cwd); - return { useContinuation: false, resumed: false }; + return { useContinuation: false, resumed: false, frontmatterRetry: false }; } } @@ -154,9 +165,12 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise ctx: AgentContext, useContinuation: boolean, beforeTurns: TurnsSnapshot, + frontmatterRetry: boolean, ): Promise { - const effectiveCtx = useContinuation ? ctx : { ...ctx, isFirstVisit: true }; - const fullPrompt = buildHermesPrompt(effectiveCtx); + // Frontmatter retry: session has full context, just re-output the format. + const fullPrompt = frontmatterRetry + ? buildFrontmatterRetryPrompt(ctx.outputFormatInstruction) + : buildHermesPrompt(useContinuation ? ctx : { ...ctx, isFirstVisit: true }); const startMs = Date.now(); const { text, sessionId, usage: acpUsage } = await client.prompt(fullPrompt); const durationSec = (Date.now() - startMs) / 1000; @@ -188,7 +202,7 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise const beforeTurns = snapshotTurns(beforeSession); try { - return await runPrompt(ctx, attempt.useContinuation, beforeTurns); + return await runPrompt(ctx, attempt.useContinuation, beforeTurns, attempt.frontmatterRetry); } catch (error) { if (!attempt.resumed) { throw error; @@ -199,7 +213,7 @@ export function createHermesAgent(resumeDisabled: boolean): () => Promise await client.close(); await client.connect(cwd); // Fresh session after retry — reset snapshot to zero - return runPrompt(ctx, false, ZERO_TURNS); + return runPrompt(ctx, false, ZERO_TURNS, false); } } diff --git a/packages/util-agent/__tests__/frontmatter-retry-prompt.test.ts b/packages/util-agent/__tests__/frontmatter-retry-prompt.test.ts new file mode 100644 index 0000000..a63d118 --- /dev/null +++ b/packages/util-agent/__tests__/frontmatter-retry-prompt.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; +import { buildFrontmatterRetryPrompt } from "../src/frontmatter-retry-prompt.js"; + +describe("buildFrontmatterRetryPrompt", () => { + test("includes correction instruction", () => { + const result = buildFrontmatterRetryPrompt("Use YAML frontmatter"); + expect(result).toContain("previous run completed"); + expect(result).toContain("do NOT need to redo any work"); + expect(result).toContain("corrected YAML frontmatter"); + }); + + test("includes outputFormatInstruction when provided", () => { + const instruction = "---\nstatus: $done | $review\nsummary: string\n---"; + const result = buildFrontmatterRetryPrompt(instruction); + expect(result).toContain(instruction); + }); + + test("works with empty outputFormatInstruction", () => { + const result = buildFrontmatterRetryPrompt(""); + expect(result).not.toContain("\n\n\n"); + expect(result).toContain("corrected YAML frontmatter"); + }); +}); diff --git a/packages/util-agent/src/frontmatter-retry-prompt.ts b/packages/util-agent/src/frontmatter-retry-prompt.ts new file mode 100644 index 0000000..42f207d --- /dev/null +++ b/packages/util-agent/src/frontmatter-retry-prompt.ts @@ -0,0 +1,21 @@ +/** + * Build a minimal prompt for retrying frontmatter output on a resumed session. + * + * Used when a previous run completed successfully but frontmatter validation + * failed — the session already has full context, we just need the agent to + * re-output correctly formatted frontmatter without redoing any work. + */ +export function buildFrontmatterRetryPrompt(outputFormatInstruction: string): string { + const parts: string[] = [ + "Your previous run completed all work successfully, but the output format was incorrect.", + "You do NOT need to redo any work — all changes are already in place.", + "", + ]; + if (outputFormatInstruction !== "") { + parts.push(outputFormatInstruction, ""); + } + parts.push( + "Please output ONLY the corrected YAML frontmatter block (--- delimited) followed by a brief summary of the work you completed.", + ); + return parts.join("\n"); +} diff --git a/packages/util-agent/src/index.ts b/packages/util-agent/src/index.ts index db0573a..6e2c77f 100644 --- a/packages/util-agent/src/index.ts +++ b/packages/util-agent/src/index.ts @@ -12,6 +12,7 @@ export { } from "./extract.js"; export type { FrontmatterFastPathResult } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js"; +export { buildFrontmatterRetryPrompt } from "./frontmatter-retry-prompt.js"; export { createAgent, parseArgv } from "./run.js"; export { getCachedSessionId, getCachePath, setCachedSessionId } from "./session-cache.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";