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,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `headlesscode orchestrate status` — read-side status command for EXTERNAL
|
|
3
|
+
* callers (an interactive orchestrator agent checking on a round from outside
|
|
4
|
+
* the watching process). Reads `.worktrees/.orchestrator-state.json` via
|
|
5
|
+
* loadStateSync (state.ts) — it never re-implements parsing and does NOT
|
|
6
|
+
* touch watch.ts's own live-monitoring loop.
|
|
7
|
+
*
|
|
8
|
+
* Reconciliation: before reporting (and on EVERY poll while waiting), every
|
|
9
|
+
* group whose `status` is non-terminal is checked against real worktree
|
|
10
|
+
* markers (`.harness.done` + `.harness.exit`), and when the ground truth
|
|
11
|
+
* proves a different status the state file is patched back via
|
|
12
|
+
* updateGroup/saveStateSync (state.ts):
|
|
13
|
+
* - `.harness.done` present -> "done" (exit 0) / "failed" (non-zero)
|
|
14
|
+
* - worktree entirely gone -> "orphaned" (terminal — evidence is gone,
|
|
15
|
+
* a human must investigate via git history)
|
|
16
|
+
* - worktree present, no marker -> left alone (genuinely still running)
|
|
17
|
+
* This closes the gap where a direct-path round (spawn-parallel-worktrees.sh,
|
|
18
|
+
* no watcher) left group entries stuck on "running" forever.
|
|
19
|
+
*
|
|
20
|
+
* Issue #116: the --wait form NEVER persists its reconciliation while a live
|
|
21
|
+
* watcher owns the state (watchGroups' heartbeat file under
|
|
22
|
+
* `<statePath>.watcher` is fresh AND written by a DIFFERENT process — the
|
|
23
|
+
* wait never writes that file itself, so its own PID can never masquerade as
|
|
24
|
+
* a live watcher; the different-PID check exists so a watcher-less wait
|
|
25
|
+
* keeps its persist-through behavior instead of locking itself out from its
|
|
26
|
+
* second poll onward) — it reconciles in-memory for terminality checks only.
|
|
27
|
+
* Persisting there would pre-empt the watcher, which then short-circuits the
|
|
28
|
+
* group as already-terminal and silently skips log analysis + the automated
|
|
29
|
+
* review pass. The one-shot form (and --wait against a watcher-less round)
|
|
30
|
+
* keeps the persist-through behavior: for a direct-path round with no
|
|
31
|
+
* watcher, `status` is the state file's only writer and must patch stale
|
|
32
|
+
* entries back.
|
|
33
|
+
*
|
|
34
|
+
* Two forms:
|
|
35
|
+
* - one-shot: print a compact per-group summary (or the reconciled state
|
|
36
|
+
* with --json) and exit.
|
|
37
|
+
* - --wait: block inside this ONE process until every group reaches a
|
|
38
|
+
* terminal status ("done" | "failed" | "needs-human" | "orphaned" —
|
|
39
|
+
* "blocked" is deliberately NOT terminal: it is a decision-escalation
|
|
40
|
+
* wait state that keeps this command waiting) or --timeout-ms elapses,
|
|
41
|
+
* then print the same summary plus a one-line verdict.
|
|
42
|
+
*
|
|
43
|
+
* Polling the state file (not fs.watch) is a deliberate choice: the file is
|
|
44
|
+
* written by watch.ts, which itself polls at DEFAULT_POLL_INTERVAL_MS, so a
|
|
45
|
+
* faster read interval buys nothing; fs.watch on a regular file is
|
|
46
|
+
* unreliable when the writer replaces the file via rename rather than
|
|
47
|
+
* truncating in place (and the file may not exist at wait-start);
|
|
48
|
+
* fs.watchFile is itself just polling with extra steps. A plain poll loop
|
|
49
|
+
* matches the existing watch.ts idiom and is trivially testable.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import * as fs from "node:fs"
|
|
53
|
+
import * as path from "node:path"
|
|
54
|
+
import { setTimeout as sleep } from "node:timers/promises"
|
|
55
|
+
|
|
56
|
+
import type { CleanupStatus } from "./cleanup.js"
|
|
57
|
+
import {
|
|
58
|
+
defaultState,
|
|
59
|
+
loadStateSync,
|
|
60
|
+
saveStateSync,
|
|
61
|
+
updateGroup,
|
|
62
|
+
type OrchestratorGroup,
|
|
63
|
+
type OrchestratorState,
|
|
64
|
+
} from "./state.js"
|
|
65
|
+
import { DEFAULT_POLL_INTERVAL_MS, DEFAULT_STALL_TIMEOUT_MS, groupWorktreePath } from "./watch.js"
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The statuses that end a round for a group. `blocked` is NOT here on
|
|
69
|
+
* purpose: a group waiting on a decision answer is not finished, and
|
|
70
|
+
* `--wait` must keep waiting rather than exit early (see state.ts's
|
|
71
|
+
* OrchestratorGroup.status doc comment). `orphaned` IS here: the group's
|
|
72
|
+
* worktree is gone, so there is nothing left to wait FOR — the evidence is
|
|
73
|
+
* gone, and reconciliation (reconcileGroups) has already surfaced the case
|
|
74
|
+
* for a human to investigate rather than silently guessing an outcome.
|
|
75
|
+
*/
|
|
76
|
+
export const TERMINAL_STATUSES = ["done", "failed", "needs-human", "orphaned"] as const
|
|
77
|
+
|
|
78
|
+
export function isTerminalStatus(status: string): boolean {
|
|
79
|
+
return TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default `--timeout-ms`: 2h, matching watch.ts's stall guard
|
|
84
|
+
* (DEFAULT_STALL_TIMEOUT_MS). A round whose groups sit non-terminal for
|
|
85
|
+
* longer than that is flagged stalled by the watcher anyway, so waiting past
|
|
86
|
+
* it for terminality is pointless — this is the ceiling and the default.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_STATUS_TIMEOUT_MS = DEFAULT_STALL_TIMEOUT_MS
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default state-file poll interval for `--wait`: 5s, matching watch.ts's
|
|
92
|
+
* DEFAULT_POLL_INTERVAL_MS — the watcher writes the file at that rate, so a
|
|
93
|
+
* shorter interval cannot observe anything earlier.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_STATUS_POLL_INTERVAL_MS = DEFAULT_POLL_INTERVAL_MS
|
|
96
|
+
|
|
97
|
+
// ─── Summary shaping ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export interface StatusGroupRow {
|
|
100
|
+
name: string
|
|
101
|
+
status: string
|
|
102
|
+
lastActivity: string
|
|
103
|
+
reviewVerdict?: string
|
|
104
|
+
findingsCount: number
|
|
105
|
+
usageCostUsd?: number
|
|
106
|
+
exitCode?: number
|
|
107
|
+
stalled: boolean
|
|
108
|
+
blockedQuestion?: string
|
|
109
|
+
qaVerdict?: string
|
|
110
|
+
/**
|
|
111
|
+
* Cleanup eligibility for TERMINAL groups (computed by the status CLI via
|
|
112
|
+
* cleanup.ts's assessGroupCleanupSync — the same gates the cleanup command
|
|
113
|
+
* uses, minus the network). eligible | blocked (<reason>) | done. Undefined
|
|
114
|
+
* for non-terminal groups (never touched by cleanup).
|
|
115
|
+
*/
|
|
116
|
+
cleanup?: CleanupStatus
|
|
117
|
+
/**
|
|
118
|
+
* Auto-continuation/rework activity (undefined/0 = never happened). A
|
|
119
|
+
* real gap this surfaces: a caller watching a round from the outside had
|
|
120
|
+
* no visibility into whether auto-continuation after --max-iterations
|
|
121
|
+
* was actually firing, so they resorted to manually re-invoking
|
|
122
|
+
* run-worker.sh against the same worktree instead of trusting the
|
|
123
|
+
* documented auto-continue behavior. Both counts already lived on
|
|
124
|
+
* OrchestratorGroup (continuationCount/reworkCount) — this just threads
|
|
125
|
+
* them through to the human/agent-facing status output.
|
|
126
|
+
*/
|
|
127
|
+
continuationCount?: number
|
|
128
|
+
reworkCount?: number
|
|
129
|
+
/**
|
|
130
|
+
* Issue #49 plan-first outcome (undefined = round was not run with
|
|
131
|
+
* --plan-first): the architect-mode planning session that ran before this
|
|
132
|
+
* group's code worker, and whether a plan actually landed in the worker's
|
|
133
|
+
* task file.
|
|
134
|
+
*/
|
|
135
|
+
planFirst?: OrchestratorGroup["plan_first"]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface StatusCounts {
|
|
139
|
+
total: number
|
|
140
|
+
done: number
|
|
141
|
+
failed: number
|
|
142
|
+
needsHuman: number
|
|
143
|
+
blocked: number
|
|
144
|
+
running: number
|
|
145
|
+
/** Worktree gone before a terminal status — surfaced for a human (terminal). */
|
|
146
|
+
orphaned: number
|
|
147
|
+
other: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface StatusSummary {
|
|
151
|
+
batch?: string
|
|
152
|
+
updated?: string
|
|
153
|
+
groups: StatusGroupRow[]
|
|
154
|
+
counts: StatusCounts
|
|
155
|
+
totalUsage?: OrchestratorState["totalUsage"]
|
|
156
|
+
/** Issue #118: usage summed over only the current batch's groups (undefined when none yet). */
|
|
157
|
+
batchUsage?: OrchestratorState["batchUsage"]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function lastActivityText(group: OrchestratorGroup): string {
|
|
161
|
+
const la = group.last_activity
|
|
162
|
+
if (typeof la === "string") {
|
|
163
|
+
return la
|
|
164
|
+
}
|
|
165
|
+
if (la !== null && typeof la === "object") {
|
|
166
|
+
return la.note ?? la.last_commit ?? ""
|
|
167
|
+
}
|
|
168
|
+
return ""
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Shape the loaded state into the compact per-group rows the CLI prints. */
|
|
172
|
+
export function buildStatusSummary(state: OrchestratorState): StatusSummary {
|
|
173
|
+
const counts: StatusCounts = {
|
|
174
|
+
total: state.groups.length,
|
|
175
|
+
done: 0,
|
|
176
|
+
failed: 0,
|
|
177
|
+
needsHuman: 0,
|
|
178
|
+
blocked: 0,
|
|
179
|
+
running: 0,
|
|
180
|
+
orphaned: 0,
|
|
181
|
+
other: 0,
|
|
182
|
+
}
|
|
183
|
+
const groups = state.groups.map((g) => {
|
|
184
|
+
switch (g.status) {
|
|
185
|
+
case "done":
|
|
186
|
+
counts.done++
|
|
187
|
+
break
|
|
188
|
+
case "failed":
|
|
189
|
+
counts.failed++
|
|
190
|
+
break
|
|
191
|
+
case "needs-human":
|
|
192
|
+
counts.needsHuman++
|
|
193
|
+
break
|
|
194
|
+
case "blocked":
|
|
195
|
+
counts.blocked++
|
|
196
|
+
break
|
|
197
|
+
case "running":
|
|
198
|
+
case "spawned":
|
|
199
|
+
counts.running++
|
|
200
|
+
break
|
|
201
|
+
case "orphaned":
|
|
202
|
+
counts.orphaned++
|
|
203
|
+
break
|
|
204
|
+
default:
|
|
205
|
+
counts.other++
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
name: g.name,
|
|
210
|
+
status: g.status,
|
|
211
|
+
lastActivity: lastActivityText(g),
|
|
212
|
+
reviewVerdict: g.review_verdict,
|
|
213
|
+
findingsCount: g.pending_review_findings?.length ?? 0,
|
|
214
|
+
usageCostUsd: g.usage?.costUsd,
|
|
215
|
+
exitCode: g.exit_code,
|
|
216
|
+
stalled: g.stalled === true,
|
|
217
|
+
blockedQuestion: g.blocked?.question,
|
|
218
|
+
qaVerdict: g.qa?.verdict,
|
|
219
|
+
continuationCount: g.continuationCount,
|
|
220
|
+
reworkCount: g.reworkCount,
|
|
221
|
+
planFirst: g.plan_first,
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
return {
|
|
225
|
+
batch: state.batch,
|
|
226
|
+
updated: state.updated,
|
|
227
|
+
groups,
|
|
228
|
+
counts,
|
|
229
|
+
totalUsage: state.totalUsage,
|
|
230
|
+
batchUsage: state.batchUsage,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One-line verdict for the `--wait` form, e.g. "3/3 groups done, 0 failed"
|
|
236
|
+
* or "timed out after 2 of 3 groups done". Orphaned groups are called out
|
|
237
|
+
* explicitly (they are terminal but NOT done — a human must investigate).
|
|
238
|
+
*/
|
|
239
|
+
export function verdictLine(summary: StatusSummary, timedOut: boolean, allDone: boolean): string {
|
|
240
|
+
const c = summary.counts
|
|
241
|
+
if (c.total === 0) {
|
|
242
|
+
return "nothing to wait for — no groups in state"
|
|
243
|
+
}
|
|
244
|
+
if (timedOut) {
|
|
245
|
+
const tail: string[] = []
|
|
246
|
+
if (c.failed > 0 || c.needsHuman > 0) {
|
|
247
|
+
tail.push(`${c.failed} failed, ${c.needsHuman} needs-human`)
|
|
248
|
+
}
|
|
249
|
+
if (c.orphaned > 0) {
|
|
250
|
+
tail.push(`${c.orphaned} orphaned`)
|
|
251
|
+
}
|
|
252
|
+
return `timed out after ${c.done} of ${c.total} group${c.total === 1 ? "" : "s"} done${tail.length > 0 ? ` (${tail.join("; ")})` : ""}`
|
|
253
|
+
}
|
|
254
|
+
if (allDone) {
|
|
255
|
+
return `${c.total}/${c.total} groups done, 0 failed`
|
|
256
|
+
}
|
|
257
|
+
// All terminal, but not all done → failed and/or needs-human and/or orphaned present.
|
|
258
|
+
const tail: string[] = []
|
|
259
|
+
if (c.failed > 0) {
|
|
260
|
+
tail.push(`${c.failed} failed`)
|
|
261
|
+
}
|
|
262
|
+
if (c.needsHuman > 0) {
|
|
263
|
+
tail.push(`${c.needsHuman} needs-human`)
|
|
264
|
+
}
|
|
265
|
+
if (c.orphaned > 0) {
|
|
266
|
+
tail.push(`${c.orphaned} orphaned`)
|
|
267
|
+
}
|
|
268
|
+
return `all ${c.total} group${c.total === 1 ? "" : "s"} terminal, but ${tail.length > 0 ? tail.join(" / ") : "something unresolved"}`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Human-readable status block (one-shot form when timedOut/allDone are omitted). */
|
|
272
|
+
export function formatStatusText(
|
|
273
|
+
summary: StatusSummary,
|
|
274
|
+
opts: {
|
|
275
|
+
repo: string
|
|
276
|
+
statePath: string
|
|
277
|
+
timedOut?: boolean
|
|
278
|
+
allDone?: boolean
|
|
279
|
+
elapsedMs?: number
|
|
280
|
+
/** Group names this call reconciled from stale markers (shown when non-empty). */
|
|
281
|
+
reconciled?: string[]
|
|
282
|
+
},
|
|
283
|
+
): string {
|
|
284
|
+
const c = summary.counts
|
|
285
|
+
const lines: string[] = []
|
|
286
|
+
lines.push(
|
|
287
|
+
`── Orchestrator status${opts.elapsedMs !== undefined ? ` (waited ${Math.round(opts.elapsedMs)}ms)` : ""} ──`,
|
|
288
|
+
)
|
|
289
|
+
lines.push(`repo: ${opts.repo}`)
|
|
290
|
+
lines.push(`state file: ${opts.statePath}`)
|
|
291
|
+
lines.push(`batch: ${summary.batch ?? "(none)"}`)
|
|
292
|
+
lines.push(`updated: ${summary.updated ?? "(never)"}`)
|
|
293
|
+
if (summary.totalUsage) {
|
|
294
|
+
const u = summary.totalUsage
|
|
295
|
+
lines.push(
|
|
296
|
+
`total usage: $${u.costUsd.toFixed(4)} · ${u.inputTokens} in / ${u.outputTokens} out tokens` +
|
|
297
|
+
(u.iterations > 0 ? ` · ${u.iterations} iterations` : ""),
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
if (summary.batchUsage) {
|
|
301
|
+
const u = summary.batchUsage
|
|
302
|
+
lines.push(
|
|
303
|
+
`batch usage: $${u.costUsd.toFixed(4)} · ${u.inputTokens} in / ${u.outputTokens} out tokens` +
|
|
304
|
+
(u.iterations > 0 ? ` · ${u.iterations} iterations` : "") +
|
|
305
|
+
" (this round only)",
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (c.total === 0) {
|
|
310
|
+
lines.push("")
|
|
311
|
+
lines.push("No orchestrator groups found in this state file (nothing to report / wait for).")
|
|
312
|
+
} else {
|
|
313
|
+
lines.push("")
|
|
314
|
+
lines.push(`${"GROUP".padEnd(14)}${"STATUS".padEnd(12)}${"CLEANUP".padEnd(12)}LAST ACTIVITY · REVIEW · QA · USAGE`)
|
|
315
|
+
for (const g of summary.groups) {
|
|
316
|
+
const bits: string[] = []
|
|
317
|
+
if (g.lastActivity) {
|
|
318
|
+
bits.push(g.lastActivity)
|
|
319
|
+
}
|
|
320
|
+
if (g.reviewVerdict) {
|
|
321
|
+
bits.push(`review:${g.reviewVerdict}${g.findingsCount > 0 ? `(${g.findingsCount})` : ""}`)
|
|
322
|
+
}
|
|
323
|
+
if (g.qaVerdict) {
|
|
324
|
+
bits.push(`qa:${g.qaVerdict}`)
|
|
325
|
+
}
|
|
326
|
+
if (g.continuationCount !== undefined && g.continuationCount > 0) {
|
|
327
|
+
bits.push(`continued:${g.continuationCount}`)
|
|
328
|
+
}
|
|
329
|
+
if (g.reworkCount !== undefined && g.reworkCount > 0) {
|
|
330
|
+
bits.push(`rework:${g.reworkCount}`)
|
|
331
|
+
}
|
|
332
|
+
if (g.planFirst !== undefined) {
|
|
333
|
+
bits.push(`plan-first:${g.planFirst.status}${g.planFirst.status === "ok" ? " (plan in task file)" : ""}`)
|
|
334
|
+
}
|
|
335
|
+
if (g.usageCostUsd !== undefined) {
|
|
336
|
+
bits.push(`$${g.usageCostUsd.toFixed(4)}`)
|
|
337
|
+
}
|
|
338
|
+
if (g.stalled) {
|
|
339
|
+
bits.push("STALLED")
|
|
340
|
+
}
|
|
341
|
+
if (g.blockedQuestion) {
|
|
342
|
+
bits.push(`blocked: ${g.blockedQuestion}`)
|
|
343
|
+
}
|
|
344
|
+
if (g.cleanup?.status === "blocked") {
|
|
345
|
+
bits.push(`cleanup: ${g.cleanup.reason}`)
|
|
346
|
+
}
|
|
347
|
+
const cleanupCell =
|
|
348
|
+
g.cleanup === undefined ? "-" : g.cleanup.status === "eligible" ? "eligible" : g.cleanup.status === "done" ? "done" : "blocked"
|
|
349
|
+
lines.push(`${g.name.padEnd(14)}${g.status.padEnd(12)}${cleanupCell.padEnd(12)}${bits.join(" · ")}`)
|
|
350
|
+
}
|
|
351
|
+
lines.push("")
|
|
352
|
+
lines.push(
|
|
353
|
+
`${c.total} group${c.total === 1 ? "" : "s"}: ${c.done} done · ${c.failed} failed · ` +
|
|
354
|
+
`${c.needsHuman} needs-human · ${c.blocked} blocked · ${c.running} running/spawned` +
|
|
355
|
+
(c.orphaned > 0 ? ` · ${c.orphaned} orphaned` : "") +
|
|
356
|
+
(c.other > 0 ? ` · ${c.other} other` : ""),
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (opts.reconciled !== undefined && opts.reconciled.length > 0) {
|
|
361
|
+
lines.push("")
|
|
362
|
+
lines.push(`reconciled: ${opts.reconciled.join(", ")} (stale status patched from worktree markers)`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (opts.timedOut !== undefined || opts.allDone !== undefined) {
|
|
366
|
+
lines.push("")
|
|
367
|
+
lines.push(`verdict: ${verdictLine(summary, opts.timedOut ?? false, opts.allDone ?? false)}`)
|
|
368
|
+
}
|
|
369
|
+
return lines.join("\n") + "\n"
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Read-side reconciliation ────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
export interface ReconcileHooks {
|
|
375
|
+
/** Worktree existence check (default: fs.existsSync). */
|
|
376
|
+
worktreeExists?: (worktreePath: string) => boolean
|
|
377
|
+
/** `.harness.done` marker check (default: directory at <wt>/.harness.done). */
|
|
378
|
+
hasDoneMarker?: (worktreePath: string) => boolean
|
|
379
|
+
/** `.harness.exit` reader (default: parse <wt>/.harness.exit, undefined when absent/invalid). */
|
|
380
|
+
readExitCode?: (worktreePath: string) => number | undefined
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface ReconcileResult {
|
|
384
|
+
/** The reconciled state (unchanged when nothing was patched). */
|
|
385
|
+
state: OrchestratorState
|
|
386
|
+
/** Group names whose status/exit_code were patched by this pass. */
|
|
387
|
+
reconciled: string[]
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Reconcile a state's non-terminal groups against REAL worktree markers.
|
|
392
|
+
* This is the fix for the 2026-08-02 `--wait` hang: a worker that genuinely
|
|
393
|
+
* finished (`.harness.done` present, process dead) but whose state entry
|
|
394
|
+
* still said "running", and groups from long-merged rounds whose worktrees
|
|
395
|
+
* were removed while the state still said "running". Only positive evidence
|
|
396
|
+
* triggers a patch — a worktree that still exists without a `.harness.done`
|
|
397
|
+
* marker is left alone (genuinely still running, not ours to second-guess).
|
|
398
|
+
*
|
|
399
|
+
* Pure: returns a new state + the names patched; does NOT persist. Callers
|
|
400
|
+
* persist via saveStateSync when `reconciled` is non-empty.
|
|
401
|
+
*/
|
|
402
|
+
export function reconcileGroups(state: OrchestratorState, repo: string, hooks: ReconcileHooks = {}): ReconcileResult {
|
|
403
|
+
const worktreeExists = hooks.worktreeExists ?? ((p: string): boolean => fs.existsSync(p))
|
|
404
|
+
const hasDoneMarker = hooks.hasDoneMarker ?? ((p: string): boolean => {
|
|
405
|
+
try {
|
|
406
|
+
return fs.statSync(path.join(p, ".harness.done")).isDirectory()
|
|
407
|
+
} catch {
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
const readExitCode = hooks.readExitCode ?? ((p: string): number | undefined => {
|
|
412
|
+
try {
|
|
413
|
+
const raw = fs.readFileSync(path.join(p, ".harness.exit"), "utf-8").trim()
|
|
414
|
+
const code = Number(raw)
|
|
415
|
+
return Number.isFinite(code) ? code : undefined
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
let current = state
|
|
422
|
+
const reconciled: string[] = []
|
|
423
|
+
for (const group of state.groups) {
|
|
424
|
+
if (isTerminalStatus(group.status)) {
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
const wtPath = groupWorktreePath(repo, group)
|
|
428
|
+
if (hasDoneMarker(wtPath)) {
|
|
429
|
+
// Ground truth exists: the worker finished. Mirror watch.ts's
|
|
430
|
+
// mapping (exit 0 → done, anything else/unknown → failed).
|
|
431
|
+
const exitCode = readExitCode(wtPath)
|
|
432
|
+
const status = exitCode === 0 ? "done" : "failed"
|
|
433
|
+
current = updateGroup(current, group.name, {
|
|
434
|
+
status,
|
|
435
|
+
...(exitCode !== undefined ? { exit_code: exitCode } : {}),
|
|
436
|
+
last_activity: {
|
|
437
|
+
note:
|
|
438
|
+
exitCode === 0
|
|
439
|
+
? "reconciled from .harness markers: worker completed cleanly (exit 0)"
|
|
440
|
+
: `reconciled from .harness markers: worker exited ${exitCode ?? "with unknown code"}`,
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
reconciled.push(group.name)
|
|
444
|
+
} else if (!worktreeExists(wtPath)) {
|
|
445
|
+
// Worktree entirely gone, never reached a terminal status: the
|
|
446
|
+
// outcome is genuinely unknowable from markers — surface it as
|
|
447
|
+
// "orphaned" for a human instead of silently guessing done.
|
|
448
|
+
current = updateGroup(current, group.name, {
|
|
449
|
+
status: "orphaned",
|
|
450
|
+
last_activity: {
|
|
451
|
+
note: "orphaned: worktree removed before a terminal status — investigate via git history",
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
reconciled.push(group.name)
|
|
455
|
+
}
|
|
456
|
+
// else: worktree still present, no .harness.done → still in progress;
|
|
457
|
+
// absence of evidence is not evidence of absence — leave as-is.
|
|
458
|
+
}
|
|
459
|
+
return { state: current, reconciled }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Blocking wait ───────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
export interface WaitForTerminalOptions {
|
|
465
|
+
/** Max wall-clock wait (default DEFAULT_STATUS_TIMEOUT_MS = 2h). */
|
|
466
|
+
timeoutMs?: number
|
|
467
|
+
/** State-file poll interval (default DEFAULT_STATUS_POLL_INTERVAL_MS = 5s). */
|
|
468
|
+
pollIntervalMs?: number
|
|
469
|
+
/** Abort the wait early (returns the current state, not timed out). */
|
|
470
|
+
signal?: AbortSignal
|
|
471
|
+
/** Injectable state reader (default: loadStateSync on statePath). */
|
|
472
|
+
readState?: () => OrchestratorState
|
|
473
|
+
/**
|
|
474
|
+
* Read-side reconciliation applied to EVERY polled state (not just the
|
|
475
|
+
* first — a group can finish mid-wait) BEFORE the terminality check.
|
|
476
|
+
* Return the (possibly patched) state plus the names reconciled; the
|
|
477
|
+
* loop accumulates the union across polls into the result's
|
|
478
|
+
* `reconciled` list.
|
|
479
|
+
*/
|
|
480
|
+
reconcile?: (state: OrchestratorState) => ReconcileResult
|
|
481
|
+
/**
|
|
482
|
+
* Called immediately when a group transitions to a terminal status
|
|
483
|
+
* (done/failed/needs-human/orphaned) during the wait — fires once per
|
|
484
|
+
* group per transition. Does NOT fire for non-terminal transitions
|
|
485
|
+
* (blocked, running, spawned). Never called for groups that were already
|
|
486
|
+
* terminal before the wait started (including groups whose initial
|
|
487
|
+
* "running" entry was stale state the FIRST reconciliation fixed to
|
|
488
|
+
* terminal — those were always terminal, just stale in the file).
|
|
489
|
+
* May return a promise; the wait loop awaits it so transitions are
|
|
490
|
+
* processed sequentially (e.g. a --on-group-terminal hook command).
|
|
491
|
+
*/
|
|
492
|
+
onGroupTerminal?: (group: OrchestratorGroup) => void | Promise<void>
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface WaitForTerminalResult {
|
|
496
|
+
state: OrchestratorState
|
|
497
|
+
/** Every group reached status "done" (all terminal AND nothing failed). */
|
|
498
|
+
allDone: boolean
|
|
499
|
+
timedOut: boolean
|
|
500
|
+
elapsedMs: number
|
|
501
|
+
/** Group names reconciled (patched to done/failed/orphaned) across all polls. */
|
|
502
|
+
reconciled: string[]
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Block until every group in the state file is terminal (or the timeout
|
|
507
|
+
* elapses, or the signal aborts). An empty state file (no round yet) returns
|
|
508
|
+
* immediately — there is nothing to wait for, and hanging on a wrong path
|
|
509
|
+
* would be worse than returning a clear "no groups" result.
|
|
510
|
+
*
|
|
511
|
+
* Transient JSON parse errors are treated as "not readable yet": the loop
|
|
512
|
+
* retries on the next poll instead of crashing the wait. This tolerance is
|
|
513
|
+
* NOT because the writer is non-atomic — saveStateSync (state.ts) writes to
|
|
514
|
+
* a pid+counter-suffixed temp sibling and renames it over the target, so a
|
|
515
|
+
* concurrent reader never observes a partially-written file. It's defensive
|
|
516
|
+
* anyway: a reader could still race a rename on filesystems/platforms where
|
|
517
|
+
* rename isn't atomic, or hit a transient ENOENT between the old file being
|
|
518
|
+
* gone and the new one appearing, so treating a parse failure as "retry, not
|
|
519
|
+
* crash" costs nothing and remains correct.
|
|
520
|
+
*/
|
|
521
|
+
export async function waitForTerminalState(
|
|
522
|
+
statePath: string,
|
|
523
|
+
opts: WaitForTerminalOptions = {},
|
|
524
|
+
): Promise<WaitForTerminalResult> {
|
|
525
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_STATUS_TIMEOUT_MS
|
|
526
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_STATUS_POLL_INTERVAL_MS
|
|
527
|
+
const readState = opts.readState ?? ((): OrchestratorState => loadStateSync(statePath))
|
|
528
|
+
const start = Date.now()
|
|
529
|
+
const deadline = start + timeoutMs
|
|
530
|
+
|
|
531
|
+
const readTolerant = (): OrchestratorState | null => {
|
|
532
|
+
try {
|
|
533
|
+
return readState()
|
|
534
|
+
} catch {
|
|
535
|
+
return null
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const reconciled: string[] = []
|
|
540
|
+
const reconciledSeen = new Set<string>()
|
|
541
|
+
|
|
542
|
+
// Apply read-side reconciliation to a freshly-read state and record any
|
|
543
|
+
// patched group names (deduped across polls). Returns the ReconcileResult
|
|
544
|
+
// (state + names patched) so callers can decide whether to persist.
|
|
545
|
+
const applyReconcile = (s: OrchestratorState): ReconcileResult => {
|
|
546
|
+
if (!opts.reconcile) {
|
|
547
|
+
return { state: s, reconciled: [] }
|
|
548
|
+
}
|
|
549
|
+
const result = opts.reconcile(s)
|
|
550
|
+
for (const name of result.reconciled) {
|
|
551
|
+
if (!reconciledSeen.has(name)) {
|
|
552
|
+
reconciledSeen.add(name)
|
|
553
|
+
reconciled.push(name)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return result
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The 2026-08-18 bug (issue #116): a `status --wait` running CONCURRENTLY
|
|
560
|
+
// with a live orchestrate round used to persist its read-side
|
|
561
|
+
// reconciliation straight back to the shared state file. The watcher's
|
|
562
|
+
// next poll then loaded the group as already-terminal and short-circuited
|
|
563
|
+
// on it — log analysis and the automated review pass never fired (groups
|
|
564
|
+
// w13/w14 settled as "done" with review_verdict/log_analysis absent).
|
|
565
|
+
// The watcher is the only process that owns review/QA/continuation
|
|
566
|
+
// workflows; a read-side `status` must never pre-empt it.
|
|
567
|
+
//
|
|
568
|
+
// Watcher heartbeat: the live watching process (watchGroups, watch.ts)
|
|
569
|
+
// stamps its own PID under <statePath>.watcher (a plain file write, no
|
|
570
|
+
// lock — the point is not exclusivity but honesty: if the watcher wrote
|
|
571
|
+
// the file RECENTLY, it is alive and owns the state). The wait NEVER
|
|
572
|
+
// writes this file itself — a wait's own PID must never masquerade as a
|
|
573
|
+
// live watcher (rework cycle 2 finding: be11a6d reintroduced the exact
|
|
574
|
+
// #116 pre-emption bug by stamping the wait's PID every poll, clobbering
|
|
575
|
+
// the real watcher's fresh heartbeat and unlocking the persist). A round
|
|
576
|
+
// with no watcher at all (direct-path spawn-parallel-worktrees.sh rounds,
|
|
577
|
+
// which `status` exists to reconcile for) leaves no heartbeat file, so
|
|
578
|
+
// the wait keeps its persist-through behavior in that case.
|
|
579
|
+
//
|
|
580
|
+
// Staleness is measured against the WATCHER's heartbeat cadence
|
|
581
|
+
// (DEFAULT_POLL_INTERVAL_MS — the watcher stamps the file once per poll),
|
|
582
|
+
// NOT this wait's own poll interval: a --wait polled faster than the
|
|
583
|
+
// watcher (e.g. --poll-interval-ms 1000 against a 5s watcher) must not
|
|
584
|
+
// declare the watcher dead between the watcher's refreshes, or it would
|
|
585
|
+
// unlock the exact #116 persist again (rework cycle 2 finding: the
|
|
586
|
+
// rework-2 mid-wait test with a 25ms poll exposed this — 3×25ms expired
|
|
587
|
+
// before the live watcher's 5s heartbeat had refreshed).
|
|
588
|
+
const WATCHER_STALENESS_MS = 3 * DEFAULT_POLL_INTERVAL_MS
|
|
589
|
+
|
|
590
|
+
const liveWatcher = (): boolean => {
|
|
591
|
+
try {
|
|
592
|
+
const stat = fs.statSync(`${statePath}.watcher`)
|
|
593
|
+
if (Date.now() - stat.mtimeMs >= WATCHER_STALENESS_MS) {
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
// The heartbeat file must carry a DIFFERENT process's PID to
|
|
597
|
+
// count: a wait must never mistake its own earlier write for a
|
|
598
|
+
// live watcher (the reviewer's finding on the first #116 fix).
|
|
599
|
+
const pid = Number(fs.readFileSync(`${statePath}.watcher`, "utf-8"))
|
|
600
|
+
return Number.isFinite(pid) && pid !== process.pid
|
|
601
|
+
} catch {
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// A single reconcile pass: apply the hook, and ONLY persist when no
|
|
607
|
+
// live watcher owns the state file (see liveWatcher above). Never
|
|
608
|
+
// persisted while a watch loop is running — that is exactly the race
|
|
609
|
+
// #116 fixes (the watcher's next poll would short-circuit the group as
|
|
610
|
+
// already-terminal and silently skip log analysis + the automated
|
|
611
|
+
// review).
|
|
612
|
+
const reconcilePass = (s: OrchestratorState): OrchestratorState => {
|
|
613
|
+
const result = applyReconcile(s)
|
|
614
|
+
if (result.reconciled.length > 0 && !liveWatcher()) {
|
|
615
|
+
try {
|
|
616
|
+
saveStateSync(statePath, result.state)
|
|
617
|
+
} catch {
|
|
618
|
+
// A read-side reconcile must never crash the wait; the next
|
|
619
|
+
// poll re-attempts.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return result.state
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
let state = readTolerant()
|
|
626
|
+
if (state !== null) {
|
|
627
|
+
state = reconcilePass(state)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Previously-seen status per group, seeded from the FIRST reconciled
|
|
631
|
+
// state (same pattern as watchGroups). The onGroupTerminal callback only
|
|
632
|
+
// fires on a genuine transition INTO a terminal status — never for a
|
|
633
|
+
// group that was already terminal when the wait started, including
|
|
634
|
+
// groups whose initial "running" entry was stale state the first
|
|
635
|
+
// reconciliation fixed to terminal (they were always terminal, just
|
|
636
|
+
// stale in the file). Comparison happens AFTER reconciliation on every
|
|
637
|
+
// poll, so a reconciliation-driven status change is only seen as a
|
|
638
|
+
// transition when the PREVIOUS poll had already observed the
|
|
639
|
+
// (reconciled) non-terminal truth — i.e. a real, live finish detected
|
|
640
|
+
// via worktree markers appearing mid-wait.
|
|
641
|
+
const lastSeenStatus = new Map<string, string>()
|
|
642
|
+
if (state !== null) {
|
|
643
|
+
for (const g of state.groups) {
|
|
644
|
+
lastSeenStatus.set(g.name, g.status)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const notifyTerminal = async (groups: OrchestratorGroup[]): Promise<void> => {
|
|
649
|
+
for (const g of groups) {
|
|
650
|
+
const prev = lastSeenStatus.get(g.name) ?? g.status
|
|
651
|
+
if (opts.onGroupTerminal && isTerminalStatus(g.status) && !isTerminalStatus(prev)) {
|
|
652
|
+
await opts.onGroupTerminal(g)
|
|
653
|
+
}
|
|
654
|
+
lastSeenStatus.set(g.name, g.status)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
while (true) {
|
|
659
|
+
if (opts.signal?.aborted) {
|
|
660
|
+
return {
|
|
661
|
+
state: state ?? defaultState(),
|
|
662
|
+
allDone: false,
|
|
663
|
+
timedOut: false,
|
|
664
|
+
elapsedMs: Date.now() - start,
|
|
665
|
+
reconciled,
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (state !== null) {
|
|
669
|
+
if (state.groups.length === 0) {
|
|
670
|
+
return { state, allDone: false, timedOut: false, elapsedMs: Date.now() - start, reconciled }
|
|
671
|
+
}
|
|
672
|
+
if (state.groups.every((g) => isTerminalStatus(g.status))) {
|
|
673
|
+
return {
|
|
674
|
+
state,
|
|
675
|
+
allDone: state.groups.every((g) => g.status === "done"),
|
|
676
|
+
timedOut: false,
|
|
677
|
+
elapsedMs: Date.now() - start,
|
|
678
|
+
reconciled,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (Date.now() >= deadline) {
|
|
683
|
+
let finalState = readTolerant() ?? state ?? defaultState()
|
|
684
|
+
finalState = reconcilePass(finalState)
|
|
685
|
+
// The final read can observe a transition that landed right at the
|
|
686
|
+
// deadline — report it before returning the timed-out result.
|
|
687
|
+
await notifyTerminal(finalState.groups)
|
|
688
|
+
return { state: finalState, allDone: false, timedOut: true, elapsedMs: Date.now() - start, reconciled }
|
|
689
|
+
}
|
|
690
|
+
await sleep(pollIntervalMs)
|
|
691
|
+
state = readTolerant()
|
|
692
|
+
if (state !== null) {
|
|
693
|
+
state = reconcilePass(state)
|
|
694
|
+
await notifyTerminal(state.groups)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|