From faa068efc2a477f2a137539c6af7668fa4d71179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luka=20Bud=C3=ADk?= Date: Mon, 20 Apr 2026 21:40:25 +0200 Subject: [PATCH] Add structured worker result passing for multi-agent orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubTurtles now write a structured result file at completion so the meta agent can pass outputs from one worker directly into the next, enabling real dependency-aware orchestration instead of manual workspace inspection. Worker result files (.superturtle/state/results/.json) carry: - summary: what was built - artifacts: files a downstream worker must read - key_decisions: choices downstream workers must conform to - questions_for_orchestrator: open items for the meta agent Python infrastructure writes a fallback result automatically if the agent forgets to, covering clean completion, fatal failure, and (via TypeScript) watchdog timeout — so every terminal wakeup kind is guaranteed to have a result file by the time the meta agent processes it. Conductor inbox items for completed workers now embed the result summary, key decisions, artifacts, and open questions directly in the notification text, so the meta agent sees everything it needs to spawn a well-informed downstream worker without manually inspecting the workspace. DECOMPOSITION_PROMPT.md gains a Result-Passing Pattern section with a ready-to-use injection template for wiring upstream outputs into downstream CLAUDE.md files. META_SHARED.md gets a matching note under task decomposition. Dashboard SubTurtle detail pages show a Worker result card. --- super_turtle/CHANGELOG.md | 8 ++ .../src/conductor-snapshot.test.ts | 41 +++++++ .../src/conductor-snapshot.ts | 13 +++ .../src/conductor-supervisor.test.ts | 68 ++++++++++++ .../src/conductor-supervisor.ts | 97 +++++++++++++++- .../src/cron-supervision-queue.test.ts | 3 + .../src/cron-supervision-queue.ts | 1 + .../src/dashboard-types.ts | 13 +++ .../src/dashboard/details.ts | 19 ++++ .../src/dashboard/renderers.ts | 32 ++++++ super_turtle/meta/DECOMPOSITION_PROMPT.md | 41 ++++++- super_turtle/meta/META_SHARED.md | 9 ++ super_turtle/state/conductor_state.py | 19 ++++ super_turtle/state/test_conductor_state.py | 30 +++++ super_turtle/subturtle/loops.py | 12 +- super_turtle/subturtle/prompts.py | 47 +++++++- super_turtle/subturtle/statefile.py | 69 +++++++++++- .../subturtle/tests/test_subturtle_main.py | 105 +++++++++++++++++- 18 files changed, 611 insertions(+), 16 deletions(-) diff --git a/super_turtle/CHANGELOG.md b/super_turtle/CHANGELOG.md index f407d8bd..10faeabb 100644 --- a/super_turtle/CHANGELOG.md +++ b/super_turtle/CHANGELOG.md @@ -7,6 +7,14 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Structured worker result files: SubTurtles now write `.superturtle/state/results/.json` at completion containing `summary`, `artifacts`, `key_decisions`, and `questions_for_orchestrator`. The meta agent can read these when spawning dependent workers, enabling true multi-agent orchestration with result passing between agents. +- Python infrastructure writes a fallback result automatically if the agent forgets to, covering both clean completion and fatal failure cases. +- Conductor inbox items for completed workers now embed the result summary, key decisions, artifacts, and open questions directly in the notification text — the meta agent no longer needs to manually inspect the workspace to spawn a well-informed downstream worker. +- Dashboard SubTurtle detail page shows a "Worker result" card with the structured output. +- `DECOMPOSITION_PROMPT.md` updated with a "Result-Passing Pattern" section and injection template for wiring upstream results into downstream `CLAUDE.md` files. +- `META_SHARED.md` updated with a result-passing note under task decomposition. + ## [0.2.9] - 2026-03-28 ### Changed diff --git a/super_turtle/claude-telegram-bot/src/conductor-snapshot.test.ts b/super_turtle/claude-telegram-bot/src/conductor-snapshot.test.ts index 2e01d9b3..30bffa20 100644 --- a/super_turtle/claude-telegram-bot/src/conductor-snapshot.test.ts +++ b/super_turtle/claude-telegram-bot/src/conductor-snapshot.test.ts @@ -172,6 +172,46 @@ describe("loadConductorSnapshotContext", () => { expect(context.workerStateJson).toBe("(missing canonical worker state)"); expect(context.prepErrors[0]).toContain("canonical worker state missing"); }); + + it("includes worker result summary when result file exists", () => { + const baseDir = makeTempDir(); + const stateDir = join(baseDir, ".superturtle", "state"); + mkdirSync(join(stateDir, "workers"), { recursive: true }); + mkdirSync(join(stateDir, "wakeups"), { recursive: true }); + mkdirSync(join(stateDir, "results"), { recursive: true }); + + writeJson(join(stateDir, "workers", "worker-with-result.json"), { + kind: "worker_state", + schema_version: 1, + worker_name: "worker-with-result", + run_id: "run-wr", + lifecycle_state: "completed", + workspace: "/tmp/worker-with-result", + current_task: "Build the auth system", + metadata: {}, + }); + + writeJson(join(stateDir, "results", "worker-with-result.json"), { + schema_version: 1, + worker_name: "worker-with-result", + completed_at: "2026-04-20T14:00:00Z", + status: "completed", + summary: "Implemented JWT middleware with RS256. 12 tests passing.", + artifacts: ["src/auth/jwt.ts"], + key_decisions: ["RS256 over HS256"], + blockers: [], + questions_for_orchestrator: ["Should refresh TTL be configurable?"], + }); + + const context = loadConductorSnapshotContext({ + stateDir, + workerName: "worker-with-result", + }); + + expect(context.workerResultJson).toContain("Implemented JWT middleware with RS256"); + expect(context.workerResultJson).toContain("RS256 over HS256"); + expect(context.conductorSummary).toContain("Result: Implemented JWT middleware with RS256"); + }); }); describe("buildPreparedSnapshotPrompt", () => { @@ -187,6 +227,7 @@ describe("buildPreparedSnapshotPrompt", () => { workerStateJson: '{"worker_name":"worker-a"}', recentEventsJson: '[{"event_type":"worker.checkpoint"}]', wakeupsJson: '[{"delivery_state":"pending"}]', + workerResultJson: "(no result file)", statusOutput: "worker-a running as 999", stateExcerpt: "# Current task\n\nShip it", gitLog: "abc123 Ship it", diff --git a/super_turtle/claude-telegram-bot/src/conductor-snapshot.ts b/super_turtle/claude-telegram-bot/src/conductor-snapshot.ts index e5fca052..7a2e4158 100644 --- a/super_turtle/claude-telegram-bot/src/conductor-snapshot.ts +++ b/super_turtle/claude-telegram-bot/src/conductor-snapshot.ts @@ -24,6 +24,7 @@ interface ConductorSnapshotContext { workerStateJson: string; recentEventsJson: string; wakeupsJson: string; + workerResultJson: string; prepErrors: string[]; } @@ -152,6 +153,12 @@ export function loadConductorSnapshotContext( : null; const resolvedState = supervisorResolvedState(workerState); + const resultPath = join(stateDir, "results", `${options.workerName}.json`); + const workerResult = readJsonObject>(resultPath); + const workerResultSummary = workerResult + ? (asString(workerResult.summary) || "(no summary)") + : "(no result file)"; + const summaryLines = [ `Worker: ${options.workerName}`, `Lifecycle state: ${workerState?.lifecycle_state || "(missing)"}`, @@ -163,6 +170,7 @@ export function loadConductorSnapshotContext( `Last checkpoint: ${checkpointSummary || "(none)"}`, `Resolved terminal state: ${resolvedState || "(none)"}`, `Pending wakeups: ${pendingWakeups.length > 0 ? pendingWakeups.map(summarizeWakeup).join(" || ") : "(none)"}`, + `Result: ${workerResultSummary}`, ]; return { @@ -174,6 +182,7 @@ export function loadConductorSnapshotContext( ), recentEventsJson: stringifyPromptJson(recentEvents, "[]"), wakeupsJson: stringifyPromptJson(wakeups, "[]"), + workerResultJson: stringifyPromptJson(workerResult, "(no result file)"), prepErrors, }; } @@ -210,6 +219,10 @@ export function buildPreparedSnapshotPrompt(snapshot: PreparedSupervisionSnapsho snapshot.wakeupsJson || "(empty)", "", "", + "", + snapshot.workerResultJson || "(no result file)", + "", + "", "Supporting context (secondary):", "", snapshot.statusOutput || "(empty)", diff --git a/super_turtle/claude-telegram-bot/src/conductor-supervisor.test.ts b/super_turtle/claude-telegram-bot/src/conductor-supervisor.test.ts index 5eb6f416..dbaf5a2a 100644 --- a/super_turtle/claude-telegram-bot/src/conductor-supervisor.test.ts +++ b/super_turtle/claude-telegram-bot/src/conductor-supervisor.test.ts @@ -474,6 +474,74 @@ Ship the shipped thing expect(events).toContain('"event_type":"worker.notification_sent"'); }); + it("embeds result file data in the inbox text when result file exists", async () => { + const baseDir = makeStateDir(); + const stateDir = join(baseDir, ".superturtle", "state"); + const archiveWorkspace = join(baseDir, ".superturtle/subturtles", ".archive", "worker-result"); + mkdirSync(join(stateDir, "workers"), { recursive: true }); + mkdirSync(join(stateDir, "wakeups"), { recursive: true }); + mkdirSync(join(stateDir, "results"), { recursive: true }); + mkdirSync(archiveWorkspace, { recursive: true }); + + writeFileSync(join(archiveWorkspace, "CLAUDE.md"), "# Current task\n\nDone\n# Backlog\n- [x] Ship it\n", "utf-8"); + + writeJson(join(stateDir, "results", "worker-result.json"), { + schema_version: 1, + worker_name: "worker-result", + completed_at: "2026-03-08T01:00:00Z", + status: "completed", + summary: "Implemented JWT middleware with RS256. 12 tests passing.", + artifacts: ["src/auth/jwt.ts", "tests/auth/jwt.test.ts"], + key_decisions: ["RS256 over HS256 for public-key verification"], + blockers: [], + questions_for_orchestrator: ["Should refresh token TTL be configurable?"], + }); + + writeJson(join(stateDir, "workers", "worker-result.json"), { + kind: "worker_state", + schema_version: 1, + worker_name: "worker-result", + run_id: "run-result", + lifecycle_state: "archived", + workspace: archiveWorkspace, + current_task: "Ship it", + metadata: {}, + }); + writeJson(join(stateDir, "wakeups", "wake-result.json"), { + kind: "wakeup", + schema_version: 1, + id: "wake-result", + worker_name: "worker-result", + run_id: "run-result", + category: "notable", + delivery_state: "pending", + summary: "worker result done", + created_at: "2026-03-08T00:00:00Z", + updated_at: "2026-03-08T00:00:00Z", + delivery: { attempts: 0 }, + payload: { kind: "completion_requested" }, + metadata: {}, + }); + + await processPendingConductorWakeups({ + stateDir, + defaultChatId: 123, + listJobs: () => [], + removeJob: () => false, + sendMessage: async () => {}, + isWorkerRunning: () => false, + nowIso: () => "2026-03-08T01:00:00Z", + }); + + const inboxItem = JSON.parse( + readFileSync(join(stateDir, "inbox", "inbox_wake-result.json"), "utf-8") + ); + expect(inboxItem.text).toContain("Implemented JWT middleware with RS256"); + expect(inboxItem.text).toContain("RS256 over HS256"); + expect(inboxItem.text).toContain("src/auth/jwt.ts"); + expect(inboxItem.text).toContain("Should refresh token TTL be configurable?"); + }); + it("reconciles fatal worker wakeups into a failed state", async () => { const baseDir = makeStateDir(); const stateDir = join(baseDir, ".superturtle", "state"); diff --git a/super_turtle/claude-telegram-bot/src/conductor-supervisor.ts b/super_turtle/claude-telegram-bot/src/conductor-supervisor.ts index 7e867aaa..dbd22c36 100644 --- a/super_turtle/claude-telegram-bot/src/conductor-supervisor.ts +++ b/super_turtle/claude-telegram-bot/src/conductor-supervisor.ts @@ -535,11 +535,61 @@ function buildMetaAgentInboxTitle( : `SubTurtle ${workerName} update`; } +function loadWorkerResultForInbox( + stateDir: string, + workerName: string +): Record | null { + const resultPath = join(stateDir, "results", `${workerName}.json`); + if (!existsSync(resultPath)) return null; + try { + const parsed = JSON.parse(readFileSync(resultPath, "utf-8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function writeFallbackResultIfAbsent( + stateDir: string, + workerName: string, + opts: { + status: string; + summary: string; + blockers?: string[]; + questions?: string[]; + timestamp?: string; + } +): void { + const resultPath = join(stateDir, "results", `${workerName}.json`); + if (existsSync(resultPath)) return; + try { + mkdirSync(join(stateDir, "results"), { recursive: true }); + const fallback = { + schema_version: 1, + worker_name: workerName, + completed_at: opts.timestamp || new Date().toISOString(), + status: opts.status, + summary: opts.summary, + artifacts: [] as string[], + key_decisions: [] as string[], + blockers: opts.blockers || [], + questions_for_orchestrator: opts.questions || [], + _generated_by: "infrastructure_fallback", + }; + writeFileSync(resultPath, `${JSON.stringify(fallback, null, 2)}\n`, "utf-8"); + } catch { + // Best-effort — never throw from result file writing. + } +} + function buildMetaAgentInboxText( wakeup: WakeupRecord, workerState: WorkerStateRecord | null, stateText: string | null, - chatId: number | null + chatId: number | null, + stateDir: string ): string { const payloadKind = typeof wakeup.payload?.kind === "string" ? wakeup.payload.kind : ""; const lines: string[] = []; @@ -551,10 +601,38 @@ function buildMetaAgentInboxText( lines.push(`Task: ${workerState.current_task.trim()}`); } if (payloadKind === "completion_requested") { + const result = loadWorkerResultForInbox(stateDir, wakeup.worker_name); + if (result) { + const isFallback = result._generated_by === "infrastructure_fallback"; + const summary = typeof result.summary === "string" ? result.summary.trim() : ""; + if (summary) lines.push(`Result: ${summary}`); + + const decisions = Array.isArray(result.key_decisions) + ? result.key_decisions.filter((d): d is string => typeof d === "string") + : []; + if (!isFallback && decisions.length > 0) { + lines.push(`Key decisions: ${decisions.slice(0, 3).join(" | ")}`); + } + + const artifacts = Array.isArray(result.artifacts) + ? result.artifacts.filter((a): a is string => typeof a === "string") + : []; + if (!isFallback && artifacts.length > 0) { + lines.push(`Key artifacts: ${artifacts.slice(0, 4).join(", ")}`); + } + + const questions = Array.isArray(result.questions_for_orchestrator) + ? result.questions_for_orchestrator.filter((q): q is string => typeof q === "string") + : []; + if (!isFallback && questions.length > 0) { + lines.push(`Questions for you: ${questions.slice(0, 2).join(" | ")}`); + } + } + const completedItems = stateText ? parseCompletedBacklogItems(stateText).slice(0, 4) : []; if (completedItems.length > 0) { lines.push(`Completed items: ${completedItems.join(" | ")}`); - } else { + } else if (!result) { lines.push("Completion was reconciled and cleanup verified."); } } else if (payloadKind === "fatal_error") { @@ -1387,11 +1465,24 @@ export async function processPendingConductorWakeups( writeWorkerState(stateDir, workingState); } + // Write a fallback result for terminal wakeup kinds that have no Python-side result writer. + // completion_requested and fatal_error are covered by Python's record_completion_pending / + // record_failure_pending. Timeout is resolved entirely on the TypeScript side. + if (payloadKind === "timeout") { + writeFallbackResultIfAbsent(stateDir, wakeup.worker_name, { + status: "blocked", + summary: `${wakeup.worker_name} timed out before completing all backlog items.`, + blockers: ["Worker exceeded its configured timeout."], + questions: ["Should this worker be restarted with a longer timeout, or is the task done?"], + timestamp: now, + }); + } + const chatId = deriveChatId(wakeup, options.defaultChatId ?? ALLOWED_USERS[0] ?? null); const stateText = readWorkspaceStateText(workingState); if (wakeup.category !== "silent") { const inboxTitle = buildMetaAgentInboxTitle(wakeup, workingState); - const inboxText = buildMetaAgentInboxText(wakeup, workingState, stateText, chatId); + const inboxText = buildMetaAgentInboxText(wakeup, workingState, stateText, chatId, stateDir); const { item: inboxItem, created } = ensureMetaAgentInboxItem({ stateDir, item: { diff --git a/super_turtle/claude-telegram-bot/src/cron-supervision-queue.test.ts b/super_turtle/claude-telegram-bot/src/cron-supervision-queue.test.ts index 8bfd5df7..e4dc362c 100644 --- a/super_turtle/claude-telegram-bot/src/cron-supervision-queue.test.ts +++ b/super_turtle/claude-telegram-bot/src/cron-supervision-queue.test.ts @@ -21,6 +21,7 @@ describe("cron supervision queue", () => { workerStateJson: "{}", recentEventsJson: "[]", wakeupsJson: "[]", + workerResultJson: "(no result file)", statusOutput: "ok", stateExcerpt: "state", gitLog: "log", @@ -45,6 +46,7 @@ describe("cron supervision queue", () => { workerStateJson: "{}", recentEventsJson: "[]", wakeupsJson: "[]", + workerResultJson: "(no result file)", statusOutput: "ok", stateExcerpt: "state", gitLog: "log", @@ -78,6 +80,7 @@ describe("cron supervision queue", () => { workerStateJson: "{}", recentEventsJson: "[]", wakeupsJson: "[]", + workerResultJson: "(no result file)", statusOutput: "ok", stateExcerpt: "s", gitLog: "g", diff --git a/super_turtle/claude-telegram-bot/src/cron-supervision-queue.ts b/super_turtle/claude-telegram-bot/src/cron-supervision-queue.ts index d5dfb508..296faef7 100644 --- a/super_turtle/claude-telegram-bot/src/cron-supervision-queue.ts +++ b/super_turtle/claude-telegram-bot/src/cron-supervision-queue.ts @@ -9,6 +9,7 @@ export interface PreparedSupervisionSnapshot { workerStateJson: string; recentEventsJson: string; wakeupsJson: string; + workerResultJson: string; statusOutput: string; stateExcerpt: string; gitLog: string; diff --git a/super_turtle/claude-telegram-bot/src/dashboard-types.ts b/super_turtle/claude-telegram-bot/src/dashboard-types.ts index de3aefd0..4ff4e6a1 100644 --- a/super_turtle/claude-telegram-bot/src/dashboard-types.ts +++ b/super_turtle/claude-telegram-bot/src/dashboard-types.ts @@ -118,6 +118,18 @@ export type SubturtleEventView = { lifecycleState: string | null; }; +export type WorkerResultRecord = { + schema_version: number; + worker_name: string; + completed_at: string; + status: string; + summary: string; + artifacts: string[]; + key_decisions: string[]; + blockers: string[]; + questions_for_orchestrator: string[]; +}; + export type SubturtleDetailResponse = { generatedAt: string; name: string; @@ -137,6 +149,7 @@ export type SubturtleDetailResponse = { backlogSummary: BacklogSummary; conductor: SubturtleConductorView | null; events: SubturtleEventView[]; + workerResult: WorkerResultRecord | null; }; export type SubturtleLogsResponse = { diff --git a/super_turtle/claude-telegram-bot/src/dashboard/details.ts b/super_turtle/claude-telegram-bot/src/dashboard/details.ts index 82ad6eec..50d41061 100644 --- a/super_turtle/claude-telegram-bot/src/dashboard/details.ts +++ b/super_turtle/claude-telegram-bot/src/dashboard/details.ts @@ -17,6 +17,7 @@ import type { SubturtleDetailResponse, SubturtleExtra, SubturtleLogsResponse, + WorkerResultRecord, } from "../dashboard-types"; import { buildCurrentJobs, @@ -101,6 +102,22 @@ function readFullWorkerState(name: string): Record | null { } } +function readWorkerResult(name: string): WorkerResultRecord | null { + const path = join(CONDUCTOR_STATE_DIR, "results", `${name}.json`); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")); + return parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + typeof (parsed as Record).worker_name === "string" + ? (parsed as WorkerResultRecord) + : null; + } catch { + return null; + } +} + export async function buildSubturtleDetail(name: string): Promise { const turtles = await readSubturtles(); const turtle = turtles.find((entry) => entry.name === name); @@ -108,6 +125,7 @@ export async function buildSubturtleDetail(name: string): Promise` : `

