tumwater 0.1.2 → 0.2.0
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 +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -1,57 +1,58 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
import { aheadOfMain, commitMessage, deleteRef, headOf, isMergedInto, refSha, setRef } from "../git/git.js";
|
|
2
3
|
import { parseCommitMetadata } from "../git/commit-message.js";
|
|
3
4
|
import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { enqueueMerge, queuedMerges } from "../merge/merge-queue.js";
|
|
6
|
+
import { mergeRefName } from "../paths.js";
|
|
6
7
|
import { shortSha } from "../text/format.js";
|
|
7
|
-
/** Salvaging a commit a previous tick left
|
|
8
|
+
/** Salvaging a commit a previous tick left unmerged (plans/merge-queue.md). Since merge queue
|
|
8
9
|
* 2/5 the role's branch is reset to main the moment its commit is pinned, so the leftover
|
|
9
10
|
* normally lives in `refs/tumwater/landing/<role>`: recovery puts that sha back on the durable
|
|
10
|
-
*
|
|
11
|
+
* merge queue, so it merges through the SAME slot, gate, and strike cap a fresh tick's change
|
|
11
12
|
* does — no crash or abort path smuggles unreviewed work into main (invariant 1), and main keeps
|
|
12
|
-
* exactly one writer, the orchestrator's
|
|
13
|
+
* exactly one writer, the orchestrator's merge slot (an in-tick recovery merge used to race
|
|
13
14
|
* the slot's batches into `merge_blocked`). A commit with NO pin — a crash in the window between
|
|
14
15
|
* the tick's commit and its pin write, or a failed pin write itself — still sits on the branch
|
|
15
16
|
* ahead of main; recovery adopts that tip into the pin scheme and queues it, so invariant 1
|
|
16
17
|
* holds whether or not the pin survived. Split out of loop.ts — which keeps the tick lifecycle
|
|
17
18
|
* around it — because this is a self-contained concern with its own entry condition and git
|
|
18
19
|
* surface; the only things it borrows from the loop are identity, the tick number, the
|
|
19
|
-
* worktree (for the no-pin fallback), and three pieces of the role's
|
|
20
|
+
* worktree (for the no-pin fallback), and three pieces of the role's merge state: its
|
|
20
21
|
* conflict streak, outstanding hand-back, and permanent-reviewer hold. */
|
|
21
|
-
/** Consecutive
|
|
22
|
+
/** Consecutive merges of one pinned sha that may end `merge_conflict` before recovery stops
|
|
22
23
|
* re-queuing it (LoopState.mergeConflicts). Each attempt costs a conflict-resolution run, and
|
|
23
24
|
* a role holding a pin never authors: without a cap an unmergeable change would re-queue
|
|
24
25
|
* forever, burning one resolution run per drain. At the cap the pin is handed back to its
|
|
25
26
|
* author with the markers in place rather than discarded (PLANS.md "Robust conflict landing,
|
|
26
27
|
* part 2/2"); a lineage already handed back once is discarded instead. */
|
|
27
28
|
export const MERGE_CONFLICT_LIMIT = 2;
|
|
28
|
-
/** Queue a commit a previous tick left
|
|
29
|
+
/** Queue a commit a previous tick left unmerged. Entry condition: the merge ref exists and
|
|
29
30
|
* its sha is not yet contained in main — or, with no pin at all, the role's branch is ahead of
|
|
30
31
|
* main (crash between the commit and the pin). A present-but-contained ref is stale — a crash
|
|
31
32
|
* between the ff-merge and the ref deletion — and is deleted without queuing anything. Returns
|
|
32
33
|
* null when there was nothing to salvage. The queued entry carries the pin's sha plus the
|
|
33
34
|
* summary, body, and high-friction flag read back from its commit message, because the
|
|
34
|
-
* authoring run that set them is gone; from there the
|
|
35
|
-
* the
|
|
35
|
+
* authoring run that set them is gone; from there the merge slot keeps or deletes the ref per
|
|
36
|
+
* the merger's usual policy. An unreadable ref or worktree reads as no leftover — only git-level
|
|
36
37
|
* errors in the stale-pin cleanup propagate. */
|
|
37
38
|
export async function recoverLeftover(ctx) {
|
|
38
|
-
// One
|
|
39
|
+
// One merge ref per role, so at most one outstanding merge per role: a second entry beside
|
|
39
40
|
// one the slot has yet to finish would share — and fight over — the same ref.
|
|
40
|
-
const queued =
|
|
41
|
+
const queued = queuedMerges(ctx.root).find((e) => e.role === ctx.role);
|
|
41
42
|
if (queued)
|
|
42
43
|
return { kind: "already_queued", entry: queued };
|
|
43
|
-
const ref =
|
|
44
|
+
const ref = mergeRefName(ctx.role);
|
|
44
45
|
let sha = await refSha(ctx.root, ref).catch(() => null);
|
|
45
46
|
if (sha) {
|
|
46
47
|
if (await isMergedInto(ctx.root, sha, ctx.mainBranch)) {
|
|
47
|
-
// Stale pin: the work already
|
|
48
|
-
// the next tick does not re-
|
|
48
|
+
// Stale pin: the work already merged and a crash skipped its un-pinning. Clean it up so
|
|
49
|
+
// the next tick does not re-merge what main already holds.
|
|
49
50
|
await deleteRef(ctx.root, ref);
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
else {
|
|
54
|
-
// No pin: either nothing was left behind, or a crash
|
|
55
|
+
// No pin: either nothing was left behind, or a crash hit the commit→pin window (or
|
|
55
56
|
// the pin write failed) and the commit still sits on the branch ahead of main. Recover that
|
|
56
57
|
// tip too — invariant 1 must hold whether or not the pin survived. An unreadable worktree
|
|
57
58
|
// reads as "no leftover", exactly like a failed ref read: never propagate into the tick.
|
|
@@ -62,11 +63,11 @@ export async function recoverLeftover(ctx) {
|
|
|
62
63
|
if (!sha)
|
|
63
64
|
return null;
|
|
64
65
|
// Adopt the unpinned commit into the pin scheme so every downstream outcome — kept on an
|
|
65
|
-
// under-cap review failure (the strike cap's retry), deleted on reject/
|
|
66
|
+
// under-cap review failure (the strike cap's retry), deleted on reject/merge/discard,
|
|
66
67
|
// discarded on a user abort — behaves exactly as for a normally pinned one. A failed
|
|
67
68
|
// adoption is logged and leaves the commit on the branch (see `unpinned`).
|
|
68
69
|
if (!(await setRef(ctx.root, ref, sha))) {
|
|
69
|
-
warnEventBestEffort(ctx.root, ctx.role, `failed to adopt unpinned leftover ${shortSha(sha)} into its
|
|
70
|
+
warnEventBestEffort(ctx.root, ctx.role, `failed to adopt unpinned leftover ${shortSha(sha)} into its merge ref`);
|
|
70
71
|
return { kind: "unpinned", sha };
|
|
71
72
|
}
|
|
72
73
|
}
|
|
@@ -75,7 +76,7 @@ export async function recoverLeftover(ctx) {
|
|
|
75
76
|
// full gate check for a request that can never succeed until the model config changes. The
|
|
76
77
|
// caller clears the hold (and re-queues) once the resolved reviewer selector differs, so a
|
|
77
78
|
// fixed config recovers automatically (BUGS.md 2026-10-06).
|
|
78
|
-
const held = ctx.
|
|
79
|
+
const held = ctx.mergeReviewError;
|
|
79
80
|
if (held?.sha === sha)
|
|
80
81
|
return { kind: "held", sha, message: held.message };
|
|
81
82
|
const attempts = ctx.mergeConflicts?.sha === sha ? ctx.mergeConflicts.count : 0;
|
|
@@ -85,7 +86,7 @@ export async function recoverLeftover(ctx) {
|
|
|
85
86
|
// This lineage already used its one hand-back and still conflicts at the cap: drop the
|
|
86
87
|
// pin (the warning names its sha and both attempts) and let the author start over.
|
|
87
88
|
await deleteRef(ctx.root, ref);
|
|
88
|
-
warnEventBestEffort(ctx.root, ctx.role, `discarding leftover ${shortSha(sha)} after ${attempts}
|
|
89
|
+
warnEventBestEffort(ctx.root, ctx.role, `discarding leftover ${shortSha(sha)} after ${attempts} merges ended in unresolved conflicts with main, even after sending it back to the role to resolve`);
|
|
89
90
|
return { kind: "discarded", sha, summary, attempts };
|
|
90
91
|
}
|
|
91
92
|
// Main has moved too far under this change for the resolver to reconcile it: keep the pin
|
|
@@ -97,9 +98,9 @@ export async function recoverLeftover(ctx) {
|
|
|
97
98
|
const entry = {
|
|
98
99
|
role: ctx.role,
|
|
99
100
|
sha,
|
|
100
|
-
|
|
101
|
-
// Name what
|
|
102
|
-
// rides the
|
|
101
|
+
run: ctx.tick,
|
|
102
|
+
// Name what merges, not merely that a recovery happened: the recovered commit's own subject
|
|
103
|
+
// rides the merge, so the failure digest's "Merged in the window" can correlate a
|
|
103
104
|
// recovered merge with the work it names (BUGS.md 2026-09-21). Unreadable messages fall
|
|
104
105
|
// back to the bare provenance label.
|
|
105
106
|
summary: meta.subject
|
|
@@ -108,18 +109,18 @@ export async function recoverLeftover(ctx) {
|
|
|
108
109
|
body: meta.body,
|
|
109
110
|
highFriction: meta.highFriction || undefined,
|
|
110
111
|
// A retriable revision keeps its round from the commit's Revision trailer, so its next
|
|
111
|
-
// rejection records the following round (not round 1 again) and a recovered
|
|
112
|
+
// rejection records the following round (not round 1 again) and a recovered merge still
|
|
112
113
|
// deletes the rejected ref (plans/revise-rejected.md). Absent on a fresh change.
|
|
113
114
|
...(meta.revisionRound !== undefined ? { revisionRound: meta.revisionRound } : {}),
|
|
114
115
|
enqueuedAt: Date.now(),
|
|
115
116
|
};
|
|
116
|
-
|
|
117
|
-
logEventBestEffort(ctx.root, {
|
|
117
|
+
enqueueMerge(ctx.root, entry);
|
|
118
|
+
logEventBestEffort(ctx.root, { source: ctx.role, type: "merge_queued", commit: sha, summary: entry.summary });
|
|
118
119
|
return { kind: "enqueued", entry };
|
|
119
120
|
}
|
|
120
121
|
/** The review-gate metadata a pinned leftover commit carries in its own message: its
|
|
121
122
|
* subject, high-friction flag, and author's body, or an empty object when the commit
|
|
122
|
-
* predates the contract or the message cannot be read. Recovery
|
|
123
|
+
* predates the contract or the message cannot be read. Recovery merges through the same gate
|
|
123
124
|
* as a fresh tick, so it must present those fields the same way — otherwise a flagged change
|
|
124
125
|
* is silently reviewed as routine (BUGS.md 2026-09-19), and the merged event names the
|
|
125
126
|
* recovery but not the work (BUGS.md 2026-09-21). */
|
|
@@ -35,7 +35,7 @@ export function foldModelFallbackPhase(ctx, fallbackCtx, pi) {
|
|
|
35
35
|
const willBeIn = next !== undefined && next.since > 0;
|
|
36
36
|
if (!wasIn && willBeIn) {
|
|
37
37
|
logEventBestEffort(ctx.root, {
|
|
38
|
-
|
|
38
|
+
source: ctx.role,
|
|
39
39
|
type: "model_fallback_started",
|
|
40
40
|
provider: fallbackCtx.fallback.provider,
|
|
41
41
|
model: fallbackCtx.fallback.model,
|
|
@@ -57,7 +57,7 @@ function emitFallbackEnded(ctx, prior, primary, now, wasIn) {
|
|
|
57
57
|
if (!wasIn)
|
|
58
58
|
return;
|
|
59
59
|
logEventBestEffort(ctx.root, {
|
|
60
|
-
|
|
60
|
+
source: ctx.role,
|
|
61
61
|
type: "model_fallback_ended",
|
|
62
62
|
provider: primary.provider,
|
|
63
63
|
model: primary.model,
|
package/dist/src/loop/loop-pi.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { runPi } from "../pi/pi.js";
|
|
2
2
|
import { agentSessionResumable } from "../agent/agent-backend.js";
|
|
3
|
-
import { HOLD_BASE_MS } from "../
|
|
3
|
+
import { HOLD_BASE_MS } from "../team/team-hold.js";
|
|
4
4
|
import { backendKindPhrase } from "../text/phrases.js";
|
|
5
|
-
import { configForRole } from "../config/config-views.js";
|
|
5
|
+
import { configForRole, resolvedAgentConfig, seamHoldKey, seamPairName } from "../config/config-views.js";
|
|
6
|
+
import { ensureProviderLease } from "../concurrency/provider-lease.js";
|
|
6
7
|
import { buildSummaryRequestPrompt } from "../prompt/prompt-followup.js";
|
|
7
8
|
import { readClaudeAccount, subscriptionWindowRejected } from "../agent/claude/claude-account.js";
|
|
8
|
-
import {
|
|
9
|
+
import { observeOpencodeRun, opencodeModelKey } from "../agent/opencode/opencode-catalog.js";
|
|
10
|
+
import { dailyCost, teamDailyCost, pairDailyCost } from "../budget/budget.js";
|
|
9
11
|
import { loadLoopState } from "./loop-state.js";
|
|
10
12
|
import { baseRoleOf, loopIds } from "../roles/loop-ids.js";
|
|
11
13
|
import { buildStageFixPrompt } from "../prompt/gate-prompts.js";
|
|
14
|
+
import { logEventBestEffort } from "../events/events.js";
|
|
12
15
|
import { nativeLogPath, piLogPath, roleNotesPath, sessionDir } from "../paths.js";
|
|
16
|
+
import { piModelsPath } from "../pi/pi-models.js";
|
|
13
17
|
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
14
18
|
import { cappedRequestTimeouts } from "../request-timeouts.js";
|
|
15
19
|
/** Upper bound on how long the transient retry waits out a provider's Retry-After hint
|
|
@@ -19,7 +23,7 @@ import { cappedRequestTimeouts } from "../request-timeouts.js";
|
|
|
19
23
|
* working). */
|
|
20
24
|
const RATE_LIMIT_RETRY_AFTER_CAP_S = 120;
|
|
21
25
|
/** What a hint-less 429 waits before its one retry. With no Retry-After from the provider, the
|
|
22
|
-
*
|
|
26
|
+
* team's own constants state the refill physics: team/team-hold.ts's base hold is one minute,
|
|
23
27
|
* "the shortest pause that lets the bucket refill". Retrying sooner re-enters the same exhausted
|
|
24
28
|
* per-minute bucket the first request just emptied and burns the tick's only retry on a
|
|
25
29
|
* near-certain second 429. A present hint always wins instead; the cap above still bounds
|
|
@@ -30,24 +34,25 @@ function sleepFor(ms) {
|
|
|
30
34
|
return new Promise((r) => setTimeout(r, ms));
|
|
31
35
|
}
|
|
32
36
|
/** The `--max-budget-usd` cap for one API-billed claude authoring run (plans/agent-backends.md
|
|
33
|
-
* §7): the remaining
|
|
37
|
+
* §7): the remaining team budget (`maxDailyCostUsd` − today's team spend), lowered to the
|
|
34
38
|
* base role's own remaining cap when `maxDailyCostUsdPerRole` names it. The cap map and its
|
|
35
39
|
* spend are keyed by BASE role, exactly as the scheduler's role-cap gate reads them
|
|
36
40
|
* (src/gates/role-cap-gates.ts: `baseRoleOf` groups `feature`/`feature-2` and sums the
|
|
37
41
|
* group's spend), so instance loop ids share their base's cap instead of silently falling
|
|
38
|
-
* through to the
|
|
42
|
+
* through to the team budget. Returns undefined — no cap — for the director, a non-claude
|
|
39
43
|
* seam, an account that is not API-billed, a disabled cap (0), or no remaining budget at all
|
|
40
44
|
* (a non-positive cap is dropped by the argv builder). Spend is read from the persisted loop
|
|
41
45
|
* states: each role's daily window is what the budget gate and the dashboards sum, and this
|
|
42
|
-
* runs before the authoring run records its own cost.
|
|
43
|
-
|
|
46
|
+
* runs before the authoring run records its own cost. A pair with a step cap of its own (a
|
|
47
|
+
* priced fallback step, `fallbackStepCaps`) is also held to that step's remaining budget. */
|
|
48
|
+
function authoringBudgetCap(root, role, cfg, agent, pair) {
|
|
44
49
|
if (role === DIRECTOR_ROLE || agent !== "claude")
|
|
45
50
|
return undefined;
|
|
46
51
|
if (readClaudeAccount(root)?.billing !== "api")
|
|
47
52
|
return undefined;
|
|
48
53
|
const now = Date.now();
|
|
49
54
|
const states = loopIds(cfg).map((r) => loadLoopState(root, r));
|
|
50
|
-
let cap = cfg.maxDailyCostUsd -
|
|
55
|
+
let cap = cfg.maxDailyCostUsd - teamDailyCost(states, now);
|
|
51
56
|
const base = baseRoleOf(role);
|
|
52
57
|
const perRole = cfg.maxDailyCostUsdPerRole?.[base];
|
|
53
58
|
if (perRole !== undefined && perRole > 0) {
|
|
@@ -57,17 +62,22 @@ function authoringBudgetCap(root, role, cfg, agent) {
|
|
|
57
62
|
: dailyCost(loadLoopState(root, role), now);
|
|
58
63
|
cap = Math.min(cap, perRole - spent);
|
|
59
64
|
}
|
|
65
|
+
// A fallback step with its own cap (applyFallbackModel's fallbackStepCaps, set only on the
|
|
66
|
+
// derived view) lowers the run to what is left of the step's budget for the day.
|
|
67
|
+
const stepCap = cfg.fallbackStepCaps?.[pair];
|
|
68
|
+
if (stepCap !== undefined)
|
|
69
|
+
cap = Math.min(cap, stepCap - pairDailyCost(states, pair, now));
|
|
60
70
|
// Round the subtraction's floating-point residue off so the argv carries a clean number.
|
|
61
71
|
cap = Math.round(cap * 1e6) / 1e6;
|
|
62
72
|
return cap > 0 ? cap : undefined;
|
|
63
73
|
}
|
|
64
74
|
/** The pi-invocation plumbing of one role loop, extracted from LoopRunner (src/loop/loop.ts):
|
|
65
75
|
* the shared per-loop wiring for every run (role config, session dir, raw log, abort
|
|
66
|
-
* signal), the one bounded transient-failure retry all runs share, the
|
|
76
|
+
* signal), the one bounded transient-failure retry all runs share, the merge slot's
|
|
67
77
|
* run (which watches only the shutdown signal), and the tick's SUMMARY follow-up turn.
|
|
68
78
|
* It owns HOW a loop talks to pi; the LoopRunner tick state machine owns WHAT runs happen
|
|
69
79
|
* and folds every run's spend back through the host's foldUsage (authoring) or
|
|
70
|
-
*
|
|
80
|
+
* foldMergeUsage (merge/review). */
|
|
71
81
|
export class LoopPi {
|
|
72
82
|
host;
|
|
73
83
|
constructor(host) {
|
|
@@ -77,15 +87,15 @@ export class LoopPi {
|
|
|
77
87
|
* role-resolved config, this loop's session dir and raw log, and the per-tick abort signal.
|
|
78
88
|
* One place for those derivations — a new PiRunOptions field touches only here instead of
|
|
79
89
|
* drifting between the author run and the SUMMARY follow-up. Callers override what differs
|
|
80
|
-
* (the follow-up's capped timeouts, the
|
|
90
|
+
* (the follow-up's capped timeouts, the merge's shutdown-only signal). */
|
|
81
91
|
loopPiOpts(wt, prompt, sessionName, kind, resume = false, config, notesPath) {
|
|
82
|
-
// A caller-supplied config (the
|
|
92
|
+
// A caller-supplied config (the merge conflict resolver's strong-tier one,
|
|
83
93
|
// plans/model-tiers.md part 4/8) replaces the role's own for that one run; the role's
|
|
84
94
|
// config is the default for every other caller. Resolved once here so the native-log path
|
|
85
95
|
// names the same agent the run actually uses.
|
|
86
96
|
const resolved = config ?? configForRole(this.host.config(), this.host.role);
|
|
87
97
|
const maxBudgetUsd = kind === "author"
|
|
88
|
-
? authoringBudgetCap(this.host.root, this.host.role, this.host.config(), resolved.agent ?? "pi")
|
|
98
|
+
? authoringBudgetCap(this.host.root, this.host.role, this.host.config(), resolved.agent ?? "pi", seamPairName(resolved))
|
|
89
99
|
: undefined;
|
|
90
100
|
return {
|
|
91
101
|
cwd: wt,
|
|
@@ -101,7 +111,7 @@ export class LoopPi {
|
|
|
101
111
|
rawLogFile: piLogPath(this.host.root, this.host.role),
|
|
102
112
|
nativeLogFile: nativeLogPath(this.host.root, this.host.role, resolved.agent ?? "pi"),
|
|
103
113
|
signal: this.host.runSignal(),
|
|
104
|
-
// Only runAuthoringPi and its SUMMARY/stage-fix follow-ups pass this; a
|
|
114
|
+
// Only runAuthoringPi and its SUMMARY/stage-fix follow-ups pass this; a merge,
|
|
105
115
|
// review, or conflict-resolution run has no notebook, and neither does the director.
|
|
106
116
|
notesPath,
|
|
107
117
|
// A tool call silent for the configured stall threshold names itself in the event feed
|
|
@@ -112,30 +122,42 @@ export class LoopPi {
|
|
|
112
122
|
// The translator's unmapped-native-type and cost_unknown warnings reach the event feed
|
|
113
123
|
// as their own warning events, once per run.
|
|
114
124
|
onAgentWarning: (warning) => this.host.warn(`agent_unknown_event: ${warning}`),
|
|
125
|
+
// A run that read an image its model cannot see (plans/image-input.md "Detecting a
|
|
126
|
+
// blind read"): the runner killed the authoring run (action "fail") or a gate run
|
|
127
|
+
// merely recorded it ("logged") — each logged here with this loop's identity so the
|
|
128
|
+
// feed names who, on which tick, read which file on which blind model.
|
|
129
|
+
onBlindRead: (path, action) => logEventBestEffort(this.host.root, {
|
|
130
|
+
source: this.host.role,
|
|
131
|
+
type: "image_blind",
|
|
132
|
+
run: this.host.tickNumber(),
|
|
133
|
+
path,
|
|
134
|
+
model: resolved.model,
|
|
135
|
+
action,
|
|
136
|
+
}),
|
|
115
137
|
};
|
|
116
138
|
}
|
|
117
|
-
/** Run the orchestrator's
|
|
139
|
+
/** Run the orchestrator's merge slot's pi (merge queue 3/5): the shared per-loop wiring
|
|
118
140
|
* of runRolePi — role config, session dir, raw log, transient-failure retry — with usage
|
|
119
141
|
* folded into the authoring loop's counters, but watching ONLY the harness shutdown signal
|
|
120
|
-
* (which may be undefined — PiRunOptions.signal is optional): a
|
|
142
|
+
* (which may be undefined — PiRunOptions.signal is optional): a merge is outside any
|
|
121
143
|
* tick, so `tumwater abort --role` (which targets a tick's per-tick abort controller) must
|
|
122
144
|
* never reach it, and the stale per-tick controller of a finished tick must not abort it
|
|
123
145
|
* either. Session naming is the caller's (the reviewer composes its own from
|
|
124
|
-
* ReviewContext.tick;
|
|
125
|
-
*
|
|
146
|
+
* ReviewContext.tick; merge-to-main.ts keeps its conflict-resolver naming through
|
|
147
|
+
* MergerContext.runPi).
|
|
126
148
|
*/
|
|
127
|
-
async
|
|
149
|
+
async runMergePi(wt, prompt, sessionName, config) {
|
|
128
150
|
return this.runWithTransientRetry({
|
|
129
151
|
...this.loopPiOpts(wt, prompt, sessionName, "gate", false, config),
|
|
130
152
|
signal: this.host.signal,
|
|
131
153
|
}, { authoring: false });
|
|
132
154
|
}
|
|
133
|
-
/** The
|
|
155
|
+
/** The merge gate's reviewer runs (review.ts's review run and verdict follow-up): the
|
|
134
156
|
* same shared transient-retry wiring — a 429 in the gate waits its hint out and retries
|
|
135
157
|
* once, exactly like an authoring run (BUGS.md 2026-10-01) — with one folding difference:
|
|
136
|
-
* the FINAL run's usage is folded by the gate's caller (
|
|
158
|
+
* the FINAL run's usage is folded by the gate's caller (merge-core's foldUsage of
|
|
137
159
|
* gate.run / gate.followUpRun), so only a RETRIED first attempt is folded here. That fold
|
|
138
|
-
* is not optional: it stamps the
|
|
160
|
+
* is not optional: it stamps the team-wide rate-limit hold's input before the wait, and
|
|
139
161
|
* it is the only record the failed attempt's spend would ever get.
|
|
140
162
|
* Takes full PiRunOptions because the gate's two runs differ in everything but the loop
|
|
141
163
|
* they charge (config, session dir and name, label, tool-call callbacks, signal).
|
|
@@ -144,7 +166,7 @@ export class LoopPi {
|
|
|
144
166
|
return this.runWithTransientRetry(opts, { foldFinal: false, authoring: false });
|
|
145
167
|
}
|
|
146
168
|
/** The one bounded transient-failure retry shared by EVERY pi run this loop makes
|
|
147
|
-
* (runRolePi,
|
|
169
|
+
* (runRolePi, runMergePi, and runGatePi): two transient failures of the world (not of
|
|
148
170
|
* the session) earn exactly one retry that continues the same session — the model server
|
|
149
171
|
* timing out an idle predict stream, the provider severing that stream outright (undici's
|
|
150
172
|
* bare "terminated", the stream-severed backend kind), the provider accepting a request
|
|
@@ -162,18 +184,49 @@ export class LoopPi {
|
|
|
162
184
|
* Extracted verbatim from runRolePi so the rule lives in one place
|
|
163
185
|
* (574a14c's loopPiOpts move was the wiring half of the same single-source-of-truth).
|
|
164
186
|
*/
|
|
187
|
+
/** Part 6c/7: demote a catalog-free opencode model the first time a run bills it. Observed
|
|
188
|
+
* here — the fold chokepoint every run of the loop passes through — because it is the one
|
|
189
|
+
* place that has the run's resolved model (opts.config) and its cost (the run) together, and
|
|
190
|
+
* it holds the loop's root and role for the cache and the event feed. pi and claude runs
|
|
191
|
+
* have no opencode catalog entry and no-op. Best-effort like observeClaudeAccount: an
|
|
192
|
+
* unwritable cache or event feed must not fail the run that observed it. */
|
|
193
|
+
observeFreeModelCost(run, config) {
|
|
194
|
+
if (config.agent !== "opencode")
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
observeOpencodeRun(this.host.root, { model: opencodeModelKey(config.model ?? ""), costUsd: run.costUsd }, { source: this.host.role });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// The next priced run demotes it; nothing this run depends on is lost.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
165
203
|
async runWithTransientRetry(opts,
|
|
166
204
|
/** runGatePi only: skip folding the FINAL returned run (the caller folds it), while a
|
|
167
205
|
* retried first attempt still folds — its spend and its rate-limit hold stamp must not
|
|
168
206
|
* vanish just because the gate routes its own folding. Default folds everything, the
|
|
169
|
-
* authoring and
|
|
207
|
+
* authoring and merge behavior. */
|
|
170
208
|
{ foldFinal = true, authoring = true } = {}) {
|
|
171
209
|
// The fold every attempt takes: an authoring run charges the loop's tick counters
|
|
172
|
-
// (foldUsage); a
|
|
173
|
-
// prompt-token totals but never the tick's pre-edit prefix (
|
|
174
|
-
|
|
210
|
+
// (foldUsage); a merge, reviewer, or conflict-resolution run charges the same cost and
|
|
211
|
+
// prompt-token totals but never the tick's pre-edit prefix (foldMergeUsage).
|
|
212
|
+
// The seam this run actually ran on, as the team hold keys it (seamHoldKey): the fold
|
|
213
|
+
// stamps the rate-limit and backend-failure observations with it, so a storm on a claude
|
|
214
|
+
// reviewer holds `claude` and a pi author on a different provider stays open. opts.config
|
|
215
|
+
// is always the run's own resolved seam (reviewRunConfig for the reviewer, the role's
|
|
216
|
+
// config or fallback pair for the author), so this names the backend that failed.
|
|
217
|
+
const provider = seamHoldKey(opts.config);
|
|
218
|
+
// The run's model pair name, the key of the per-step daily spend (LoopState.dayCostByPair).
|
|
219
|
+
const pair = seamPairName(opts.config);
|
|
220
|
+
const fold = (run) => authoring ? this.host.foldUsage(run, provider, pair) : this.host.foldMergeUsage(run, provider, pair);
|
|
221
|
+
// The run re-keys the calling tick's provider lease to the provider THIS run uses, if it is
|
|
222
|
+
// not already there (plans/provider-concurrency.md §5): a budget handback or a model-failure
|
|
223
|
+
// switch that moved this run onto another seam moves the tick's lease with it, so a
|
|
224
|
+
// claude-authored tick whose gate run lands on omlx is counted there. No-op when the tick
|
|
225
|
+
// carries no lease (a bare LoopPi unit test, `tumwater tick`).
|
|
226
|
+
await ensureProviderLease(opts.config);
|
|
175
227
|
const pi = await runPi(opts);
|
|
176
228
|
this.warnFallbackClone(pi);
|
|
229
|
+
this.observeFreeModelCost(pi, opts.config);
|
|
177
230
|
if (!pi.aborted &&
|
|
178
231
|
!pi.timedOut &&
|
|
179
232
|
!pi.quietKilled &&
|
|
@@ -185,19 +238,19 @@ export class LoopPi {
|
|
|
185
238
|
// A subscription claude usage-window rejection is not the 429 the retry's minute-scale
|
|
186
239
|
// wait beats: the window resets hours out, so a within-run retry is guaranteed to fail
|
|
187
240
|
// after burning up to RATE_LIMIT_RETRY_AFTER_CAP_S. The tick ends and the kept session
|
|
188
|
-
// resumes once the
|
|
241
|
+
// resumes once the team hold lifts (plans/agent-backends.md §8, part 6e).
|
|
189
242
|
!subscriptionWindowRejected(pi)) {
|
|
190
243
|
this.host.warn(pi.transientPiCrash
|
|
191
244
|
? `pi crashed on malformed JSON (${pi.errorMessage ?? "no detail"}) — resuming the session once`
|
|
192
245
|
: pi.transientRateLimit
|
|
193
|
-
? `provider rate-limited the request (429${pi.retryAfterSeconds ? `, retry after ${pi.retryAfterSeconds}s` : `, no hint — waiting ${RATE_LIMIT_NO_HINT_RETRY_S}s`}) — retrying the
|
|
246
|
+
? `provider rate-limited the request (429${pi.retryAfterSeconds ? `, retry after ${pi.retryAfterSeconds}s` : `, no hint — waiting ${RATE_LIMIT_NO_HINT_RETRY_S}s`}) — retrying the agent run once`
|
|
194
247
|
: pi.backendKind === "stream-severed"
|
|
195
|
-
? "provider severed the in-flight stream (terminated) — retrying the
|
|
248
|
+
? "provider severed the in-flight stream (terminated) — retrying the agent run once"
|
|
196
249
|
: pi.backendKind === "timeout"
|
|
197
|
-
? "provider request timed out after accepting the connection — retrying the
|
|
198
|
-
: "model server timed out an idle predict stream (e.g. machine sleep) — retrying the
|
|
250
|
+
? "provider request timed out after accepting the connection — retrying the agent run once"
|
|
251
|
+
: "model server timed out an idle predict stream (e.g. machine sleep) — retrying the agent run once");
|
|
199
252
|
// The failed attempt folds NOW, before the wait and the retry: a 429 it ended on is the
|
|
200
|
-
//
|
|
253
|
+
// team-wide rate-limit hold's input (LoopRunner.lastRateLimit, stamped at fold time), and
|
|
201
254
|
// folding after a retry that ran on for an hour would report the storm an hour late.
|
|
202
255
|
fold(pi);
|
|
203
256
|
// The pause is the rate-limit branch's, plus the timeout kind's: a server timeout or
|
|
@@ -224,6 +277,7 @@ export class LoopPi {
|
|
|
224
277
|
// partial progress is not re-done. The next tick still starts fresh.
|
|
225
278
|
const retry = await runPi({ ...opts, continueSession: true });
|
|
226
279
|
this.warnFallbackClone(retry);
|
|
280
|
+
this.observeFreeModelCost(retry, opts.config);
|
|
227
281
|
if (foldFinal)
|
|
228
282
|
fold(retry);
|
|
229
283
|
this.warnBackendKindFloor(retry);
|
|
@@ -237,8 +291,8 @@ export class LoopPi {
|
|
|
237
291
|
/** The backend-kind floor (BUGS.md 2026-09-29): a failed run that ended on a
|
|
238
292
|
* provider-backend failure (connection down, 5xx, model load) still warns — parity with
|
|
239
293
|
* the 429 branch above, so any episode is visible from the feed alone instead of living
|
|
240
|
-
* only in per-tick error events an operator must aggregate by hand. The
|
|
241
|
-
* (src/
|
|
294
|
+
* only in per-tick error events an operator must aggregate by hand. The team-wide hold
|
|
295
|
+
* (src/team/team-hold.ts) and the storm alarms judge the spread; this line is the per-run
|
|
242
296
|
* floor under them, not a retry: a dead backend is not something an immediate re-run beats.
|
|
243
297
|
* Applied to whichever run is returned (BUGS.md 2026-10-09): the retry's returned run is a
|
|
244
298
|
* second, independent run the retry predicate was never applied to, so a retry that dies
|
|
@@ -253,7 +307,7 @@ export class LoopPi {
|
|
|
253
307
|
!run.quietKilled &&
|
|
254
308
|
run.transientBackend &&
|
|
255
309
|
!this.retryCoversBackendKind(run)) {
|
|
256
|
-
this.host.warn(`provider backend failure (${backendKindPhrase(run.backendKind)}) — the transient retry does not cover this kind; the
|
|
310
|
+
this.host.warn(`provider backend failure (${backendKindPhrase(run.backendKind)}) — the transient retry does not cover this kind; the team-wide wait watches for a storm`);
|
|
257
311
|
}
|
|
258
312
|
}
|
|
259
313
|
/** Whether the one bounded transient retry admits `run` on its backend-kind alone: the
|
|
@@ -271,13 +325,13 @@ export class LoopPi {
|
|
|
271
325
|
warnFallbackClone(run) {
|
|
272
326
|
if (!run.fallbackClone)
|
|
273
327
|
return;
|
|
274
|
-
this.host.warn(`pi could not resolve the model id exactly (${run.fallbackClone}) — the run uses the provider default's price and context window; add an exact entry to
|
|
328
|
+
this.host.warn(`pi could not resolve the model id exactly (${run.fallbackClone}) — the run uses the provider default's price and context window; add an exact entry to ${piModelsPath()}`);
|
|
275
329
|
}
|
|
276
330
|
/** Run pi for this loop's authoring run in worktree `wt`: the shared per-loop wiring and
|
|
277
331
|
* the transient-failure retry, with usage folded into the tick's counters. It is the ONE
|
|
278
332
|
* entry point that carries the role notebook (PLANS.md "Role notebook": TUMWATER_NOTES_PATH,
|
|
279
|
-
* read by the
|
|
280
|
-
*
|
|
333
|
+
* read by the `tumwater-notes` command): every other run (runRolePi's merge/conflict path,
|
|
334
|
+
* runMergePi, runGatePi) leaves it unset, and the director is excluded because its work is
|
|
281
335
|
* the operator's prompt, not a recurring search.
|
|
282
336
|
*
|
|
283
337
|
* Every run starts a FRESH pi session: context never accumulates across ticks, so ticks start
|
|
@@ -290,22 +344,21 @@ export class LoopPi {
|
|
|
290
344
|
}
|
|
291
345
|
/** The notebook path an authoring run carries: the role's note file, or undefined for the
|
|
292
346
|
* director, which has no notebook. The SUMMARY/stage-fix follow-ups share the authoring
|
|
293
|
-
* tick's session and must carry the same notes wiring —
|
|
294
|
-
*
|
|
295
|
-
* TUMWATER_NOTES_PATH from the environment. */
|
|
347
|
+
* tick's session and must carry the same notes wiring — every invocation reads the path
|
|
348
|
+
* from TUMWATER_NOTES_PATH in the environment, so the follow-up re-passes the same path. */
|
|
296
349
|
authorNotesPath() {
|
|
297
350
|
return this.host.role === DIRECTOR_ROLE
|
|
298
351
|
? undefined
|
|
299
352
|
: roleNotesPath(this.host.root, this.host.role);
|
|
300
353
|
}
|
|
301
|
-
/** The shared authoring/conflict run wiring WITHOUT the role notebook. The
|
|
354
|
+
/** The shared authoring/conflict run wiring WITHOUT the role notebook. The merge pipeline's
|
|
302
355
|
* conflict resolver reaches pi through this (plans/merge-queue.md), and a conflict resolution
|
|
303
356
|
* is not the role's recurring search: it must not carry the notes tool or rewrite the note.
|
|
304
357
|
* Authoring ticks run through runAuthoringPi above, which is the only notebook-carrying path. */
|
|
305
358
|
async runRolePi(wt, prompt, sessionName, resume = false, config) {
|
|
306
|
-
// The refusal-note
|
|
359
|
+
// The refusal-note merge (loop.ts's merge) reaches pi here, in the ROLE's own worktree,
|
|
307
360
|
// so its runs demux as author — exactly the verdict the pre-part-1/5 cwd test gave them.
|
|
308
|
-
// The shared-worktree conflict resolver goes through
|
|
361
|
+
// The shared-worktree conflict resolver goes through runMergePi and demuxes as gate.
|
|
309
362
|
return this.runWithTransientRetry(this.loopPiOpts(wt, prompt, sessionName, "author", resume, config));
|
|
310
363
|
}
|
|
311
364
|
/** Hard caps on the SUMMARY follow-up turn: it should take one short reply on a warm session,
|
|
@@ -331,7 +384,7 @@ export class LoopPi {
|
|
|
331
384
|
* authoring run's budget: one short reply on a warm session. The run takes the loop's SHARED
|
|
332
385
|
* transient-failure retry (BUGS.md 2026-10-01): a 429 on the follow-up turn waits its
|
|
333
386
|
* Retry-After hint out and retries once on the same session, exactly like the authoring run,
|
|
334
|
-
* the
|
|
387
|
+
* the merge slot's run, and the gate's reviewer runs it mirrors. The caps bound BOTH
|
|
335
388
|
* attempts (the retry inherits this capped config), so a flaky provider cannot stretch the
|
|
336
389
|
* follow-up past its budget by more than one capped turn. The wiring folds every attempt
|
|
337
390
|
* (the failed 429 before the wait), and the returned final run is folded here by the wiring
|
|
@@ -341,12 +394,13 @@ export class LoopPi {
|
|
|
341
394
|
const dir = sessionDir(this.host.root, this.host.role);
|
|
342
395
|
// The follow-up continues THIS tick's author session. The backend's own resumable rule
|
|
343
396
|
// keeps it honest: pi would reach the newest file, claude its recorded id, so a conflict
|
|
344
|
-
// resolver's gate run in the same dir cannot be mistaken for this tick's session.
|
|
345
|
-
|
|
397
|
+
// resolver's gate run in the same dir cannot be mistaken for this tick's session. The
|
|
398
|
+
// claude config dir rides along so a claude team's resume looks under its own dir.
|
|
399
|
+
if (!agentSessionResumable(dir, { kind: "author", agent: cfg.agent ?? "pi" }, resolvedAgentConfig(cfg, "claude").configDir)) {
|
|
346
400
|
return null;
|
|
347
401
|
}
|
|
348
402
|
return this.runWithTransientRetry({
|
|
349
|
-
...this.loopPiOpts(wt, prompt, `tumwater-${this.host.role}-${this.host.tickNumber()}-${suffix}`, "author", true,
|
|
403
|
+
...this.loopPiOpts(wt, prompt, `tumwater-${this.host.role}-${this.host.tickNumber()}-${suffix}`, "author", true, cfg, this.authorNotesPath()),
|
|
350
404
|
config: {
|
|
351
405
|
...cfg,
|
|
352
406
|
...cappedRequestTimeouts(cfg, timeoutS, LoopPi.SUMMARY_REQUEST_QUIET_S),
|
|
@@ -357,8 +411,8 @@ export class LoopPi {
|
|
|
357
411
|
* bounded turn, folded into the tick's usage like every other run. Null when there is no
|
|
358
412
|
* session to continue (pi never wrote one) — the caller then derives a subject itself. The
|
|
359
413
|
* run is returned even when it failed so the caller can honor a shutdown abort. */
|
|
360
|
-
async requestSummary(wt, config) {
|
|
361
|
-
return this.requestFollowUp(wt, buildSummaryRequestPrompt(), "summary", LoopPi.SUMMARY_REQUEST_TIMEOUT_S, config);
|
|
414
|
+
async requestSummary(wt, config, overLength) {
|
|
415
|
+
return this.requestFollowUp(wt, buildSummaryRequestPrompt(overLength), "summary", LoopPi.SUMMARY_REQUEST_TIMEOUT_S, config);
|
|
362
416
|
}
|
|
363
417
|
/** Ask the tick's own pi session (--continue) to fix the pre-queue self-check's findings:
|
|
364
418
|
* one bounded turn, folded into the tick's usage like every other run. Null when there is no
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setRef } from "../git/git.js";
|
|
2
2
|
import { resetWorktreeToMain } from "../git/worktree.js";
|
|
3
|
-
import {
|
|
3
|
+
import { mergeRefName } from "../paths.js";
|
|
4
4
|
import { shortSha } from "../text/format.js";
|
|
5
5
|
/** Finish a tick whose pi run was killed mid-flight — shared by the author-run and review-
|
|
6
6
|
* gate abort branches, which have identical semantics; only what the kill left behind
|
|
@@ -33,38 +33,38 @@ export async function finishAbortedTickPhase(ctx, userPrompt, wt, userAborted, h
|
|
|
33
33
|
// prompt says the run was moved back to the primary model, not that the harness restarted.
|
|
34
34
|
return handedBack ? { result: "aborted", resumeCause: "budget-resumed" } : { result: "aborted" };
|
|
35
35
|
}
|
|
36
|
-
/** Pin `sha` by this role's
|
|
36
|
+
/** Pin `sha` by this role's merge ref and free the worktree (plans/merge-queue.md invariant
|
|
37
37
|
* 4): the pin must exist BEFORE resetWorktreeToMain moves the branch, or the commit is
|
|
38
38
|
* orphaned. Returns false when the pin write failed — the caller then leaves the commit on its
|
|
39
39
|
* branch (no reset) and defers to next-tick recovery, which adopts it into the pin scheme.
|
|
40
|
-
* After a successful pin the review gate and the rebase run in the
|
|
41
|
-
* never in this worktree (merge queue 2/5). */
|
|
40
|
+
* After a successful pin the review gate and the rebase run in the verification's leased pool
|
|
41
|
+
* slot, never in this worktree (merge queue 2/5). */
|
|
42
42
|
export async function pinAndResetPhase(ctx, wt, sha) {
|
|
43
|
-
const pinned = await setRef(ctx.root,
|
|
43
|
+
const pinned = await setRef(ctx.root, mergeRefName(ctx.role), sha);
|
|
44
44
|
if (!pinned) {
|
|
45
|
-
ctx.warn(`failed to pin ${shortSha(sha)} by its
|
|
45
|
+
ctx.warn(`failed to pin ${shortSha(sha)} by its merge ref — leaving the commit on the branch for the next run's recovery`);
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
await resetWorktreeToMain(wt, ctx.mainBranch);
|
|
49
49
|
return true;
|
|
50
50
|
}
|
|
51
51
|
/** End a tick whose leftover recovery found work to salvage (src/loop/leftover.ts) without an
|
|
52
|
-
* authoring run — the leftover owns the role's one
|
|
52
|
+
* authoring run — the leftover owns the role's one merge ref until its merge resolves. A
|
|
53
53
|
* tick's dequeued user prompt goes back to its queue, since nothing ran it. A pin put on
|
|
54
|
-
* the
|
|
55
|
-
* interlock then holds the role until the slot
|
|
56
|
-
* holding the commit. When the pin is left over from a
|
|
57
|
-
* (`
|
|
54
|
+
* the merge queue ends the tick `queued` exactly like a fresh changed tick (the merge-queue
|
|
55
|
+
* interlock then holds the role until the slot merges it) and frees the worktree, the pin now
|
|
56
|
+
* holding the commit. When the pin is left over from a merge that failed non-terminally
|
|
57
|
+
* (`priorMergeFailure`), re-queuing it is a retry of a PERSISTENT failure the `queued` result
|
|
58
58
|
* cannot express, so it rides the outcome's recoveryFailure into the error streak and the
|
|
59
|
-
* warning that names the stuck gate (BUGS.md 2026-09-21). A role whose
|
|
59
|
+
* warning that names the stuck gate (BUGS.md 2026-09-21). A role whose merge is still queued
|
|
60
60
|
* ends `queued` on that entry — nothing enqueued twice, nothing reset. A leftover that could
|
|
61
61
|
* not be pinned stays on the branch and the tick fails like a fresh tick's failed pin. Every
|
|
62
62
|
* arm is `recoveredLeftover`: no model ran, so the tick is no evidence about the backend. */
|
|
63
|
-
export async function finishRecoveryTickPhase(ctx, recovered, userPrompt, wt,
|
|
63
|
+
export async function finishRecoveryTickPhase(ctx, recovered, userPrompt, wt, priorMergeFailure) {
|
|
64
64
|
ctx.pending.clear();
|
|
65
65
|
ctx.pending.requeueUnfulfilled(userPrompt);
|
|
66
66
|
if (recovered.kind === "unpinned") {
|
|
67
|
-
ctx.state.lastError = `failed to pin leftover ${shortSha(recovered.sha)} by its
|
|
67
|
+
ctx.state.lastError = `failed to pin leftover ${shortSha(recovered.sha)} by its merge ref; left for the next run's recovery`;
|
|
68
68
|
return { result: "error", summary: ctx.state.lastError, recoveredLeftover: true };
|
|
69
69
|
}
|
|
70
70
|
if (recovered.kind === "held") {
|
|
@@ -77,7 +77,7 @@ export async function finishRecoveryTickPhase(ctx, recovered, userPrompt, wt, pr
|
|
|
77
77
|
}
|
|
78
78
|
const { entry } = recovered;
|
|
79
79
|
if (recovered.kind === "enqueued") {
|
|
80
|
-
ctx.setRecoveryFailure(
|
|
80
|
+
ctx.setRecoveryFailure(priorMergeFailure);
|
|
81
81
|
await resetWorktreeToMain(wt, ctx.mainBranch);
|
|
82
82
|
}
|
|
83
83
|
return {
|