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"