No events recorded.

`; + const resultHtml = detail.workerResult + ? (() => { + const r = detail.workerResult; + const list = (items: string[]) => + items.length > 0 + ? `
    ${items.map((i) => `
  • ${escapeHtml(i)}
  • `).join("")}
` + : `

None.

`; + return ( + `

Status: ${escapeHtml(r.status)}  ·  ` + + `Completed: ${escapeHtml(formatTimestamp(r.completed_at))}

` + + `

${escapeHtml(r.summary)}

` + + (r.artifacts.length > 0 + ? `
Artifacts (${r.artifacts.length})${list(r.artifacts)}
` + : "") + + (r.key_decisions.length > 0 + ? `
Key decisions${list(r.key_decisions)}
` + : "") + + (r.questions_for_orchestrator.length > 0 + ? `
Questions for orchestrator${list(r.questions_for_orchestrator)}
` + : "") + + (r.blockers.length > 0 + ? `
Blockers${list(r.blockers)}
` + : "") + ); + })() + : `

No result file written yet.

`; + const pct = detail.backlogSummary.progressPct; const progressHtml = detail.backlog.length > 0 ? `
` + @@ -1318,6 +1345,11 @@ ${DETAIL_THEME_CSS} ${progressHtml ? `

Progress

${progressHtml}${renderBacklogChecklist(detail.backlog)}
` : `

