headlesscode 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/ATTRIBUTION.md +53 -0
  2. package/CODE_OF_CONDUCT.md +130 -0
  3. package/CONTRIBUTING.md +107 -0
  4. package/LICENSE +202 -0
  5. package/README.md +486 -0
  6. package/SECURITY.md +211 -0
  7. package/bin/headlesscode.mjs +83 -0
  8. package/package.json +63 -0
  9. package/shared/prompts/review-mode-prompt-short.md +93 -0
  10. package/shared/prompts/review-mode-prompt.md +281 -0
  11. package/shared/rules-code/rules.md +22 -0
  12. package/shared/stacks/cpp/rules.md +30 -0
  13. package/shared/stacks/fastapi/rules.md +30 -0
  14. package/shared/stacks/javascript/rules.md +37 -0
  15. package/shared/stacks/postgresql/rules.md +31 -0
  16. package/shared/stacks/python/rules.md +35 -0
  17. package/shared/stacks/react/rules.md +11 -0
  18. package/shared/stacks/typescript/rules.md +10 -0
  19. package/src/budget/budget.ts +221 -0
  20. package/src/budget/concurrency.ts +126 -0
  21. package/src/budget/cost.ts +309 -0
  22. package/src/budget/index.ts +8 -0
  23. package/src/checkpoints/cli.ts +256 -0
  24. package/src/checkpoints/service.ts +227 -0
  25. package/src/cli.ts +1535 -0
  26. package/src/cloud/docker-provider.ts +334 -0
  27. package/src/cloud/provider.ts +300 -0
  28. package/src/codeintel/call-graph.ts +78 -0
  29. package/src/codeintel/find-references.ts +123 -0
  30. package/src/codeintel/go-to-definition.ts +193 -0
  31. package/src/codeintel/handlers.ts +190 -0
  32. package/src/codeintel/import-graph.ts +173 -0
  33. package/src/codeintel/outline.ts +180 -0
  34. package/src/codeintel/position.ts +77 -0
  35. package/src/codeintel/program.ts +350 -0
  36. package/src/codeintel/rename-symbol.ts +213 -0
  37. package/src/codeintel/tools.ts +280 -0
  38. package/src/codemap/build.ts +135 -0
  39. package/src/codemap/cli.ts +190 -0
  40. package/src/codemap/extract.ts +339 -0
  41. package/src/codemap/files.ts +236 -0
  42. package/src/codemap/fingerprint.ts +65 -0
  43. package/src/codemap/flows.ts +62 -0
  44. package/src/codemap/html.ts +451 -0
  45. package/src/codemap/lock.ts +80 -0
  46. package/src/codemap/types.ts +101 -0
  47. package/src/codesearch/airunner-embedder.ts +185 -0
  48. package/src/codesearch/chunk.ts +339 -0
  49. package/src/codesearch/cli.ts +223 -0
  50. package/src/codesearch/embedder.ts +332 -0
  51. package/src/codesearch/files.ts +280 -0
  52. package/src/codesearch/index.ts +469 -0
  53. package/src/codesearch/ollama-embedder.ts +205 -0
  54. package/src/codesearch/search.ts +141 -0
  55. package/src/codesearch/types.ts +100 -0
  56. package/src/config/mode-models.ts +218 -0
  57. package/src/dashboard/aggregate.ts +364 -0
  58. package/src/dashboard/chat-thread.ts +141 -0
  59. package/src/dashboard/checkpoints.ts +124 -0
  60. package/src/dashboard/cli.ts +193 -0
  61. package/src/dashboard/codemap.ts +44 -0
  62. package/src/dashboard/files.ts +121 -0
  63. package/src/dashboard/page.ts +2803 -0
  64. package/src/dashboard/self-improvement-metrics.ts +282 -0
  65. package/src/dashboard/server.ts +1103 -0
  66. package/src/dashboard/session-launch.ts +310 -0
  67. package/src/dashboard/timeline.ts +273 -0
  68. package/src/dashboard/tool-exec.ts +107 -0
  69. package/src/dashboard/trend-cli.ts +141 -0
  70. package/src/dashboard/trend.ts +413 -0
  71. package/src/decision-proxy/cli.ts +261 -0
  72. package/src/decision-proxy/proxy.ts +569 -0
  73. package/src/deploy/gate-cli.ts +147 -0
  74. package/src/deploy/gate.ts +254 -0
  75. package/src/engine/condense.ts +512 -0
  76. package/src/engine/events.ts +428 -0
  77. package/src/engine/handoff.ts +71 -0
  78. package/src/engine/lazy-tools.ts +160 -0
  79. package/src/engine/local-explore.ts +653 -0
  80. package/src/engine/logger.ts +96 -0
  81. package/src/engine/loop.ts +5517 -0
  82. package/src/engine/parser.ts +347 -0
  83. package/src/engine/prompt.ts +860 -0
  84. package/src/engine/reports.ts +47 -0
  85. package/src/engine/stacks.ts +448 -0
  86. package/src/engine/types.ts +291 -0
  87. package/src/engine/usage.ts +186 -0
  88. package/src/github/app-auth.ts +161 -0
  89. package/src/github/cli.ts +448 -0
  90. package/src/github/installations.ts +133 -0
  91. package/src/github/pr.ts +321 -0
  92. package/src/github/provision.ts +118 -0
  93. package/src/github/push.ts +122 -0
  94. package/src/index-util.ts +50 -0
  95. package/src/index.ts +81 -0
  96. package/src/init/cli.ts +248 -0
  97. package/src/init/gitignore.ts +74 -0
  98. package/src/llm/ollama.ts +308 -0
  99. package/src/llm/openrouter.ts +868 -0
  100. package/src/llm/preflight.ts +367 -0
  101. package/src/llm/transcript-capture.ts +84 -0
  102. package/src/memory/embed.ts +110 -0
  103. package/src/memory/index.ts +22 -0
  104. package/src/memory/local.ts +259 -0
  105. package/src/memory/summarizer.ts +283 -0
  106. package/src/memory/types.ts +153 -0
  107. package/src/memory/uwuchat.ts +157 -0
  108. package/src/migrate/cli.ts +115 -0
  109. package/src/orchestrator/analyze-cli.ts +104 -0
  110. package/src/orchestrator/auto-split.ts +206 -0
  111. package/src/orchestrator/cleanup.ts +1003 -0
  112. package/src/orchestrator/cli.ts +3571 -0
  113. package/src/orchestrator/cost-estimate.ts +564 -0
  114. package/src/orchestrator/cost-history-cli.ts +242 -0
  115. package/src/orchestrator/cost-history.ts +397 -0
  116. package/src/orchestrator/git-sync.ts +250 -0
  117. package/src/orchestrator/index.ts +153 -0
  118. package/src/orchestrator/log-analysis.ts +0 -0
  119. package/src/orchestrator/merge-check.ts +108 -0
  120. package/src/orchestrator/pipeline.ts +411 -0
  121. package/src/orchestrator/resume.ts +1940 -0
  122. package/src/orchestrator/reviewer.ts +503 -0
  123. package/src/orchestrator/split.ts +296 -0
  124. package/src/orchestrator/state.ts +542 -0
  125. package/src/orchestrator/status.ts +697 -0
  126. package/src/orchestrator/verification-gate.ts +134 -0
  127. package/src/orchestrator/watch.ts +898 -0
  128. package/src/permissions/commands.ts +1083 -0
  129. package/src/permissions/config.ts +241 -0
  130. package/src/permissions/index.ts +12 -0
  131. package/src/permissions/protected-files.ts +96 -0
  132. package/src/permissions/store-protection.ts +272 -0
  133. package/src/project-store.ts +648 -0
  134. package/src/projects/cli.ts +382 -0
  135. package/src/qa/qa.ts +487 -0
  136. package/src/tools/browser/handler.ts +346 -0
  137. package/src/tools/browser/service.ts +406 -0
  138. package/src/tools/browser/smoke.ts +78 -0
  139. package/src/tools/browser/tool.ts +99 -0
  140. package/src/tools/executor.ts +2575 -0
  141. package/src/tools/language-detect.ts +183 -0
  142. package/src/tools/output-summarizer.ts +369 -0
  143. package/src/tools/run-tests.ts +302 -0
  144. package/src/tools/set-indentation-tool.ts +49 -0
  145. package/src/tools/test-selection.ts +160 -0
  146. package/src/vendor/tests/smoke.ts +103 -0
  147. package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
  148. package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
  149. package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
  150. package/src/vendor/zoo-code/shim/os-name.ts +18 -0
  151. package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
  152. package/src/vendor/zoo-code/shim/vscode.ts +76 -0
  153. package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
  154. package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
  155. package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
  156. package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
  157. package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
  158. package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
  159. package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
  160. package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
  161. package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
  162. package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
  163. package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
  164. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
  165. package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
  166. package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
  167. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
  168. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
  169. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
  170. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
  171. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
  172. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
  173. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
  174. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
  175. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
  176. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
  177. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
  178. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
  179. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
  180. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
  181. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
  182. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
  183. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
  184. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
  185. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
  186. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
  187. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
  188. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
  189. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
  190. package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
  191. package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
  192. package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
  193. package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
  194. package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
  195. package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
  196. package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
  197. package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
  198. package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
  199. package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
  200. package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
  201. package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
  202. package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
  203. package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
  204. package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
  205. package/src/vendor/zoo-code/src/shared/language.ts +43 -0
  206. package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
  207. package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
  208. package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
  209. package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
  210. package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
  211. package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
  212. package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
  213. package/src/vendor/zoo-code/src/utils/object.ts +18 -0
  214. package/src/vendor/zoo-code/src/utils/path.ts +94 -0
  215. package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
  216. package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
  217. package/src/vendor/zoo-code/types/global-settings.ts +19 -0
  218. package/src/vendor/zoo-code/types/index.ts +22 -0
  219. package/src/vendor/zoo-code/types/message.ts +375 -0
  220. package/src/vendor/zoo-code/types/mode.ts +241 -0
  221. package/src/vendor/zoo-code/types/todo.ts +19 -0
  222. package/src/vendor/zoo-code/types/tool-params.ts +116 -0
  223. package/src/vendor/zoo-code/types/tool.ts +67 -0
  224. package/src/vendor/zoo-code/types/vscode.ts +84 -0
  225. package/src/vision/describe.ts +242 -0
  226. package/src/vision/tool.ts +91 -0
  227. package/src/watcher/cli.ts +369 -0
  228. package/src/watcher/github.ts +304 -0
  229. package/src/watcher/index.ts +59 -0
  230. package/src/watcher/state.ts +254 -0
  231. package/src/watcher/watch.ts +562 -0
  232. package/tsconfig.json +18 -0
