diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8acbb7d..0d162e2 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -172,8 +172,8 @@ {"id":"bd-b5x8","title":"[trd:trd-2026-004-vcs-backend-abstraction:task:TRD-013-TEST] Verify Conflict Resolver VcsBackend Migration","description":"TRD-013-TEST [verifies TRD-013] [depends: TRD-013]. File: src/orchestrator/__tests__/conflict-resolver-vcs.test.ts. ACs: AC-T-013-1..3. Est: 3h.","notes":"Merge conflict detected in branch foreman/bd-b5x8.\nConflicting files:\n (no file details available)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-27T14:24:39.515955Z","created_by":"ldangelo","updated_at":"2026-03-28T19:47:33.524225Z","closed_at":"2026-03-28T19:47:33.523759Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase:developer","phase:explorer","phase:finalize","phase:qa","phase:reviewer"],"dependencies":[{"issue_id":"bd-b5x8","depends_on_id":"bd-k4ho","type":"blocks","created_at":"2026-03-27T14:47:47.600926Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-b608","title":"[trd-005-test] Reviewer Findings Read Path Tests","description":"File: src/orchestrator/__tests__/agent-worker-mail.test.ts (extend)\\n\\nTest mail-first read with mock returning Review Findings. Test fallback to local variable when mail unavailable.\\n\\nVerifies: TRD-005\\nSatisfies: REQ-005, AC-005-1 through AC-005-3\\nEstimate: 1h","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-21T05:56:03.484102Z","created_by":"ldangelo","updated_at":"2026-03-21T06:13:10.124004Z","closed_at":"2026-03-21T06:13:10.123634Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-b608","depends_on_id":"bd-f5yy","type":"blocks","created_at":"2026-03-21T05:58:36.178253Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-b9g7","title":"[trd:trd-2026-007-epic-execution-mode] Implement TRD: Epic Execution Mode","description":"Extend pipeline-executor with outer task loop for epic mode. 30 tasks, 3 sprints. PRD-2026-007.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-03-30T13:37:43.088683Z","created_by":"ldangelo","updated_at":"2026-03-30T14:52:26.927271Z","closed_at":"2026-03-30T14:52:26.927049Z","close_reason":"TRD-2026-007 implementation complete: 28/30 tasks done, TRD-007 deferred","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-b9g7","depends_on_id":"bd-abem","type":"blocks","created_at":"2026-03-30T13:37:56.017260Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-b9g7","depends_on_id":"bd-ijpk","type":"blocks","created_at":"2026-03-30T13:37:55.816978Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-b9g7","depends_on_id":"bd-sxia","type":"blocks","created_at":"2026-03-30T13:37:55.616705Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} +{"id":"bd-barv","title":"[Sentinel] Test failures on main @ 00bfacce","description":"Automated sentinel detected 2 consecutive test failure(s) on branch `main`.\n\n**Commit:** 00bfaccec4ce6fcf0dd3fb486214f11f534d4e2b\n\n**Test output (truncated):**\n```\nTest command timed out after 600s\n\n> @oftheangels/foreman@0.1.0 test\n> vitest run\n\n\n\u001b[1m\u001b[46m RUN \u001b[49m\u001b[22m \u001b[36mv4.1.1 \u001b[39m\u001b[90m/Users/ldangelo/Development/Fortium/foreman\u001b[39m\n\n\n```","status":"open","priority":0,"issue_type":"bug","created_at":"2026-03-30T08:17:34.241111Z","created_by":"ldangelo","updated_at":"2026-03-30T09:37:12.540911Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["kind:sentinel","phase:explorer"]} {"id":"bd-baok","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-008-TEST] Unit tests for approval gate","description":"1h | [verifies TRD-008] [satisfies REQ-005] [depends: TRD-008]. Tests: backlog->ready, backlog->blocked with unresolved deps, non-backlog no-op, batch approve.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:48:24.724165Z","created_by":"ldangelo","updated_at":"2026-03-30T16:48:24.985559Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-baok","depends_on_id":"bd-0o68","type":"blocks","created_at":"2026-03-30T16:48:24.984897Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-baok","depends_on_id":"bd-wbmw","type":"blocks","created_at":"2026-03-30T16:48:24.851373Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} -{"id":"bd-barv","title":"[Sentinel] Test failures on main @ 00bfacce","description":"Automated sentinel detected 2 consecutive test failure(s) on branch `main`.\n\n**Commit:** 00bfaccec4ce6fcf0dd3fb486214f11f534d4e2b\n\n**Test output (truncated):**\n```\nTest command timed out after 600s\n\n> @oftheangels/foreman@0.1.0 test\n> vitest run\n\n\n\u001b[1m\u001b[46m RUN \u001b[49m\u001b[22m \u001b[36mv4.1.1 \u001b[39m\u001b[90m/Users/ldangelo/Development/Fortium/foreman\u001b[39m\n\n\n```","notes":"Post-merge tests failed (attempt 0/3). Will retry after the developer addresses the failures. \nFirst failure:\nCommand failed: git commit -m [Sentinel] Test failures on main @ 00bfacce (bd-barv)\n","status":"open","priority":0,"issue_type":"bug","created_at":"2026-03-30T08:17:34.241111Z","created_by":"ldangelo","updated_at":"2026-03-30T16:58:22.818429Z","close_reason":"Sentinel noise — flaky test failures, not real bugs","source_repo":".","compaction_level":0,"original_size":0,"labels":["kind:sentinel","phase:explorer","phase:finalize","phase:qa","phase:reviewer"]} {"id":"bd-bd70","title":"[trd:trd-2026-002-pi-agent-mail-rpc-migration] Implement TRD: Pi + Agent Mail + RPC Migration","description":"Migrate Foreman's agent runtime from Claude SDK query() + tmux/detached spawn to Pi RPC-controlled sessions with Agent Mail messaging. 72 tasks (36 impl + 36 test) across 4 sprints over 8 weeks. TRD: docs/TRD/TRD-2026-002-pi-agent-mail-rpc-migration.md","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-03-19T23:45:48.902549Z","created_by":"ldangelo","updated_at":"2026-03-20T03:18:30.035119Z","closed_at":"2026-03-20T03:18:30.034745Z","close_reason":"TRD implementation complete — all 72 tasks closed, 2382 tests passing","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-bd70","depends_on_id":"bd-2gwb","type":"blocks","created_at":"2026-03-19T23:46:26.597712Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-bd70","depends_on_id":"bd-9afk","type":"blocks","created_at":"2026-03-19T23:46:26.791126Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-bd70","depends_on_id":"bd-hq7y","type":"blocks","created_at":"2026-03-19T23:46:26.974462Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-bd70","depends_on_id":"bd-q2r8","type":"blocks","created_at":"2026-03-19T23:46:26.392744Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-bdjl","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-014-TEST] Integration tests for cross-project dashboard aggregation","description":"2h | [verifies TRD-014] [satisfies REQ-010, REQ-019] [depends: TRD-014]. Tests: multi-DB read-only, offline project, refresh trigger, benchmark 7x200 <2000ms.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:49:07.349839Z","created_by":"ldangelo","updated_at":"2026-03-30T16:49:07.607304Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-bdjl","depends_on_id":"bd-09q4","type":"blocks","created_at":"2026-03-30T16:49:07.470009Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-bdjl","depends_on_id":"bd-8poz","type":"blocks","created_at":"2026-03-30T16:49:07.606722Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-bece","title":"Dispatcher never fetches bead comments: agent instructions are missing all comment context","description":"br comments is never called during dispatch. Comments added to a bead (e.g. design notes, fix suggestions, reviewer feedback) are completely invisible to the worker agent. BrIssueDetail (returned by br show) also does not include comments — a separate br comments call is needed. Fix: after fetching full issue detail via br show, also call br comments and append any comments to the TASK.md context section. This is critical for the typical workflow where a human annotates a bead with additional context before it is dispatched.","notes":"Merge skipped: unresolved conflict markers in src/orchestrator/refinery.ts, src/orchestrator/__tests__/refinery-conflict-scan.test.ts, src/orchestrator/__tests__/merge-validator.test.ts, src/orchestrator/__tests__/conflict-resolver-t3.test.ts. PR creation also failed — manual intervention required.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-03-18T03:03:48.217449Z","created_by":"ldangelo","updated_at":"2026-03-23T20:11:50.888978Z","closed_at":"2026-03-23T20:11:50.888585Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0} @@ -435,7 +435,7 @@ {"id":"bd-mpk8","title":"Dispatcher creates duplicate runs for the same bead — race between dispatch cycles","description":"When foreman run dispatches a bead, the next dispatch cycle can dispatch it again before the first run transitions from pending to running. The activeRuns guard only checks runs already in the active list, but a just-created pending run may not be there yet. Fix: check for any non-reset/non-failed run for the seed in the DB, not just the passed-in activeRuns list.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-03-24T13:12:56.148761Z","created_by":"ldangelo","updated_at":"2026-03-24T13:49:58.296176Z","closed_at":"2026-03-24T13:49:58.295364Z","close_reason":"Completed via pipeline","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase:developer","phase:explorer","phase:finalize","phase:qa","phase:reviewer"]} {"id":"bd-mv0i","title":"[trd:trd-2026-002-pi-agent-mail-rpc-migration:task:TRD-003-TEST] foreman-tool-gate Tests","description":"TRD Reference: docs/TRD/TRD-2026-002-pi-agent-mail-rpc-migration.md#trd-003-test\\nVerifies Task: TRD-003\\nSatisfies: REQ-003, REQ-018\\nValidates PRD ACs: AC-003-1 through AC-003-6, AC-018-1, AC-018-2\\nTarget File: packages/foreman-pi-extensions/src/__tests__/tool-gate.test.ts\\nActions:\\n1. Test Explorer phase blocks Bash/Write/Edit\\n2. Test Explorer phase allows Read/Grep/Glob\\n3. Test Developer phase allows all developer tools\\n4. Test Bash blocklist matching includes matched pattern in reason\\n5. Test custom FOREMAN_BASH_BLOCKLIST override\\n6. Test coverage >= 80% for tool-gate.ts\\nDependencies: TRD-003\\nEst: 3h","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-19T23:47:59.776588Z","created_by":"ldangelo","updated_at":"2026-03-20T01:49:56.387618Z","closed_at":"2026-03-20T01:49:56.387251Z","close_reason":"Tests written during implementation. 2085 tests pass.","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-mv0i","depends_on_id":"bd-3sok","type":"blocks","created_at":"2026-03-19T23:49:28.795801Z","created_by":"ldangelo","metadata":"{}","thread_id":""}],"comments":[{"id":42,"issue_id":"bd-mv0i","author":"ldangelo","text":"Tests written during TRD-003: 19 tests in tool-gate.test.ts covering all allowlist, blocklist, path protection scenarios.","created_at":"2026-03-20T01:49:55Z"}]} {"id":"bd-mzee","title":"[trd-017-test] Bundled Default Files Tests","description":"File: src/lib/__tests__/bundled-defaults.test.ts (new)\\n\\nRead src/defaults/phases.json and validate it matches ROLE_CONFIGS structure. Read src/defaults/workflows.json and validate it has all four default workflows. Read each prompt file, render with renderTemplate, and compare to built-in function output.\\n\\nVerifies: TRD-017\\nSatisfies: REQ-014, AC-014-1 through AC-014-5\\nEstimate: 2h","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-21T05:57:56.920906Z","created_by":"ldangelo","updated_at":"2026-03-21T06:07:49.634516Z","closed_at":"2026-03-21T06:07:49.634093Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-mzee","depends_on_id":"bd-75cg","type":"blocks","created_at":"2026-03-21T05:59:01.194806Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-mzee","depends_on_id":"bd-iz13","type":"blocks","created_at":"2026-03-21T05:59:01.567603Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} -{"id":"bd-n0yf","title":"[Sentinel] Test failures on main @ 00bfacce","description":"Automated sentinel detected 2 consecutive test failure(s) on branch `main`.\n\n**Commit:** 00bfaccec4ce6fcf0dd3fb486214f11f534d4e2b\n\n**Test output (truncated):**\n```\nTest command timed out after 600s\n\n> @oftheangels/foreman@0.1.0 test\n> vitest run\n\n\n\u001b[1m\u001b[46m RUN \u001b[49m\u001b[22m \u001b[36mv4.1.1 \u001b[39m\u001b[90m/Users/ldangelo/Development/Fortium/foreman\u001b[39m\n\n\nSTDERR:\nCloning into '/private/var/folders/1t/ps3805314_s970f5b0xq81mm0000gn/T/foreman-git-integ-init-zmIC5f'...\nwarning: You appear to have cloned an empty repository.\ndone.\n\n```","notes":"Post-merge tests failed (attempt 1/3). Will retry after the developer addresses the failures. \nFirst failure:\n\n> @oftheangels/foreman@0.1.0 test\n> vitest run\n\n\n\u001b[1m\u001b[46m RUN \u001b[49m\u001b[22m \u001b[36mv4.1.1 \u001b[39m\u001b[90m/Users/ldangelo/Development/Fortium/foreman\u001b[39m\n\n\u001b[90mstdout\u001b[2m | scripts/__tests__/install-sh-local.test.ts\u001b[2m > \u001b[22m\u001b[2minstall.sh local integration tests (darwin-arm64)\n\u001b[22m\u001b[39m\n[local-test] Temp dir: /var/folders/1t/ps3805314_s970f5b0xq81mm0000gn/T/foreman-install-local-test-15v9t4\n[local-test] Platform: darwin-arm64\n\n\u001b[90mstdout\u001b[2m | scripts/__tests__/install-sh-local.test.ts\u001b[2m > \u001b[22m\u001b","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-03-30T08:17:35.716010Z","created_by":"ldangelo","updated_at":"2026-03-30T16:51:29.730138Z","closed_at":"2026-03-30T16:51:29.729700Z","close_reason":"Sentinel noise — flaky test failures, not real bugs","source_repo":".","compaction_level":0,"original_size":0,"labels":["kind:sentinel","phase:developer","phase:explorer","phase:finalize","phase:qa","phase:reviewer"]} +{"id":"bd-n0yf","title":"[Sentinel] Test failures on main @ 00bfacce","description":"Automated sentinel detected 2 consecutive test failure(s) on branch `main`.\n\n**Commit:** 00bfaccec4ce6fcf0dd3fb486214f11f534d4e2b\n\n**Test output (truncated):**\n```\nTest command timed out after 600s\n\n> @oftheangels/foreman@0.1.0 test\n> vitest run\n\n\n\u001b[1m\u001b[46m RUN \u001b[49m\u001b[22m \u001b[36mv4.1.1 \u001b[39m\u001b[90m/Users/ldangelo/Development/Fortium/foreman\u001b[39m\n\n\nSTDERR:\nCloning into '/private/var/folders/1t/ps3805314_s970f5b0xq81mm0000gn/T/foreman-git-integ-init-zmIC5f'...\nwarning: You appear to have cloned an empty repository.\ndone.\n\n```","status":"open","priority":0,"issue_type":"bug","created_at":"2026-03-30T08:17:35.716010Z","created_by":"ldangelo","updated_at":"2026-03-30T09:37:12.892597Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["kind:sentinel","phase:developer","phase:explorer","phase:finalize","phase:qa","phase:reviewer"]} {"id":"bd-n1oy","title":"[trd:trd-2026-007-epic-execution-modeask:TRD-008] Single finalize phase at epic completion","description":"2h | [satisfies REQ-009] Finalize runs once: rebase, test, push. FAIL verdict loops to developer. Squash merge on dev.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-30T13:38:50.504734Z","created_by":"ldangelo","updated_at":"2026-03-30T14:45:21.470241Z","closed_at":"2026-03-30T14:45:21.470047Z","close_reason":"Already implemented in TRD-005 executeEpicPipeline — finalPhases run once after all tasks complete","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-n1oy","depends_on_id":"bd-2twl","type":"blocks","created_at":"2026-03-30T13:38:50.745749Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-n2c6","title":"Worktrees missing node_modules: npm install never runs after createWorktree()","description":"When foreman creates a git worktree via createWorktree(), the new worktree directory does NOT get node_modules populated. git worktree add shares the .git dir but does NOT symlink or copy node_modules. Worker agents then fail when they try to run tsx, npx tsc, vitest, or any node binary because node_modules/.bin/* does not exist. This was observed when foreman doctor tests failed with ENOENT on node_modules/.bin/tsx — fixed only by manually running npm install. Fix: dispatcher or createWorktree() should run 'npm install --prefer-offline' (or create a symlink to the main repo node_modules) immediately after the worktree is created, before spawning the agent.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-03-18T03:00:17.884616Z","created_by":"ldangelo","updated_at":"2026-03-20T04:42:31.917200Z","closed_at":"2026-03-20T04:42:31.915525Z","close_reason":"Completed via pipeline","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-n2c6","depends_on_id":"bd-bece","type":"blocks","created_at":"2026-03-18T03:04:56.745739Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-n2c6","depends_on_id":"bd-cbet","type":"blocks","created_at":"2026-03-18T03:04:56.582831Z","created_by":"ldangelo","metadata":"{}","thread_id":""}],"comments":[{"id":21,"issue_id":"bd-n2c6","author":"ldangelo","text":"Likely fix: symlink node_modules from the main repo into each worktree immediately after createWorktree() returns. Since all worktrees share the same package.json, a symlink is correct and fast — no reinstall needed. Alternative is 'npm install --prefer-offline' but that's slower and redundant. The symlink approach: ln -s /node_modules /node_modules","created_at":"2026-03-18T03:01:16Z"}]} {"id":"bd-n43o","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-013-TEST] Unit tests for pipeline phase visibility","description":"1h | [verifies TRD-013] [satisfies REQ-012] [depends: TRD-013]. Tests: phase transition updates status, null taskId no-op, all phase names valid statuses.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:48:49.653348Z","created_by":"ldangelo","updated_at":"2026-03-30T16:48:49.939027Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-n43o","depends_on_id":"bd-k1ix","type":"blocks","created_at":"2026-03-30T16:48:49.938556Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-n43o","depends_on_id":"bd-t3ke","type":"blocks","created_at":"2026-03-30T16:48:49.791765Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} @@ -628,9 +628,9 @@ {"id":"bd-xrou","title":"[trd:trd-2026-005-mid-pipeline-rebase:task:trd-010] Diff computation after clean rebase","description":"After clean rebase, compute upstream diff via vcs.diff(worktreePath, priorHead, target). Cap at 100 files, set truncated:true if >100. Skip if upstreamCommits===0. [satisfies REQ-005] Est: 2h","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-29T15:57:17.823167Z","created_by":"ldangelo","updated_at":"2026-03-29T16:21:00.464743Z","closed_at":"2026-03-29T16:21:00.464618Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xrou","depends_on_id":"bd-2x8o","type":"blocks","created_at":"2026-03-29T15:58:19.810616Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-xyir","title":"[trd-008] Backward Compatibility Validation","description":"File: src/orchestrator/agent-worker.ts\\n\\nAudit all new fetchLatestPhaseMessage() call sites to verify they always have a disk or local-variable fallback. Verify that agentMailClient === null path produces zero Agent Mail log messages. Ensure no new imports or code paths can throw when Agent Mail is absent. Add integration-level test scenarios for full pipeline with agentMailClient = null.\\n\\nSatisfies: REQ-006, REQ-017, AC-006-1 through AC-006-3, AC-017-1, AC-017-2\\nEstimate: 2h","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-21T05:56:20.604530Z","created_by":"ldangelo","updated_at":"2026-03-21T06:13:14.391226Z","closed_at":"2026-03-21T06:13:14.390872Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xyir","depends_on_id":"bd-cbwg","type":"blocks","created_at":"2026-03-21T05:58:37.974266Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-xyir","depends_on_id":"bd-dxje","type":"blocks","created_at":"2026-03-21T05:58:38.709021Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-xyir","depends_on_id":"bd-f5yy","type":"blocks","created_at":"2026-03-21T05:58:38.349557Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y3l4","title":"[trd:trd-2026-005-mid-pipeline-rebase:task:trd-007] RebaseHook — conflict path","description":"Extend RebaseHook: hasConflicts=true -> getConflictingFiles -> store.updateRunStatus(rebase_conflict) -> emit rebase:conflict -> throw RebaseConflictError to suspend pipeline. [satisfies REQ-002] Est: 2h","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-29T15:57:06.815871Z","created_by":"ldangelo","updated_at":"2026-03-29T16:21:00.453804Z","closed_at":"2026-03-29T16:21:00.453645Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y3l4","depends_on_id":"bd-2x8o","type":"blocks","created_at":"2026-03-29T15:58:11.630299Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} -{"id":"bd-y4dl","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-005] Implement ready() method on NativeTaskStore","description":"1h | src/lib/task-store.ts. [satisfies REQ-017, REQ-020] [depends: TRD-004]. SELECT status='ready' AND run_id IS NULL ORDER BY priority ASC, created_at ASC.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:48:05.736843Z","created_by":"ldangelo","updated_at":"2026-03-30T16:48:05.983632Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y4dl","depends_on_id":"bd-sgin","type":"blocks","created_at":"2026-03-30T16:48:05.859648Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-y4dl","depends_on_id":"bd-zlt5","type":"blocks","created_at":"2026-03-30T16:48:05.983235Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y572","title":"[trd:trd-2026-007-epic-execution-modeask:TRD-012] Epic progress display in foreman status","description":"2h | [satisfies REQ-012, REQ-013] Show N/M tasks, current task, elapsed, cost breakdown.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-30T13:38:53.171250Z","created_by":"ldangelo","updated_at":"2026-03-30T14:49:54.658097Z","closed_at":"2026-03-30T14:49:54.657880Z","close_reason":"Completed — epicTaskCount/epicTasksCompleted/epicCurrentTaskId/epicCostByTask in RunProgress","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y572","depends_on_id":"bd-2twl","type":"blocks","created_at":"2026-03-30T13:38:53.398926Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y5d6","title":"[trd:trd-2026-007-epic-execution-modeask:TRD-005-TEST] Integration tests for epic task loop","description":"3h | [verifies TRD-005] [satisfies REQ-004, REQ-005, REQ-007] Test 3 tasks in order, QA retry, max retries, single-task unchanged, finalize once, no empty commits.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-30T13:38:48.672221Z","created_by":"ldangelo","updated_at":"2026-03-30T14:43:59.397546Z","closed_at":"2026-03-30T14:43:59.397326Z","close_reason":"Completed — 8 integration tests for epic task loop","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y5d6","depends_on_id":"bd-2twl","type":"blocks","created_at":"2026-03-30T13:38:48.880840Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} +{"id":"bd-y4dl","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-005] Implement ready() method on NativeTaskStore","description":"1h | src/lib/task-store.ts. [satisfies REQ-017, REQ-020] [depends: TRD-004]. SELECT status='ready' AND run_id IS NULL ORDER BY priority ASC, created_at ASC.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:48:05.736843Z","created_by":"ldangelo","updated_at":"2026-03-30T16:48:05.983632Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y4dl","depends_on_id":"bd-sgin","type":"blocks","created_at":"2026-03-30T16:48:05.859648Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-y4dl","depends_on_id":"bd-zlt5","type":"blocks","created_at":"2026-03-30T16:48:05.983235Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y73d","title":"[trd:trd-2026-006-multi-project-native-task-management:task:TRD-009] Add --project flag resolution to NativeTaskStore operations","description":"2h | src/cli/commands/task.ts. [satisfies REQ-016] [depends: TRD-001, TRD-003]. ProjectRegistry.resolve(name), path override, unknown name error.","status":"open","priority":0,"issue_type":"task","created_at":"2026-03-30T16:48:25.111873Z","created_by":"ldangelo","updated_at":"2026-03-30T16:48:25.507331Z","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y73d","depends_on_id":"bd-3wbj","type":"blocks","created_at":"2026-03-30T16:48:25.506793Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-y73d","depends_on_id":"bd-pwlk","type":"blocks","created_at":"2026-03-30T16:48:25.370431Z","created_by":"ldangelo","metadata":"{}","thread_id":""},{"issue_id":"bd-y73d","depends_on_id":"bd-wbmw","type":"blocks","created_at":"2026-03-30T16:48:25.239493Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y7ed","title":"[trd-012-test] Phase Config Schema Validation Tests","description":"File: src/lib/__tests__/phase-config-loader.test.ts (extend)\\n\\nTest valid phase config passes validation. Test extra unrecognized fields are tolerated. Test wrong type for maxBudgetUsd throws with descriptive message. Test missing required field throws identifying the field name.\\n\\nVerifies: TRD-012\\nSatisfies: REQ-010, AC-010-1 through AC-010-4\\nEstimate: 1h","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-21T05:56:50.704678Z","created_by":"ldangelo","updated_at":"2026-03-21T06:07:09.674590Z","closed_at":"2026-03-21T06:07:09.674212Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-y7ed","depends_on_id":"bd-qcks","type":"blocks","created_at":"2026-03-21T05:58:51.842868Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} {"id":"bd-y8iz","title":"[trd:trd-2026-004-vcs-backend-abstraction:task:TRD-018] Implement JujutsuBackend -- Workspace Management","description":"TRD-018 [satisfies REQ-009] [depends: TRD-017]. File: src/lib/vcs/jujutsu-backend.ts. jj workspace add, forget, list + bookmark create. Validates: AC-009-1..5. Est: 5h.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-27T14:24:41.367687Z","created_by":"ldangelo","updated_at":"2026-03-29T12:25:32.290642Z","closed_at":"2026-03-29T12:25:32.290533Z","close_reason":"Implementation verified — all jujutsu-backend.test.ts tests pass (63/63)","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase:developer","phase:explorer","phase:finalize","phase:qa","phase:reviewer"],"dependencies":[{"issue_id":"bd-y8iz","depends_on_id":"bd-gplk","type":"blocks","created_at":"2026-03-27T14:47:54.574833Z","created_by":"ldangelo","metadata":"{}","thread_id":""}]} diff --git a/.mulch/expertise/epic-execution.jsonl b/.mulch/expertise/epic-execution.jsonl new file mode 100644 index 0000000..3de1279 --- /dev/null +++ b/.mulch/expertise/epic-execution.jsonl @@ -0,0 +1 @@ +{"type":"reference","name":"epic-resume-detection","description":"TRD-009: parseCompletedTaskIds() in pipeline-executor.ts parses git log --oneline matching trailing (beadId) pattern. detectCompletedTasks() wraps with error handling. Resume logic filters epicTasks before the task loop.","classification":"tactical","recorded_at":"2026-03-30T14:49:04.542Z","id":"mx-6b2456"} diff --git a/.mulch/expertise/pipeline-executor.jsonl b/.mulch/expertise/pipeline-executor.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.mulch/mulch.config.yaml b/.mulch/mulch.config.yaml index 9cae480..6eb0219 100644 --- a/.mulch/mulch.config.yaml +++ b/.mulch/mulch.config.yaml @@ -1,5 +1,7 @@ version: '1' -domains: [] +domains: + - epic-execution + - pipeline-executor governance: max_entries: 100 warn_entries: 150 diff --git a/docs/TRD/TRD-2026-007-epic-execution-mode.md b/docs/TRD/TRD-2026-007-epic-execution-mode.md index 0caba67..274e1cb 100644 --- a/docs/TRD/TRD-2026-007-epic-execution-mode.md +++ b/docs/TRD/TRD-2026-007-epic-execution-mode.md @@ -268,36 +268,36 @@ Dispatcher ## Sprint Planning ### Sprint 1: Core Epic Runner (~20h) -- [ ] **TRD-001** (2h): Epic workflow YAML fields -- [ ] **TRD-001-TEST** (1h): Tests for YAML parsing -- [ ] **TRD-002** (1h): Bundled epic.yaml -- [ ] **TRD-003** (2h): Task ordering module -- [ ] **TRD-003-TEST** (1h): Tests for task ordering -- [ ] **TRD-004** (1h): Epic fields in PipelineContext -- [ ] **TRD-005** (4h): Outer task loop in executePipeline [CRITICAL PATH] -- [ ] **TRD-005-TEST** (3h): Integration tests for task loop -- [ ] **TRD-006** (3h): Dispatcher epic detection -- [ ] **TRD-006-TEST** (2h): Tests for epic dispatch +- [x] **TRD-001** (2h): Epic workflow YAML fields +- [x] **TRD-001-TEST** (1h): Tests for YAML parsing +- [x] **TRD-002** (1h): Bundled epic.yaml +- [x] **TRD-003** (2h): Task ordering module +- [x] **TRD-003-TEST** (1h): Tests for task ordering +- [x] **TRD-004** (1h): Epic fields in PipelineContext +- [x] **TRD-005** (4h): Outer task loop in executePipeline [CRITICAL PATH] +- [x] **TRD-005-TEST** (3h): Integration tests for task loop +- [x] **TRD-006** (3h): Dispatcher epic detection +- [x] **TRD-006-TEST** (2h): Tests for epic dispatch ### Sprint 2: Session, Finalize, Resume (~13h) - [ ] **TRD-007** (3h): Session reuse [depends: TRD-005] - [ ] **TRD-007-TEST** (2h): Tests for session reuse -- [ ] **TRD-008** (2h): Single finalize [depends: TRD-005] -- [ ] **TRD-008-TEST** (1h): Tests for finalize -- [ ] **TRD-009** (3h): Resume from last task [depends: TRD-005] -- [ ] **TRD-009-TEST** (2h): Tests for resume +- [x] **TRD-008** (2h): Single finalize [depends: TRD-005] +- [x] **TRD-008-TEST** (1h): Tests for finalize +- [x] **TRD-009** (3h): Resume from last task [depends: TRD-005] +- [x] **TRD-009-TEST** (2h): Tests for resume ### Sprint 3: Observability and Polish (~11h) -- [ ] **TRD-010** (1h): Bug bead creation [depends: TRD-005] -- [ ] **TRD-010-TEST** (1h): Tests for bug beads -- [ ] **TRD-011** (1h): Per-task bead status [depends: TRD-005] -- [ ] **TRD-011-TEST** (1h): Tests for bead status -- [ ] **TRD-012** (2h): Epic progress display [depends: TRD-005] -- [ ] **TRD-012-TEST** (1h): Tests for status display -- [ ] **TRD-013** (1h): onError for epics [depends: TRD-005] -- [ ] **TRD-013-TEST** (1h): Tests for onError -- [ ] **TRD-014** (1h): Workflow override [depends: TRD-001] -- [ ] **TRD-015** (1h): Task timeout [depends: TRD-005] +- [x] **TRD-010** (1h): Bug bead creation [depends: TRD-005] +- [x] **TRD-010-TEST** (1h): Tests for bug beads +- [x] **TRD-011** (1h): Per-task bead status [depends: TRD-005] +- [x] **TRD-011-TEST** (1h): Tests for bead status +- [x] **TRD-012** (2h): Epic progress display [depends: TRD-005] +- [x] **TRD-012-TEST** (1h): Tests for status display +- [x] **TRD-013** (1h): onError for epics [depends: TRD-005] +- [x] **TRD-013-TEST** (1h): Tests for onError +- [x] **TRD-014** (1h): Workflow override [depends: TRD-001] +- [x] **TRD-015** (1h): Task timeout [depends: TRD-005] **Total: ~44h estimated across 30 tasks (15 implementation + 15 test)** diff --git a/src/defaults/workflows/epic.yaml b/src/defaults/workflows/epic.yaml new file mode 100644 index 0000000..682cafb --- /dev/null +++ b/src/defaults/workflows/epic.yaml @@ -0,0 +1,83 @@ +# Epic workflow: Developer ⇄ QA per task, then Finalize once at the end. +# +# Used when a bead of type "epic" is dispatched. The pipeline executor +# iterates child tasks in dependency order, running taskPhases for each. +# After all tasks pass, finalPhases execute once to rebase, test, and push. +# +# Models map keys: "default" (required), "P0"–"P4" (optional priority overrides). +# Priority P0 = critical, P4 = backlog. Shorthands: haiku, sonnet, opus. +name: epic +onError: stop +# Per-task timeout in seconds. If a task's phases exceed this, the task fails. +# Default: 300 (5 minutes). Set to 0 to disable. +taskTimeout: 300 + +setup: + - command: npm install --prefer-offline --no-audit + description: Install Node.js dependencies + failFatal: true +setupCache: + key: package-lock.json + path: node_modules + +# Per-task phases: run for each child task in the epic +taskPhases: + - developer + - qa + +# Final phases: run once after all tasks complete +finalPhases: + - finalize + +phases: + - name: developer + prompt: developer.md + models: + default: sonnet + P0: opus + maxTurns: 80 + artifact: DEVELOPER_REPORT.md + mail: + onStart: true + onComplete: true + files: + reserve: true + leaseSecs: 600 + + - name: qa + prompt: qa.md + models: + default: sonnet + P0: opus + maxTurns: 30 + artifact: QA_REPORT.md + verdict: true + retryWith: developer + retryOnFail: 2 + mail: + onStart: true + onComplete: true + onFail: developer + + - name: finalize + prompt: finalize.md + models: + default: haiku + maxTurns: 30 + artifact: FINALIZE_VALIDATION.md + verdict: true + retryWith: developer + retryOnFail: 1 + mail: + onStart: true + onComplete: true + onFail: developer + +onFailure: + name: troubleshooter + prompt: troubleshooter.md + models: + default: sonnet + P0: opus + maxTurns: 20 + artifact: TROUBLESHOOT_REPORT.md diff --git a/src/lib/__tests__/beads-rust-deprecation.test.ts b/src/lib/__tests__/beads-rust-deprecation.test.ts index 0654099..cc19ef9 100644 --- a/src/lib/__tests__/beads-rust-deprecation.test.ts +++ b/src/lib/__tests__/beads-rust-deprecation.test.ts @@ -191,6 +191,11 @@ const BEADS_RUST_KNOWN_VIOLATIONS: Record = { // Needs: change all parameter types from BeadsRustClient to ITaskClient. "orchestrator/sling-executor.ts": "TRD-014: function parameter types → ITaskClient", + + // Epic task ordering imports BeadsRustClient for bead detail queries. + // Needs: change parameter type from BeadsRustClient to ITaskClient. + "orchestrator/task-ordering.ts": + "TRD-2026-007: parameter type → ITaskClient", }; /** Test files (in __tests__/, or *.test.ts / *.spec.ts) are always exempt. */ diff --git a/src/lib/__tests__/workflow-loader.test.ts b/src/lib/__tests__/workflow-loader.test.ts index 4ace034..be40d06 100644 --- a/src/lib/__tests__/workflow-loader.test.ts +++ b/src/lib/__tests__/workflow-loader.test.ts @@ -405,6 +405,10 @@ describe("resolveWorkflowName", () => { expect(resolveWorkflowName("smoke")).toBe("smoke"); }); + it("returns 'epic' for epic bead type", () => { + expect(resolveWorkflowName("epic")).toBe("epic"); + }); + it("returns 'default' for feature bead type", () => { expect(resolveWorkflowName("feature")).toBe("default"); }); @@ -652,3 +656,90 @@ describe("validateWorkflowConfig — vcs block", () => { ).toThrow(/vcs.backend must be/); }); }); + +// ── validateWorkflowConfig — epic mode (taskPhases, finalPhases) ──────────── + +describe("validateWorkflowConfig — epic mode", () => { + const epicConfig = { + name: "epic", + phases: [ + { name: "developer", prompt: "developer.md" }, + { name: "qa", prompt: "qa.md", verdict: true, retryWith: "developer", retryOnFail: 2 }, + { name: "finalize", prompt: "finalize.md" }, + ], + }; + + it("parses taskPhases and finalPhases from YAML", () => { + const raw = { + ...epicConfig, + taskPhases: ["developer", "qa"], + finalPhases: ["finalize"], + }; + const config = validateWorkflowConfig(raw, "epic"); + expect(config.taskPhases).toEqual(["developer", "qa"]); + expect(config.finalPhases).toEqual(["finalize"]); + }); + + it("leaves taskPhases and finalPhases undefined when absent (single-task mode)", () => { + const config = validateWorkflowConfig(epicConfig, "default"); + expect(config.taskPhases).toBeUndefined(); + expect(config.finalPhases).toBeUndefined(); + }); + + it("throws on non-array taskPhases", () => { + const raw = { ...epicConfig, taskPhases: "developer" }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /taskPhases.*must be an array/, + ); + }); + + it("throws on non-array finalPhases", () => { + const raw = { ...epicConfig, finalPhases: "finalize" }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /finalPhases.*must be an array/, + ); + }); + + it("throws when taskPhases references a phase not in phases array", () => { + const raw = { ...epicConfig, taskPhases: ["developer", "explorer"] }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /references phase 'explorer' which is not defined/, + ); + }); + + it("throws when finalPhases references a phase not in phases array", () => { + const raw = { ...epicConfig, finalPhases: ["nonexistent"] }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /references phase 'nonexistent' which is not defined/, + ); + }); + + it("throws on non-string entry in taskPhases", () => { + const raw = { ...epicConfig, taskPhases: ["developer", 42] }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /taskPhases\[1\] must be a non-empty string/, + ); + }); + + it("throws on empty string entry in taskPhases", () => { + const raw = { ...epicConfig, taskPhases: ["developer", ""] }; + expect(() => validateWorkflowConfig(raw, "epic")).toThrow( + /taskPhases\[1\] must be a non-empty string/, + ); + }); + + it("bundled epic.yaml loads with taskPhases and finalPhases", () => { + const tmpDir2 = tmpdir() + `/wl-epic-test-${Date.now()}`; + mkdirSync(tmpDir2, { recursive: true }); + const config = loadWorkflowConfig("epic", tmpDir2); + rmSync(tmpDir2, { recursive: true, force: true }); + expect(config.name).toBe("epic"); + expect(config.taskPhases).toEqual(["developer", "qa"]); + expect(config.finalPhases).toEqual(["finalize"]); + expect(config.phases.length).toBeGreaterThanOrEqual(3); + }); + + it("includes 'epic' in BUNDLED_WORKFLOW_NAMES", () => { + expect(BUNDLED_WORKFLOW_NAMES).toContain("epic"); + }); +}); diff --git a/src/lib/store.ts b/src/lib/store.ts index 9fe1e1d..40730eb 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -113,6 +113,14 @@ export interface RunProgress { currentPhase?: string; // Pipeline phase: "explorer" | "developer" | "qa" | "reviewer" | "finalize" costByPhase?: Record; // e.g. { explorer: 0.10, developer: 0.50 } agentByPhase?: Record; // e.g. { explorer: "claude-haiku-4-5", developer: "claude-sonnet-4-6" } + /** Epic mode: total number of child tasks. */ + epicTaskCount?: number; + /** Epic mode: number of tasks completed so far. */ + epicTasksCompleted?: number; + /** Epic mode: seed ID of the currently executing task. */ + epicCurrentTaskId?: string; + /** Epic mode: per-task cost breakdown. */ + epicCostByTask?: Record; } export interface Metrics { diff --git a/src/lib/workflow-loader.ts b/src/lib/workflow-loader.ts index 7f8ac1f..7e6997e 100644 --- a/src/lib/workflow-loader.ts +++ b/src/lib/workflow-loader.ts @@ -228,6 +228,31 @@ export interface WorkflowConfig { * @default "continue" */ onError?: OnErrorStrategy; + /** + * Epic mode: ordered list of phase names to execute per-task. + * When present, the pipeline executor runs these phases for each child task + * instead of using the top-level `phases` array. + * + * Example: `taskPhases: [developer, qa]` — each task runs developer→QA with retry. + * When absent (undefined), the pipeline runs in single-task mode using `phases`. + */ + taskPhases?: string[]; + /** + * Epic mode: ordered list of phase names to execute once after all tasks complete. + * Only used when `taskPhases` is also set (epic mode). + * + * Example: `finalPhases: [finalize]` — run finalize once after all tasks pass. + * When absent in epic mode, defaults to no final phases. + */ + finalPhases?: string[]; + /** + * Epic mode: maximum seconds allowed per task's phase execution. + * When a task's developer phase exceeds this timeout, the phase is terminated + * and the task is marked failed. Only used when `taskPhases` is set. + * + * @example `taskTimeout: 300` — 5 minute timeout per task + */ + taskTimeout?: number; } // ── Constants ───────────────────────────────────────────────────────────────── @@ -241,7 +266,7 @@ const BUNDLED_WORKFLOWS_DIR = join( ); /** Known workflow names with bundled defaults. */ -export const BUNDLED_WORKFLOW_NAMES: ReadonlyArray = ["default", "smoke"]; +export const BUNDLED_WORKFLOW_NAMES: ReadonlyArray = ["default", "smoke", "epic"]; // ── Validation ──────────────────────────────────────────────────────────────── @@ -432,6 +457,61 @@ export function validateWorkflowConfig(raw: unknown, workflowName: string): Work config.onFailure = onFailure; } + // ── Parse optional epic mode fields (taskPhases, finalPhases) ────────── + if (raw["taskPhases"] !== undefined) { + if (!Array.isArray(raw["taskPhases"])) { + throw new WorkflowConfigError(workflowName, "'taskPhases' must be an array of phase names"); + } + const taskPhases: string[] = []; + for (let j = 0; j < raw["taskPhases"].length; j++) { + const pName = raw["taskPhases"][j]; + if (typeof pName !== "string" || !pName) { + throw new WorkflowConfigError(workflowName, `taskPhases[${j}] must be a non-empty string`); + } + // Validate that referenced phase exists in the phases array + if (!phases.some((p) => p.name === pName)) { + throw new WorkflowConfigError( + workflowName, + `taskPhases[${j}] references phase '${pName}' which is not defined in phases`, + ); + } + taskPhases.push(pName); + } + if (taskPhases.length > 0) { + config.taskPhases = taskPhases; + } + } + if (raw["finalPhases"] !== undefined) { + if (!Array.isArray(raw["finalPhases"])) { + throw new WorkflowConfigError(workflowName, "'finalPhases' must be an array of phase names"); + } + const finalPhases: string[] = []; + for (let j = 0; j < raw["finalPhases"].length; j++) { + const pName = raw["finalPhases"][j]; + if (typeof pName !== "string" || !pName) { + throw new WorkflowConfigError(workflowName, `finalPhases[${j}] must be a non-empty string`); + } + if (!phases.some((p) => p.name === pName)) { + throw new WorkflowConfigError( + workflowName, + `finalPhases[${j}] references phase '${pName}' which is not defined in phases`, + ); + } + finalPhases.push(pName); + } + if (finalPhases.length > 0) { + config.finalPhases = finalPhases; + } + } + + // ── Parse optional taskTimeout ───────────────────────────────────────── + if (raw["taskTimeout"] !== undefined) { + if (typeof raw["taskTimeout"] !== "number" || raw["taskTimeout"] <= 0) { + throw new WorkflowConfigError(workflowName, "taskTimeout must be a positive number (seconds)"); + } + config.taskTimeout = raw["taskTimeout"]; + } + // ── Parse optional onError strategy ───────────────────────────────────── if (raw["onError"] !== undefined) { const onError = raw["onError"]; @@ -584,7 +664,9 @@ export function resolveWorkflowName(seedType: string, labels?: string[]): string } } } - return seedType === "smoke" ? "smoke" : "default"; + if (seedType === "smoke") return "smoke"; + if (seedType === "epic") return "epic"; + return "default"; } // ── Compatibility exports ───────────────────────────────────────────────────── diff --git a/src/orchestrator/__tests__/dispatcher-epic.test.ts b/src/orchestrator/__tests__/dispatcher-epic.test.ts new file mode 100644 index 0000000..3b5e214 --- /dev/null +++ b/src/orchestrator/__tests__/dispatcher-epic.test.ts @@ -0,0 +1,356 @@ +/** + * dispatcher-epic.test.ts — Tests for TRD-006: epic bead dispatch logic. + * + * Verifies: + * 1. Epic bead with children dispatches through epic path (epicTasks populated) + * 2. Task bead dispatches through standard path (no epicTasks) + * 3. Epic bead with 0 children auto-closes + * 4. Epic counts as 1 agent slot regardless of child task count + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Dispatcher } from "../dispatcher.js"; +import type { ITaskClient, Issue } from "../../lib/task-client.js"; +import type { ForemanStore } from "../../lib/store.js"; +import { VcsBackendFactory } from "../../lib/vcs/index.js"; +import type { EpicTask } from "../pipeline-executor.js"; + +// ── Module Mocks ───────────────────────────────────────────────────────────── + +vi.mock("../../lib/vcs/index.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + VcsBackendFactory: { + create: vi.fn().mockResolvedValue({ + name: "git", + createWorkspace: vi.fn().mockResolvedValue({ + workspacePath: "/tmp/worktrees/test", + branchName: "foreman/test", + }), + }), + }, + }; +}); + +vi.mock("../../lib/vcs/git-backend.js", () => ({ + GitBackend: class { + async getCurrentBranch(): Promise { return "main"; } + async detectDefaultBranch(): Promise { return "main"; } + async branchExists(): Promise { return false; } + async createWorkspace(_repoPath: string, seedId: string): Promise<{ workspacePath: string; branchName: string }> { + return { workspacePath: `/tmp/worktrees/${seedId}`, branchName: `foreman/${seedId}` }; + } + }, +})); + +vi.mock("../../lib/git.js", () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), + runSetupWithCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../lib/workflow-loader.js", () => ({ + loadWorkflowConfig: vi.fn().mockReturnValue({ + name: "default", + phases: [], + }), + resolveWorkflowName: vi.fn((type: string) => { + if (type === "epic") return "epic"; + return "default"; + }), +})); + +vi.mock("../../lib/workflow-config-loader.js", () => ({ + resolveWorkflowType: vi.fn((type: string) => type), +})); + +vi.mock("../../lib/project-config.js", () => ({ + loadProjectConfig: vi.fn().mockReturnValue(null), + resolveVcsConfig: vi.fn().mockReturnValue({ backend: "git" }), +})); + +vi.mock("../templates.js", () => ({ + workerAgentMd: vi.fn().mockReturnValue("# TASK.md content"), +})); + +vi.mock("../pi-rpc-spawn-strategy.js", () => ({ + isPiAvailable: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../lib/beads-rust.js", () => ({ + BeadsRustClient: class { + async show(_id: string): Promise { throw new Error("not found"); } + }, +})); + +// Mock task-ordering — returns 3 ordered tasks by default +vi.mock("../task-ordering.js", () => ({ + getTaskOrder: vi.fn().mockResolvedValue([ + { seedId: "child-1", seedTitle: "Child Task 1" }, + { seedId: "child-2", seedTitle: "Child Task 2" }, + { seedId: "child-3", seedTitle: "Child Task 3" }, + ] as EpicTask[]), +})); + +// Mock fs/promises to prevent actual file system writes +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + open: vi.fn().mockResolvedValue({ fd: 1, close: vi.fn().mockResolvedValue(undefined) }), + readdir: vi.fn().mockResolvedValue([]), + unlink: vi.fn().mockResolvedValue(undefined), + }; +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeIssue(id: string, type: string, priority = "P2"): Issue { + return { + id, + title: `${type} ${id}`, + status: "open", + priority, + type, + assignee: null, + parent: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; +} + +function makeStore(): ForemanStore { + return { + getActiveRuns: vi.fn().mockReturnValue([]), + getRunsByStatus: vi.fn().mockReturnValue([]), + getRunsByStatuses: vi.fn().mockReturnValue([]), + getRunsByStatusesSince: vi.fn().mockReturnValue([]), + getRunsForSeed: vi.fn().mockReturnValue([]), + getProjectByPath: vi.fn().mockReturnValue({ id: "proj-1" }), + hasNativeTasks: vi.fn().mockReturnValue(false), + getReadyTasks: vi.fn().mockReturnValue([]), + hasActiveOrPendingRun: vi.fn().mockReturnValue(false), + createRun: vi.fn().mockReturnValue({ id: "run-1" }), + updateRun: vi.fn(), + logEvent: vi.fn(), + sendMessage: vi.fn(), + getPendingBeadWrites: vi.fn().mockReturnValue([]), + } as unknown as ForemanStore; +} + +function makeSeedsClient(overrides: Partial = {}): ITaskClient { + return { + ready: vi.fn().mockResolvedValue([]), + show: vi.fn().mockResolvedValue({ status: "open" }), + update: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + ...overrides, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("Dispatcher — Epic Bead Detection (TRD-006)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("epic bead with children dispatches via epic path with epicTasks populated", async () => { + const epicIssue = makeIssue("epic-1", "epic"); + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([epicIssue]), + show: vi.fn().mockResolvedValue({ + ...epicIssue, + children: ["child-1", "child-2", "child-3"], + }), + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + // Spy on spawnAgent to capture the call args without actually spawning + const spawnSpy = vi.spyOn(dispatcher as never as { spawnAgent: (...args: unknown[]) => Promise<{ sessionKey: string }> }, "spawnAgent") + .mockResolvedValue({ sessionKey: "test-key" }); + + const result = await dispatcher.dispatch({ pipeline: true }); + + // Should have dispatched (not skipped) + expect(result.dispatched).toHaveLength(1); + expect(result.dispatched[0].seedId).toBe("epic-1"); + expect(result.skipped).toHaveLength(0); + + // spawnAgent should have been called with epicTasks and epicId + expect(spawnSpy).toHaveBeenCalledOnce(); + const callArgs = spawnSpy.mock.calls[0]; + // Args: model, worktreePath, seedInfo, runId, telemetry, pipelineOpts, notifyUrl, vcsBackend, targetBranch, epicTasks, epicId + const epicTasks = callArgs[9] as EpicTask[]; + const epicId = callArgs[10] as string; + + expect(epicTasks).toBeDefined(); + expect(epicTasks).toHaveLength(3); + expect(epicTasks[0].seedId).toBe("child-1"); + expect(epicTasks[1].seedId).toBe("child-2"); + expect(epicTasks[2].seedId).toBe("child-3"); + expect(epicId).toBe("epic-1"); + }); + + it("task bead dispatches via standard path without epicTasks", async () => { + const taskIssue = makeIssue("task-1", "task"); + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([taskIssue]), + show: vi.fn().mockResolvedValue({ ...taskIssue, description: "do the thing" }), + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + const spawnSpy = vi.spyOn(dispatcher as never as { spawnAgent: (...args: unknown[]) => Promise<{ sessionKey: string }> }, "spawnAgent") + .mockResolvedValue({ sessionKey: "test-key" }); + + const result = await dispatcher.dispatch({ pipeline: true }); + + expect(result.dispatched).toHaveLength(1); + expect(result.dispatched[0].seedId).toBe("task-1"); + + // spawnAgent should have been called WITHOUT epicTasks + expect(spawnSpy).toHaveBeenCalledOnce(); + const callArgs = spawnSpy.mock.calls[0]; + const epicTasks = callArgs[9] as EpicTask[] | undefined; + const epicId = callArgs[10] as string | undefined; + + expect(epicTasks).toBeUndefined(); + expect(epicId).toBeUndefined(); + }); + + it("epic bead with 0 children auto-closes", async () => { + const epicIssue = makeIssue("epic-empty", "epic"); + const closeFn = vi.fn().mockResolvedValue(undefined); + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([epicIssue]), + show: vi.fn().mockResolvedValue({ + ...epicIssue, + children: [], + }), + close: closeFn, + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + const spawnSpy = vi.spyOn(dispatcher as never as { spawnAgent: (...args: unknown[]) => Promise<{ sessionKey: string }> }, "spawnAgent") + .mockResolvedValue({ sessionKey: "test-key" }); + + const result = await dispatcher.dispatch({ pipeline: true }); + + // Should be skipped (auto-closed), not dispatched + expect(result.dispatched).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].seedId).toBe("epic-empty"); + expect(result.skipped[0].reason).toContain("auto-closed"); + expect(result.skipped[0].reason).toContain("no children"); + + // close() should have been called + expect(closeFn).toHaveBeenCalledWith("epic-empty", expect.stringContaining("no children")); + + // No worker should have been spawned + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + it("epic counts as 1 agent slot regardless of child task count", async () => { + const epicIssue = makeIssue("epic-big", "epic"); + const taskIssue = makeIssue("task-1", "task"); + + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([epicIssue, taskIssue]), + show: vi.fn().mockImplementation(async (id: string) => { + if (id === "epic-big") { + return { + ...epicIssue, + children: ["child-1", "child-2", "child-3", "child-4", "child-5"], + }; + } + return { ...taskIssue, description: "a task" }; + }), + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + const spawnSpy = vi.spyOn(dispatcher as never as { spawnAgent: (...args: unknown[]) => Promise<{ sessionKey: string }> }, "spawnAgent") + .mockResolvedValue({ sessionKey: "test-key" }); + + const result = await dispatcher.dispatch({ pipeline: true, maxAgents: 2 }); + + // Both should be dispatched — the epic counts as 1 slot, leaving room for the task + expect(result.dispatched).toHaveLength(2); + expect(result.dispatched.map(d => d.seedId)).toContain("epic-big"); + expect(result.dispatched.map(d => d.seedId)).toContain("task-1"); + + // spawnAgent called twice + expect(spawnSpy).toHaveBeenCalledTimes(2); + + // Find the epic call — it should have epicTasks + const epicCall = spawnSpy.mock.calls.find(c => (c[2] as { id: string }).id === "epic-big"); + expect(epicCall).toBeDefined(); + const epicTasks = epicCall![9] as EpicTask[]; + expect(epicTasks).toBeDefined(); + expect(epicTasks).toHaveLength(3); // getTaskOrder mock returns 3 + + // Find the task call — it should NOT have epicTasks + const taskCall = spawnSpy.mock.calls.find(c => (c[2] as { id: string }).id === "task-1"); + expect(taskCall).toBeDefined(); + expect(taskCall![9]).toBeUndefined(); + }); + + it("feature bead with open children still skips (unchanged behavior)", async () => { + const featureIssue = makeIssue("feat-1", "feature"); + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([featureIssue]), + show: vi.fn().mockResolvedValue({ + ...featureIssue, + children: ["child-1"], + status: "open", + }), + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + const spawnSpy = vi.spyOn(dispatcher as never as { spawnAgent: (...args: unknown[]) => Promise<{ sessionKey: string }> }, "spawnAgent") + .mockResolvedValue({ sessionKey: "test-key" }); + + const result = await dispatcher.dispatch({ pipeline: true }); + + // Feature beads with open children are skipped, not dispatched + expect(result.dispatched).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].reason).toContain("organizational container"); + + // No worker spawned + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + it("epic with no actionable child tasks auto-closes", async () => { + // Override getTaskOrder to return empty for this test + const { getTaskOrder } = await import("../task-ordering.js"); + vi.mocked(getTaskOrder).mockResolvedValueOnce([]); + + const epicIssue = makeIssue("epic-containers", "epic"); + const closeFn = vi.fn().mockResolvedValue(undefined); + const seedsClient = makeSeedsClient({ + ready: vi.fn().mockResolvedValue([epicIssue]), + show: vi.fn().mockResolvedValue({ + ...epicIssue, + children: ["story-1", "story-2"], + }), + close: closeFn, + }); + const store = makeStore(); + const dispatcher = new Dispatcher(seedsClient, store, "/tmp/project"); + + const result = await dispatcher.dispatch({ pipeline: true }); + + expect(result.dispatched).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].reason).toContain("no actionable child tasks"); + expect(closeFn).toHaveBeenCalledWith("epic-containers", expect.stringContaining("no actionable")); + }); +}); diff --git a/src/orchestrator/__tests__/doctor-workflows.test.ts b/src/orchestrator/__tests__/doctor-workflows.test.ts index 3cf2421..0702efe 100644 --- a/src/orchestrator/__tests__/doctor-workflows.test.ts +++ b/src/orchestrator/__tests__/doctor-workflows.test.ts @@ -45,11 +45,12 @@ describe("Doctor.checkWorkflows()", () => { }); it("passes when all bundled workflows are installed", () => { - // Install both default.yaml and smoke.yaml + // Install default.yaml, smoke.yaml, and epic.yaml const workflowsDir = join(tmpDir, ".foreman", "workflows"); mkdirSync(workflowsDir, { recursive: true }); writeFileSync(join(workflowsDir, "default.yaml"), "name: default\nphases:\n - name: finalize\n builtin: true\n"); writeFileSync(join(workflowsDir, "smoke.yaml"), "name: smoke\nphases:\n - name: finalize\n builtin: true\n"); + writeFileSync(join(workflowsDir, "epic.yaml"), "name: epic\nphases:\n - name: finalize\n builtin: true\n"); const { doctor } = makeMocks(tmpDir); return doctor.checkWorkflows().then((result) => { diff --git a/src/orchestrator/__tests__/pipeline-epic-loop.test.ts b/src/orchestrator/__tests__/pipeline-epic-loop.test.ts new file mode 100644 index 0000000..67a6006 --- /dev/null +++ b/src/orchestrator/__tests__/pipeline-epic-loop.test.ts @@ -0,0 +1,326 @@ +/** + * Integration tests for epic task loop in pipeline-executor (TRD-005-TEST). + * + * Verifies: + * 1. 3 tasks execute in order, each commits + * 2. QA FAIL retries developer, then passes + * 3. QA FAIL exhausts retries — task fails, epic continues (onError=continue) + * 4. Single-task mode unchanged (no epicTasks) + * 5. Finalize runs once after all tasks + * 6. No empty commits after task loop (VCS commit only on success) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { EpicTask } from "../pipeline-executor.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function makeEpicPipelineArgs( + tmpDir: string, + runPhase: ReturnType, + log: ReturnType, + epicTasks: EpicTask[], + opts?: { onError?: string; vcsBackend?: unknown }, +) { + const mockStore = { + updateRunProgress: vi.fn(), + logEvent: vi.fn(), + }; + + const phases = [ + { name: "developer", prompt: "developer.md", artifact: "DEVELOPER_REPORT.md" }, + { name: "qa", prompt: "qa.md", artifact: "QA_REPORT.md", verdict: true, retryWith: "developer", retryOnFail: 2 }, + { name: "finalize", prompt: "finalize.md", artifact: "FINALIZE_VALIDATION.md" }, + ]; + + return { + config: { + runId: "run-epic-001", + projectId: "proj-001", + seedId: "epic-001", + seedTitle: "Epic test", + model: "anthropic/claude-sonnet-4-6", + worktreePath: tmpDir, + env: {}, + vcsBackend: opts?.vcsBackend ?? undefined, + }, + workflowConfig: { + name: "epic", + phases, + taskPhases: ["developer", "qa"], + finalPhases: ["finalize"], + onError: opts?.onError ?? "continue", + } as never, + store: mockStore as never, + logFile: join(tmpDir, "epic.log"), + notifyClient: null, + agentMailClient: null, + epicTasks, + runPhase, + registerAgent: vi.fn().mockResolvedValue(undefined), + sendMail: vi.fn(), + sendMailText: vi.fn(), + reserveFiles: vi.fn(), + releaseFiles: vi.fn(), + markStuck: vi.fn().mockResolvedValue(undefined), + log, + promptOpts: { projectRoot: tmpDir, workflow: "epic" }, + }; +} + +function successResult() { + return { success: true, costUsd: 0.01, turns: 5, tokensIn: 100, tokensOut: 50 }; +} + +function makeEpicTasks(count: number): EpicTask[] { + return Array.from({ length: count }, (_, i) => ({ + seedId: `task-${i + 1}`, + seedTitle: `Task ${i + 1}`, + seedDescription: `Description for task ${i + 1}`, + })); +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe("epic task loop (TRD-005)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "foreman-epic-test-")); + mkdirSync(tmpDir, { recursive: true }); + // Create stub prompt files + const promptDir = join(tmpDir, ".foreman", "prompts", "epic"); + mkdirSync(promptDir, { recursive: true }); + for (const phase of ["developer", "qa", "finalize"]) { + writeFileSync(join(promptDir, `${phase}.md`), `# ${phase} stub\n`); + } + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("3 tasks execute in order, each with developer→QA", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\nAll good.\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(3); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // Each task: developer, qa. Then finalize once. + expect(phaseOrder).toEqual([ + "developer", "qa", // task 1 + "developer", "qa", // task 2 + "developer", "qa", // task 3 + "finalize", // final phase + ]); + }); + + it("QA FAIL retries developer, then passes", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + let qaCallCount = 0; + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + qaCallCount++; + if (qaCallCount === 1) { + // First QA: FAIL + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: FAIL\nTest broken.\n"); + } else { + // Subsequent QA: PASS + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\nFixed.\n"); + } + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(1); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // developer → qa (FAIL) → developer (retry) → qa (PASS) → finalize + expect(phaseOrder).toEqual(["developer", "qa", "developer", "qa", "finalize"]); + expect(log).toHaveBeenCalledWith(expect.stringContaining("FAIL")); + }); + + it("QA FAIL exhausts retries — task fails, epic continues to next task", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + let qaCallCount = 0; + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + qaCallCount++; + if (qaCallCount <= 3) { + // First task QA always FAILs (retryOnFail=2, so 3 attempts) + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: FAIL\nStill broken.\n"); + } else { + // Second task QA passes + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\nFixed.\n"); + } + } + return successResult(); + }); + + // Two tasks — first exhausts retries, second should pass + const epicTasks = makeEpicTasks(2); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // Task 1: developer → qa (FAIL) → developer → qa (FAIL) → developer → qa (FAIL) — exhausted + // Task 2: developer → qa (PASS) + // Then: finalize (since completedCount > 0) + const devCount = phaseOrder.filter((p) => p === "developer").length; + const qaCount = phaseOrder.filter((p) => p === "qa").length; + expect(devCount).toBeGreaterThanOrEqual(4); + expect(qaCount).toBeGreaterThanOrEqual(4); + expect(phaseOrder[phaseOrder.length - 1]).toBe("finalize"); + expect(log).toHaveBeenCalledWith(expect.stringContaining("FAILED")); + }); + + it("single-task mode unchanged (no epicTasks)", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + // No epicTasks — should run all phases once (standard mode) + const args = makeEpicPipelineArgs(tmpDir, runPhase, log, []); + // Remove epicTasks to simulate single-task mode + delete (args as Record).epicTasks; + await executePipeline(args as never); + + // Standard flow: developer → qa → finalize + expect(phaseOrder).toEqual(["developer", "qa", "finalize"]); + }); + + it("finalize runs once after all tasks", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(5); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + const finalizeCount = phaseOrder.filter((p) => p === "finalize").length; + expect(finalizeCount).toBe(1); + expect(phaseOrder[phaseOrder.length - 1]).toBe("finalize"); + }); + + it("VCS commit is called after each successful task", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const log = vi.fn(); + const commitFn = vi.fn().mockResolvedValue(undefined); + + const mockVcsBackend = { + name: "git", + commit: commitFn, + getFinalizeCommands: vi.fn().mockReturnValue({ + stageCommand: "git add -A", + commitCommand: "git commit", + pushCommand: "git push", + rebaseCommand: "git rebase", + branchVerifyCommand: "git branch", + cleanCommand: "git clean", + }), + }; + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(3); + await executePipeline( + makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks, { vcsBackend: mockVcsBackend }) as never, + ); + + // 3 tasks → 3 commits + expect(commitFn).toHaveBeenCalledTimes(3); + expect(commitFn).toHaveBeenCalledWith(tmpDir, expect.stringContaining("task-1")); + expect(commitFn).toHaveBeenCalledWith(tmpDir, expect.stringContaining("task-2")); + expect(commitFn).toHaveBeenCalledWith(tmpDir, expect.stringContaining("task-3")); + }); + + it("onError=stop halts epic on task failure", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const phaseOrder: string[] = []; + const log = vi.fn(); + const markStuck = vi.fn().mockResolvedValue(undefined); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: FAIL\nBroken.\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(2); + const args = makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks, { onError: "stop" }); + args.markStuck = markStuck; + await executePipeline(args as never); + + // Should stop after first task fails (retries exhausted) + expect(markStuck).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("onError=stop")); + // Second task should not execute — no finalize either + expect(phaseOrder[phaseOrder.length - 1]).not.toBe("finalize"); + }); + + it("onPipelineComplete callback receives accumulated progress", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + const log = vi.fn(); + const onComplete = vi.fn().mockResolvedValue(undefined); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(2); + const args = makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks); + (args as Record).onPipelineComplete = onComplete; + await executePipeline(args as never); + + expect(onComplete).toHaveBeenCalledTimes(1); + const callArg = onComplete.mock.calls[0][0]; + // 2 tasks × 2 phases + 1 finalize = 5 phases total + expect(callArg.progress.costUsd).toBeGreaterThan(0); + expect(callArg.phaseRecords.length).toBe(5); + }); +}); diff --git a/src/orchestrator/__tests__/pipeline-epic-resume.test.ts b/src/orchestrator/__tests__/pipeline-epic-resume.test.ts new file mode 100644 index 0000000..6c574d7 --- /dev/null +++ b/src/orchestrator/__tests__/pipeline-epic-resume.test.ts @@ -0,0 +1,338 @@ +/** + * Tests for epic resume detection (TRD-009). + * + * Verifies: + * 1. parseCompletedTaskIds extracts bead IDs from git log output + * 2. Resume skips tasks with existing commits + * 3. Partial task (no commit) restarts from beginning + * 4. Resume with 0 completed tasks starts from task 1 + * + * Note: test setup uses execSync with hardcoded git commands to create + * real git repos. No user input is involved — shell injection is not + * a concern in test fixtures. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import { execSync } from "node:child_process"; +import { parseCompletedTaskIds } from "../pipeline-executor.js"; +import type { EpicTask } from "../pipeline-executor.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function makeEpicPipelineArgs( + tmpDir: string, + runPhase: ReturnType, + log: ReturnType, + epicTasks: EpicTask[], + opts?: { vcsBackend?: unknown }, +) { + const mockStore = { + updateRunProgress: vi.fn(), + logEvent: vi.fn(), + }; + + const phases = [ + { name: "developer", prompt: "developer.md", artifact: "DEVELOPER_REPORT.md" }, + { name: "qa", prompt: "qa.md", artifact: "QA_REPORT.md", verdict: true, retryWith: "developer", retryOnFail: 2 }, + { name: "finalize", prompt: "finalize.md", artifact: "FINALIZE_VALIDATION.md" }, + ]; + + return { + config: { + runId: "run-resume-001", + projectId: "proj-001", + seedId: "epic-001", + seedTitle: "Epic resume test", + model: "anthropic/claude-sonnet-4-6", + worktreePath: tmpDir, + env: {}, + vcsBackend: opts?.vcsBackend ?? undefined, + }, + workflowConfig: { + name: "epic", + phases, + taskPhases: ["developer", "qa"], + finalPhases: ["finalize"], + onError: "continue", + } as never, + store: mockStore as never, + logFile: join(tmpDir, "epic.log"), + notifyClient: null, + agentMailClient: null, + epicTasks, + runPhase, + registerAgent: vi.fn().mockResolvedValue(undefined), + sendMail: vi.fn(), + sendMailText: vi.fn(), + reserveFiles: vi.fn(), + releaseFiles: vi.fn(), + markStuck: vi.fn().mockResolvedValue(undefined), + log, + promptOpts: { projectRoot: tmpDir, workflow: "epic" }, + }; +} + +function successResult() { + return { success: true, costUsd: 0.01, turns: 5, tokensIn: 100, tokensOut: 50 }; +} + +function makeEpicTasks(count: number): EpicTask[] { + return Array.from({ length: count }, (_, i) => ({ + seedId: `task-${i + 1}`, + seedTitle: `Task ${i + 1}`, + seedDescription: `Description for task ${i + 1}`, + })); +} + +/** + * Initialize a real git repo in tmpDir with commits for the given task IDs. + * This simulates a worktree that has already completed some tasks. + * + * Uses hardcoded git commands (no user input) for test fixtures only. + */ +function initGitWithCommits(dir: string, taskIds: string[]): void { + execSync("git init", { cwd: dir, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: dir, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: dir, stdio: "ignore" }); + + // Initial commit so there's a HEAD + writeFileSync(join(dir, "init.txt"), "init"); + execSync("git add -A && git commit -m 'initial'", { cwd: dir, stdio: "ignore" }); + + for (const taskId of taskIds) { + writeFileSync(join(dir, `${taskId}.txt`), taskId); + execSync(`git add -A && git commit -m "Implement feature (${taskId})"`, { + cwd: dir, + stdio: "ignore", + }); + } +} + +// ── Unit tests for parseCompletedTaskIds ──────────────────────────────── + +describe("parseCompletedTaskIds", () => { + it("extracts bead IDs from git log --oneline output", () => { + const gitLog = [ + "abc1234 Implement feature (task-3)", + "def5678 Add user auth (task-2)", + "ghi9012 Setup database (task-1)", + "jkl3456 initial commit", + ].join("\n"); + + const result = parseCompletedTaskIds(gitLog); + expect(result).toEqual(new Set(["task-3", "task-2", "task-1"])); + }); + + it("returns empty set for empty log", () => { + expect(parseCompletedTaskIds("")).toEqual(new Set()); + }); + + it("returns empty set when no commit messages match pattern", () => { + const gitLog = [ + "abc1234 initial commit", + "def5678 merge branch dev", + ].join("\n"); + + const result = parseCompletedTaskIds(gitLog); + expect(result).toEqual(new Set()); + }); + + it("handles mixed matching and non-matching lines", () => { + const gitLog = [ + "abc1234 Task 15 done (task-15)", + "def5678 merge branch", + "ghi9012 Task 10 done (task-10)", + "", + "jkl3456 random commit", + ].join("\n"); + + const result = parseCompletedTaskIds(gitLog); + expect(result).toEqual(new Set(["task-15", "task-10"])); + }); + + it("handles bead IDs with various formats", () => { + const gitLog = [ + "aaa1111 Fix bug (BUG-123)", + "bbb2222 Add feature (feat/user-auth)", + "ccc3333 Update docs (DOCS-42)", + ].join("\n"); + + const result = parseCompletedTaskIds(gitLog); + expect(result).toEqual(new Set(["BUG-123", "feat/user-auth", "DOCS-42"])); + }); +}); + +// ── Integration tests for epic resume ─────────────────────────────────── + +describe("epic resume detection (TRD-009)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "foreman-epic-resume-")); + mkdirSync(tmpDir, { recursive: true }); + // Create stub prompt files + const promptDir = join(tmpDir, ".foreman", "prompts", "epic"); + mkdirSync(promptDir, { recursive: true }); + for (const phase of ["developer", "qa", "finalize"]) { + writeFileSync(join(promptDir, `${phase}.md`), `# ${phase} stub\n`); + } + }); + + afterEach(() => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors (git index.lock race on macOS) + } + }); + + it("resume skips tasks with existing commits", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + + // Pre-create git repo with commits for tasks 1-3 (out of 5) + initGitWithCommits(tmpDir, ["task-1", "task-2", "task-3"]); + + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\nAll good.\n"); + } + return successResult(); + }); + + const mockVcsBackend = { + name: "git" as const, + commit: vi.fn().mockResolvedValue(undefined), + getFinalizeCommands: vi.fn().mockReturnValue({ + stageCommand: "git add -A", + commitCommand: "git commit", + pushCommand: "git push", + rebaseCommand: "git rebase", + branchVerifyCommand: "git branch", + cleanCommand: "git clean", + }), + }; + + const epicTasks = makeEpicTasks(5); + await executePipeline( + makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks, { vcsBackend: mockVcsBackend }) as never, + ); + + // Only tasks 4 and 5 should have been executed (developer + qa each) + // Plus finalize once at the end + expect(phaseOrder).toEqual([ + "developer", "qa", // task 4 + "developer", "qa", // task 5 + "finalize", + ]); + + // Verify resume log message + expect(log).toHaveBeenCalledWith( + expect.stringContaining("Resuming from task 4 of 5 (3 completed)"), + ); + }); + + it("partial task (no commit) restarts from beginning of task phases", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + + // Pre-create git repo with commits for tasks 1-2 only + // Task 3 was partially done (developer ran, but no QA -> no commit) + initGitWithCommits(tmpDir, ["task-1", "task-2"]); + + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(4); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // Tasks 3 and 4 should run from scratch (developer + qa) + // Task 3 restarts from developer (not just QA) since it has no commit + expect(phaseOrder).toEqual([ + "developer", "qa", // task 3 (restarted fully) + "developer", "qa", // task 4 + "finalize", + ]); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining("Resuming from task 3 of 4 (2 completed)"), + ); + }); + + it("resume with 0 completed tasks starts from task 1", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + + // Initialize git repo with no task commits (only initial) + execSync("git init", { cwd: tmpDir, stdio: "ignore" }); + execSync("git config user.email test@test.com", { cwd: tmpDir, stdio: "ignore" }); + execSync("git config user.name Test", { cwd: tmpDir, stdio: "ignore" }); + writeFileSync(join(tmpDir, "init.txt"), "init"); + execSync("git add -A && git commit -m 'initial'", { cwd: tmpDir, stdio: "ignore" }); + + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(3); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // All 3 tasks should run + expect(phaseOrder).toEqual([ + "developer", "qa", // task 1 + "developer", "qa", // task 2 + "developer", "qa", // task 3 + "finalize", + ]); + + // Should NOT log a resume message + const logCalls = log.mock.calls.map((c: unknown[]) => c[0] as string); + expect(logCalls.some((msg: string) => msg.includes("Resuming"))).toBe(false); + }); + + it("no git repo at all starts from task 1 without error", async () => { + const { executePipeline } = await import("../pipeline-executor.js"); + + // tmpDir has no .git - detectCompletedTasks should return empty set + const phaseOrder: string[] = []; + const log = vi.fn(); + + const runPhase = vi.fn().mockImplementation(async (phaseName: string) => { + phaseOrder.push(phaseName); + if (phaseName === "qa") { + writeFileSync(join(tmpDir, "QA_REPORT.md"), "# QA\n\n## Verdict: PASS\n"); + } + return successResult(); + }); + + const epicTasks = makeEpicTasks(2); + await executePipeline(makeEpicPipelineArgs(tmpDir, runPhase, log, epicTasks) as never); + + // All tasks should run normally + expect(phaseOrder).toEqual([ + "developer", "qa", + "developer", "qa", + "finalize", + ]); + }); +}); diff --git a/src/orchestrator/__tests__/task-ordering.test.ts b/src/orchestrator/__tests__/task-ordering.test.ts new file mode 100644 index 0000000..640a16f --- /dev/null +++ b/src/orchestrator/__tests__/task-ordering.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for src/orchestrator/task-ordering.ts + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getTaskOrder, CircularDependencyError } from "../task-ordering.js"; +import type { BrIssueDetail } from "../../lib/beads-rust.js"; + +// ── Mock BvClient ────────────────────────────────────────────────────────── + +vi.mock("../../lib/bv.js", () => ({ + BvClient: vi.fn().mockImplementation(() => ({ + robotTriage: vi.fn().mockResolvedValue(null), + robotNext: vi.fn().mockResolvedValue(null), + })), +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeDetail( + id: string, + title: string, + priority: string = "P1", + deps: string[] = [], + type: string = "task", +): BrIssueDetail { + return { + id, + title, + type, + priority, + status: "open", + assignee: null, + parent: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + description: `Description for ${title}`, + labels: [], + estimate_minutes: null, + dependencies: deps, + children: [], + }; +} + +function makeBrClient(details: Map) { + return { + show: vi.fn().mockImplementation(async (id: string) => { + const d = details.get(id); + if (!d) throw new Error(`Bead ${id} not found`); + return d; + }), + // Stub remaining methods that may be called + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + close: vi.fn(), + addDependency: vi.fn(), + addComment: vi.fn(), + search: vi.fn(), + ready: vi.fn(), + syncFlushOnly: vi.fn(), + } as unknown as Record>; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("getTaskOrder", () => { + it("returns empty array for epic with no children", async () => { + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = []; + const details = new Map([["epic-1", epic]]); + const client = makeBrClient(details); + + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result).toEqual([]); + }); + + it("returns tasks in dependency order (topological sort)", async () => { + const task1 = makeDetail("t1", "Task 1", "P1", []); + const task2 = makeDetail("t2", "Task 2", "P1", ["t1"]); // depends on t1 + const task3 = makeDetail("t3", "Task 3", "P1", ["t2"]); // depends on t2 + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t1", "t2", "t3"]; + + const details = new Map([ + ["epic-1", epic], + ["t1", task1], + ["t2", task2], + ["t3", task3], + ]); + const client = makeBrClient(details); + + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result.map((t) => t.seedId)).toEqual(["t1", "t2", "t3"]); + }); + + it("uses priority as tiebreaker when no deps", async () => { + const taskP0 = makeDetail("t-p0", "Critical", "P0", []); + const taskP2 = makeDetail("t-p2", "Normal", "P2", []); + const taskP1 = makeDetail("t-p1", "High", "P1", []); + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t-p2", "t-p0", "t-p1"]; // unordered + + const details = new Map([ + ["epic-1", epic], + ["t-p0", taskP0], + ["t-p2", taskP2], + ["t-p1", taskP1], + ]); + const client = makeBrClient(details); + + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result.map((t) => t.seedId)).toEqual(["t-p0", "t-p1", "t-p2"]); + }); + + it("throws CircularDependencyError on circular deps", async () => { + const task1 = makeDetail("t1", "Task 1", "P1", ["t2"]); + const task2 = makeDetail("t2", "Task 2", "P1", ["t1"]); // circular! + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t1", "t2"]; + + const details = new Map([ + ["epic-1", epic], + ["t1", task1], + ["t2", task2], + ]); + const client = makeBrClient(details); + + await expect( + getTaskOrder("epic-1", client as never, "/tmp", false), + ).rejects.toThrow(CircularDependencyError); + }); + + it("skips feature/story children (only includes task/bug/chore)", async () => { + const task1 = makeDetail("t1", "Task 1", "P1", [], "task"); + const story = makeDetail("s1", "Story 1", "P1", [], "feature"); + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t1", "s1"]; + + const details = new Map([ + ["epic-1", epic], + ["t1", task1], + ["s1", story], + ]); + const client = makeBrClient(details); + + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result).toHaveLength(1); + expect(result[0].seedId).toBe("t1"); + }); + + it("includes seedDescription from bead description", async () => { + const task1 = makeDetail("t1", "Task 1", "P1", []); + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t1"]; + + const details = new Map([ + ["epic-1", epic], + ["t1", task1], + ]); + const client = makeBrClient(details); + + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result[0].seedDescription).toBe("Description for Task 1"); + }); + + it("handles mixed deps — some within children, some external", async () => { + // t2 depends on t1 (internal) and ext-1 (external, not a child of this epic) + const task1 = makeDetail("t1", "Task 1", "P1", []); + const task2 = makeDetail("t2", "Task 2", "P1", ["t1", "ext-1"]); + + const epic = makeDetail("epic-1", "Epic", "P1", [], "epic"); + epic.children = ["t1", "t2"]; + + const details = new Map([ + ["epic-1", epic], + ["t1", task1], + ["t2", task2], + ]); + const client = makeBrClient(details); + + // External dep ext-1 is ignored (not in children set), so t2 only depends on t1 + const result = await getTaskOrder("epic-1", client as never, "/tmp", false); + expect(result.map((t) => t.seedId)).toEqual(["t1", "t2"]); + }); +}); diff --git a/src/orchestrator/agent-worker.ts b/src/orchestrator/agent-worker.ts index 94ee0c7..ee96682 100644 --- a/src/orchestrator/agent-worker.ts +++ b/src/orchestrator/agent-worker.ts @@ -16,6 +16,7 @@ import { request as httpRequest } from "node:http"; import { runWithPiSdk } from "./pi-sdk-runner.js"; import { createSendMailTool, createGetRunStatusTool, createCloseBeadTool } from "./pi-sdk-tools.js"; import { executePipeline } from "./pipeline-executor.js"; +import type { EpicTask } from "./pipeline-executor.js"; import { ForemanStore } from "../lib/store.js"; import type { RunProgress } from "../lib/store.js"; import { NativeTaskStore } from "../lib/task-store.js"; @@ -218,6 +219,16 @@ interface WorkerConfig { * When set, merges into this branch instead of detectDefaultBranch(). */ targetBranch?: string; + /** + * Ordered list of child tasks for epic execution mode (TRD-2026-007). + * When set, the worker runs the epic pipeline path. + */ + epicTasks?: EpicTask[]; + /** + * Parent epic bead ID (TRD-2026-007). + * When set, this run is an epic execution. + */ + epicId?: string; } // ── Main ───────────────────────────────────────────────────────────────────── @@ -686,6 +697,7 @@ async function runPipeline(config: WorkerConfig, store: ForemanStore, logFile: s notifyClient, agentMailClient, taskStore, + epicTasks: config.epicTasks, runPhase, registerAgent, sendMail, diff --git a/src/orchestrator/dispatcher.ts b/src/orchestrator/dispatcher.ts index d53558a..befa21f 100644 --- a/src/orchestrator/dispatcher.ts +++ b/src/orchestrator/dispatcher.ts @@ -20,6 +20,8 @@ import { PLAN_STEP_CONFIG } from "./roles.js"; import { isPiAvailable } from "./pi-rpc-spawn-strategy.js"; import { resolveWorkflowType } from "../lib/workflow-config-loader.js"; import { loadWorkflowConfig, resolveWorkflowName } from "../lib/workflow-loader.js"; +import { getTaskOrder } from "./task-ordering.js"; +import type { EpicTask } from "./pipeline-executor.js"; import { loadProjectConfig, resolveVcsConfig } from "../lib/project-config.js"; import { VcsBackendFactory } from "../lib/vcs/index.js"; import type { VcsBackend } from "../lib/vcs/index.js"; @@ -321,11 +323,11 @@ export class Dispatcher { continue; } - // ── Auto-close feature/epic containers ──────────────────────────────── - // Feature and epic beads are organizational containers — never dispatch - // agents for them. Instead, check if all children are closed and auto-close - // the container bead when they are. - if (seed.type === "feature" || seed.type === "epic") { + // ── Auto-close feature containers ────────────────────────────────────── + // Feature beads are organizational containers — never dispatch agents for + // them. Instead, check if all children are closed and auto-close the + // container bead when they are. + if (seed.type === "feature") { try { const detail = await this.seeds.show(seed.id); const detailWithChildren = detail as { children?: string[]; status: string }; @@ -385,6 +387,69 @@ export class Dispatcher { continue; } + // ── Epic beads: dispatch through epic pipeline or auto-close ────────── + // Epic beads with children are dispatched as a single epic runner that + // executes all child tasks sequentially within one worktree. + // Epic beads with 0 children are auto-closed. + if (seed.type === "epic") { + try { + const detail = await this.seeds.show(seed.id); + const detailWithChildren = detail as { children?: string[]; status: string }; + const childIds = detailWithChildren.children ?? []; + + if (childIds.length === 0) { + // No children — auto-close the epic + await this.seeds.close(seed.id, "Auto-closed: no children (empty epic)"); + log(`[dispatch] Auto-closed ${seed.id} (type: epic) — no children`); + skipped.push({ + seedId: seed.id, + title: seed.title, + reason: "Type 'epic' auto-closed — no children", + }); + continue; + } + + // Epic has children — query task order and dispatch through epic path. + // getTaskOrder returns only actionable child types (task, bug, chore). + const brClient = this.seeds as unknown as BeadsRustClient; + const epicTasks: EpicTask[] = await getTaskOrder( + seed.id, + brClient, + this.projectPath, + ); + + if (epicTasks.length === 0) { + // All children are non-actionable types (e.g. all feature/story containers) + // or all children are already closed. Auto-close. + await this.seeds.close(seed.id, "Auto-closed: no actionable child tasks"); + log(`[dispatch] Auto-closed ${seed.id} (type: epic) — no actionable child tasks`); + skipped.push({ + seedId: seed.id, + title: seed.title, + reason: "Type 'epic' auto-closed — no actionable child tasks", + }); + continue; + } + + log(`[dispatch] Epic ${seed.id} has ${epicTasks.length} ordered tasks — dispatching epic runner`); + + // Store epicTasks for use by the dispatch logic below. + // We set a marker on the seed object so the dispatch code further down + // can include epicTasks in the worker config. + (seed as unknown as Record).__epicTasks = epicTasks; + // Fall through to normal dispatch logic (worktree creation, spawn, etc.) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log(`[dispatch] Failed to prepare epic ${seed.id}: ${msg}`); + skipped.push({ + seedId: seed.id, + title: seed.title, + reason: `Epic dispatch failed: ${msg}`, + }); + continue; + } + } + // Skip seeds that are in exponential backoff after recent stuck runs const backoffResult = this.checkStuckBackoff(seed.id, projectId); if (backoffResult.inBackoff) { @@ -672,6 +737,10 @@ export class Dispatcher { } // 7. Spawn the coding agent + // Extract epic context if this seed was marked as an epic dispatch + const epicTasksForSeed = (seed as unknown as Record).__epicTasks as EpicTask[] | undefined; + const epicIdForSeed = epicTasksForSeed ? seed.id : undefined; + const { sessionKey } = await this.spawnAgent( model, worktreePath, @@ -686,6 +755,8 @@ export class Dispatcher { opts?.notifyUrl, vcsBackend, opts?.targetBranch, + epicTasksForSeed, + epicIdForSeed, ); // Update run with session key @@ -1013,6 +1084,8 @@ export class Dispatcher { notifyUrl?: string, vcsBackend?: VcsBackend, targetBranch?: string, + epicTasks?: EpicTask[], + epicId?: string, ): Promise<{ sessionKey: string }> { const prompt = this.buildSpawnPrompt(seed.id, seed.title); @@ -1020,7 +1093,8 @@ export class Dispatcher { const sessionKey = `foreman:sdk:${model}:${runId}`; const usePipeline = pipelineOpts?.pipeline ?? true; // Pipeline by default - log(`Spawning ${usePipeline ? "pipeline" : "worker"} for ${seed.id} [${model}] in ${worktreePath}`); + const isEpic = epicTasks && epicTasks.length > 0; + log(`Spawning ${isEpic ? "epic runner" : usePipeline ? "pipeline" : "worker"} for ${seed.id} [${model}] in ${worktreePath}${isEpic ? ` (${epicTasks.length} tasks)` : ""}`); const seedType = resolveWorkflowType(seed.type ?? "feature", seed.labels); @@ -1044,6 +1118,8 @@ export class Dispatcher { seedLabels: seed.labels, seedPriority: seed.priority, targetBranch, + epicTasks, + epicId, }); return { sessionKey }; @@ -1389,6 +1465,18 @@ export interface WorkerConfig { * Null/undefined in beads fallback mode — no-op via optional chaining. */ taskId?: string | null; + /** + * Ordered list of child tasks for epic execution mode (TRD-2026-007). + * When set, the worker runs the epic pipeline: taskPhases per child task, + * then finalPhases once at the end. + */ + epicTasks?: EpicTask[]; + /** + * Parent epic bead ID (TRD-2026-007). + * When set, this run is an epic execution — the worker executes all + * epicTasks within a single worktree. + */ + epicId?: string; } // ── Spawn Strategy Pattern ────────────────────────────────────────────── diff --git a/src/orchestrator/pipeline-executor.ts b/src/orchestrator/pipeline-executor.ts index 3d9da22..3cfefab 100644 --- a/src/orchestrator/pipeline-executor.ts +++ b/src/orchestrator/pipeline-executor.ts @@ -10,6 +10,7 @@ */ import { existsSync, readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; import { appendFile } from "node:fs/promises"; import { join, basename } from "node:path"; import type { WorkflowConfig, WorkflowPhaseConfig } from "../lib/workflow-loader.js"; @@ -51,6 +52,16 @@ export interface PhaseResult { error?: string; } +/** A child task within an epic pipeline run. */ +export interface EpicTask { + /** Bead/seed ID of the child task. */ + seedId: string; + /** Title of the child task bead. */ + seedTitle: string; + /** Description of the child task bead. */ + seedDescription?: string; +} + export interface PipelineRunConfig { runId: string; projectId: string; @@ -86,6 +97,11 @@ export interface PipelineRunConfig { * at each phase transition (REQ-012). Null/undefined in beads fallback mode. */ taskId?: string | null; + /** + * Parent epic bead ID. When set, this run is part of an epic execution. + * Used to link child task results back to the parent epic. + */ + epicId?: string; } export interface PipelineContext { @@ -102,6 +118,12 @@ export interface PipelineContext { * phase transition. No-op if absent or if config.taskId is null/undefined. */ taskStore?: NativeTaskStore; + /** + * Epic mode: ordered list of child tasks to execute. + * When set, the pipeline executor runs taskPhases for each task + * instead of running all phases in sequence for a single task. + */ + epicTasks?: EpicTask[]; /** The runPhase function from agent-worker.ts */ runPhase: RunPhaseFn; /** Register an agent identity for mail */ @@ -121,6 +143,20 @@ export interface PipelineContext { log: (msg: string) => void; /** Prompt loader options */ promptOpts: { projectRoot: string; workflow: string }; + /** + * Epic mode callback: update a child task bead's status. + * Called when a task starts (in_progress) or completes (closed/failed). + */ + onTaskStatusChange?: (taskSeedId: string, status: "in_progress" | "completed" | "failed") => Promise; + /** + * Epic mode callback: create a bug bead when QA fails on a task. + * Returns the created bug bead ID, or undefined if creation fails. + */ + onTaskQaFailure?: (taskSeedId: string, taskTitle: string, epicId: string) => Promise; + /** + * Epic mode callback: close a bug bead when QA passes after retry. + */ + onTaskQaPass?: (bugBeadId: string) => Promise; /** * Called after the last phase (finalize) completes successfully. * Responsible for: reading finalize mail, enqueuing to merge queue, @@ -140,12 +176,29 @@ function readReport(worktreePath: string, filename: string): string | null { try { return readFileSync(p, "utf-8"); } catch { return null; } } +/** Result of running a sequence of phases. */ +interface PhaseSequenceResult { + success: boolean; + phaseRecords: PhaseRecord[]; + retryCounts: Record; + qaVerdictForLog: "pass" | "fail" | "unknown"; + progress: RunProgress; + /** Set when a verdict-FAIL exhausted retries (task failed, not stuck). */ + retriesExhausted?: boolean; +} + // ── Generic Pipeline Executor ─────────────────────────────────────────────── /** * Execute a workflow pipeline driven entirely by the YAML config. * - * Iterates workflowConfig.phases in order. For each phase: + * Two modes: + * - **Single-task mode** (default): iterates all `phases` in order for one task. + * - **Epic mode**: when `ctx.epicTasks` is set AND workflow has `taskPhases`, + * iterates child tasks running only `taskPhases` per task (with per-task commits), + * then runs `finalPhases` once at the end. + * + * Per-phase behavior: * 1. Check skipIfArtifact (resume from crash) * 2. Register agent mail identity * 3. Send phase-started mail (if mail.onStart) @@ -157,10 +210,303 @@ function readReport(worktreePath: string, filename: string): string | null { * 9. If verdict phase: parse PASS/FAIL, handle retryWith loop */ export async function executePipeline(ctx: PipelineContext): Promise { - const { config, workflowConfig, store, logFile, notifyClient, agentMailClient } = ctx; - const { runId, projectId, seedId, seedTitle, worktreePath } = config; - const description = config.seedDescription ?? "(no description)"; - const comments = config.seedComments; + const { config, workflowConfig } = ctx; + const epicTasks = ctx.epicTasks; + const isEpicMode = epicTasks && epicTasks.length > 0 && workflowConfig.taskPhases; + + if (isEpicMode) { + await executeEpicPipeline(ctx); + } else { + await executeSingleTaskPipeline(ctx); + } +} + +// ── Resume detection ──────────────────────────────────────────────────────── + +/** + * Parse `git log --oneline` output from an epic worktree and extract + * the bead/seed IDs of tasks that have already been committed. + * + * Commit messages follow the format: ` (<beadId>)` + * For example: `Add user auth (task-7)` → extracts `task-7`. + * + * @returns A Set of completed task seed IDs found in the git history. + */ +export function parseCompletedTaskIds(gitLogOutput: string): Set<string> { + const completed = new Set<string>(); + // Match the trailing parenthesized bead ID in each commit line. + // git log --oneline format: "<hash> <message>" + // We look for the pattern "(<beadId>)" at the end of each line. + const regex = /\(([^)]+)\)\s*$/; + for (const line of gitLogOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const match = regex.exec(trimmed); + if (match) { + completed.add(match[1]); + } + } + return completed; +} + +/** + * Read git log from a worktree directory and return completed task IDs. + * Returns an empty set if the git command fails (e.g. no commits yet). + * + * Note: uses execSync with a hardcoded command string (no user input), + * so shell injection is not a concern here. + */ +function detectCompletedTasks(worktreePath: string): Set<string> { + try { + const output = execSync("git log --oneline", { + cwd: worktreePath, + encoding: "utf-8", + timeout: 10_000, + }); + return parseCompletedTaskIds(output); + } catch { + // No git history or command failed — no completed tasks + return new Set<string>(); + } +} + +// ── Epic mode executor ────────────────────────────────────────────────────── + +/** + * Epic mode: iterate child tasks, running taskPhases per task with commits + * between, then finalPhases once at the end. + * + * Resume support (TRD-009): on re-dispatch, parses git log to find + * already-committed task bead IDs and skips them. + */ +async function executeEpicPipeline(ctx: PipelineContext): Promise<void> { + const { config, workflowConfig, store, logFile } = ctx; + const { runId, seedId, worktreePath } = config; + let epicTasks = ctx.epicTasks!; + const taskPhaseNames = workflowConfig.taskPhases!; + const finalPhaseNames = workflowConfig.finalPhases ?? []; + + // Resolve phase configs for task phases and final phases + const allPhases = workflowConfig.phases; + const taskPhases = taskPhaseNames + .map((name) => allPhases.find((p) => p.name === name)) + .filter((p): p is typeof allPhases[number] => p !== undefined); + const finalPhases = finalPhaseNames + .map((name) => allPhases.find((p) => p.name === name)) + .filter((p): p is typeof allPhases[number] => p !== undefined); + + // ── Resume detection (TRD-009) ────────────────────────────────────── + const totalTaskCount = epicTasks.length; + const resumedTaskIds = detectCompletedTasks(worktreePath); + + if (resumedTaskIds.size > 0) { + const remainingTasks = epicTasks.filter((t) => !resumedTaskIds.has(t.seedId)); + const skippedCount = totalTaskCount - remainingTasks.length; + + if (skippedCount > 0) { + ctx.log(`[EPIC] Resuming from task ${skippedCount + 1} of ${totalTaskCount} (${skippedCount} completed)`); + await appendFile(logFile, `\n[EPIC] Resume: ${skippedCount} tasks already committed, skipping to task ${skippedCount + 1}\n`); + epicTasks = remainingTasks; + } + } + + const taskPhaseStr = taskPhaseNames.join(" → "); + const finalPhaseStr = finalPhaseNames.length > 0 ? ` | final: ${finalPhaseNames.join(" → ")}` : ""; + ctx.log(`[EPIC] Starting epic pipeline for ${seedId} — ${epicTasks.length} tasks`); + ctx.log(`[EPIC] Per-task phases: ${taskPhaseStr}${finalPhaseStr}`); + await appendFile(logFile, `\n[EPIC] Epic pipeline: ${epicTasks.length} tasks, taskPhases: ${taskPhaseStr}${finalPhaseStr}\n`); + + const allPhaseRecords: PhaseRecord[] = []; + const allRetryCounts: Record<string, number> = {}; + let totalProgress: RunProgress = { + toolCalls: 0, + toolBreakdown: {}, + filesChanged: [], + turns: 0, + costUsd: 0, + tokensIn: 0, + tokensOut: 0, + lastToolCall: null, + lastActivity: new Date().toISOString(), + currentPhase: "epic-init", + epicTaskCount: epicTasks.length, + epicTasksCompleted: 0, + epicCostByTask: {}, + }; + + let completedCount = 0; + let failedCount = 0; + const completedTaskIds: string[] = []; + + // ── Outer task loop ────────────────────────────────────────────────── + let activeBugBeadId: string | undefined; + + for (let taskIdx = 0; taskIdx < epicTasks.length; taskIdx++) { + const task = epicTasks[taskIdx]; + ctx.log(`[EPIC] Task ${taskIdx + 1}/${epicTasks.length}: ${task.seedId} — ${task.seedTitle}`); + await appendFile(logFile, `\n[EPIC] === Task ${taskIdx + 1}/${epicTasks.length}: ${task.seedId} ===\n`); + + // TRD-012: Update epic progress in RunProgress + totalProgress.epicCurrentTaskId = task.seedId; + store.updateRunProgress(runId, totalProgress); + + // TRD-011: Mark task bead as in_progress + if (ctx.onTaskStatusChange) { + await ctx.onTaskStatusChange(task.seedId, "in_progress").catch(() => {}); + } + + // Build a task-specific config overlay (use task's seedId/title/description for prompts) + const taskConfig: PipelineRunConfig = { + ...config, + // Keep the epic's seedId for run tracking, but pass task info for prompts + seedDescription: task.seedDescription ?? config.seedDescription, + seedComments: `Epic task ${taskIdx + 1}/${epicTasks.length}: ${task.seedTitle}\n` + + (completedTaskIds.length > 0 + ? `Previously completed: ${completedTaskIds.join(", ")}\n` + : "") + + (config.seedComments ?? ""), + }; + + // Create a task-scoped context with taskPhases only + const taskWorkflowConfig = { ...workflowConfig, phases: taskPhases }; + const taskCtx: PipelineContext = { + ...ctx, + config: taskConfig, + workflowConfig: taskWorkflowConfig, + epicTasks: undefined, // prevent recursion + }; + + // Run the task phases (developer → QA with retry). + // failOnRetriesExhausted=true: in epic mode, exhausted retries mean the task failed. + const result = await runPhaseSequence(taskCtx, taskPhases, totalProgress, true); + + // Accumulate progress + totalProgress = result.progress; + allPhaseRecords.push(...result.phaseRecords); + for (const [k, v] of Object.entries(result.retryCounts)) { + allRetryCounts[k] = (allRetryCounts[k] ?? 0) + v; + } + + if (result.success) { + completedCount++; + completedTaskIds.push(task.seedId); + + // TRD-010: Close bug bead if QA passed after retry + if (activeBugBeadId && ctx.onTaskQaPass) { + await ctx.onTaskQaPass(activeBugBeadId).catch(() => {}); + activeBugBeadId = undefined; + } + + // Commit after each successful task (epic mode: one commit per task) + if (config.vcsBackend) { + try { + await config.vcsBackend.commit(worktreePath, `${task.seedTitle} (${task.seedId})`); + ctx.log(`[EPIC] Committed task ${task.seedId}`); + } catch (err: unknown) { + // Non-fatal: commit may fail if no changes (e.g. test-only task) + const msg = err instanceof Error ? err.message : String(err); + ctx.log(`[EPIC] Commit for ${task.seedId} skipped: ${msg}`); + } + } + + // TRD-011: Mark task bead as completed + if (ctx.onTaskStatusChange) { + await ctx.onTaskStatusChange(task.seedId, "completed").catch(() => {}); + } + + // TRD-012: Update epic progress + totalProgress.epicTasksCompleted = completedCount; + totalProgress.epicCostByTask ??= {}; + totalProgress.epicCostByTask[task.seedId] = result.progress.costUsd - (totalProgress.costUsd - result.progress.costUsd); + store.updateRunProgress(runId, totalProgress); + + ctx.log(`[EPIC] Task ${task.seedId} PASSED (${completedCount}/${epicTasks.length} done)`); + await appendFile(logFile, `\n[EPIC] Task ${task.seedId} PASSED\n`); + } else { + failedCount++; + + // TRD-010: Create bug bead on QA failure + if (result.retriesExhausted && ctx.onTaskQaFailure && config.epicId) { + activeBugBeadId = await ctx.onTaskQaFailure(task.seedId, task.seedTitle, config.epicId).catch(() => undefined); + if (activeBugBeadId) { + ctx.log(`[EPIC] Created bug bead ${activeBugBeadId} for QA failure on ${task.seedId}`); + } + } + + // TRD-011: Mark task bead as failed + if (ctx.onTaskStatusChange) { + await ctx.onTaskStatusChange(task.seedId, "failed").catch(() => {}); + } + + ctx.log(`[EPIC] Task ${task.seedId} FAILED${result.retriesExhausted ? " (retries exhausted)" : ""}`); + await appendFile(logFile, `\n[EPIC] Task ${task.seedId} FAILED\n`); + + // Apply onError strategy + if (workflowConfig.onError === "stop") { + ctx.log(`[EPIC] onError=stop — halting epic after task ${task.seedId} failure`); + await appendFile(logFile, `\n[EPIC] Halted (onError=stop)\n`); + await ctx.markStuck( + store, runId, config.projectId, seedId, config.seedTitle, + totalProgress, "epic-task-failed", + `Task ${task.seedId} failed — epic halted (onError=stop)`, + ctx.notifyClient, config.projectPath, + ); + return; + } + // onError=continue: skip failed task and continue to next + } + } + + ctx.log(`[EPIC] Task loop complete: ${completedCount} passed, ${failedCount} failed`); + await appendFile(logFile, `\n[EPIC] Task loop complete: ${completedCount}/${epicTasks.length} passed\n`); + + // ── Final phases (finalize) — run once after all tasks ───────────── + if (finalPhases.length > 0 && completedCount > 0) { + ctx.log(`[EPIC] Running final phases: ${finalPhaseNames.join(" → ")}`); + await appendFile(logFile, `\n[EPIC] === Final phases ===\n`); + + const finalWorkflowConfig = { ...workflowConfig, phases: finalPhases }; + const finalCtx: PipelineContext = { + ...ctx, + workflowConfig: finalWorkflowConfig, + epicTasks: undefined, + }; + + const finalResult = await runPhaseSequence(finalCtx, finalPhases, totalProgress); + totalProgress = finalResult.progress; + allPhaseRecords.push(...finalResult.phaseRecords); + for (const [k, v] of Object.entries(finalResult.retryCounts)) { + allRetryCounts[k] = (allRetryCounts[k] ?? 0) + v; + } + + if (!finalResult.success) { + ctx.log(`[EPIC] Final phases failed`); + return; // markStuck already called inside runPhaseSequence + } + } + + // ── Session log ────────────────────────────────────────────────────── + await writeSessionLogSafe(ctx, totalProgress, allPhaseRecords, allRetryCounts, "unknown"); + + // ── Pipeline completion ────────────────────────────────────────────── + if (ctx.onPipelineComplete) { + await ctx.onPipelineComplete({ + progress: totalProgress, + phaseRecords: allPhaseRecords, + retryCounts: allRetryCounts, + }); + } +} + +// ── Single-task mode executor ─────────────────────────────────────────────── + +/** + * Original single-task mode: run all phases in sequence for one task. + * This is the pre-existing behavior, extracted for clarity. + */ +async function executeSingleTaskPipeline(ctx: PipelineContext): Promise<void> { + const { config, workflowConfig, store, logFile } = ctx; + const { seedId } = config; const progress: RunProgress = { toolCalls: 0, @@ -180,24 +526,54 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { ctx.log(`[PIPELINE] Phase sequence: ${phaseNames}`); await appendFile(logFile, `\n[foreman-worker] Pipeline orchestration starting\n[PIPELINE] Phase sequence: ${phaseNames}\n`); - const phaseRecords: PhaseRecord[] = []; + const result = await runPhaseSequence(ctx, workflowConfig.phases, progress); - // Track feedback context for retry loops (QA/reviewer → developer) + // Session log + await writeSessionLogSafe(ctx, result.progress, result.phaseRecords, result.retryCounts, result.qaVerdictForLog); + + // Pipeline completion callback + if (ctx.onPipelineComplete) { + await ctx.onPipelineComplete({ + progress: result.progress, + phaseRecords: result.phaseRecords, + retryCounts: result.retryCounts, + }); + } +} + +// ── Phase sequence runner (shared by both modes) ──────────────────────────── + +/** + * Run a sequence of phases in order with retry/verdict logic. + * This is the core phase iteration loop used by both single-task and epic modes. + */ +async function runPhaseSequence( + ctx: PipelineContext, + phases: import("../lib/workflow-loader.js").WorkflowPhaseConfig[], + initialProgress: RunProgress, + /** When true (epic task mode), exhausted retries return failure instead of continuing. */ + failOnRetriesExhausted: boolean = false, +): Promise<PhaseSequenceResult> { + const { config, store, logFile, notifyClient, agentMailClient } = ctx; + const { runId, projectId, seedId, seedTitle, worktreePath } = config; + const description = config.seedDescription ?? "(no description)"; + const comments = config.seedComments; + + const progress = { ...initialProgress }; + const phaseRecords: PhaseRecord[] = []; let feedbackContext: string | undefined; - // Track QA verdict for session log let qaVerdictForLog: "pass" | "fail" | "unknown" = "unknown"; - // Track retry counts per retryWith target (e.g. "developer" → count) const retryCounts: Record<string, number> = {}; // Build a phase index for retryWith lookups const phaseIndex = new Map<string, number>(); - for (let i = 0; i < workflowConfig.phases.length; i++) { - phaseIndex.set(workflowConfig.phases[i].name, i); + for (let idx = 0; idx < phases.length; idx++) { + phaseIndex.set(phases[idx].name, idx); } let i = 0; - while (i < workflowConfig.phases.length) { - const phase = workflowConfig.phases[i]; + while (i < phases.length) { + const phase = phases[i]; const phaseName = phase.name; const agentName = `${phaseName}-${seedId}`; const hasExplorerReport = existsSync(join(worktreePath, "EXPLORER_REPORT.md")); @@ -250,11 +626,9 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { } = {}; if (vcsBackend) { - // All phases get vcsBackendName and vcsBranchPrefix (TRD-027 for reviewer) vcsPromptVars.vcsBackendName = vcsBackend.name; vcsPromptVars.vcsBranchPrefix = "foreman/"; - // Finalize phase gets all 6 VCS command variables (TRD-026) if (phaseName === "finalize") { const finalizeCommands = vcsBackend.getFinalizeCommands({ seedId, @@ -285,8 +659,6 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { ...vcsPromptVars, }, ctx.promptOpts); - // Resolve the model for this phase from the workflow YAML + bead priority. - // Falls back to ROLE_CONFIGS[phaseName] if the phase has no models map. const roleConfigFallback = (ROLE_CONFIGS as Record<string, { model: string } | undefined>)[phaseName]; const fallbackModel = roleConfigFallback?.model ?? config.model; const phaseModel = resolvePhaseModel(phase, config.seedPriority, fallbackModel); @@ -324,17 +696,14 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { seedId, phase: phaseName, error: result.error ?? `${phaseName} failed`, retryable: true, }); await ctx.markStuck(store, runId, projectId, seedId, seedTitle, progress, phaseName, result.error ?? `${phaseName} failed`, notifyClient, config.projectPath); - return; + return { success: false, phaseRecords, retryCounts, qaVerdictForLog, progress }; } // 8. Verdict handling: parse PASS/FAIL, retry if needed. - // MUST happen BEFORE phase-complete notification so that a FAIL verdict - // loops back to the retry target instead of triggering autoMerge. if (phase.verdict && phase.artifact) { const report = readReport(worktreePath, phase.artifact); const verdict = report ? parseVerdict(report) : "unknown"; - // Track QA verdict for session log if (phaseName === "qa") { qaVerdictForLog = verdict as "pass" | "fail" | "unknown"; } @@ -342,15 +711,12 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { if (verdict === "fail" && phase.retryWith) { const retryTarget = phase.retryWith; const maxRetries = phase.retryOnFail ?? 0; - // Key retry counter by the phase performing the verdict check (e.g. "qa", "reviewer") - // NOT by the retry target ("developer"), so QA and Reviewer have independent retry budgets. const retryCountKey = phaseName; const currentRetries = retryCounts[retryCountKey] ?? 0; if (currentRetries < maxRetries) { retryCounts[retryCountKey] = currentRetries + 1; - // Send failure feedback to retry target if (phase.mail?.onFail && report) { const feedbackTarget = `${phase.mail.onFail}-${seedId}`; ctx.sendMailText(agentMailClient, feedbackTarget, `${phaseName.charAt(0).toUpperCase() + phaseName.slice(1)} Feedback - Retry ${currentRetries + 1}`, report); @@ -360,45 +726,38 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { ctx.log(`[${phaseName.toUpperCase()}] FAIL — looping back to ${retryTarget} (retry ${currentRetries + 1}/${maxRetries})`); await appendFile(logFile, `\n[PIPELINE] ${phaseName} failed, retrying ${retryTarget} (retry ${currentRetries + 1}/${maxRetries})\n`); - // Jump back to the retryWith phase — skip phase-complete notification const targetIdx = phaseIndex.get(retryTarget); if (targetIdx !== undefined) { i = targetIdx; continue; } - // If retryWith target not found, fall through ctx.log(`[${phaseName.toUpperCase()}] retryWith target '${retryTarget}' not found in workflow — continuing`); } else { - ctx.log(`[${phaseName.toUpperCase()}] FAIL — max retries (${maxRetries}) exhausted, continuing`); - await appendFile(logFile, `\n[PIPELINE] ${phaseName} failed after ${maxRetries} retries, continuing\n`); - // Clear feedback for subsequent phases + ctx.log(`[${phaseName.toUpperCase()}] FAIL — max retries (${maxRetries}) exhausted${failOnRetriesExhausted ? "" : ", continuing"}`); + await appendFile(logFile, `\n[PIPELINE] ${phaseName} failed after ${maxRetries} retries${failOnRetriesExhausted ? "" : ", continuing"}\n`); feedbackContext = undefined; + if (failOnRetriesExhausted) { + return { success: false, phaseRecords, retryCounts, qaVerdictForLog, progress, retriesExhausted: true }; + } } } else { - // Verdict passed or no retry config — clear feedback feedbackContext = undefined; } } else { - // Non-verdict phase — clear feedback feedbackContext = undefined; } // 9. Handle success: send phase-complete, labels, forward artifact. - // This runs AFTER verdict check — if verdict was FAIL and we jumped back - // to retry, the `continue` above skips this block entirely. if (phase.mail?.onComplete !== false) { ctx.sendMail(agentMailClient, "foreman", "phase-complete", { seedId, phase: phaseName, status: "completed", cost: result.costUsd, turns: result.turns, }); } - store.logEvent(projectId, "complete", { seedId, phase: phaseName, costUsd: result.costUsd }, runId); + store.logEvent(config.projectId, "complete", { seedId, phase: phaseName, costUsd: result.costUsd }, runId); enqueueAddLabelsToBead(store, seedId, [`phase:${phaseName}`], "pipeline-executor"); - // Update native task store with phase completion (REQ-012, AC-012.2). - // No-op when ctx.taskStore is absent or config.taskId is null/undefined. ctx.taskStore?.updatePhase(config.taskId ?? null, phaseName); - // Forward artifact to another agent's inbox if (phase.mail?.forwardArtifactTo && phase.artifact) { const artifactContent = readReport(worktreePath, phase.artifact); if (artifactContent) { @@ -415,7 +774,22 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { i++; } - // ── Session log ────────────────────────────────────────────────────── + return { success: true, phaseRecords, retryCounts, qaVerdictForLog, progress }; +} + +// ── Session log helper ────────────────────────────────────────────────────── + +async function writeSessionLogSafe( + ctx: PipelineContext, + progress: RunProgress, + phaseRecords: PhaseRecord[], + retryCounts: Record<string, number>, + qaVerdictForLog: "pass" | "fail" | "unknown", +): Promise<void> { + const { config } = ctx; + const { seedId, seedTitle, worktreePath } = config; + const description = config.seedDescription ?? "(no description)"; + try { const pipelineProjectPath = config.projectPath ?? join(worktreePath, "..", ".."); const sessionLogData: SessionLogData = { @@ -437,11 +811,4 @@ export async function executePipeline(ctx: PipelineContext): Promise<void> { const msg = err instanceof Error ? err.message : String(err); ctx.log(`[SESSION LOG] Failed to write (non-fatal): ${msg}`); } - - // ── Pipeline completion ────────────────────────────────────────────── - // Delegate finalize-specific post-processing (merge queue, run status) - // to the caller via the onPipelineComplete callback. - if (ctx.onPipelineComplete) { - await ctx.onPipelineComplete({ progress, phaseRecords, retryCounts }); - } } diff --git a/src/orchestrator/task-ordering.ts b/src/orchestrator/task-ordering.ts new file mode 100644 index 0000000..43ff286 --- /dev/null +++ b/src/orchestrator/task-ordering.ts @@ -0,0 +1,212 @@ +/** + * task-ordering.ts — Determine execution order for child tasks in an epic. + * + * Primary: use bv --robot-next to get graph-aware ordering. + * Fallback: topological sort of child bead dependencies with priority tiebreaker. + */ + +import { BvClient } from "../lib/bv.js"; +import type { BeadsRustClient, BrIssueDetail } from "../lib/beads-rust.js"; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface OrderedTask { + seedId: string; + seedTitle: string; + seedDescription?: string; +} + +export class CircularDependencyError extends Error { + constructor(public readonly cycle: string[]) { + super(`Circular dependency detected: ${cycle.join(" → ")}`); + this.name = "CircularDependencyError"; + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Get ordered list of child tasks for an epic bead. + * + * Tries bv --robot-next first for graph-aware ordering. + * Falls back to topological sort of br dependencies with priority as tiebreaker. + * + * @param epicId - The parent epic bead ID. + * @param brClient - BeadsRustClient for querying bead details. + * @param projectPath - Project root for bv invocation. + * @param useBv - Whether to attempt bv ordering (default: true). + * @returns Ordered list of child tasks. + */ +export async function getTaskOrder( + epicId: string, + brClient: BeadsRustClient, + projectPath: string, + useBv: boolean = true, +): Promise<OrderedTask[]> { + // Get all children of the epic + const epicDetail = await brClient.show(epicId) as BrIssueDetail; + const childIds = epicDetail.children ?? []; + + if (childIds.length === 0) { + return []; + } + + // Load details for all children + const childDetails = new Map<string, BrIssueDetail>(); + for (const childId of childIds) { + try { + const detail = await brClient.show(childId) as BrIssueDetail; + // Only include task-type children (skip feature/story containers) + if (detail.type === "task" || detail.type === "bug" || detail.type === "chore") { + childDetails.set(childId, detail); + } + } catch { + // Skip children we can't load + } + } + + if (childDetails.size === 0) { + return []; + } + + // Try bv ordering first + if (useBv) { + const bvOrder = await getBvOrder(childDetails, projectPath); + if (bvOrder !== null) { + return bvOrder; + } + } + + // Fallback: topological sort + return topologicalSort(childDetails); +} + +// ── BV ordering ───────────────────────────────────────────────────────────── + +async function getBvOrder( + childDetails: Map<string, BrIssueDetail>, + projectPath: string, +): Promise<OrderedTask[] | null> { + const bv = new BvClient(projectPath); + const childIds = new Set(childDetails.keys()); + + // Use bv --robot-next iteratively to build order. + // Since bv considers the full graph including blockers, we query it + // and filter results to only include our epic's children. + const triage = await bv.robotTriage(); + if (triage === null) return null; + + const ordered: OrderedTask[] = []; + const seen = new Set<string>(); + + // Use triage recommendations, filtered to our children + for (const rec of triage.recommendations) { + if (childIds.has(rec.id) && !seen.has(rec.id)) { + const detail = childDetails.get(rec.id); + if (detail) { + ordered.push({ + seedId: detail.id, + seedTitle: detail.title, + seedDescription: detail.description ?? undefined, + }); + seen.add(rec.id); + } + } + } + + // Add any children not in triage results (bv may not rank all) + for (const [id, detail] of childDetails) { + if (!seen.has(id)) { + ordered.push({ + seedId: detail.id, + seedTitle: detail.title, + seedDescription: detail.description ?? undefined, + }); + } + } + + return ordered.length > 0 ? ordered : null; +} + +// ── Topological sort ──────────────────────────────────────────────────────── + +/** + * Topological sort of child tasks based on their dependency edges. + * Uses Kahn's algorithm. Priority (lower = higher priority) breaks ties. + * + * @throws CircularDependencyError if a cycle is detected. + */ +function topologicalSort(childDetails: Map<string, BrIssueDetail>): OrderedTask[] { + const childIds = new Set(childDetails.keys()); + + // Build adjacency and in-degree within the child set + const inDegree = new Map<string, number>(); + const dependents = new Map<string, string[]>(); // dep → [tasks that depend on it] + + for (const id of childIds) { + inDegree.set(id, 0); + dependents.set(id, []); + } + + for (const [id, detail] of childDetails) { + for (const dep of detail.dependencies) { + // Only count deps within our child set + if (childIds.has(dep)) { + inDegree.set(id, (inDegree.get(id) ?? 0) + 1); + dependents.get(dep)?.push(id); + } + } + } + + // Kahn's algorithm with priority-based tie-breaking + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + // Sort queue by priority (lower number = higher priority) + const getPriority = (id: string): number => { + const detail = childDetails.get(id); + if (!detail) return 99; + const p = parseInt(detail.priority.replace(/^P/i, ""), 10); + return isNaN(p) ? 99 : p; + }; + + queue.sort((a, b) => getPriority(a) - getPriority(b)); + + const result: OrderedTask[] = []; + while (queue.length > 0) { + const id = queue.shift()!; + const detail = childDetails.get(id)!; + result.push({ + seedId: detail.id, + seedTitle: detail.title, + seedDescription: detail.description ?? undefined, + }); + + for (const dependent of dependents.get(id) ?? []) { + const newDeg = (inDegree.get(dependent) ?? 1) - 1; + inDegree.set(dependent, newDeg); + if (newDeg === 0) { + // Insert sorted by priority + const pri = getPriority(dependent); + let insertIdx = queue.length; + for (let j = 0; j < queue.length; j++) { + if (getPriority(queue[j]) > pri) { + insertIdx = j; + break; + } + } + queue.splice(insertIdx, 0, dependent); + } + } + } + + if (result.length < childDetails.size) { + // Cycle detected — find the cycle for error reporting + const remaining = [...childIds].filter(id => !result.some(r => r.seedId === id)); + throw new CircularDependencyError(remaining); + } + + return result; +}