Backlog

${renderBacklogChecklist(detail.backlog)}
`} +
+

Worker result

+ ${resultHtml} +
+

Conductor state

diff --git a/super_turtle/meta/DECOMPOSITION_PROMPT.md b/super_turtle/meta/DECOMPOSITION_PROMPT.md index a625fbe6..726d64b6 100644 --- a/super_turtle/meta/DECOMPOSITION_PROMPT.md +++ b/super_turtle/meta/DECOMPOSITION_PROMPT.md @@ -47,11 +47,42 @@ Do not decompose when any of these apply: ## Dependency Handling Rule If B depends on A: -- Spawn A first. -- Record B as queued. -- Spawn B immediately after A reaches completion. - -Never spawn a blocked SubTurtle "just in case." +- Spawn A first and record B as queued. +- When A completes, read `.superturtle/state/results/.json` before spawning B. +- Inject A's results into B's CLAUDE.md using the format below. +- Never spawn a blocked SubTurtle "just in case." + +## Result-Passing Pattern + +Workers write a structured result file at `.superturtle/state/results/.json` when +they complete all backlog items. When spawning a dependent worker B after A completes, always +read A's result file first, then inject A's output into B's `# End goal with specs` section +as a `## Inputs from upstream workers` subsection: + +```markdown +## Inputs from upstream workers + +**** completed at . +Summary: + +Key decisions your work must conform to: +- +- + +Artifacts to read before starting: +- +- + +Open questions from upstream (address each or note N/A): +- +``` + +Rules for injection: +- Always read the actual result file — never rely on memory or assumption about what A built. +- `key_decisions` define interfaces B must conform to — mandatory reading. +- `artifacts` are the files B will integrate with — read them before coding. +- If the result file does not exist yet, check A's CLAUDE.md and `git log` as a fallback + and note that structured results were unavailable. ## Pattern 1: Frontend Feature (Component-per-SubTurtle) diff --git a/super_turtle/meta/META_SHARED.md b/super_turtle/meta/META_SHARED.md index c909f50e..a5cb482a 100644 --- a/super_turtle/meta/META_SHARED.md +++ b/super_turtle/meta/META_SHARED.md @@ -85,6 +85,15 @@ You can decompose a request into multiple SubTurtles. See `{{SUPER_TURTLE_DIR}}/ Target: **up to 5 parallel SubTurtles**. Default type: `yolo-codex` when available, else `yolo`. Use `slow` only for complex spec-heavy tasks. If B depends on A, spawn A first and queue B. +**Result-passing (sequential dependencies):** +When spawning B after A completes, always read A's result file first: +```bash +cat .superturtle/state/results/.json +``` +Inject `summary`, `key_decisions`, `artifacts`, and `questions_for_orchestrator` from A's result +into B's `## Inputs from upstream workers` subsection inside `# End goal with specs`. +See `{{SUPER_TURTLE_DIR}}/meta/DECOMPOSITION_PROMPT.md` for the full injection format. + ## Writing CLAUDE.md for SubTurtles **YOLO loops have NO Plan/Groom phase** — the CLAUDE.md must be concrete: diff --git a/super_turtle/state/conductor_state.py b/super_turtle/state/conductor_state.py index 2f02579f..9df3ae31 100644 --- a/super_turtle/state/conductor_state.py +++ b/super_turtle/state/conductor_state.py @@ -95,6 +95,7 @@ class ConductorPaths: events_jsonl_file: Path workers_dir: Path wakeups_dir: Path + results_dir: Path runs_jsonl_file: Path handoff_md_file: Path @@ -103,6 +104,7 @@ def ensure_conductor_state_paths(state_dir: str | Path) -> ConductorPaths: base_dir = Path(state_dir) workers_dir = base_dir / "workers" wakeups_dir = base_dir / "wakeups" + results_dir = base_dir / "results" events_jsonl_file = base_dir / "events.jsonl" runs_jsonl_file = base_dir / "runs.jsonl" handoff_md_file = base_dir / "handoff.md" @@ -110,6 +112,7 @@ def ensure_conductor_state_paths(state_dir: str | Path) -> ConductorPaths: base_dir.mkdir(parents=True, exist_ok=True) workers_dir.mkdir(parents=True, exist_ok=True) wakeups_dir.mkdir(parents=True, exist_ok=True) + results_dir.mkdir(parents=True, exist_ok=True) events_jsonl_file.touch(exist_ok=True) runs_jsonl_file.touch(exist_ok=True) @@ -118,6 +121,7 @@ def ensure_conductor_state_paths(state_dir: str | Path) -> ConductorPaths: events_jsonl_file=events_jsonl_file, workers_dir=workers_dir, wakeups_dir=wakeups_dir, + results_dir=results_dir, runs_jsonl_file=runs_jsonl_file, handoff_md_file=handoff_md_file, ) @@ -145,6 +149,21 @@ def load_worker_state(self, worker_name: str) -> dict[str, Any] | None: raise ValueError(f"worker state at {path} must be a JSON object") return loaded + def result_path(self, worker_name: str) -> Path: + """Return the canonical result file path for a worker.""" + return self.paths.results_dir / f"{_validate_worker_name(worker_name)}.json" + + def load_worker_result(self, worker_name: str) -> dict[str, Any] | None: + """Load a worker's structured result file if it exists.""" + path = self.result_path(worker_name) + if not path.exists(): + return None + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + return loaded if isinstance(loaded, dict) else None + except (OSError, json.JSONDecodeError): + return None + def delete_worker_state(self, worker_name: str) -> bool: path = self.worker_state_path(worker_name) if not path.exists(): diff --git a/super_turtle/state/test_conductor_state.py b/super_turtle/state/test_conductor_state.py index 09d5c069..7b8db302 100644 --- a/super_turtle/state/test_conductor_state.py +++ b/super_turtle/state/test_conductor_state.py @@ -22,9 +22,39 @@ def test_ensure_conductor_state_paths_creates_layout(self) -> None: self.assertTrue(paths.runs_jsonl_file.exists()) self.assertTrue(paths.workers_dir.exists()) self.assertTrue(paths.wakeups_dir.exists()) + self.assertTrue(paths.results_dir.exists()) self.assertEqual(paths.events_jsonl_file.read_text(encoding="utf-8"), "") self.assertEqual(paths.runs_jsonl_file.read_text(encoding="utf-8"), "") + def test_load_worker_result_returns_none_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + store = ConductorStateStore(tmp_dir) + result = store.load_worker_result("nonexistent-worker") + self.assertIsNone(result) + + def test_load_worker_result_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + store = ConductorStateStore(tmp_dir) + result_data = { + "schema_version": 1, + "worker_name": "worker-x", + "completed_at": "2026-04-20T14:00:00Z", + "status": "completed", + "summary": "Implemented the thing.", + "artifacts": ["src/foo.ts"], + "key_decisions": ["Used RS256"], + "blockers": [], + "questions_for_orchestrator": [], + } + result_path = store.result_path("worker-x") + result_path.write_text(json.dumps(result_data), encoding="utf-8") + + loaded = store.load_worker_result("worker-x") + self.assertIsNotNone(loaded) + self.assertEqual(loaded["worker_name"], "worker-x") + self.assertEqual(loaded["status"], "completed") + self.assertEqual(loaded["artifacts"], ["src/foo.ts"]) + def test_write_and_load_worker_state_roundtrip(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: store = ConductorStateStore(tmp_dir) diff --git a/super_turtle/subturtle/loops.py b/super_turtle/subturtle/loops.py index 9b9206a6..1f3eed8c 100644 --- a/super_turtle/subturtle/loops.py +++ b/super_turtle/subturtle/loops.py @@ -169,8 +169,13 @@ def _run_single_agent_loop( ) -> None: """Run the shared retry/checkpoint loop used by single-agent variants.""" state_file, state_ref = _resolve_state_ref(state_dir, name) - prompt = prompts.YOLO_PROMPT.format(state_file=state_ref) project_dir = Path.cwd() + result_file = str(statefile.result_file_path(project_dir, name)) + prompt = prompts.YOLO_PROMPT.format( + state_file=state_ref, + worker_name=name, + result_file=result_file, + ) iteration = 0 consecutive_failures = 0 stopped_by_directive = False @@ -214,14 +219,15 @@ def run_slow_loop(state_dir: Path, name: str, skills: list[str] | None = None) - _require_cli(name, "codex") state_file, state_ref = _resolve_state_ref(state_dir, name) - prompt_bundle = prompts.build_prompts(state_ref) + project_dir = Path.cwd() + result_file = str(statefile.result_file_path(project_dir, name)) + prompt_bundle = prompts.build_prompts(state_ref, worker_name=name, result_file=result_file) _log_loop_start(name, "slow loop: plan -> groom -> execute -> review", state_ref, skills) add_dirs = _skill_dirs(skills) claude = Claude(add_dirs=add_dirs) codex = Codex(add_dirs=add_dirs) - project_dir = Path.cwd() iteration = 0 consecutive_failures = 0 stopped_by_directive = False diff --git a/super_turtle/subturtle/prompts.py b/super_turtle/subturtle/prompts.py index e29f2aeb..6f1d8895 100644 --- a/super_turtle/subturtle/prompts.py +++ b/super_turtle/subturtle/prompts.py @@ -82,6 +82,13 @@ - Split the blocked item into smaller steps, prerequisites, or diagnostics. 6. If ALL backlog items in {state_file} are `[x]`, append `## Loop Control\nSTOP` to {state_file}. +7. **Write result file when all work is done** — If ALL backlog items are `[x]`: + Create `.superturtle/state/results/` if needed (`mkdir -p`), then write `{result_file}` + with `schema_version` 1, `worker_name` `{worker_name}`, `completed_at` (current UTC ISO-8601), + `status` "completed", `summary` (1-2 factual sentences: what was built, key files, test status), + `artifacts` (key output files a downstream worker must read), `key_decisions` (choices downstream + must conform to), `blockers` (empty array on clean completion), and `questions_for_orchestrator` + (unresolved questions for the meta agent). Include this file in the final commit alongside STOP. ## The plan that was executed @@ -130,16 +137,50 @@ - You MUST NOT leave a blocked current item unchanged and simply retry it next iteration without new evidence, a narrower plan, or a rewritten actionable backlog item. - Do NOT ask questions. Make reasonable decisions and move forward. - Do NOT over-scope. One commit, one focused change. Stop after committing. + +## Writing your result (on completion) + +When ALL backlog items are `[x]` and you are about to append `## Loop Control\\nSTOP`: + +1. Create `.superturtle/state/results/` if it does not exist (`mkdir -p`), then write `{result_file}`: + +```json +{{ + "schema_version": 1, + "worker_name": "{worker_name}", + "completed_at": "", + "status": "completed", + "summary": "<1-2 factual sentences: what was built, key files, test status>", + "artifacts": [""], + "key_decisions": [""], + "blockers": [], + "questions_for_orchestrator": [""] +}} +``` + +2. Include `{result_file}` in the same commit as the STOP directive. +3. `summary`: factual and specific ("Implemented RS256 JWT middleware at src/auth/jwt.ts, 3 endpoints, 12 tests passing"). +4. `artifacts`: only files a downstream worker needs to integrate. Omit intermediate/scratch files. +5. `key_decisions`: anything a downstream worker MUST conform to (API contract, data model, chosen library). +6. If you stopped without completing all backlog items, set `"status": "blocked"` and list blockers. """ -def build_prompts(state_file: str) -> dict[str, str]: - """Build slow-loop prompts with the state-file path baked in.""" +def build_prompts( + state_file: str, + worker_name: str = "", + result_file: str = "", +) -> dict[str, str]: + """Build slow-loop prompts with the state-file path, worker name, and result file baked in.""" return { "planner": PLANNER_PROMPT.format(state_file=state_file), "groomer": GROOMER_PROMPT.format(state_file=state_file), "executor": EXECUTOR_PROMPT.format(state_file=state_file), - "reviewer": REVIEWER_PROMPT.format(state_file=state_file), + "reviewer": REVIEWER_PROMPT.format( + state_file=state_file, + worker_name=worker_name, + result_file=result_file, + ), } diff --git a/super_turtle/subturtle/statefile.py b/super_turtle/subturtle/statefile.py index 55cbf674..9004e602 100644 --- a/super_turtle/subturtle/statefile.py +++ b/super_turtle/subturtle/statefile.py @@ -55,6 +55,11 @@ def run_state_dir(project_dir: Path) -> Path: return project_dir / ".superturtle" / "state" +def result_file_path(project_dir: Path, worker_name: str) -> Path: + """Return the canonical result file path for a worker.""" + return run_state_dir(project_dir) / "results" / f"{worker_name}.json" + + def extract_current_task(state_file: Path) -> str | None: """Read the first non-empty line from the Current task section.""" try: @@ -104,6 +109,44 @@ def git_head_sha(project_dir: Path) -> str | None: return sha or None +def _write_fallback_result( + project_dir: Path, + name: str, + status: str, + summary: str, + blockers: list[str] | None = None, + questions: list[str] | None = None, + timestamp: str | None = None, +) -> None: + """Write a minimal result file if the agent did not write one. + + Only writes if no result file already exists — never overwrites an + agent-written result. + """ + result_path = result_file_path(project_dir, name) + if result_path.exists(): + return + fallback: dict = { + "schema_version": 1, + "worker_name": name, + "completed_at": timestamp or utc_now_iso(), + "status": status, + "summary": summary, + "artifacts": [], + "key_decisions": [], + "blockers": blockers or [], + "questions_for_orchestrator": questions or [], + "_generated_by": "infrastructure_fallback", + } + try: + result_path.parent.mkdir(parents=True, exist_ok=True) + result_path.write_text( + json.dumps(fallback, indent=2) + "\n", encoding="utf-8" + ) + except OSError: + pass + + def record_completion_pending(state_dir: Path, name: str, project_dir: Path) -> None: """Persist a self-stop completion request and enqueue reconciliation.""" state_file = state_dir / "CLAUDE.md" @@ -120,6 +163,7 @@ def record_completion_pending(state_dir: Path, name: str, project_dir: Path) -> payload={"kind": "self_stop", "stop_directive": True}, ) + current_task = extract_current_task(state_file) or existing.get("current_task") or "" state = store.make_worker_state( worker_name=name, lifecycle_state="completion_pending", @@ -130,7 +174,7 @@ def record_completion_pending(state_dir: Path, name: str, project_dir: Path) -> pid=existing.get("pid"), timeout_seconds=existing.get("timeout_seconds"), cron_job_id=existing.get("cron_job_id"), - current_task=extract_current_task(state_file) or existing.get("current_task"), + current_task=current_task or None, stop_reason="completed", completion_requested_at=completion_requested_at, terminal_at=existing.get("terminal_at"), @@ -146,6 +190,17 @@ def record_completion_pending(state_dir: Path, name: str, project_dir: Path) -> ) store.write_worker_state(state) + # Write a fallback result file if the agent did not write a structured one. + fallback_summary = ( + f"{name} completed: {current_task}" + if current_task + else f"{name} completed all backlog items." + ) + " (Fallback — no structured agent result written; see CLAUDE.md and git log.)" + _write_fallback_result( + project_dir, name, "completed", fallback_summary, + timestamp=completion_requested_at, + ) + wakeup = store.make_wakeup( worker_name=name, category="notable", @@ -287,6 +342,17 @@ def record_failure_pending( ) store.write_worker_state(state) + # Write a failure result so downstream orchestration sees what went wrong. + _write_fallback_result( + project_dir, + name, + "failed", + f"{name} failed after consecutive agent failures: {message}", + blockers=[message], + questions=["Worker failed — should this be retried, reassigned, or skipped?"], + timestamp=event["timestamp"], + ) + wakeup = store.make_wakeup( worker_name=name, category="critical", @@ -350,6 +416,7 @@ def should_stop(state_file: Path, name: str) -> bool: "record_fatal_error", "refresh_handoff", "resolve_state_ref", + "result_file_path", "run_state_dir", "should_stop", "utc_now_iso", diff --git a/super_turtle/subturtle/tests/test_subturtle_main.py b/super_turtle/subturtle/tests/test_subturtle_main.py index f758738c..cb9e8584 100644 --- a/super_turtle/subturtle/tests/test_subturtle_main.py +++ b/super_turtle/subturtle/tests/test_subturtle_main.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import json import os from pathlib import Path import subprocess @@ -40,7 +41,11 @@ def _assert_imports_succeed(tmp_path, pythonpath_root: Path, module_names: list[ def test_yolo_prompt_allows_rewriting_blocked_backlog_items() -> None: - prompt = subturtle_prompts.YOLO_PROMPT.format(state_file=".superturtle/subturtles/demo/CLAUDE.md") + prompt = subturtle_prompts.YOLO_PROMPT.format( + state_file=".superturtle/subturtles/demo/CLAUDE.md", + worker_name="demo", + result_file=".superturtle/state/results/demo.json", + ) assert "If it is blocked, too vague, or not feasible with the current repo/context, rewrite the backlog" in prompt assert "move `<- current` to the next actionable item" in prompt @@ -55,6 +60,45 @@ def test_slow_loop_prompts_allow_blocked_item_replanning() -> None: assert "Rewrite the backlog so the next iteration has a concrete unblocker" in prompts["reviewer"] +def test_yolo_prompt_format_with_all_variables() -> None: + state_file = ".superturtle/subturtles/worker-auth/CLAUDE.md" + worker_name = "worker-auth" + result_file = ".superturtle/state/results/worker-auth.json" + + prompt = subturtle_prompts.YOLO_PROMPT.format( + state_file=state_file, + worker_name=worker_name, + result_file=result_file, + ) + + assert worker_name in prompt + assert result_file in prompt + assert state_file in prompt + assert "Writing your result" in prompt + + +def test_build_prompts_reviewer_contains_worker_name_and_result_file() -> None: + state_file = ".superturtle/subturtles/demo/CLAUDE.md" + worker_name = "worker-demo" + result_file = ".superturtle/state/results/worker-demo.json" + + prompts = subturtle_prompts.build_prompts( + state_file, + worker_name=worker_name, + result_file=result_file, + ) + + assert worker_name in prompts["reviewer"] + assert result_file in prompts["reviewer"] + assert "Write result file" in prompts["reviewer"] + + +def test_result_file_path() -> None: + project_dir = Path("/project") + path = subturtle_statefile.result_file_path(project_dir, "worker-auth") + assert path == Path("/project/.superturtle/state/results/worker-auth.json") + + def test_main_dispatches_to_run_loop(monkeypatch, tmp_path) -> None: state_dir = tmp_path / ".superturtle/subturtles" / "worker-cli" state_dir.mkdir(parents=True) @@ -362,6 +406,65 @@ def test_record_completion_pending_writes_state_event_and_wakeup(tmp_path) -> No assert wakeups[0]["category"] == "notable" assert wakeups[0]["worker_name"] == "worker-2" + # Fallback result file should be written since no agent-written result exists. + result_path = subturtle_statefile.result_file_path(project_dir, "worker-2") + assert result_path.exists() + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result["status"] == "completed" + assert result["worker_name"] == "worker-2" + assert result["_generated_by"] == "infrastructure_fallback" + + +def test_record_completion_pending_does_not_overwrite_agent_result(tmp_path) -> None: + state_dir = tmp_path / ".superturtle/subturtles" / "worker-3" + project_dir = tmp_path + state_dir.mkdir(parents=True) + (state_dir / "CLAUDE.md").write_text("# Current task\n\nDone\n", encoding="utf-8") + + store = ConductorStateStore(project_dir / ".superturtle" / "state") + store.write_worker_state(store.make_worker_state( + worker_name="worker-3", lifecycle_state="running", updated_by="subturtle", + )) + + # Pre-write an agent result (not a fallback). + result_path = subturtle_statefile.result_file_path(project_dir, "worker-3") + result_path.parent.mkdir(parents=True, exist_ok=True) + agent_result = {"schema_version": 1, "worker_name": "worker-3", "status": "completed", + "summary": "Agent wrote this.", "artifacts": ["src/foo.ts"], + "key_decisions": [], "blockers": [], "questions_for_orchestrator": []} + result_path.write_text(json.dumps(agent_result), encoding="utf-8") + + subturtle_statefile.record_completion_pending(state_dir, "worker-3", project_dir) + + # Should NOT have been overwritten. + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result["summary"] == "Agent wrote this." + assert "_generated_by" not in result + + +def test_record_failure_pending_writes_failure_result(tmp_path) -> None: + state_dir = tmp_path / ".superturtle/subturtles" / "worker-fail" + project_dir = tmp_path + state_dir.mkdir(parents=True) + (state_dir / "CLAUDE.md").write_text("# Current task\n\nFailed task\n", encoding="utf-8") + + store = ConductorStateStore(project_dir / ".superturtle" / "state") + store.write_worker_state(store.make_worker_state( + worker_name="worker-fail", lifecycle_state="running", updated_by="subturtle", + )) + + subturtle_statefile.record_failure_pending( + state_dir, "worker-fail", project_dir, "yolo", "max consecutive failures reached" + ) + + result_path = subturtle_statefile.result_file_path(project_dir, "worker-fail") + assert result_path.exists() + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result["status"] == "failed" + assert result["worker_name"] == "worker-fail" + assert "max consecutive failures" in result["blockers"][0] + assert result["_generated_by"] == "infrastructure_fallback" + def test_record_checkpoint_updates_worker_state_and_event(monkeypatch, tmp_path) -> None: state_dir = tmp_path / ".superturtle/subturtles" / "worker-3"