@@ -0,0 +1,898 @@
1
+ /**
2
+ * Completion monitoring for the orchestrator (Phase 2).
3
+ *
4
+ * Replaces the GUI wake-up (xdotool phone-home / wmctrl) with
5
+ * process/IPC signals written by scripts/run-worker.sh:
6
+ *
7
+ * <worktree>/.harness.pid worker PID (written at launch)
8
+ * <worktree>/.harness.pgid worker process-group id (written by the
9
+ * wrapper under setsid; issue #20 — lets a
10
+ * stop command kill the WHOLE tree)
11
+ * <worktree>/.harness.exit worker exit code (written on harness exit)
12
+ * <worktree>/.harness.done/ completion marker DIR (mkdir-based, atomic,
13
+ * same lock-mutex convention as phone-home.sh)
14
+ * <worktree>/.harness.needs-decision decision escalation marker (JSON:
15
+ * question/suggestions?/askedAt — see
16
+ * src/tools/executor.ts's
17
+ * ask_followup_question handler)
18
+ * <worktree>/harness.log worker stdout+stderr
19
+ *
20
+ * `watchGroups` polls those markers (short configurable interval, default 5s —
21
+ * NO fixed 20s sleep), updates `.orchestrator-state.json` (status
22
+ * done|failed|blocked, exit code, summary from the harness.log tail,
23
+ * last_activity), and invokes an optional per-group callback (used to trigger
24
+ * the reviewer) when a group transitions to done. A stall guard flags groups
25
+ * whose spawned timestamp exceeds HEADLESSCODE_STALL_TIMEOUT (default 2h,
26
+ * matching the original 2h stall detection) with no completion — a group that is
27
+ * "blocked" (needs-decision marker present) is exempt from the stall guard
28
+ * while the marker exists: waiting on a real question is not stalling. Once
29
+ * the marker disappears (answered via scripts/headlesscode-answer.sh, or the
30
+ * worker's wait timed out and it moved on), the group flows back to "running"
31
+ * on the next poll.
32
+ */
33
+
34
+ import { execFileSync } from "node:child_process"
35
+ import * as fs from "node:fs"
36
+ import * as path from "node:path"
37
+ import { setTimeout as sleep } from "node:timers/promises"
38
+
39
+ import type { UsageRecord } from "../engine/usage.js"
40
+ import {
41
+ loadStateSync,
42
+ mutateState,
43
+ updateGroup,
44
+ type OrchestratorGroup,
45
+ type OrchestratorState,
46
+ } from "./state.js"
47
+ import { recordGroupCost, recordAllSessionCosts } from "./cost-history.js"
48
+
49
+ export const DEFAULT_POLL_INTERVAL_MS = 5_000
50
+ export const DEFAULT_STALL_TIMEOUT_MS = 2 * 60 * 60 * 1000 // 2h
51
+
52
+ /** Number of harness.log lines kept as the group summary. */
53
+ const SUMMARY_TAIL_LINES = 40
54
+
55
+ /**
56
+ * The harness loop's exact iteration-cap failure message (the `result.error`
57
+ * from src/engine/loop.ts, printed by src/cli.ts to stderr, which
58
+ * run-worker.sh redirects into harness.log and the watcher captures in the
59
+ * group summary). The ONLY failure reason that may auto-continue a group —
60
+ * a budget stop or a real error must stay terminal.
61
+ */
62
+ export const MAX_ITERATIONS_ERROR_RE = /Max iterations \(\d+\) reached without task completion/
63
+
64
+ /**
65
+ * Run-boundary separator that scripts/run-worker.sh appends to harness.log
66
+ * before launching each session. Since a worktree is reused across
67
+ * continuation/rework respawns and harness.log is now append-only (never
68
+ * truncated, so full log history + accumulated spend/token context survive a
69
+ * restart), tailLog() must scope to content AFTER the last separator —
70
+ * otherwise a short current-run log could still have a stale "Max iterations
71
+ * reached" line from a previous session inside its last-N-lines window and
72
+ * misfire another continuation.
73
+ */
74
+ const RUN_SEPARATOR_RE = /^===== headlesscode run start: .* =====$/
75
+
76
+ /**
77
+ * Whether a worker's log summary indicates it failed specifically because it
78
+ * hit the iteration cap ("the task is bigger than one session") vs. some
79
+ * other reason. Pure + unit-testable so the watcher's continuation decision
80
+ * stays thin (see handleIterationExhaustion in cli.ts).
81
+ */
82
+ export function isIterationExhaustion(summary: string | undefined): boolean {
83
+ return typeof summary === "string" && MAX_ITERATIONS_ERROR_RE.test(summary)
84
+ }
85
+
86
+ /**
87
+ * The harness loop's main-call failure message (src/engine/loop.ts's
88
+ * `LLM request failed on iteration N: ...`) — this is what used to be a
89
+ * completely SILENT dead end: a provider blip that outlasted the one retry
90
+ * in src/engine/loop.ts's callMainLlm (see isRetryableOpenRouterError in
91
+ * src/llm/openrouter.ts) killed the whole session, the group landed in the
92
+ * generic terminal "failed" state indistinguishable from a real code/logic
93
+ * failure, and nothing ever looked at it again — a human had to happen to
94
+ * open harness.log to learn the task itself was never actually attempted at
95
+ * fault. Observed live 2026-08-08: a hard-pinned model with no fallback
96
+ * provider (allow_fallbacks: false) hit an HTTP 520 mid-session and the
97
+ * group just sat there.
98
+ *
99
+ * Matched by the SAME error-message shapes isRetryableOpenRouterError
100
+ * classifies as transient (provider 5xx/429/no-allowed-providers, or a raw
101
+ * network-error message) — this is deliberately a narrower net than "any
102
+ * failure": a deterministic error (bad tool call, budget stop, real bug)
103
+ * must never auto-continue, only "the model provider itself misbehaved."
104
+ */
105
+ export const PROVIDER_FAILURE_RE =
106
+ /LLM request failed on iteration \d+:.*(?:OpenRouter returned HTTP (?:429|5\d\d)|no allowed providers|Network error calling OpenRouter)/is
107
+
108
+ /** Whether a worker's log summary indicates a transient LLM-provider failure (see PROVIDER_FAILURE_RE) rather than a real task/code failure. */
109
+ export function isProviderFailure(summary: string | undefined): boolean {
110
+ return typeof summary === "string" && PROVIDER_FAILURE_RE.test(summary)
111
+ }
112
+
113
+ export interface WatchOptions {
114
+ /** Target repo root containing `.worktrees/` (also used to resolve). */
115
+ repoRoot: string
116
+ /** State file path (default: <repoRoot>/.worktrees/.orchestrator-state.json). */
117
+ statePath?: string
118
+ /** Poll interval (default 5s). */
119
+ pollIntervalMs?: number
120
+ /** Stall guard (default: $HEADLESSCODE_STALL_TIMEOUT or 2h). */
121
+ stallTimeoutMs?: number
122
+ /**
123
+ * Optional callback fired when a group transitions to done/failed.
124
+ * Returning `true` tells watchGroups that the callback REWROTE the state
125
+ * file (e.g. the rework loop reset a group back to "running" and spawned a
126
+ * new worker on the same worktree): the loop reloads the state from disk
127
+ * and re-polls, so the group is picked up as "running" → "done" →
128
+ * re-reviewed exactly like a first attempt.
129
+ */
130
+ onGroupUpdate?: (group: OrchestratorGroup, state: OrchestratorState) => void | Promise<void | boolean>
131
+ /**
132
+ * Injectable stderr writer for the proactive status-change signal
133
+ * (default: process.stderr) — lets tests capture the emitted lines.
134
+ */
135
+ stderrWriter?: (text: string) => void
136
+ /** Abort the watch loop (e.g. from a parent orchestrator). */
137
+ signal?: AbortSignal
138
+ /**
139
+ * Whether this round runs the automated review step (default true,
140
+ * matching orchestrate's own `--no-review` default). Cost-history
141
+ * recording (recordCostIfTerminal) needs this: a "done" group's review
142
+ * (and, if review is clean, QA) hasn't necessarily run yet the FIRST
143
+ * moment the worker's own `.harness.done` marker appears — recording
144
+ * right then would miss those sessions' cost. When review is enabled,
145
+ * recording waits for `group.review_verdict` to be set.
146
+ */
147
+ reviewEnabled?: boolean
148
+ /**
149
+ * Whether this round runs QA (default false, matching orchestrate's own
150
+ * `--qa` opt-in default). When enabled, cost-history recording for a
151
+ * "done" group additionally waits for `group.qa` to be set.
152
+ */
153
+ qaEnabled?: boolean
154
+ }
155
+
156
+ export interface WatchSummary {
157
+ state: OrchestratorState
158
+ /** True when every group reached a terminal state (done/failed/needs-human/stalled). */
159
+ allTerminal: boolean
160
+ }
161
+
162
+ /**
163
+ * Tail a file's last `maxLines` lines (used for the group summary), scoped to
164
+ * content after the LAST run-start separator so a respawned worker's summary
165
+ * (and the exhaustion check derived from it) never sees a previous session's
166
+ * output — see RUN_SEPARATOR_RE.
167
+ */
168
+ export function tailLog(logPath: string, maxLines = SUMMARY_TAIL_LINES): string {
169
+ try {
170
+ const raw = fs.readFileSync(logPath, "utf-8")
171
+ const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== "")
172
+ let lastSeparator = -1
173
+ for (let i = lines.length - 1; i >= 0; i--) {
174
+ if (RUN_SEPARATOR_RE.test(lines[i])) {
175
+ lastSeparator = i
176
+ break
177
+ }
178
+ }
179
+ const currentRun = lastSeparator >= 0 ? lines.slice(lastSeparator + 1) : lines
180
+ return currentRun.slice(-maxLines).join("\n")
181
+ } catch {
182
+ return ""
183
+ }
184
+ }
185
+
186
+ /** Absolute path of a group's worktree inside the target repo. */
187
+ export function groupWorktreePath(repoRoot: string, group: OrchestratorGroup): string {
188
+ const rel = group.worktree ?? `.worktrees/${group.name}`
189
+ return path.resolve(repoRoot, rel)
190
+ }
191
+
192
+ function readExitCode(wtPath: string): number | undefined {
193
+ try {
194
+ const raw = fs.readFileSync(path.join(wtPath, ".harness.exit"), "utf-8").trim()
195
+ const code = Number(raw)
196
+ return Number.isFinite(code) ? code : undefined
197
+ } catch {
198
+ return undefined
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Cost/token monitoring (workstream 3): sum every usage record found under
204
+ * `<worktree>/.headlesscode/usage/*.jsonl` (the worker's session usage — see
205
+ * `src/engine/usage.ts`). Defensive: a worktree is expected to hold exactly
206
+ * one worker session's usage file, but a worktree reused across multiple
207
+ * sessions would have more than one file (or a file with multiple lines) —
208
+ * summing all records found is the correct rollup either way. Returns
209
+ * undefined when no usage data is found (missing dir, no files, or every
210
+ * file unreadable/empty) so callers don't stamp a spurious all-zero usage.
211
+ */
212
+ export function readWorktreeUsage(wtPath: string): OrchestratorGroup["usage"] | undefined {
213
+ const dir = path.join(wtPath, ".headlesscode", "usage")
214
+ let files: string[]
215
+ try {
216
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"))
217
+ } catch {
218
+ return undefined
219
+ }
220
+
221
+ let costUsd = 0
222
+ let inputTokens = 0
223
+ let outputTokens = 0
224
+ let cachedTokens = 0
225
+ let iterations = 0
226
+ let found = false
227
+
228
+ for (const file of files) {
229
+ let raw: string
230
+ try {
231
+ raw = fs.readFileSync(path.join(dir, file), "utf-8")
232
+ } catch {
233
+ continue
234
+ }
235
+ for (const line of raw.split("\n")) {
236
+ const trimmed = line.trim()
237
+ if (!trimmed) {
238
+ continue
239
+ }
240
+ try {
241
+ const record = JSON.parse(trimmed) as Partial<UsageRecord>
242
+ if (typeof record.sessionId !== "string") {
243
+ continue
244
+ }
245
+ costUsd += typeof record.costUsd === "number" ? record.costUsd : 0
246
+ inputTokens += typeof record.inputTokens === "number" ? record.inputTokens : 0
247
+ outputTokens += typeof record.outputTokens === "number" ? record.outputTokens : 0
248
+ cachedTokens += typeof record.cachedTokens === "number" ? record.cachedTokens : 0
249
+ iterations += typeof record.iterations === "number" ? record.iterations : 0
250
+ found = true
251
+ } catch {
252
+ // Loose validation: skip malformed / partially-written lines.
253
+ }
254
+ }
255
+ }
256
+
257
+ return found ? { costUsd, inputTokens, outputTokens, cachedTokens, iterations } : undefined
258
+ }
259
+
260
+ /** The rollup shape shared by totalUsage/batchUsage (see OrchestratorState). */
261
+ export interface UsageRollup {
262
+ costUsd: number
263
+ inputTokens: number
264
+ outputTokens: number
265
+ cachedTokens?: number
266
+ iterations: number
267
+ }
268
+
269
+ /** Sum every group's `usage` into a round-level total (see OrchestratorState.totalUsage). */
270
+ export function computeTotalUsage(state: OrchestratorState): UsageRollup | undefined {
271
+ return sumGroupUsage(state.groups)
272
+ }
273
+
274
+ /**
275
+ * Issue #118: per-batch usage aggregate — the sum of every group's `usage`
276
+ * that BELONGS to the current batch, matching `state.batch` by prefix against
277
+ * the group's `spawned` ISO timestamp (e.g. `batch: "round-2026-08-17"` ->
278
+ * `spawned: "2026-08-17T…"`). `totalUsage` is cumulative across ALL groups
279
+ * ever recorded in the state file (prior rounds' groups are retained for
280
+ * history/cleanup bookkeeping), so it can diverge arbitrarily from what the
281
+ * CURRENT round actually cost; this scopes the sum back to the round at hand.
282
+ * Returns undefined when no current-batch group has a usage record yet.
283
+ */
284
+ export function computeBatchUsage(state: OrchestratorState): UsageRollup | undefined {
285
+ const batch = state.batch
286
+ if (!batch) {
287
+ return undefined
288
+ }
289
+ const prefix = batch.replace(/^round-/, "")
290
+ return sumGroupUsage(
291
+ state.groups.filter((group) => {
292
+ if (!group.spawned) {
293
+ return false
294
+ }
295
+ const spawned = group.spawned
296
+ const spawnedDate = spawned.slice(0, 10)
297
+ // Full-date batch ("2026-08-17") must match the spawned DATE
298
+ // exactly; a timestamp-prefixed batch ("2026-08-17T…") must run
299
+ // through the 'T' separator (>= 11 chars) so a truncated prefix
300
+ // like "2026-08-1" can never over-match a whole day.
301
+ return spawnedDate === prefix || (prefix.length >= 11 && spawned.startsWith(prefix))
302
+ }),
303
+ )
304
+ }
305
+
306
+ function sumGroupUsage(groups: OrchestratorGroup[]): UsageRollup | undefined {
307
+ let costUsd = 0
308
+ let inputTokens = 0
309
+ let outputTokens = 0
310
+ let cachedTokens = 0
311
+ let iterations = 0
312
+ let found = false
313
+ for (const group of groups) {
314
+ if (!group.usage) {
315
+ continue
316
+ }
317
+ found = true
318
+ costUsd += group.usage.costUsd
319
+ inputTokens += group.usage.inputTokens
320
+ outputTokens += group.usage.outputTokens
321
+ cachedTokens += group.usage.cachedTokens ?? 0
322
+ iterations += group.usage.iterations
323
+ }
324
+ return found ? { costUsd, inputTokens, outputTokens, cachedTokens, iterations } : undefined
325
+ }
326
+
327
+ function hasDoneMarker(wtPath: string): boolean {
328
+ try {
329
+ return fs.statSync(path.join(wtPath, ".harness.done")).isDirectory()
330
+ } catch {
331
+ return false
332
+ }
333
+ }
334
+
335
+ /** Decision-escalation marker contents (see src/tools/executor.ts). */
336
+ interface NeedsDecision {
337
+ question: string
338
+ suggestions?: string[]
339
+ askedAt?: string
340
+ }
341
+
342
+ /** Read + loosely validate `.harness.needs-decision`; undefined when absent/malformed. */
343
+ function readNeedsDecision(wtPath: string): NeedsDecision | undefined {
344
+ let raw: string
345
+ try {
346
+ raw = fs.readFileSync(path.join(wtPath, ".harness.needs-decision"), "utf-8")
347
+ } catch {
348
+ return undefined
349
+ }
350
+ try {
351
+ const parsed: unknown = JSON.parse(raw)
352
+ if (parsed === null || typeof parsed !== "object" || typeof (parsed as Record<string, unknown>).question !== "string") {
353
+ return undefined
354
+ }
355
+ const obj = parsed as Record<string, unknown>
356
+ return {
357
+ question: obj.question as string,
358
+ ...(Array.isArray(obj.suggestions) ? { suggestions: obj.suggestions as string[] } : {}),
359
+ ...(typeof obj.askedAt === "string" ? { askedAt: obj.askedAt as string } : {}),
360
+ }
361
+ } catch {
362
+ return undefined
363
+ }
364
+ }
365
+
366
+ /** Read + validate a numeric pid/pgid marker file; undefined when absent/invalid. */
367
+ function readPidFile(wtPath: string, name: string): number | undefined {
368
+ let raw: string
369
+ try {
370
+ raw = fs.readFileSync(path.join(wtPath, name), "utf-8").trim()
371
+ } catch {
372
+ return undefined
373
+ }
374
+ if (!/^\d+$/.test(raw)) {
375
+ return undefined
376
+ }
377
+ return Number(raw)
378
+ }
379
+
380
+ /** `process.kill(pid, 0)` liveness probe (a negative pid targets a process group). */
381
+ function processAlive(pid: number): boolean {
382
+ try {
383
+ process.kill(pid, 0)
384
+ return true
385
+ } catch {
386
+ return false
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Whether the process recorded in a pid file (default `.harness.pid`) is
392
+ * ACTUALLY still running — not just whether the file exists. A worker that
393
+ * crashes or is killed without cleaning up its own pid file leaves a stale
394
+ * file behind forever; checking existence alone made a dead worker
395
+ * indistinguishable from a live one, which is what caused the 2026-08-02
396
+ * incident below (see `inspectGroup`'s stall-guard branch).
397
+ *
398
+ * The pid-file name is a parameter so the same liveness check can be reused
399
+ * for the QA session marker (`.qa.pid`) by orchestrate cleanup — one
400
+ * mechanism, never reinvented.
401
+ *
402
+ * Issue #20: for the worker's OWN pid file specifically, liveness is checked
403
+ * at the process GROUP level when available (`.harness.pgid`, written by
404
+ * run-worker.sh's setsid launch): killing only the wrapper bash — the
405
+ * `.harness.pid` process — leaves the real child running reparented to
406
+ * init, and a wrapper-only check would report the worker dead while it
407
+ * keeps burning the session. Workers launched before the setsid change (and
408
+ * any non-default pidFile, e.g. the QA session's `.qa.pid`, which has no
409
+ * group concept) fall back to the single-pid check.
410
+ */
411
+ export function isPidAlive(wtPath: string, pidFile = ".harness.pid"): boolean {
412
+ if (pidFile === ".harness.pid") {
413
+ const pgid = readPidFile(wtPath, ".harness.pgid")
414
+ if (pgid !== undefined) {
415
+ return processAlive(-pgid)
416
+ }
417
+ }
418
+ const pid = readPidFile(wtPath, pidFile)
419
+ return pid !== undefined && processAlive(pid)
420
+ }
421
+
422
+ /**
423
+ * Inspect one group's worktree and return the next state patch, or undefined
424
+ * when nothing changed. Resolves completion (done/failed) from the
425
+ * `.harness.done` marker + `.harness.exit` code, and stalls via the guard.
426
+ */
427
+ export function inspectGroup(
428
+ repoRoot: string,
429
+ group: OrchestratorGroup,
430
+ now: number,
431
+ stallTimeoutMs: number,
432
+ ): Partial<OrchestratorGroup> | undefined {
433
+ const wtPath = groupWorktreePath(repoRoot, group)
434
+ const startedAt = group.spawned !== undefined ? Date.parse(group.spawned) : NaN
435
+ // `needs-human` is terminal: a group that exhausted its rework attempts is
436
+ // never re-polled for completion markers (no worker is running on it).
437
+ const isTerminal = group.status === "done" || group.status === "failed" || group.status === "needs-human"
438
+
439
+ if (hasDoneMarker(wtPath)) {
440
+ if (isTerminal) {
441
+ return undefined
442
+ }
443
+ const exitCode = readExitCode(wtPath)
444
+ const status = exitCode === 0 ? "done" : "failed"
445
+ const summary = tailLog(path.join(wtPath, "harness.log"))
446
+ const note =
447
+ exitCode === 0
448
+ ? `worker completed cleanly (exit 0)${summary ? " — see harness.log" : ""}`
449
+ : `worker exited ${exitCode ?? "with unknown code"} (see harness.log)`
450
+ const usage = readWorktreeUsage(wtPath)
451
+ return {
452
+ status,
453
+ last_activity: {
454
+ note,
455
+ last_commit: lastCommit(wtPath),
456
+ },
457
+ commits: group.commits ?? [],
458
+ ...(exitCode !== undefined ? { exit_code: exitCode } : {}),
459
+ // `.slice(-4000)`, NOT `.slice(0, 4000)`: this is a TAIL (already the
460
+ // last SUMMARY_TAIL_LINES lines of the run) being capped to a char
461
+ // budget. Keeping the FIRST 4000 chars of a tail throws away the most
462
+ // recent content — exactly the lines that report the actual terminal
463
+ // outcome (a worker's final error, e.g. "Max iterations (N) reached
464
+ // without task completion"). This was a real, confirmed production
465
+ // bug: it silently broke isIterationExhaustion's detection (the
466
+ // summary never contained the error string it matches against), so
467
+ // auto-continuation never fired for genuinely iteration-exhausted
468
+ // groups, AND it broke human/agent-facing status reporting (the
469
+ // "summary" shown was always a stale mid-run snapshot, never the
470
+ // real ending) — both from the same one-line bug.
471
+ summary: summary.slice(-4000),
472
+ ...(usage ? { usage } : {}),
473
+ }
474
+ }
475
+
476
+ // No done marker yet.
477
+ if (isTerminal) {
478
+ return undefined
479
+ }
480
+
481
+ // Decision escalation: the worker's ask_followup_question call is blocked
482
+ // waiting for a human/orchestrator answer. Not stalling — exempt from the
483
+ // stall guard below while the marker is present.
484
+ const decision = readNeedsDecision(wtPath)
485
+ if (decision) {
486
+ const alreadyBlocked =
487
+ group.status === "blocked" &&
488
+ group.blocked?.question === decision.question &&
489
+ group.blocked?.askedAt === decision.askedAt
490
+ if (alreadyBlocked) {
491
+ return undefined
492
+ }
493
+ return {
494
+ status: "blocked",
495
+ blocked: decision,
496
+ last_activity: {
497
+ note: `blocked: awaiting decision — ${decision.question}`,
498
+ },
499
+ }
500
+ }
501
+
502
+ // Marker gone: a previously blocked group resumed (answered or timed out
503
+ // and fell back to autonomous decision) — flow back to "running".
504
+ if (group.status === "blocked") {
505
+ return { status: "running", blocked: undefined }
506
+ }
507
+
508
+ // Stall guard: spawned long ago with no completion marker.
509
+ //
510
+ // 2026-08-02 incident: this branch used to return a freshly-constructed
511
+ // patch on EVERY poll once a group passed stallTimeoutMs, even when
512
+ // nothing had actually changed since the previous poll (same "still
513
+ // stalled" note, forever). watchGroups' loop only sleeps when nothing
514
+ // changed, and treated "inspectGroup returned a patch" as "something
515
+ // changed" -- so once any group entered this state, the outer loop
516
+ // never slept again: a zero-delay busy loop that ran for 11+ hours at
517
+ // ~76% CPU, continuously rewriting .orchestrator-state.json, on a
518
+ // worktree whose worker had actually already died (a stale
519
+ // `.harness.pid` file made `hasPid` return true forever, so the branch
520
+ // never resolved to a terminal state either -- see `isPidAlive` above,
521
+ // which replaces a file-existence check with an actual liveness check).
522
+ //
523
+ // Fixed by two changes: (1) resolve to "failed" the moment the process
524
+ // is confirmed dead, instead of reporting "still stalled" forever with
525
+ // no live process behind it; (2) for a genuinely still-alive-but-slow
526
+ // worker, report the stall ONCE (the first time `group.stalled` flips
527
+ // true) rather than on every subsequent poll -- an unresolved stall
528
+ // that hasn't changed state is not new information.
529
+ if (!Number.isNaN(startedAt) && now - startedAt > stallTimeoutMs) {
530
+ if (!isPidAlive(wtPath)) {
531
+ return {
532
+ status: "failed",
533
+ stalled: true,
534
+ last_activity: {
535
+ note:
536
+ `STALLED: no completion marker after ${Math.round(stallTimeoutMs / 60000)}m ` +
537
+ `and the worker process is no longer running (spawned ${group.spawned})`,
538
+ },
539
+ }
540
+ }
541
+ if (group.stalled === true) {
542
+ // Already reported; still alive, still no completion -- not new.
543
+ return undefined
544
+ }
545
+ return {
546
+ stalled: true,
547
+ last_activity: {
548
+ note: `STALLED: no completion marker after ${Math.round(stallTimeoutMs / 60000)}m (spawned ${group.spawned})`,
549
+ },
550
+ }
551
+ }
552
+
553
+ return undefined
554
+ }
555
+
556
+ function lastCommit(wtPath: string): string {
557
+ try {
558
+ const out = execFileSync("git", ["-C", wtPath, "log", "--oneline", "-1"], {
559
+ encoding: "utf-8",
560
+ timeout: 5000,
561
+ })
562
+ return out.trim()
563
+ } catch {
564
+ return ""
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Print a concise, human-facing status-change line to the watch loop's
570
+ * stderr writer (proactive monitoring — see plans/live-monitoring-gaps.md,
571
+ * gap 2). Only ever called on a genuine status transition by watchGroups,
572
+ * so a group that stays blocked/running never spams the same lines. The
573
+ * review verdict per group is intentionally omitted: the orchestrator CLI
574
+ * already prints it (`[orchestrate] <group> review verdict: …`) after
575
+ * running the reviewer.
576
+ */
577
+ function printStatusTransition(
578
+ write: (text: string) => void,
579
+ group: OrchestratorGroup,
580
+ patch: Partial<Omit<OrchestratorGroup, "name">>,
581
+ newStatus: string,
582
+ ): void {
583
+ const worktree = group.worktree ?? `.worktrees/${group.name}`
584
+ switch (newStatus) {
585
+ case "blocked": {
586
+ const question = patch.blocked?.question ?? ""
587
+ write(`[orchestrate] BLOCKED: group "${group.name}" needs a decision — "${question}"\n`)
588
+ write(`[orchestrate] answer with: scripts/headlesscode-answer.sh ${worktree} "<answer>"\n`)
589
+ break
590
+ }
591
+ case "done":
592
+ write(`[orchestrate] DONE: group "${group.name}" finished (exit ${patch.exit_code ?? 0})\n`)
593
+ break
594
+ case "failed":
595
+ write(
596
+ `[orchestrate] FAILED: group "${group.name}" failed` +
597
+ (patch.stalled ? " (stalled)" : ` (exit ${patch.exit_code ?? "?"})`) +
598
+ "\n",
599
+ )
600
+ break
601
+ case "needs-human":
602
+ write(
603
+ `[orchestrate] NEEDS-HUMAN: group "${group.name}" exhausted ` +
604
+ `${group.reworkCount ?? 0} rework attempt(s) and still has review findings — a human must look at this\n`,
605
+ )
606
+ break
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Poll all non-terminal groups until every group is terminal (done/failed),
612
+ * the abort signal fires, or the callback aborts. State changes are persisted
613
+ * to the state file; `onGroupUpdate` is called after each persisted change.
614
+ *
615
+ * Returns the final state + whether all groups reached a terminal status.
616
+ */
617
+ export async function watchGroups(options: WatchOptions): Promise<WatchSummary> {
618
+ const repoRoot = path.resolve(options.repoRoot)
619
+ const statePath = options.statePath ?? path.join(repoRoot, ".worktrees", ".orchestrator-state.json")
620
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
621
+ const stallTimeoutMs =
622
+ options.stallTimeoutMs ?? Number(process.env.HEADLESSCODE_STALL_TIMEOUT ?? DEFAULT_STALL_TIMEOUT_MS)
623
+
624
+ let state = loadStateSync(statePath)
625
+
626
+ const writeStderr = options.stderrWriter ?? ((text: string) => process.stderr.write(text))
627
+
628
+ // Watcher heartbeat (issue #116): stamp <statePath>.watcher with our PID
629
+ // at the same cadence as the poll loop. A concurrent read-side `status
630
+ // --wait` (status.ts) checks this file's freshness to decide whether a
631
+ // live watch loop owns the state — when it does, the status call must NOT
632
+ // persist its read-side reconciliation (that would pre-empt this loop,
633
+ // which then short-circuits the group as already-terminal and silently
634
+ // skips log analysis + the automated review). Best-effort: a heartbeat
635
+ // write failure must never crash the watcher.
636
+ const heartbeatPath = `${statePath}.watcher`
637
+ const heartbeat = (): void => {
638
+ try {
639
+ fs.writeFileSync(heartbeatPath, String(process.pid))
640
+ } catch {
641
+ // Best-effort — see above.
642
+ }
643
+ }
644
+ heartbeat()
645
+
646
+ // Previously-seen status per group, seeded from the loaded state: the
647
+ // loop only prints a status-change line on a genuine transition, so a
648
+ // group that stays blocked/running across polls never re-spams it.
649
+ const lastSeenStatus = new Map<string, string>()
650
+ for (const g of state.groups) {
651
+ lastSeenStatus.set(g.name, g.status)
652
+ }
653
+
654
+ const isTerminal = (g: OrchestratorGroup): boolean =>
655
+ g.status === "done" || g.status === "failed" || g.status === "needs-human"
656
+
657
+ const reviewEnabled = options.reviewEnabled ?? true
658
+ const qaEnabled = options.qaEnabled ?? false
659
+
660
+ /**
661
+ * Whether a group has NO more automated work pending, i.e. it's safe to
662
+ * record its cost as final. For "failed"/"needs-human" this is just
663
+ * `isTerminal` — cli.ts's review/QA sections both gate on
664
+ * `group.status === "done"`, so a failed or needs-human group never gets
665
+ * reviewed/QA'd at all. For "done" specifically, review (if enabled) and
666
+ * QA (if enabled) may still be IN PROGRESS the first moment the worker's
667
+ * own `.harness.done` marker appears — recording right then would miss
668
+ * those sessions' real cost entirely (they're separate HeadlessSession
669
+ * runs against the same worktree, each writing their own
670
+ * `.headlesscode/usage/*.jsonl`, but nothing re-triggers a usage rollup
671
+ * for a "done" group unless a rework respawn happens). So for "done",
672
+ * wait for `review_verdict`/`qa` to actually be populated first.
673
+ */
674
+ const isSettled = (g: OrchestratorGroup): boolean => {
675
+ if (g.status !== "done") {
676
+ return isTerminal(g)
677
+ }
678
+ const reviewSettled = !reviewEnabled || g.review_verdict !== undefined
679
+ const qaSettled = !qaEnabled || g.qa !== undefined
680
+ return reviewSettled && qaSettled
681
+ }
682
+
683
+ // Mandatory cost/token history recording (cost-history.ts): fires exactly
684
+ // once per group, the FIRST time it's observed SETTLED (see isSettled),
685
+ // regardless of which of orchestrate's many branches got it there (a
686
+ // clean "done" + review + QA, a real failure, a rework-cap exhaustion, a
687
+ // continuation-cap exhaustion all reach this the same way). A single
688
+ // insertion point here — rather than one call per terminal-outcome
689
+ // branch in cli.ts's onGroupUpdate — means no future branch can
690
+ // silently skip recording by forgetting to call it. Called from two
691
+ // spots below: for a group ALREADY settled at the top of a poll pass
692
+ // (e.g. reloaded from disk after onGroupUpdate marked it needs-human),
693
+ // and immediately after a group's settledness may have changed WITHIN
694
+ // the current pass (needed because `allTerminal` can return before the
695
+ // loop ever revisits that group at the top of a fresh pass).
696
+ const recordCostIfSettled = async (g: OrchestratorGroup): Promise<void> => {
697
+ if (!isSettled(g) || g.cost_recorded !== undefined) {
698
+ return
699
+ }
700
+ // Recompute usage FRESH from the worktree rather than trusting
701
+ // `g.usage` (which may predate the review/QA sessions that just ran
702
+ // against the same worktree, each writing their own usage file) —
703
+ // this is what actually makes review/QA cost visible in the record.
704
+ const freshUsage = readWorktreeUsage(groupWorktreePath(repoRoot, g))
705
+ try {
706
+ await recordGroupCost(repoRoot, { ...g, usage: freshUsage })
707
+ } catch (err) {
708
+ writeStderr(
709
+ `[orchestrate] cost recording for ${g.name} failed: ${err instanceof Error ? err.message : String(err)}\n`,
710
+ )
711
+ }
712
+ // Per-session breakdown (with outcome: success/error/budget/killed) —
713
+ // makes wasted spend (a session that errored, hit budget, or was
714
+ // killed before finishing, e.g. a rework respawned by a bug) visible
715
+ // on its own, not just silently folded into the group's combined
716
+ // total above.
717
+ try {
718
+ await recordAllSessionCosts(repoRoot, g)
719
+ } catch (err) {
720
+ writeStderr(
721
+ `[orchestrate] per-session cost recording for ${g.name} failed: ${err instanceof Error ? err.message : String(err)}\n`,
722
+ )
723
+ }
724
+ // Mark recorded regardless of whether recordGroupCost found usage to
725
+ // write (undefined return = nothing to record, e.g. failed before any
726
+ // session wrote a usage file) — either way, don't retry forever.
727
+ //
728
+ // Issue #79: go through mutateState (cross-process lock, same as
729
+ // patchGroup) rather than a manual loadStateSync/updateGroup/
730
+ // saveStateSync sequence — reloading fresh right before the save
731
+ // narrows the race but does not close it; a concurrent onGroupUpdate
732
+ // callback can write directly to the state file via patchGroup between
733
+ // this function's loadStateSync and its saveStateSync (e.g. cli.ts's
734
+ // clean-verdict path after a rework reset), which would otherwise
735
+ // clobber the callback's own write — a real regression this exact fix
736
+ // caught (cli.test.ts's rework/continuation-reset re-poll tests).
737
+ state = await mutateState(statePath, (fresh) => updateGroup(fresh, g.name, { cost_recorded: new Date().toISOString() }))
738
+ }
739
+
740
+ // onGroupUpdate calls in flight (keyed by group name), fired without
741
+ // blocking this loop's iteration over the OTHER groups — see issue #24:
742
+ // awaiting each group's review/QA inline in this loop meant one group's
743
+ // multi-minute review fully monopolized the watcher, leaving every other
744
+ // group's completion undetected (and its own review/QA unstarted) for as
745
+ // long as the in-flight one took. `allTerminal` below must not fire until
746
+ // this set drains, or the round would report done while review/QA for a
747
+ // group is still silently running in the background.
748
+ const inFlightCallbacks = new Map<string, Promise<void>>()
749
+
750
+ while (true) {
751
+ if (options.signal?.aborted) {
752
+ // Issue #135: a group's onGroupUpdate callback can itself be what
753
+ // triggers the abort (e.g. a caller stopping the loop the moment it
754
+ // observes the first update) — at that point the callback's own
755
+ // async tail (state re-fetch + recordCostIfSettled's saveState) is
756
+ // still in flight in inFlightCallbacks. Returning immediately let
757
+ // that write race the caller's own post-return cleanup (a test's
758
+ // `finally { rm(tmpDir) }`, or a real process exit) and occasionally
759
+ // lose: an ENOENT on saveState's rename once the target directory
760
+ // was already gone. Drain every in-flight callback before handing
761
+ // control back, so no write from this loop can ever outlive it.
762
+ await Promise.all(inFlightCallbacks.values())
763
+ break
764
+ }
765
+ // Refresh the watcher heartbeat FIRST so a concurrently-running
766
+ // `status --wait` (status.ts) sees this loop as alive before it
767
+ // considers persisting its own read-side reconciliation.
768
+ heartbeat()
769
+ // Reload fresh from disk every pass rather than trusting the loop's
770
+ // in-memory `state`: a concurrently in-flight onGroupUpdate call (see
771
+ // inFlightCallbacks above) writes review/QA patches and rework/
772
+ // continuation resets directly to the state file via patchGroup, with
773
+ // no synchronous signal back to this loop. Anything this pass reads
774
+ // must come from disk to see those writes.
775
+ state = loadStateSync(statePath)
776
+ // Resync transition tracking against whatever the fresh state shows:
777
+ // a background callback may have reset a group's status (e.g.
778
+ // "done" -> "running" for a rework/continuation respawn) since the
779
+ // last pass, and that must still print a transition line even though
780
+ // no `inspectGroup` patch drove it this pass.
781
+ for (const group of state.groups) {
782
+ const prevStatus = lastSeenStatus.get(group.name) ?? group.status
783
+ if (group.status !== prevStatus) {
784
+ printStatusTransition(writeStderr, group, { status: group.status }, group.status)
785
+ lastSeenStatus.set(group.name, group.status)
786
+ }
787
+ }
788
+ const now = Date.now()
789
+ let changed = false
790
+
791
+ for (const group of state.groups) {
792
+ // A group whose onGroupUpdate is still in flight must not be
793
+ // re-inspected: a rework/continuation reset writes the "running"
794
+ // status BEFORE it finishes clearing and re-creating the
795
+ // .harness.done/.harness.exit markers (each fs op is a separate
796
+ // await), so a poll landing in that window would see "running" +
797
+ // stale leftover markers and spuriously re-fire the callback for
798
+ // the SAME group a second time before the first invocation ever
799
+ // finished resetting them.
800
+ if (inFlightCallbacks.has(group.name)) {
801
+ continue
802
+ }
803
+ if (isTerminal(group)) {
804
+ await recordCostIfSettled(group)
805
+ continue
806
+ }
807
+ const patch = inspectGroup(repoRoot, group, now, stallTimeoutMs)
808
+ if (!patch) {
809
+ continue
810
+ }
811
+ // Proactive status-change signal (gap 2): print a clear stderr line
812
+ // on the transition into blocked / done / failed / needs-human —
813
+ // only when the status genuinely changed, never on every poll while
814
+ // a group stays in the same state.
815
+ if (typeof patch.status === "string") {
816
+ const prevStatus = lastSeenStatus.get(group.name) ?? group.status
817
+ if (patch.status !== prevStatus) {
818
+ printStatusTransition(writeStderr, group, patch, patch.status)
819
+ }
820
+ lastSeenStatus.set(group.name, patch.status)
821
+ }
822
+ // Issue #79: route this write through mutateState's cross-process
823
+ // lock (the same infra patchGroup uses) rather than applying `patch`
824
+ // to this pass's start-of-loop `state` snapshot and saving that
825
+ // directly. Without this, a concurrent in-flight onGroupUpdate
826
+ // callback (see inFlightCallbacks below) writing via patchGroup can
827
+ // have its just-written review/QA verdict silently clobbered by this
828
+ // loop's stale snapshot the next time it saves — the exact
829
+ // lost-update bug patchGroup exists to prevent, just reached from
830
+ // this loop instead of from a callback.
831
+ state = await mutateState(statePath, (fresh) => {
832
+ let next = updateGroup(fresh, group.name, patch)
833
+ // Cost/token monitoring: recompute the round-level totals whenever
834
+ // a group's usage may have changed (i.e. every state update —
835
+ // cheap, and keeps totalUsage/batchUsage never stale). totalUsage
836
+ // is cumulative across every group ever recorded; batchUsage is
837
+ // scoped to the current batch (issue #118).
838
+ next = {
839
+ ...next,
840
+ totalUsage: computeTotalUsage(next),
841
+ batchUsage: computeBatchUsage(next),
842
+ }
843
+ return next
844
+ })
845
+ changed = true
846
+ const updated = state.groups.find((g) => g.name === group.name)
847
+ // Fire the callback WITHOUT blocking this loop's iteration over the
848
+ // other groups (issue #24) — `updated` just transitioned into a
849
+ // terminal status this pass, so the top-of-loop `isTerminal` check
850
+ // above guards against re-firing it for the same group while this
851
+ // call is still in flight (its status stays "done"/"failed"/
852
+ // "needs-human" in the state file the whole time this callback
853
+ // runs, even mid-review, since review/QA outcomes are separate
854
+ // fields). Guard with inFlightCallbacks too, defensively, in case
855
+ // a future status shape changes that invariant.
856
+ if (updated && !inFlightCallbacks.has(group.name)) {
857
+ const callback = (async () => {
858
+ try {
859
+ await options.onGroupUpdate?.(updated, state)
860
+ } catch (err) {
861
+ writeStderr(
862
+ `[orchestrate] onGroupUpdate for ${group.name} failed: ${err instanceof Error ? err.message : String(err)}\n`,
863
+ )
864
+ }
865
+ // The callback (if any) persisted its patches directly to the
866
+ // state file via patchGroup — re-fetch fresh rather than
867
+ // trusting this closure's `state`, which may now be stale
868
+ // relative to disk (this group's own writes, or another
869
+ // group's concurrent writes). Record cost now rather than
870
+ // waiting for a future pass, since `allTerminal` only drains
871
+ // via inFlightCallbacks.size, not by revisiting this group.
872
+ const freshAfterCallback = loadStateSync(statePath).groups.find((g) => g.name === group.name)
873
+ if (freshAfterCallback) {
874
+ await recordCostIfSettled(freshAfterCallback)
875
+ }
876
+ })()
877
+ inFlightCallbacks.set(group.name, callback)
878
+ void callback.finally(() => {
879
+ inFlightCallbacks.delete(group.name)
880
+ })
881
+ }
882
+ }
883
+
884
+ const allTerminal =
885
+ state.groups.length > 0 && state.groups.every(isTerminal) && inFlightCallbacks.size === 0
886
+ if (allTerminal) {
887
+ // Reload once more: an in-flight callback observed as drained just
888
+ // above may have written its final patch to disk after this pass's
889
+ // `state` was last touched.
890
+ return { state: loadStateSync(statePath), allTerminal: true }
891
+ }
892
+ if (!changed) {
893
+ await sleep(pollIntervalMs)
894
+ }
895
+ }
896
+
897
+ return { state, allTerminal: state.groups.length > 0 && state.groups.every(isTerminal) }
898
+ }