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.
- package/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- 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
|
+
}
|