tumwater 0.4.0 → 0.4.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/README.md +4 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +5 -2
- package/dist/src/agent/backend-traits.js +21 -0
- package/dist/src/agent/claude/claude-backend.js +1 -1
- package/dist/src/agent/claude/claude-stream.js +181 -182
- package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
- package/dist/src/agent/claude/claude-write-guard.js +5 -4
- package/dist/src/agent/codex/codex-backend.js +1 -1
- package/dist/src/agent/codex/codex-rollout.js +48 -45
- package/dist/src/agent/codex/codex-stream.js +269 -215
- package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
- package/dist/src/agent/codex/codex-write-guard.js +3 -3
- package/dist/src/agent/image-input.js +18 -87
- package/dist/src/agent/notes-shim.js +2 -2
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/agent/opencode/opencode-catalog.js +2 -2
- package/dist/src/agent/opencode/opencode-config.js +2 -2
- package/dist/src/agent/opencode/opencode-stream.js +101 -92
- package/dist/src/agent/run-agent.js +76 -382
- package/dist/src/agent/run-settle.js +25 -15
- package/dist/src/agent/run-spawn.js +226 -0
- package/dist/src/agent/run-stream.js +185 -0
- package/dist/src/agent/session-ledger.js +16 -0
- package/dist/src/agent/write-guard.js +5 -3
- package/dist/src/backlog/backlog-archive.js +86 -170
- package/dist/src/backlog/backlog-cli-view.js +89 -70
- package/dist/src/backlog/backlog-eligibility.js +51 -59
- package/dist/src/backlog/backlog-file-bugs.js +133 -0
- package/dist/src/backlog/backlog-index-data.js +159 -0
- package/dist/src/backlog/backlog-index.js +7 -5
- package/dist/src/backlog/backlog-md.js +39 -28
- package/dist/src/backlog/backlog-release.js +21 -36
- package/dist/src/backlog/backlog-render.js +1 -1
- package/dist/src/backlog/backlog-structure.js +14 -145
- package/dist/src/backlog/backlog-write.js +31 -18
- package/dist/src/backlog/backlog.js +14 -4
- package/dist/src/backlog/question-answer.js +33 -15
- package/dist/src/baseline/main-baseline.js +71 -26
- package/dist/src/baseline/main-guardrails.js +76 -15
- package/dist/src/baseline/main-red.js +45 -94
- package/dist/src/baseline/main-watch.js +9 -3
- package/dist/src/brief/brief.js +40 -0
- package/dist/src/budget/budget-fallback-view.js +50 -8
- package/dist/src/budget/budget-wiring.js +15 -11
- package/dist/src/budget/budget.js +3 -2
- package/dist/src/build/build-check-events.js +4 -2
- package/dist/src/build/build-check-report.js +2 -1
- package/dist/src/build/build-check-scoped.js +70 -41
- package/dist/src/change/change-data.js +4 -2
- package/dist/src/cli/cli-args.js +9 -0
- package/dist/src/cli/cli-command-args.js +199 -98
- package/dist/src/cli/cli-flag-specs.js +6 -4
- package/dist/src/cli/cli-marker-commands.js +2 -2
- package/dist/src/cli/cli-query-commands.js +41 -6
- package/dist/src/cli/cli-review-commands.js +2 -1
- package/dist/src/cli/cli-role-command.js +46 -92
- package/dist/src/cli/cli-start.js +123 -47
- package/dist/src/cli/follow-view.js +3 -1
- package/dist/src/cli/help.js +19 -12
- package/dist/src/cli/log-commands.js +128 -107
- package/dist/src/cli/question-commands.js +82 -63
- package/dist/src/cli.js +396 -343
- package/dist/src/collections.js +46 -9
- package/dist/src/config/config-fallbacks.js +0 -4
- package/dist/src/config/config-live.js +179 -147
- package/dist/src/config/config-request.js +176 -51
- package/dist/src/config/config-validation-roles.js +271 -0
- package/dist/src/config/config-validation.js +10 -205
- package/dist/src/config/config-views.js +2 -7
- package/dist/src/config/config-write.js +205 -175
- package/dist/src/config/config.js +2 -1
- package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
- package/dist/src/doctor/doctor-backlog-plans.js +24 -25
- package/dist/src/doctor/doctor-backlog.js +34 -42
- package/dist/src/doctor/doctor-check-names.js +4 -2
- package/dist/src/doctor/doctor-checks.js +16 -4
- package/dist/src/doctor/doctor-claude-checks.js +2 -2
- package/dist/src/doctor/doctor-codex-checks.js +9 -20
- package/dist/src/doctor/doctor-contention-checks.js +52 -19
- package/dist/src/doctor/doctor-holds.js +22 -20
- package/dist/src/doctor/doctor-model-checks.js +8 -184
- package/dist/src/doctor/doctor-notify.js +2 -5
- package/dist/src/doctor/doctor-opencode-checks.js +90 -41
- package/dist/src/doctor/doctor-principles.js +26 -0
- package/dist/src/doctor/doctor-project-checks.js +69 -19
- package/dist/src/doctor/doctor-role-notes.js +33 -0
- package/dist/src/doctor/doctor.js +13 -6
- package/dist/src/doctor/probe.js +1 -1
- package/dist/src/events/event-format.js +307 -162
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-legacy.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +15 -2
- package/dist/src/events/notify.js +1 -1
- package/dist/src/failure/failure-cluster.js +4 -3
- package/dist/src/failure/failure-data.js +126 -94
- package/dist/src/failure/failure-render.js +58 -17
- package/dist/src/failure/failure-state-change.js +308 -27
- package/dist/src/failure/time-spend.js +139 -106
- package/dist/src/files/files.js +7 -0
- package/dist/src/files/json-object.js +15 -2
- package/dist/src/gates/budget-gates.js +90 -68
- package/dist/src/gates/gate-polls.js +92 -61
- package/dist/src/gates/role-cap-gates.js +1 -1
- package/dist/src/gates/startup-gate.js +28 -28
- package/dist/src/gates/streak-gate.js +1 -1
- package/dist/src/git/git.js +1 -1
- package/dist/src/git/slot-salvage.js +9 -9
- package/dist/src/git/slots-state.js +12 -3
- package/dist/src/git/worktree-pool-startup.js +127 -0
- package/dist/src/git/worktree-pool.js +1 -120
- package/dist/src/gui/gui-admin-endpoints.js +3 -4
- package/dist/src/gui/gui-args.js +1 -1
- package/dist/src/gui/gui-backlog-commands.js +20 -11
- package/dist/src/gui/gui-backlog-endpoints.js +2 -8
- package/dist/src/gui/gui-config-commands.js +51 -76
- package/dist/src/gui/gui-endpoint-commands.js +4 -2
- package/dist/src/gui/gui-endpoint-common.js +4 -3
- package/dist/src/gui/gui-history-endpoints.js +36 -27
- package/dist/src/gui/gui-operator-commands.js +43 -17
- package/dist/src/gui/gui-prompt-commands.js +27 -21
- package/dist/src/gui/gui-quarantine-commands.js +30 -0
- package/dist/src/gui/gui-report-endpoints.js +13 -9
- package/dist/src/gui/gui-server.js +5 -3
- package/dist/src/gui/gui-team-commands.js +1 -1
- package/dist/src/history/history-data.js +7 -1
- package/dist/src/history/history-render.js +15 -10
- package/dist/src/history/history.js +103 -68
- package/dist/src/inbox/inbox-submit.js +1 -5
- package/dist/src/inbox/pending-prompt.js +3 -2
- package/dist/src/inbox/prompt-commands.js +194 -161
- package/dist/src/init/init.js +104 -25
- package/dist/src/merge/merge-core.js +1 -1
- package/dist/src/merge/merge-drain.js +1 -1
- package/dist/src/merge/merge-git.js +5 -5
- package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
- package/dist/src/merge/merge-queue.js +2 -1
- package/dist/src/merge/merge-to-main.js +1 -1
- package/dist/src/merge/merge-verify.js +3 -2
- package/dist/src/operator/operator-commands.js +84 -52
- package/dist/src/operator/operator-intent.js +26 -3
- package/dist/src/operator/retire.js +2 -1
- package/dist/src/operator/revert.js +18 -12
- package/dist/src/orchestrator/orchestrator-poll.js +2 -2
- package/dist/src/orchestrator/orchestrator.js +1 -1
- package/dist/src/orchestrator/retention.js +6 -5
- package/dist/src/paths.js +10 -10
- package/dist/src/pi/pi-bin.js +2 -2
- package/dist/src/pi/pi-models.js +9 -0
- package/dist/src/pi/pi-stream.js +63 -41
- package/dist/src/pi/pi-watchdogs.js +84 -49
- package/dist/src/pi-extension/context-shake.js +55 -31
- package/dist/src/pi-extension/full-output.js +2 -3
- package/dist/src/process/process.js +24 -1
- package/dist/src/process/run-marker.js +2 -2
- package/dist/src/process/supervisor.js +24 -5
- package/dist/src/prompt/gate-notes.js +41 -16
- package/dist/src/prompt/notes-backlog.js +22 -29
- package/dist/src/prompt/principles.js +11 -0
- package/dist/src/prompt/prompt-followup.js +4 -12
- package/dist/src/prompt/prompt.js +15 -12
- package/dist/src/redeploy/redeploy-probes.js +11 -1
- package/dist/src/redeploy/redeployer.js +54 -7
- package/dist/src/redeploy/self-reload.js +7 -2
- package/dist/src/report/report-data.js +224 -320
- package/dist/src/report/report-fold.js +23 -231
- package/dist/src/report/report-main-red.js +70 -3
- package/dist/src/report/report-render.js +181 -48
- package/dist/src/report/report-since-data.js +218 -0
- package/dist/src/report/report-usage-fold.js +288 -0
- package/dist/src/report/report.js +2 -1
- package/dist/src/review/known-flakes.js +22 -3
- package/dist/src/review/post-merge-review-accept.js +3 -2
- package/dist/src/review/post-merge-review.js +16 -14
- package/dist/src/review/quarantine-render.js +29 -11
- package/dist/src/review/review-precheck.js +2 -1
- package/dist/src/roles/role-catalog.js +30 -29
- package/dist/src/roles/role-guidance.js +26 -43
- package/dist/src/roles/role-render.js +19 -0
- package/dist/src/roles/role-view.js +19 -5
- package/dist/src/roles/roles.js +2 -22
- package/dist/src/run/ci-parity.js +5 -6
- package/dist/src/run/feedback-filings.js +10 -1
- package/dist/src/run/qa-coverage.js +4 -4
- package/dist/src/run/run-apply.js +14 -2
- package/dist/src/run/run-detail-data.js +50 -10
- package/dist/src/run/run-detail-render.js +32 -1
- package/dist/src/run/run-detail.js +44 -22
- package/dist/src/run/run-dismiss.js +43 -0
- package/dist/src/run/run-finalize.js +75 -46
- package/dist/src/run/run-prompt.js +271 -187
- package/dist/src/run/run-resume.js +32 -31
- package/dist/src/run/run-stage.js +116 -64
- package/dist/src/run/run-timing.js +18 -4
- package/dist/src/run/run-verdict.js +119 -83
- package/dist/src/run/stage-backlog-checks.js +4 -31
- package/dist/src/run/stage-check.js +4 -12
- package/dist/src/run/stage-checks-shared.js +15 -0
- package/dist/src/run/stage-name-checks.js +3 -13
- package/dist/src/run/stage-path-checks.js +26 -29
- package/dist/src/runner/leftover.js +13 -4
- package/dist/src/runner/revision.js +2 -2
- package/dist/src/runner/runner-fallback.js +1 -1
- package/dist/src/runner/runner-outcome.js +0 -5
- package/dist/src/runner/runner-pi.js +72 -49
- package/dist/src/runner/runner-run.js +19 -102
- package/dist/src/runner/runner-state-load.js +0 -10
- package/dist/src/runner/runner.js +1 -32
- package/dist/src/scheduling/claims.js +4 -11
- package/dist/src/status/status-data.js +140 -91
- package/dist/src/status/status-polls.js +41 -5
- package/dist/src/status/status-role-notes.js +53 -0
- package/dist/src/status/status-roles.js +10 -37
- package/dist/src/text/datetime.js +8 -1
- package/dist/src/text/format.js +13 -1
- package/dist/src/text/phrases.js +16 -2
- package/dist/src/text/text.js +12 -0
- package/dist/src/ui/alert-feeds.js +115 -0
- package/dist/src/ui/badges.js +3 -4
- package/dist/src/ui/gui/gui-client-boot.js +78 -57
- package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
- package/dist/src/ui/gui/gui-client-drawer.js +18 -2
- package/dist/src/ui/gui/gui-client-feed.js +3 -8
- package/dist/src/ui/gui/gui-client-history.js +16 -16
- package/dist/src/ui/gui/gui-client-model.js +9 -9
- package/dist/src/ui/gui/gui-client-operator.js +6 -10
- package/dist/src/ui/gui/gui-client-pending.js +4 -4
- package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
- package/dist/src/ui/gui/gui-client-report.js +70 -11
- package/dist/src/ui/gui/gui-client-roles.js +52 -12
- package/dist/src/ui/gui/gui-client-settings.js +31 -26
- package/dist/src/ui/gui/gui-client-team.js +28 -2
- package/dist/src/ui/gui/gui-client.js +33 -4
- package/dist/src/ui/gui/gui-icons.js +7 -1
- package/dist/src/ui/progress-data.js +2 -2
- package/dist/src/ui/run-progress-model.js +2 -2
- package/dist/src/ui/status-model.js +26 -16
- package/dist/src/ui/status-payload.js +149 -326
- package/dist/src/ui/status-render.js +131 -80
- package/dist/src/ui/team-alert-backlog.js +236 -0
- package/dist/src/ui/team-alert-shared.js +92 -0
- package/dist/src/ui/team-alert-status.js +326 -0
- package/dist/src/ui/team-alert-tail.js +238 -0
- package/dist/src/ui/team-alerts.js +16 -984
- package/dist/src/ui/transcript-tail.js +137 -94
- package/dist/src/ui/tui/tui-backlog.js +1 -1
- package/dist/src/ui/tui/tui-editor-answer.js +54 -44
- package/dist/src/ui/tui/tui-editor-attach.js +74 -63
- package/dist/src/ui/tui/tui-editor-backends.js +62 -56
- package/dist/src/ui/tui/tui-editor-brief.js +64 -54
- package/dist/src/ui/tui/tui-editor-budget.js +106 -89
- package/dist/src/ui/tui/tui-editor-config.js +2 -4
- package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
- package/dist/src/ui/tui/tui-editor-entry.js +171 -153
- package/dist/src/ui/tui/tui-editor-instances.js +2 -1
- package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
- package/dist/src/ui/tui/tui-editor-interval.js +39 -112
- package/dist/src/ui/tui/tui-editor-notes.js +28 -84
- package/dist/src/ui/tui/tui-editor-pause.js +210 -183
- package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
- package/dist/src/ui/tui/tui-editor-queued.js +74 -64
- package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
- package/dist/src/ui/tui/tui-editor-release.js +85 -0
- package/dist/src/ui/tui/tui-editor-retire.js +57 -46
- package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
- package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
- package/dist/src/ui/tui/tui-editor-search.js +70 -58
- package/dist/src/ui/tui/tui-editor-session.js +3 -0
- package/dist/src/ui/tui/tui-editors.js +4 -1
- package/dist/src/ui/tui/tui-frame.js +7 -3
- package/dist/src/ui/tui/tui-history.js +12 -10
- package/dist/src/ui/tui/tui-keys.js +6 -1
- package/dist/src/ui/tui/tui-pane-activity.js +62 -50
- package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +45 -26
- package/dist/src/ui/tui/tui-pane-history.js +296 -264
- package/dist/src/ui/tui/tui-pane-report.js +74 -57
- package/dist/src/ui/tui/tui-pane-session.js +6 -2
- package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
- package/dist/src/ui/tui/tui-pane.js +201 -174
- package/dist/src/ui/tui/tui-panes.js +211 -148
- package/dist/src/ui/tui/tui.js +4 -4
- package/dist/src/verdict/fix-claim.js +18 -6
- package/dist/src/verdict/refusal.js +32 -7
- package/package.json +1 -1
- package/dist/src/doctor/image-probe.js +0 -257
- /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
- /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
package/dist/src/pi/pi-stream.js
CHANGED
|
@@ -334,18 +334,8 @@ export class PiStreamParser {
|
|
|
334
334
|
this.buffer = this.buffer.slice(start);
|
|
335
335
|
}
|
|
336
336
|
feedLine(line) {
|
|
337
|
-
|
|
338
|
-
// cumulative message snapshot from them, so the delta event is all they carry — and a
|
|
339
|
-
// content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
|
|
340
|
-
// it must not reset the hang watchdog. A content-bearing delta is real generation, and
|
|
341
|
-
// counting it is what keeps a long thinking block on a slow model from being quiet-killed
|
|
342
|
-
// as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
|
|
343
|
-
// Nothing else in this parser acts on the type, so the line is done either way.
|
|
344
|
-
if (piEventType(line) === "message_update") {
|
|
345
|
-
if (messageUpdateHasContent(parseJsonObject(line)))
|
|
346
|
-
this.progressCount += 1;
|
|
337
|
+
if (this.applyMessageUpdate(line))
|
|
347
338
|
return;
|
|
348
|
-
}
|
|
349
339
|
// pi's stream is one JSON object per line: a torn, non-JSON, or valid-JSON-scalar line is
|
|
350
340
|
// noise, not an event — parseJsonObject reads it as no data (the same policy parseEventLine
|
|
351
341
|
// applies to the harness event log and parsePiEventLine applies to pi's log lines) instead
|
|
@@ -355,6 +345,37 @@ export class PiStreamParser {
|
|
|
355
345
|
if (!parsed)
|
|
356
346
|
return; // Non-JSON noise on stdout; ignore.
|
|
357
347
|
const event = parsed;
|
|
348
|
+
this.applyErrorTexts(event);
|
|
349
|
+
this.applyRunCost(event);
|
|
350
|
+
this.applyRunUsage(event);
|
|
351
|
+
// A canonical rate_limit event carries an account-window reading: collect the newest per
|
|
352
|
+
// type, note any overage, and treat a rejected window as the transient rate limit it is.
|
|
353
|
+
if (event.type === "rate_limit")
|
|
354
|
+
this.applyRateLimit(event);
|
|
355
|
+
if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
|
|
356
|
+
this.billing = event.billing;
|
|
357
|
+
this.countProgress(event);
|
|
358
|
+
this.applyToolExecution(event);
|
|
359
|
+
if (event.type !== "message_end" || event.message?.role !== "assistant")
|
|
360
|
+
return;
|
|
361
|
+
this.applyAssistantMessage(event.message);
|
|
362
|
+
}
|
|
363
|
+
// Streaming deltas are ~72% of log bytes in observed runs. pi's JSON protocol strips the
|
|
364
|
+
// cumulative message snapshot from them, so the delta event is all they carry — and a
|
|
365
|
+
// content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
|
|
366
|
+
// it must not reset the hang watchdog. A content-bearing delta is real generation, and
|
|
367
|
+
// counting it is what keeps a long thinking block on a slow model from being quiet-killed
|
|
368
|
+
// as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
|
|
369
|
+
// Nothing else in this parser acts on the type, so the line is done either way.
|
|
370
|
+
applyMessageUpdate(line) {
|
|
371
|
+
if (piEventType(line) === "message_update") {
|
|
372
|
+
if (messageUpdateHasContent(parseJsonObject(line)))
|
|
373
|
+
this.progressCount += 1;
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
applyErrorTexts(event) {
|
|
358
379
|
// A translator-classified error (claude's `tumwaterKind`) is trusted before the text
|
|
359
380
|
// regexes: the backend read schemas, not prose, and its signal wins.
|
|
360
381
|
if (typeof event.tumwaterKind === "string" && event.tumwaterKind) {
|
|
@@ -389,19 +410,23 @@ export class PiStreamParser {
|
|
|
389
410
|
}
|
|
390
411
|
}
|
|
391
412
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
413
|
+
}
|
|
414
|
+
// A canonical run_cost is the run's authoritative cost and REPLACES the per-message sum
|
|
415
|
+
// (claude's split-block messages report cost.total 0; the final result carries the real
|
|
416
|
+
// number). A backend that emits none keeps the summed per-message figure (pi).
|
|
417
|
+
applyRunCost(event) {
|
|
395
418
|
if (event.type === "run_cost")
|
|
396
419
|
this.costUsd = usageNumber(event.total);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
}
|
|
421
|
+
// Likewise a run_usage replaces the summed per-message token figures (claude's per-message
|
|
422
|
+
// usage is the stream-start snapshot, about 8x low). OpenCode's export drives a run_usage
|
|
423
|
+
// that carries the run's whole token split — input, output, cacheRead, cacheWrite — and
|
|
424
|
+
// replaces all four summed fields, the way run_cost replaces the cost sum; a stream without
|
|
425
|
+
// one keeps its summed figures. A run_usage bearing only outputTokens (claude) keeps the
|
|
426
|
+
// historical behavior of replacing just the output sum. The split's sum is a cumulative RUN
|
|
427
|
+
// total, not a single request, so it never touches peakContextTokens (a per-request peak
|
|
428
|
+
// the stream keeps computing); the export carries no total and none is invented here.
|
|
429
|
+
applyRunUsage(event) {
|
|
405
430
|
if (event.type === "run_usage") {
|
|
406
431
|
if (event.input !== undefined || event.cacheRead !== undefined || event.cacheWrite !== undefined) {
|
|
407
432
|
const input = usageNumber(event.input);
|
|
@@ -416,26 +441,24 @@ export class PiStreamParser {
|
|
|
416
441
|
this.outputTokens = usageNumber(event.outputTokens);
|
|
417
442
|
}
|
|
418
443
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// progress, and so is a content-bearing update — but a content-free one never is: bash
|
|
427
|
-
// emits an empty tool_execution_update right after start, and a zombie stream's keepalive
|
|
428
|
-
// updates must not reset the hang watchdog any more than a content-free message delta
|
|
429
|
-
// would (message_update deltas are counted above, before this point, by the same rule).
|
|
444
|
+
}
|
|
445
|
+
// Every structured event (turn/tool/message boundaries, retries, session) is real
|
|
446
|
+
// progress, and so is a content-bearing update — but a content-free one never is: bash
|
|
447
|
+
// emits an empty tool_execution_update right after start, and a zombie stream's keepalive
|
|
448
|
+
// updates must not reset the hang watchdog any more than a content-free message delta
|
|
449
|
+
// would (message_update deltas are counted above, before this point, by the same rule).
|
|
450
|
+
countProgress(event) {
|
|
430
451
|
if (!(event.type === "tool_execution_update" && !toolUpdateHasContent(event.partialResult)))
|
|
431
452
|
this.progressCount += 1;
|
|
432
453
|
if (event.type === "compaction_start")
|
|
433
454
|
this.compacted = true;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
455
|
+
}
|
|
456
|
+
// Open-tool-call tracking for the stall warning: a call that sits open and silent is
|
|
457
|
+
// surfaced by name while the quiet watchdog still counts down. The start/update/end state
|
|
458
|
+
// machine lives in applyToolExecutionEvent, shared with the dashboards' live flag
|
|
459
|
+
// (src/ui/progress-data.ts); only the label is surface-specific — the warning names the
|
|
460
|
+
// command even when pi omits a toolName, where progress falls back to "tool".
|
|
461
|
+
applyToolExecution(event) {
|
|
439
462
|
if (event.type === "tool_execution_start" ||
|
|
440
463
|
event.type === "tool_execution_update" ||
|
|
441
464
|
event.type === "tool_execution_end") {
|
|
@@ -468,9 +491,8 @@ export class PiStreamParser {
|
|
|
468
491
|
}
|
|
469
492
|
}
|
|
470
493
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const msg = event.message;
|
|
494
|
+
}
|
|
495
|
+
applyAssistantMessage(msg) {
|
|
474
496
|
const text = messageText(msg);
|
|
475
497
|
this.finalMessageContentless = !messageContent(msg).some((c) => c.type === "toolCall" ||
|
|
476
498
|
(c.type === "text" && typeof c.text === "string" && Boolean(c.text.trim())));
|
|
@@ -7,18 +7,10 @@
|
|
|
7
7
|
* handler reads back; run-agent.ts feeds the parser live through the deps accessors, so no
|
|
8
8
|
* notification path is needed when events land. */
|
|
9
9
|
import { bufferedCommandStallMs, callBuffersOutput } from "./command-shape.js";
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
|
|
14
|
-
* settle path make. */
|
|
15
|
-
export function startPiWatchdogs(deps) {
|
|
16
|
-
let lastProgressAt = Date.now();
|
|
17
|
-
let lastProgressCount = 0;
|
|
18
|
-
let sawOutput = false;
|
|
19
|
-
let timedOut = false;
|
|
20
|
-
let timedOutProgressing = false;
|
|
21
|
-
let quietKilled = false;
|
|
10
|
+
/** The run-deadline clock: kills the process tree when it fires, after giving a
|
|
11
|
+
* progressing gate run one extension, and records whether the run was still progressing
|
|
12
|
+
* (BUGS.md 2026-09-29, 2026-10-09, 2026-10-07). */
|
|
13
|
+
function startDeadlineWatch(state, deps) {
|
|
22
14
|
// A gate run earns one deadline extension while it is still making progress, so a large
|
|
23
15
|
// diff's review can finish instead of timing out mid-verification and being re-read from
|
|
24
16
|
// scratch on the next attempt (BUGS.md 2026-10-07). "Still making progress" means a
|
|
@@ -37,26 +29,28 @@ export function startPiWatchdogs(deps) {
|
|
|
37
29
|
// checkpoint covers the extension test and the kill's classification, so the two cannot
|
|
38
30
|
// disagree, and re-arming it for the extended deadline keeps the bar at `progressWindowMs`
|
|
39
31
|
// before whichever deadline finally fires.
|
|
40
|
-
let checkpoint;
|
|
41
32
|
const armCheckpoint = (deadlineMs) => {
|
|
42
|
-
clearTimeout(checkpoint);
|
|
43
|
-
checkpoint = setTimeout(() => {
|
|
33
|
+
clearTimeout(state.checkpoint);
|
|
34
|
+
state.checkpoint = setTimeout(() => {
|
|
44
35
|
checkpointProgress = deps.progressCount();
|
|
45
36
|
}, Math.max(0, deadlineMs - progressWindowMs));
|
|
46
37
|
};
|
|
47
38
|
armCheckpoint(deps.runTimeoutMs);
|
|
39
|
+
// One short beat the deadline gives a run that has begun producing progress but whose
|
|
40
|
+
// count was flat at fire time: time for a drain-delayed run's output to land before the
|
|
41
|
+
// discard (BUGS.md 2026-10-26).
|
|
42
|
+
const progressGraceMs = 250;
|
|
48
43
|
let extended = false;
|
|
49
|
-
let
|
|
50
|
-
|
|
44
|
+
let graceArmed = false; // one grace per deadline, so a genuinely stalled run is never granted two
|
|
45
|
+
state.timeoutBudgetMs = deps.runTimeoutMs;
|
|
51
46
|
const fireDeadline = () => {
|
|
52
47
|
if (deps.extendOnProgress && !extended && deps.progressCount() > checkpointProgress) {
|
|
53
48
|
extended = true;
|
|
54
|
-
timeoutBudgetMs = deps.runTimeoutMs * 2;
|
|
49
|
+
state.timeoutBudgetMs = deps.runTimeoutMs * 2;
|
|
55
50
|
armCheckpoint(deps.runTimeoutMs); // the extended deadline gets its own progress window
|
|
56
|
-
timeout = setTimeout(fireDeadline, deps.runTimeoutMs); // one more slice; the next firing is final
|
|
51
|
+
state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs); // one more slice; the next firing is final
|
|
57
52
|
return;
|
|
58
53
|
}
|
|
59
|
-
timedOut = true;
|
|
60
54
|
// A deadline that fires on a run still making progress killed a slow run, not a hung
|
|
61
55
|
// one: "recent progress" is progress since the checkpoint `progressWindowMs` before this
|
|
62
56
|
// deadline — the same window the extension test uses, so the kill's classification
|
|
@@ -64,10 +58,30 @@ export function startPiWatchdogs(deps) {
|
|
|
64
58
|
// budget (BUGS.md 2026-09-29, 2026-10-09). A run with no progress event at all — never
|
|
65
59
|
// emitted a byte, or bytes without one structured event — keeps today's discard path: it
|
|
66
60
|
// has not demonstrably begun.
|
|
67
|
-
|
|
61
|
+
//
|
|
62
|
+
// The checkpoint comparison can mis-classify a run that IS still emitting: the checkpoint
|
|
63
|
+
// and this deadline can fire in the same drain (a firing timers phase precedes the poll
|
|
64
|
+
// phase that delivers stdout), so on a main-thread stall both read the same already-final
|
|
65
|
+
// count and a progressing run looks stalled (BUGS.md 2026-10-26). A run that has begun
|
|
66
|
+
// producing structured progress therefore gets ONE short grace to drain queued output and
|
|
67
|
+
// re-prove itself; only a run still flat after the grace — or one that never progressed —
|
|
68
|
+
// keeps the discard path. The grace is one-shot, so a genuinely stalled run is not
|
|
69
|
+
// granted a second chance.
|
|
70
|
+
if (!graceArmed && deps.progressCount() > 0) {
|
|
71
|
+
graceArmed = true;
|
|
72
|
+
state.timeout = setTimeout(fireDeadline, progressGraceMs);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
state.timedOut = true;
|
|
76
|
+
state.timedOutProgressing = deps.progressCount() > 0 && deps.progressCount() > checkpointProgress;
|
|
68
77
|
deps.kill();
|
|
69
78
|
};
|
|
70
|
-
timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
|
|
79
|
+
state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
|
|
80
|
+
}
|
|
81
|
+
/** The quiet/stall clock: quiet-kills a run that stopped making progress and warns on
|
|
82
|
+
* stalled tool calls, on one interval against the wall clock (BUGS.md 2026-09-23,
|
|
83
|
+
* 2026-09-28, 2026-09-29, 2026-10-06). */
|
|
84
|
+
function startQuietWatch(state, deps) {
|
|
71
85
|
// Quiet watchdog: a healthy run makes PROGRESS continuously even when slow — messages
|
|
72
86
|
// start and end, turns and tool calls complete. Prolonged lack of progress means a hung
|
|
73
87
|
// tool (an interactive command waiting for input) or a zombie stream (a dead generation
|
|
@@ -100,7 +114,7 @@ export function startPiWatchdogs(deps) {
|
|
|
100
114
|
// begun; bytes without progress keep the doubled window (the zombie-stream case), and
|
|
101
115
|
// once real progress has landed quietTimeoutSeconds applies unchanged.
|
|
102
116
|
const zeroByteSilenceMs = Math.max(deps.quietMs * 2, 30 * 60_000);
|
|
103
|
-
const allowedSilenceMs = () => deps.progressCount() > 0 ? deps.quietMs : sawOutput ? deps.quietMs * 2 : zeroByteSilenceMs;
|
|
117
|
+
const allowedSilenceMs = () => deps.progressCount() > 0 ? deps.quietMs : state.sawOutput ? deps.quietMs * 2 : zeroByteSilenceMs;
|
|
104
118
|
// The stall warning's threshold (distinct from the kill above): one hung tool call is
|
|
105
119
|
// surfaced by name even while sibling calls keep streaming, so total silence is not
|
|
106
120
|
// required. One warning per stalled call — the interval keeps firing until the kill or
|
|
@@ -149,48 +163,69 @@ export function startPiWatchdogs(deps) {
|
|
|
149
163
|
}
|
|
150
164
|
};
|
|
151
165
|
const checkEveryMs = deps.quietMs > 0 ? deps.quietMs : deps.stallMs;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
state.quietCheck =
|
|
167
|
+
checkEveryMs > 0
|
|
168
|
+
? setInterval(() => {
|
|
169
|
+
if (deps.progressCount() > state.lastProgressCount) {
|
|
170
|
+
state.lastProgressCount = deps.progressCount();
|
|
171
|
+
state.lastProgressAt = Date.now();
|
|
172
|
+
}
|
|
173
|
+
else if (deps.quietMs > 0 && Date.now() - state.lastProgressAt > allowedSilenceMs()) {
|
|
174
|
+
state.quietKilled = true;
|
|
175
|
+
deps.kill();
|
|
176
|
+
}
|
|
177
|
+
if (deps.stallMs > 0)
|
|
178
|
+
warnStalledToolCalls();
|
|
179
|
+
}, Math.min(Math.max(checkEveryMs / 2, 250), 30_000))
|
|
180
|
+
: undefined;
|
|
181
|
+
}
|
|
182
|
+
/** Start one pi run's two watchdog clocks: the run deadline (kills the process tree when it
|
|
183
|
+
* fires, recording whether the run was still progressing) and the quiet/stall interval
|
|
184
|
+
* (quiet-kills a run that stopped making progress and warns on stalled tool calls). Returns
|
|
185
|
+
* the live flags the run's close handler reads and the note/stop calls its data handlers and
|
|
186
|
+
* settle path make. */
|
|
187
|
+
export function startPiWatchdogs(deps) {
|
|
188
|
+
const state = {
|
|
189
|
+
lastProgressAt: Date.now(),
|
|
190
|
+
lastProgressCount: 0,
|
|
191
|
+
sawOutput: false,
|
|
192
|
+
timedOut: false,
|
|
193
|
+
timedOutProgressing: false,
|
|
194
|
+
quietKilled: false,
|
|
195
|
+
timeoutBudgetMs: deps.runTimeoutMs,
|
|
196
|
+
};
|
|
197
|
+
// Each watchdog owns its own timer and the rule it enforces: the deadline/extension rule
|
|
198
|
+
// and the quiet/stall rule are each found by name here, and `stop` clears all three below.
|
|
199
|
+
startDeadlineWatch(state, deps);
|
|
200
|
+
startQuietWatch(state, deps);
|
|
166
201
|
return {
|
|
167
202
|
get timedOut() {
|
|
168
|
-
return timedOut;
|
|
203
|
+
return state.timedOut;
|
|
169
204
|
},
|
|
170
205
|
get timeoutBudgetMs() {
|
|
171
|
-
return timeoutBudgetMs;
|
|
206
|
+
return state.timeoutBudgetMs;
|
|
172
207
|
},
|
|
173
208
|
get timedOutProgressing() {
|
|
174
|
-
return timedOutProgressing;
|
|
209
|
+
return state.timedOutProgressing;
|
|
175
210
|
},
|
|
176
211
|
get quietKilled() {
|
|
177
|
-
return quietKilled;
|
|
212
|
+
return state.quietKilled;
|
|
178
213
|
},
|
|
179
214
|
get sawOutput() {
|
|
180
|
-
return sawOutput;
|
|
215
|
+
return state.sawOutput;
|
|
181
216
|
},
|
|
182
217
|
noteByte() {
|
|
183
|
-
sawOutput = true; // any byte ends the starting phase, progress or not
|
|
218
|
+
state.sawOutput = true; // any byte ends the starting phase, progress or not
|
|
184
219
|
},
|
|
185
220
|
noteStderr() {
|
|
186
|
-
sawOutput = true;
|
|
187
|
-
lastProgressAt = Date.now();
|
|
221
|
+
state.sawOutput = true;
|
|
222
|
+
state.lastProgressAt = Date.now();
|
|
188
223
|
},
|
|
189
224
|
stop() {
|
|
190
|
-
clearTimeout(timeout);
|
|
191
|
-
clearTimeout(checkpoint);
|
|
192
|
-
if (quietCheck)
|
|
193
|
-
clearInterval(quietCheck);
|
|
225
|
+
clearTimeout(state.timeout);
|
|
226
|
+
clearTimeout(state.checkpoint);
|
|
227
|
+
if (state.quietCheck)
|
|
228
|
+
clearInterval(state.quietCheck);
|
|
194
229
|
},
|
|
195
230
|
};
|
|
196
231
|
}
|
|
@@ -179,43 +179,67 @@ export function shakePlan(messages, usage, writeFullOutput) {
|
|
|
179
179
|
export function shakeNote(elidedCount, reclaimedTokens) {
|
|
180
180
|
return `[tumwater: elided ${elidedCount} old tool results (~${compactTokens(reclaimedTokens)} tokens); each pointer says where the full text is]`;
|
|
181
181
|
}
|
|
182
|
+
/** Records the tool-call parts of an assistant message by id, so a later tool result can find
|
|
183
|
+
* its matching call. */
|
|
184
|
+
function recordToolCalls(msg, calls) {
|
|
185
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content))
|
|
186
|
+
return;
|
|
187
|
+
for (const part of msg.content) {
|
|
188
|
+
if (part?.type === "toolCall" && typeof part.id === "string")
|
|
189
|
+
calls.set(part.id, part);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Resolves the read-input pointer (file:range) from a matching tool call, when it was a read. */
|
|
193
|
+
function readInputFor(call) {
|
|
194
|
+
if (call?.name === "read") {
|
|
195
|
+
return {
|
|
196
|
+
path: call.arguments?.path,
|
|
197
|
+
offset: call.arguments?.offset,
|
|
198
|
+
limit: call.arguments?.limit,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
/** Copies a tool result's fields (toolName, toolCallId, isError, fullOutputPath, readInput) onto
|
|
204
|
+
* the message from the recorded calls. */
|
|
205
|
+
function applyToolResultFields(message, msg, calls) {
|
|
206
|
+
if (msg.role === "toolResult") {
|
|
207
|
+
if (msg.toolName)
|
|
208
|
+
message.toolName = msg.toolName;
|
|
209
|
+
if (msg.toolCallId)
|
|
210
|
+
message.toolCallId = msg.toolCallId;
|
|
211
|
+
if (msg.isError !== undefined)
|
|
212
|
+
message.isError = msg.isError;
|
|
213
|
+
if (msg.details?.fullOutputPath)
|
|
214
|
+
message.fullOutputPath = msg.details.fullOutputPath;
|
|
215
|
+
const call = msg.toolCallId ? calls.get(msg.toolCallId) : undefined;
|
|
216
|
+
const readInput = readInputFor(call);
|
|
217
|
+
if (readInput)
|
|
218
|
+
message.readInput = readInput;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Assembles one message's fields (text, entry id, role, then its tool-result fields). */
|
|
222
|
+
function buildShakeMessage(msg, entryId, calls) {
|
|
223
|
+
const message = { text: textOf(msg) };
|
|
224
|
+
if (entryId)
|
|
225
|
+
message.entryId = entryId;
|
|
226
|
+
if (msg.role)
|
|
227
|
+
message.role = msg.role;
|
|
228
|
+
applyToolResultFields(message, msg, calls);
|
|
229
|
+
return message;
|
|
230
|
+
}
|
|
182
231
|
/** Flatten a turn_end event's projected context into the ordered messages the planner reads,
|
|
183
|
-
* mapping each assistant tool call's input onto the tool result it produced.
|
|
232
|
+
* mapping each assistant tool call's input onto the tool result it produced. The per-job work
|
|
233
|
+
* lives in the named helpers `recordToolCalls`, `readInputFor`, `applyToolResultFields` and
|
|
234
|
+
* `buildShakeMessage`; this function is their composition. */
|
|
184
235
|
export function shakeMessages(event) {
|
|
185
236
|
const calls = new Map();
|
|
186
237
|
const out = [];
|
|
187
238
|
for (const entry of event.context?.contextEntries ?? []) {
|
|
239
|
+
const entryId = entry.sourceEntry?.id;
|
|
188
240
|
for (const msg of entry.messages ?? []) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (part?.type === "toolCall" && typeof part.id === "string")
|
|
192
|
-
calls.set(part.id, part);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const message = { text: textOf(msg) };
|
|
196
|
-
if (entry.sourceEntry?.id)
|
|
197
|
-
message.entryId = entry.sourceEntry.id;
|
|
198
|
-
if (msg.role)
|
|
199
|
-
message.role = msg.role;
|
|
200
|
-
if (msg.role === "toolResult") {
|
|
201
|
-
if (msg.toolName)
|
|
202
|
-
message.toolName = msg.toolName;
|
|
203
|
-
if (msg.toolCallId)
|
|
204
|
-
message.toolCallId = msg.toolCallId;
|
|
205
|
-
if (msg.isError !== undefined)
|
|
206
|
-
message.isError = msg.isError;
|
|
207
|
-
if (msg.details?.fullOutputPath)
|
|
208
|
-
message.fullOutputPath = msg.details.fullOutputPath;
|
|
209
|
-
const call = msg.toolCallId ? calls.get(msg.toolCallId) : undefined;
|
|
210
|
-
if (call?.name === "read") {
|
|
211
|
-
message.readInput = {
|
|
212
|
-
path: call.arguments?.path,
|
|
213
|
-
offset: call.arguments?.offset,
|
|
214
|
-
limit: call.arguments?.limit,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
out.push(message);
|
|
241
|
+
recordToolCalls(msg, calls);
|
|
242
|
+
out.push(buildShakeMessage(msg, entryId, calls));
|
|
219
243
|
}
|
|
220
244
|
}
|
|
221
245
|
return out;
|
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* side effects it must not grow. pi itself loads the bundled extensions, so this stays
|
|
12
12
|
* dependency-free beyond node built-ins and the path helper.
|
|
13
13
|
*/
|
|
14
|
-
import fs from "node:fs";
|
|
15
14
|
import path from "node:path";
|
|
16
15
|
import { toolOutputDir } from "../paths.js";
|
|
17
|
-
import { isDirectory, walkUp, writeTextAtomic } from "../files/files.js";
|
|
16
|
+
import { ensureDir, isDirectory, walkUp, writeTextAtomic } from "../files/files.js";
|
|
18
17
|
/** Walk up from `startDir` looking for a `.tumwater/` directory — the harness root. Worktrees
|
|
19
18
|
* under it (`.tumwater/worktrees/<role>`, merger, gate alike) reach it three levels up
|
|
20
19
|
* (`../../..`, the repo checkout that owns `.tumwater/`); a harness root carries it directly.
|
|
@@ -41,7 +40,7 @@ export function writeFullOutput(text, toolCallId, startDir = process.cwd()) {
|
|
|
41
40
|
return null;
|
|
42
41
|
try {
|
|
43
42
|
const dir = toolOutputDir(root);
|
|
44
|
-
|
|
43
|
+
ensureDir(dir);
|
|
45
44
|
const id = typeof toolCallId === "string" && toolCallId
|
|
46
45
|
? toolCallId.replace(/[^\w-]/g, "_")
|
|
47
46
|
: `result-${Date.now()}`;
|
|
@@ -24,7 +24,7 @@ export function execFileAsync(file, args, options = {}) {
|
|
|
24
24
|
* added after the abort event never fires, so `addEventListener` alone would leave the caller
|
|
25
25
|
* waiting on a shutdown that has come and gone. A missing signal (no shutdown wiring)
|
|
26
26
|
* registers nothing. Shared by spawnSupervised (supervisor.ts), runPi's child spawn
|
|
27
|
-
* (src/agent/run-
|
|
27
|
+
* (src/agent/run-spawn.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
|
|
28
28
|
* (merge-pipeline.ts).
|
|
29
29
|
*
|
|
30
30
|
* Returns a disposer that detaches `fn`. A `{ once: true }` listener detaches itself when the
|
|
@@ -65,6 +65,29 @@ export function pidAlive(pid) {
|
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
/** The identity of the live process on `pid`, or null when no process of ours answers for it —
|
|
69
|
+
* a non-positive or non-integer pid, a reaped pid, or a pid the probe cannot read (a foreign
|
|
70
|
+
* process `ps` will not disclose, or a host whose ps lacks `lstart`). Null reads as "not
|
|
71
|
+
* alive" to `identityAlive`, so the reap checks' bounded poll reports only a genuine hang. */
|
|
72
|
+
export async function pidIdentity(pid) {
|
|
73
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
74
|
+
return null;
|
|
75
|
+
try {
|
|
76
|
+
const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
|
|
77
|
+
const start = stdout.trim();
|
|
78
|
+
return start.length > 0 ? { pid, start } : null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** True when the process instance `id` names is still alive: the pid answers and the OS still
|
|
85
|
+
* reports the same start time. A reaped pid (no answer), a pid reused by a different process
|
|
86
|
+
* (a different start), or null reads as not alive. */
|
|
87
|
+
export async function identityAlive(id) {
|
|
88
|
+
const now = await pidIdentity(id.pid);
|
|
89
|
+
return now !== null && now.start === id.start;
|
|
90
|
+
}
|
|
68
91
|
/** Terminate a detached child and everything it started: SIGTERM to the process group now,
|
|
69
92
|
* escalating to SIGKILL after 10 s if any of it is still alive. The child is spawned detached
|
|
70
93
|
* (its own process group leader — runPi does this for pi), so a negative PID reaches every
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* probe's runMarkers check and doctor's orphan judgment), and the sweep that SIGTERMs
|
|
5
5
|
* this run's leftovers whatever process group they ended up in. Split out of
|
|
6
6
|
* process-table.ts — which keeps the doctor-facing probe interface and the ps/lsof/top
|
|
7
|
-
* parsing it is made of — because this half serves src/agent/run-
|
|
7
|
+
* parsing it is made of — because this half serves src/agent/run-spawn.ts's run stamping and
|
|
8
8
|
* the exit sweep, and shares none of the probe's table-parsing concerns; the probe reads marks
|
|
9
9
|
* through the same parsers exported here. */
|
|
10
10
|
import crypto from "node:crypto";
|
|
@@ -43,7 +43,7 @@ export function runMarkerEnv(base, marker) {
|
|
|
43
43
|
* check-in preload removed (`withoutLaunchServicesCheckIn`) and this run's marker appended
|
|
44
44
|
* (`runMarkerEnv`). All three adapters (pi, claude, opencode) layer their own settings on
|
|
45
45
|
* top, so the composition lives here rather than being re-derived in each. `binDir`, when
|
|
46
|
-
* given, is prepended to PATH — run-
|
|
46
|
+
* given, is prepended to PATH — run-spawn passes the notes shim dir (notesShimDir) so every
|
|
47
47
|
* run finds the `tumwater-notes` command first (plans/notes-command.md "Reaching it from a
|
|
48
48
|
* run"). */
|
|
49
49
|
export function backendBaseEnv(marker, base = process.env, binDir) {
|
|
@@ -3,6 +3,7 @@ import { TUMWATER_SOURCE } from "../events/events.js";
|
|
|
3
3
|
import { RESTART_EXIT_CODE } from "../redeploy/redeploy-policy.js";
|
|
4
4
|
import { childEnv, runOnAbort } from "./process.js";
|
|
5
5
|
import { liveNodeBin } from "./node-bin.js";
|
|
6
|
+
import { parsePositiveInt } from "../text/text.js";
|
|
6
7
|
/** The respawn loop behind `tumwater start`. The command runs as two processes: this supervisor
|
|
7
8
|
* (the one the operator started, holding the terminal) and a child that runs the orchestrator.
|
|
8
9
|
* When the child rebuilt dist/ onto a newer main (src/redeploy/redeployer.ts) it exits
|
|
@@ -49,9 +50,22 @@ function intervalPoll(intervalMs) {
|
|
|
49
50
|
* disarms the watch (a no-op once it fired or was already stopped). */
|
|
50
51
|
export function startParentDeathWatch(onGone, opts = {}) {
|
|
51
52
|
const ppid = opts.ppid ?? (() => process.ppid);
|
|
52
|
-
const
|
|
53
|
+
const expectedGiven = opts.expectedPpid !== undefined;
|
|
54
|
+
const original = expectedGiven ? opts.expectedPpid : ppid();
|
|
53
55
|
let cancel;
|
|
54
56
|
let fired = false;
|
|
57
|
+
// No expectation was handed down, so the baseline is the watch's own first read. A first
|
|
58
|
+
// read of 1 (PID 1 — launchd/init, never a real parent) means the process was already
|
|
59
|
+
// reparented before the watch armed: the shell that started it exited before this child's
|
|
60
|
+
// first ppid read, the detached-`&` leak. There is no distinct parent left to watch, so
|
|
61
|
+
// the child is already orphaned — fire immediately instead of pinning the baseline to 1,
|
|
62
|
+
// where the watch could never observe a change. This is the same route the follow
|
|
63
|
+
// commands' parent-death rule closes: a follow that cannot see a distinct parent ends now.
|
|
64
|
+
if (!expectedGiven && original === 1) {
|
|
65
|
+
fired = true;
|
|
66
|
+
onGone();
|
|
67
|
+
return () => { };
|
|
68
|
+
}
|
|
55
69
|
// Release the poll at most once: a fired watch, or an explicit stop, must leave nothing armed.
|
|
56
70
|
const release = () => {
|
|
57
71
|
const stopPoll = cancel;
|
|
@@ -59,6 +73,9 @@ export function startParentDeathWatch(onGone, opts = {}) {
|
|
|
59
73
|
stopPoll?.();
|
|
60
74
|
};
|
|
61
75
|
const check = () => {
|
|
76
|
+
// When an expectation was handed down, the first read may legitimately equal it even
|
|
77
|
+
// when the parent is already gone (the expected pid is compared, not the first read);
|
|
78
|
+
// otherwise the baseline IS the first read, so only a later change fires the watch.
|
|
62
79
|
if (fired || ppid() === original)
|
|
63
80
|
return;
|
|
64
81
|
fired = true;
|
|
@@ -78,11 +95,13 @@ export function startParentDeathWatch(onGone, opts = {}) {
|
|
|
78
95
|
};
|
|
79
96
|
}
|
|
80
97
|
/** The supervisor pid a supervised child was handed, or undefined when the mark is absent or
|
|
81
|
-
* names no process (a
|
|
82
|
-
* the watch's own first read stands instead.
|
|
98
|
+
* names no process (a hex/exponent/whitespace spelling, or 1 — launchd/init, never a supervisor):
|
|
99
|
+
* the watch's own first read stands instead. The mark is written as String(process.pid) (plain
|
|
100
|
+
* decimal), so it is read by the shared plain-decimal rule, not Number()'s silent coercion. */
|
|
83
101
|
export function expectedSupervisorPid(env = process.env) {
|
|
84
|
-
const
|
|
85
|
-
|
|
102
|
+
const mark = env[SUPERVISOR_PID_ENV];
|
|
103
|
+
const pid = mark === undefined ? null : parsePositiveInt(mark);
|
|
104
|
+
return pid !== null && pid > 1 ? pid : undefined;
|
|
86
105
|
}
|
|
87
106
|
/** Respawns within this window count toward the crash-loop guard. */
|
|
88
107
|
export const RESPAWN_WINDOW_MS = 60_000;
|