@quickchatai/quickreview 0.0.0-stage → 1.1.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/LICENSE +23 -0
- package/README.md +385 -2
- package/THIRD-PARTY-NOTICES.md +134 -0
- package/agents/pass-abstraction.md +64 -0
- package/agents/pass-ask-human.md +134 -0
- package/agents/pass-backcompat.md +67 -0
- package/agents/pass-baseline.md +61 -0
- package/agents/pass-behaviour-delta.md +64 -0
- package/agents/pass-bounds.md +36 -0
- package/agents/pass-claims.md +31 -0
- package/agents/pass-concurrency.md +168 -0
- package/agents/pass-consistency.md +57 -0
- package/agents/pass-contracts.md +30 -0
- package/agents/pass-data-safety.md +54 -0
- package/agents/pass-deferred.md +35 -0
- package/agents/pass-delivery.md +37 -0
- package/agents/pass-duplication.md +60 -0
- package/agents/pass-intent.md +63 -0
- package/agents/pass-lint-conformance.md +46 -0
- package/agents/pass-maintainability.md +67 -0
- package/agents/pass-placement.md +68 -0
- package/agents/pass-prod-risk.md +24 -0
- package/agents/pass-rollout.md +32 -0
- package/agents/pass-roundtrip.md +46 -0
- package/agents/pass-security.md +31 -0
- package/agents/pass-simplicity.md +68 -0
- package/agents/pass-statelessness.md +27 -0
- package/agents/pass-style.md +37 -0
- package/agents/pass-sweep.md +57 -0
- package/agents/pass-tests.md +58 -0
- package/agents/pass-ui.md +23 -0
- package/agents/pass-validate.md +50 -0
- package/agents/pass-widened.md +46 -0
- package/agents/scan/pass-scan.md +86 -0
- package/bin/qcr.js +11 -0
- package/config.example.toml +1067 -0
- package/deploy/ansible/README.md +356 -0
- package/deploy/ansible/group_vars/all.yml +212 -0
- package/deploy/ansible/inventory.example.ini +25 -0
- package/deploy/ansible/playbook.yml +360 -0
- package/deploy/collect-secrets.sh +289 -0
- package/deploy/collect-slack-secrets.mjs +1190 -0
- package/deploy/github-app-manifest.json +31 -0
- package/deploy/manifest.schema.json +75 -0
- package/deploy/qcr-update.sh +803 -0
- package/deploy/reviewer/AGENTS.override.md +12 -0
- package/dist/extensions/cli/bash-guard-ambient.js +42 -0
- package/dist/extensions/cli/bash-guard-ambient.js.map +1 -0
- package/dist/extensions/cli/bash-guard.js +5 -0
- package/dist/extensions/cli/bash-guard.js.map +1 -0
- package/dist/extensions/cli/companion-guard.js +5 -0
- package/dist/extensions/cli/companion-guard.js.map +1 -0
- package/dist/extensions/cli/debug-round.js +8 -0
- package/dist/extensions/cli/debug-round.js.map +1 -0
- package/dist/extensions/cli/inject-bridge.js +5 -0
- package/dist/extensions/cli/inject-bridge.js.map +1 -0
- package/dist/extensions/cli/ledger-pin.js +5 -0
- package/dist/extensions/cli/ledger-pin.js.map +1 -0
- package/dist/extensions/cli/lens.js +13 -0
- package/dist/extensions/cli/lens.js.map +1 -0
- package/dist/extensions/cli/muted-theme.js +5 -0
- package/dist/extensions/cli/muted-theme.js.map +1 -0
- package/dist/extensions/cli/onboarding.js +5 -0
- package/dist/extensions/cli/onboarding.js.map +1 -0
- package/dist/extensions/cli/setup.js +5 -0
- package/dist/extensions/cli/setup.js.map +1 -0
- package/dist/extensions/cli/tui-view.js +5 -0
- package/dist/extensions/cli/tui-view.js.map +1 -0
- package/dist/extensions/companion-guard.js +361 -0
- package/dist/extensions/companion-guard.js.map +1 -0
- package/dist/extensions/debug-round.js +204 -0
- package/dist/extensions/debug-round.js.map +1 -0
- package/dist/extensions/inject-bridge.js +603 -0
- package/dist/extensions/inject-bridge.js.map +1 -0
- package/dist/extensions/muted-theme.js +12 -0
- package/dist/extensions/muted-theme.js.map +1 -0
- package/dist/extensions/onboarding.js +175 -0
- package/dist/extensions/onboarding.js.map +1 -0
- package/dist/extensions/setup-replay.js +269 -0
- package/dist/extensions/setup-replay.js.map +1 -0
- package/dist/extensions/setup-scan.js +998 -0
- package/dist/extensions/setup-scan.js.map +1 -0
- package/dist/extensions/setup-triage.js +175 -0
- package/dist/extensions/setup-triage.js.map +1 -0
- package/dist/extensions/setup.js +1160 -0
- package/dist/extensions/setup.js.map +1 -0
- package/dist/extensions/tui-view.js +389 -0
- package/dist/extensions/tui-view.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/client.js +376 -0
- package/dist/extensions/vendor/openviking-pi/client.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/config.js +189 -0
- package/dist/extensions/vendor/openviking-pi/config.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/index.js +421 -0
- package/dist/extensions/vendor/openviking-pi/index.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +86 -0
- package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs +408 -0
- package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +41 -0
- package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hint.js +290 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hint.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-honest.js +148 -0
- package/dist/extensions/vendor/openviking-pi/qcr-honest.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +68 -0
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-leaf.js +77 -0
- package/dist/extensions/vendor/openviking-pi/qcr-leaf.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-lesson.js +216 -0
- package/dist/extensions/vendor/openviking-pi/qcr-lesson.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js +154 -0
- package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-recall.js +254 -0
- package/dist/extensions/vendor/openviking-pi/qcr-recall.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-shim.js +94 -0
- package/dist/extensions/vendor/openviking-pi/qcr-shim.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/qcr-write.js +333 -0
- package/dist/extensions/vendor/openviking-pi/qcr-write.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/recall.js +108 -0
- package/dist/extensions/vendor/openviking-pi/recall.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs +529 -0
- package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs +205 -0
- package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs +442 -0
- package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs +286 -0
- package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +189 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs +580 -0
- package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs +11 -0
- package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs +63 -0
- package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs +58 -0
- package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
- package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs.map +1 -0
- package/dist/extensions/vendor/openviking-pi/sync.js +127 -0
- package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/takeover.js +25 -0
- package/dist/extensions/vendor/openviking-pi/takeover.js.map +1 -0
- package/dist/extensions/vendor/openviking-pi/tools.js +417 -0
- package/dist/extensions/vendor/openviking-pi/tools.js.map +1 -0
- package/dist/src/anomalies.js +483 -0
- package/dist/src/anomalies.js.map +1 -0
- package/dist/src/answerworker.js +180 -0
- package/dist/src/answerworker.js.map +1 -0
- package/dist/src/appstamp.js +68 -0
- package/dist/src/appstamp.js.map +1 -0
- package/dist/src/archive/admission.js +3 -0
- package/dist/src/archive/admission.js.map +1 -0
- package/dist/src/archive/archivedb.js +474 -0
- package/dist/src/archive/archivedb.js.map +1 -0
- package/dist/src/archive/archiver.js +1770 -0
- package/dist/src/archive/archiver.js.map +1 -0
- package/dist/src/archive/archivewrite.js +198 -0
- package/dist/src/archive/archivewrite.js.map +1 -0
- package/dist/src/archive/bundle.js +266 -0
- package/dist/src/archive/bundle.js.map +1 -0
- package/dist/src/archive/dbmove.js +638 -0
- package/dist/src/archive/dbmove.js.map +1 -0
- package/dist/src/archive/duplicatecounts.js +80 -0
- package/dist/src/archive/duplicatecounts.js.map +1 -0
- package/dist/src/archive/eligibility.js +113 -0
- package/dist/src/archive/eligibility.js.map +1 -0
- package/dist/src/archive/extract.js +288 -0
- package/dist/src/archive/extract.js.map +1 -0
- package/dist/src/archive/fsutil.js +330 -0
- package/dist/src/archive/fsutil.js.map +1 -0
- package/dist/src/archive/gzfile.js +277 -0
- package/dist/src/archive/gzfile.js.map +1 -0
- package/dist/src/archive/kinds.js +22 -0
- package/dist/src/archive/kinds.js.map +1 -0
- package/dist/src/archive/livecheckpoint.js +178 -0
- package/dist/src/archive/livecheckpoint.js.map +1 -0
- package/dist/src/archive/liveretention.js +145 -0
- package/dist/src/archive/liveretention.js.map +1 -0
- package/dist/src/archive/manifest.js +284 -0
- package/dist/src/archive/manifest.js.map +1 -0
- package/dist/src/archive/overseer.js +283 -0
- package/dist/src/archive/overseer.js.map +1 -0
- package/dist/src/archive/piglobal.js +129 -0
- package/dist/src/archive/piglobal.js.map +1 -0
- package/dist/src/archive/plan.js +398 -0
- package/dist/src/archive/plan.js.map +1 -0
- package/dist/src/archive/prarchive.js +831 -0
- package/dist/src/archive/prarchive.js.map +1 -0
- package/dist/src/archive/prfiles.js +303 -0
- package/dist/src/archive/prfiles.js.map +1 -0
- package/dist/src/archive/report.js +169 -0
- package/dist/src/archive/report.js.map +1 -0
- package/dist/src/archive/restore.js +276 -0
- package/dist/src/archive/restore.js.map +1 -0
- package/dist/src/archive/rows.js +106 -0
- package/dist/src/archive/rows.js.map +1 -0
- package/dist/src/archive/seal.js +480 -0
- package/dist/src/archive/seal.js.map +1 -0
- package/dist/src/archive/status.js +528 -0
- package/dist/src/archive/status.js.map +1 -0
- package/dist/src/archive/storepass.js +409 -0
- package/dist/src/archive/storepass.js.map +1 -0
- package/dist/src/backfill.js +217 -0
- package/dist/src/backfill.js.map +1 -0
- package/dist/src/bench/cost.js +87 -0
- package/dist/src/bench/exec.js +76 -0
- package/dist/src/bench/values.js +14 -0
- package/dist/src/botcontrol.js +127 -0
- package/dist/src/botcontrol.js.map +1 -0
- package/dist/src/botworker.js +805 -0
- package/dist/src/botworker.js.map +1 -0
- package/dist/src/capture.js +234 -0
- package/dist/src/capture.js.map +1 -0
- package/dist/src/cases.js +1491 -0
- package/dist/src/cases.js.map +1 -0
- package/dist/src/channelfs.js +27 -0
- package/dist/src/channelfs.js.map +1 -0
- package/dist/src/checklist.js +312 -0
- package/dist/src/checklist.js.map +1 -0
- package/dist/src/ciretry.js +91 -0
- package/dist/src/ciretry.js.map +1 -0
- package/dist/src/cli.js +1916 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/cliops.js +333 -0
- package/dist/src/cliops.js.map +1 -0
- package/dist/src/clonedir.js +15 -0
- package/dist/src/clonedir.js.map +1 -0
- package/dist/src/commandeffects.js +43 -0
- package/dist/src/commandeffects.js.map +1 -0
- package/dist/src/commands.js +1312 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/companioninbox.js +203 -0
- package/dist/src/companioninbox.js.map +1 -0
- package/dist/src/compare.js +673 -0
- package/dist/src/compare.js.map +1 -0
- package/dist/src/config.js +1027 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/configcheck.js +111 -0
- package/dist/src/configcheck.js.map +1 -0
- package/dist/src/coreext.js +43 -0
- package/dist/src/coreext.js.map +1 -0
- package/dist/src/daemon.js +929 -0
- package/dist/src/daemon.js.map +1 -0
- package/dist/src/debuground.js +731 -0
- package/dist/src/debuground.js.map +1 -0
- package/dist/src/debugroundpi.js +219 -0
- package/dist/src/debugroundpi.js.map +1 -0
- package/dist/src/diagnostics.js +14 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/digest.js +139 -0
- package/dist/src/digest.js.map +1 -0
- package/dist/src/dispatch.js +1726 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/engine/answerguard.js +18 -0
- package/dist/src/engine/answerguard.js.map +1 -0
- package/dist/src/engine/botsdk.js +262 -0
- package/dist/src/engine/botsdk.js.map +1 -0
- package/dist/src/engine/caserunner-sdk.js +332 -0
- package/dist/src/engine/caserunner-sdk.js.map +1 -0
- package/dist/src/engine/debugpi.js +194 -0
- package/dist/src/engine/debugpi.js.map +1 -0
- package/dist/src/eod.js +473 -0
- package/dist/src/eod.js.map +1 -0
- package/dist/src/eodpinwatch.js +391 -0
- package/dist/src/eodpinwatch.js.map +1 -0
- package/dist/src/escalation.js +1009 -0
- package/dist/src/escalation.js.map +1 -0
- package/dist/src/eventloop.js +779 -0
- package/dist/src/eventloop.js.map +1 -0
- package/dist/src/execboundary.js +430 -0
- package/dist/src/execboundary.js.map +1 -0
- package/dist/src/feedback.js +915 -0
- package/dist/src/feedback.js.map +1 -0
- package/dist/src/findingscheck.js +200 -0
- package/dist/src/findingscheck.js.map +1 -0
- package/dist/src/fsdenied.js +26 -0
- package/dist/src/fsdenied.js.map +1 -0
- package/dist/src/ghread.js +181 -0
- package/dist/src/ghread.js.map +1 -0
- package/dist/src/github.js +1063 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/homes.js +103 -0
- package/dist/src/homes.js.map +1 -0
- package/dist/src/hostroute.js +24 -0
- package/dist/src/hostroute.js.map +1 -0
- package/dist/src/improvements.js +625 -0
- package/dist/src/improvements.js.map +1 -0
- package/dist/src/install.js +946 -0
- package/dist/src/install.js.map +1 -0
- package/dist/src/installkind.js +40 -0
- package/dist/src/installkind.js.map +1 -0
- package/dist/src/ipc/capability.js +286 -0
- package/dist/src/ipc/capability.js.map +1 -0
- package/dist/src/ipc/client.js +226 -0
- package/dist/src/ipc/client.js.map +1 -0
- package/dist/src/ipc/control.js +70 -0
- package/dist/src/ipc/control.js.map +1 -0
- package/dist/src/ipc/ghreadshim.js +77 -0
- package/dist/src/ipc/ghreadshim.js.map +1 -0
- package/dist/src/ipc/launcher.js +65 -0
- package/dist/src/ipc/launcher.js.map +1 -0
- package/dist/src/ipc/permits.js +293 -0
- package/dist/src/ipc/permits.js.map +1 -0
- package/dist/src/ipc/postshim.js +137 -0
- package/dist/src/ipc/postshim.js.map +1 -0
- package/dist/src/ipc/protocol.js +239 -0
- package/dist/src/ipc/protocol.js.map +1 -0
- package/dist/src/ipc/server.js +312 -0
- package/dist/src/ipc/server.js.map +1 -0
- package/dist/src/ipc/workerspec.js +148 -0
- package/dist/src/ipc/workerspec.js.map +1 -0
- package/dist/src/learner/learner.js +1272 -0
- package/dist/src/learner/learner.js.map +1 -0
- package/dist/src/learner/miner.js +948 -0
- package/dist/src/learner/miner.js.map +1 -0
- package/dist/src/learner/packet.js +216 -0
- package/dist/src/learner/packet.js.map +1 -0
- package/dist/src/learner/receipt.js +135 -0
- package/dist/src/learner/receipt.js.map +1 -0
- package/dist/src/learner/status.js +38 -0
- package/dist/src/learner/status.js.map +1 -0
- package/dist/src/ledgercheck.js +120 -0
- package/dist/src/ledgercheck.js.map +1 -0
- package/dist/src/lessonledger.js +451 -0
- package/dist/src/lessonledger.js.map +1 -0
- package/dist/src/lessonselect.js +639 -0
- package/dist/src/lessonselect.js.map +1 -0
- package/dist/src/lessonshadow.js +540 -0
- package/dist/src/lessonshadow.js.map +1 -0
- package/dist/src/levers.js +302 -0
- package/dist/src/levers.js.map +1 -0
- package/dist/src/live/projection.js +450 -0
- package/dist/src/live/projection.js.map +1 -0
- package/dist/src/live/publish.js +83 -0
- package/dist/src/live/publish.js.map +1 -0
- package/dist/src/live/read.js +60 -0
- package/dist/src/live/read.js.map +1 -0
- package/dist/src/livecli.js +288 -0
- package/dist/src/livecli.js.map +1 -0
- package/dist/src/loginhint.js +42 -0
- package/dist/src/loginhint.js.map +1 -0
- package/dist/src/loginrefresh/atomic.js +66 -0
- package/dist/src/loginrefresh/atomic.js.map +1 -0
- package/dist/src/loginrefresh/lock.js +93 -0
- package/dist/src/loginrefresh/lock.js.map +1 -0
- package/dist/src/loginrefresh/main.js +58 -0
- package/dist/src/loginrefresh/main.js.map +1 -0
- package/dist/src/loginrefresh/pirefresh.js +49 -0
- package/dist/src/loginrefresh/pirefresh.js.map +1 -0
- package/dist/src/loginrefresh/refresher.js +286 -0
- package/dist/src/loginrefresh/refresher.js.map +1 -0
- package/dist/src/loginrefresh/supervisor.js +253 -0
- package/dist/src/loginrefresh/supervisor.js.map +1 -0
- package/dist/src/loginrefresh/types.js +4 -0
- package/dist/src/loginrefresh/types.js.map +1 -0
- package/dist/src/memory/context.js +251 -0
- package/dist/src/memory/context.js.map +1 -0
- package/dist/src/memory/datafence.js +5 -0
- package/dist/src/memory/datafence.js.map +1 -0
- package/dist/src/memory/delivery.js +131 -0
- package/dist/src/memory/delivery.js.map +1 -0
- package/dist/src/memory/eventtags.js +81 -0
- package/dist/src/memory/eventtags.js.map +1 -0
- package/dist/src/memory/health.js +713 -0
- package/dist/src/memory/health.js.map +1 -0
- package/dist/src/memory/import.js +504 -0
- package/dist/src/memory/import.js.map +1 -0
- package/dist/src/memory/layout.js +252 -0
- package/dist/src/memory/layout.js.map +1 -0
- package/dist/src/memory/legacy-file.js +100 -0
- package/dist/src/memory/legacy-file.js.map +1 -0
- package/dist/src/memory/lessons.js +370 -0
- package/dist/src/memory/lessons.js.map +1 -0
- package/dist/src/memory/ovsession.js +449 -0
- package/dist/src/memory/ovsession.js.map +1 -0
- package/dist/src/memory/relayout.js +259 -0
- package/dist/src/memory/relayout.js.map +1 -0
- package/dist/src/memory/roundactivity.js +180 -0
- package/dist/src/memory/roundactivity.js.map +1 -0
- package/dist/src/memory/selection.js +690 -0
- package/dist/src/memory/selection.js.map +1 -0
- package/dist/src/memory/shadowstatus.js +69 -0
- package/dist/src/memory/shadowstatus.js.map +1 -0
- package/dist/src/memory/store.js +83 -0
- package/dist/src/memory/store.js.map +1 -0
- package/dist/src/memory/textidentity.js +8 -0
- package/dist/src/memory/textidentity.js.map +1 -0
- package/dist/src/metrics.js +773 -0
- package/dist/src/metrics.js.map +1 -0
- package/dist/src/missingconfig.js +8 -0
- package/dist/src/missingconfig.js.map +1 -0
- package/dist/src/npmverbs.js +226 -0
- package/dist/src/npmverbs.js.map +1 -0
- package/dist/src/onboard/choices.js +191 -0
- package/dist/src/onboard/choices.js.map +1 -0
- package/dist/src/onboard/engine.js +344 -0
- package/dist/src/onboard/engine.js.map +1 -0
- package/dist/src/onboard/launch.js +83 -0
- package/dist/src/onboard/launch.js.map +1 -0
- package/dist/src/onboard/steps/appslug.js +211 -0
- package/dist/src/onboard/steps/appslug.js.map +1 -0
- package/dist/src/onboard/steps/bringup.js +408 -0
- package/dist/src/onboard/steps/bringup.js.map +1 -0
- package/dist/src/onboard/steps/compose.js +343 -0
- package/dist/src/onboard/steps/compose.js.map +1 -0
- package/dist/src/onboard/steps/configdelivery.js +50 -0
- package/dist/src/onboard/steps/configdelivery.js.map +1 -0
- package/dist/src/onboard/steps/configfill.js +1148 -0
- package/dist/src/onboard/steps/configfill.js.map +1 -0
- package/dist/src/onboard/steps/deploysh.js +80 -0
- package/dist/src/onboard/steps/deploysh.js.map +1 -0
- package/dist/src/onboard/steps/embeddings.js +472 -0
- package/dist/src/onboard/steps/embeddings.js.map +1 -0
- package/dist/src/onboard/steps/escalation.js +571 -0
- package/dist/src/onboard/steps/escalation.js.map +1 -0
- package/dist/src/onboard/steps/githubapp.js +1092 -0
- package/dist/src/onboard/steps/githubapp.js.map +1 -0
- package/dist/src/onboard/steps/golive.js +506 -0
- package/dist/src/onboard/steps/golive.js.map +1 -0
- package/dist/src/onboard/steps/index.js +67 -0
- package/dist/src/onboard/steps/index.js.map +1 -0
- package/dist/src/onboard/steps/ingress.js +762 -0
- package/dist/src/onboard/steps/ingress.js.map +1 -0
- package/dist/src/onboard/steps/inventory.js +420 -0
- package/dist/src/onboard/steps/inventory.js.map +1 -0
- package/dist/src/onboard/steps/llmauth.js +893 -0
- package/dist/src/onboard/steps/llmauth.js.map +1 -0
- package/dist/src/onboard/steps/memory.js +1060 -0
- package/dist/src/onboard/steps/memory.js.map +1 -0
- package/dist/src/onboard/steps/memorykeys.js +43 -0
- package/dist/src/onboard/steps/memorykeys.js.map +1 -0
- package/dist/src/onboard/steps/preflight.js +172 -0
- package/dist/src/onboard/steps/preflight.js.map +1 -0
- package/dist/src/onboard/steps/proof.js +502 -0
- package/dist/src/onboard/steps/proof.js.map +1 -0
- package/dist/src/onboard/steps/ssh.js +300 -0
- package/dist/src/onboard/steps/ssh.js.map +1 -0
- package/dist/src/onboard/steps/topology.js +291 -0
- package/dist/src/onboard/steps/topology.js.map +1 -0
- package/dist/src/onboard/steps/transport.js +511 -0
- package/dist/src/onboard/steps/transport.js.map +1 -0
- package/dist/src/onboard/steps/verifyup.js +362 -0
- package/dist/src/onboard/steps/verifyup.js.map +1 -0
- package/dist/src/onboard/steps/vmdeploy.js +368 -0
- package/dist/src/onboard/steps/vmdeploy.js.map +1 -0
- package/dist/src/onboard/toml.js +53 -0
- package/dist/src/onboard/toml.js.map +1 -0
- package/dist/src/opencli.js +508 -0
- package/dist/src/opencli.js.map +1 -0
- package/dist/src/openpr.js +457 -0
- package/dist/src/openpr.js.map +1 -0
- package/dist/src/openviking/client.js +821 -0
- package/dist/src/openviking/client.js.map +1 -0
- package/dist/src/openviking/context.js +218 -0
- package/dist/src/openviking/context.js.map +1 -0
- package/dist/src/openviking/embedder.js +457 -0
- package/dist/src/openviking/embedder.js.map +1 -0
- package/dist/src/openviking/gapsink.js +38 -0
- package/dist/src/openviking/gapsink.js.map +1 -0
- package/dist/src/openviking/health.js +69 -0
- package/dist/src/openviking/health.js.map +1 -0
- package/dist/src/openviking/index.js +24 -0
- package/dist/src/openviking/index.js.map +1 -0
- package/dist/src/openviking/lessonfind.js +54 -0
- package/dist/src/openviking/lessonfind.js.map +1 -0
- package/dist/src/openviking/probe.js +89 -0
- package/dist/src/openviking/probe.js.map +1 -0
- package/dist/src/openviking/proc.js +95 -0
- package/dist/src/openviking/proc.js.map +1 -0
- package/dist/src/openviking/provisionreport.js +144 -0
- package/dist/src/openviking/provisionreport.js.map +1 -0
- package/dist/src/openviking/reembed.js +317 -0
- package/dist/src/openviking/reembed.js.map +1 -0
- package/dist/src/openviking/relayout.js +153 -0
- package/dist/src/openviking/relayout.js.map +1 -0
- package/dist/src/openviking/sidecar-main.js +39 -0
- package/dist/src/openviking/sidecar-main.js.map +1 -0
- package/dist/src/openviking/sidecar.js +79 -0
- package/dist/src/openviking/sidecar.js.map +1 -0
- package/dist/src/openviking/store.js +1758 -0
- package/dist/src/openviking/store.js.map +1 -0
- package/dist/src/openviking/supervisor.js +422 -0
- package/dist/src/openviking/supervisor.js.map +1 -0
- package/dist/src/openviking/types.js +5 -0
- package/dist/src/openviking/types.js.map +1 -0
- package/dist/src/operator-recovery.js +172 -0
- package/dist/src/operator-recovery.js.map +1 -0
- package/dist/src/operatormessage.js +44 -0
- package/dist/src/operatormessage.js.map +1 -0
- package/dist/src/outcomes.js +1361 -0
- package/dist/src/outcomes.js.map +1 -0
- package/dist/src/piauthcopy.js +5 -0
- package/dist/src/piauthcopy.js.map +1 -0
- package/dist/src/picli.js +45 -0
- package/dist/src/picli.js.map +1 -0
- package/dist/src/picwd.js +25 -0
- package/dist/src/picwd.js.map +1 -0
- package/dist/src/pilogin.js +35 -0
- package/dist/src/pilogin.js.map +1 -0
- package/dist/src/pingpong.js +1028 -0
- package/dist/src/pingpong.js.map +1 -0
- package/dist/src/posting-journal.js +441 -0
- package/dist/src/posting-journal.js.map +1 -0
- package/dist/src/posting-outbox.js +662 -0
- package/dist/src/posting-outbox.js.map +1 -0
- package/dist/src/posting-receipt.js +203 -0
- package/dist/src/posting-receipt.js.map +1 -0
- package/dist/src/posting.js +348 -0
- package/dist/src/posting.js.map +1 -0
- package/dist/src/postops.js +637 -0
- package/dist/src/postops.js.map +1 -0
- package/dist/src/pr-activity.js +11 -0
- package/dist/src/pr-activity.js.map +1 -0
- package/dist/src/pr-author.js +5 -0
- package/dist/src/pr-author.js.map +1 -0
- package/dist/src/pr-conversation.js +355 -0
- package/dist/src/pr-conversation.js.map +1 -0
- package/dist/src/pr-session-lifecycle.js +542 -0
- package/dist/src/pr-session-lifecycle.js.map +1 -0
- package/dist/src/preflight.js +341 -0
- package/dist/src/preflight.js.map +1 -0
- package/dist/src/priorresolve.js +275 -0
- package/dist/src/priorresolve.js.map +1 -0
- package/dist/src/qcrbin.js +180 -0
- package/dist/src/qcrbin.js.map +1 -0
- package/dist/src/recheck.js +1398 -0
- package/dist/src/recheck.js.map +1 -0
- package/dist/src/reconcile.js +820 -0
- package/dist/src/reconcile.js.map +1 -0
- package/dist/src/recordedfindings.js +15 -0
- package/dist/src/recordedfindings.js.map +1 -0
- package/dist/src/regression.js +546 -0
- package/dist/src/regression.js.map +1 -0
- package/dist/src/replay.js +598 -0
- package/dist/src/replay.js.map +1 -0
- package/dist/src/requiredpasses.js +113 -0
- package/dist/src/requiredpasses.js.map +1 -0
- package/dist/src/resumecheck.js +168 -0
- package/dist/src/resumecheck.js.map +1 -0
- package/dist/src/reviewcli.js +671 -0
- package/dist/src/reviewcli.js.map +1 -0
- package/dist/src/reviewcontext.js +22 -0
- package/dist/src/reviewcontext.js.map +1 -0
- package/dist/src/reviewdetach.js +574 -0
- package/dist/src/reviewdetach.js.map +1 -0
- package/dist/src/reviewhistory.js +284 -0
- package/dist/src/reviewhistory.js.map +1 -0
- package/dist/src/reviewhome.js +26 -0
- package/dist/src/reviewhome.js.map +1 -0
- package/dist/src/reviewmemory.js +110 -0
- package/dist/src/reviewmemory.js.map +1 -0
- package/dist/src/reviewrendercli.js +104 -0
- package/dist/src/reviewrendercli.js.map +1 -0
- package/dist/src/round-work.js +20 -0
- package/dist/src/round-work.js.map +1 -0
- package/dist/src/runhost.js +72 -0
- package/dist/src/runhost.js.map +1 -0
- package/dist/src/scanauth.js +25 -0
- package/dist/src/scanauth.js.map +1 -0
- package/dist/src/scanbox.js +846 -0
- package/dist/src/scanbox.js.map +1 -0
- package/dist/src/scancli.js +1074 -0
- package/dist/src/scancli.js.map +1 -0
- package/dist/src/scandriverlog.js +30 -0
- package/dist/src/scandriverlog.js.map +1 -0
- package/dist/src/session-bridge.js +75 -0
- package/dist/src/session-bridge.js.map +1 -0
- package/dist/src/session-command-payload.js +29 -0
- package/dist/src/session-command-payload.js.map +1 -0
- package/dist/src/session-command-supervisor.js +939 -0
- package/dist/src/session-command-supervisor.js.map +1 -0
- package/dist/src/sessionjsonl.js +85 -0
- package/dist/src/sessionjsonl.js.map +1 -0
- package/dist/src/sessions.js +541 -0
- package/dist/src/sessions.js.map +1 -0
- package/dist/src/setup/adapters.js +330 -0
- package/dist/src/setup/adapters.js.map +1 -0
- package/dist/src/setup/baselines.js +64 -0
- package/dist/src/setup/baselines.js.map +1 -0
- package/dist/src/setup/caserunner.js +554 -0
- package/dist/src/setup/caserunner.js.map +1 -0
- package/dist/src/setup/configcommit.js +358 -0
- package/dist/src/setup/configcommit.js.map +1 -0
- package/dist/src/setup/engine.js +672 -0
- package/dist/src/setup/engine.js.map +1 -0
- package/dist/src/setup/facts.js +593 -0
- package/dist/src/setup/facts.js.map +1 -0
- package/dist/src/setup/findings.js +948 -0
- package/dist/src/setup/findings.js.map +1 -0
- package/dist/src/setup/history.js +338 -0
- package/dist/src/setup/history.js.map +1 -0
- package/dist/src/setup/interview.js +185 -0
- package/dist/src/setup/interview.js.map +1 -0
- package/dist/src/setup/ledger.js +207 -0
- package/dist/src/setup/ledger.js.map +1 -0
- package/dist/src/setup/pins.js +43 -0
- package/dist/src/setup/pins.js.map +1 -0
- package/dist/src/setup/portrait.js +432 -0
- package/dist/src/setup/portrait.js.map +1 -0
- package/dist/src/setup/presentation.js +9 -0
- package/dist/src/setup/presentation.js.map +1 -0
- package/dist/src/setup/profile.js +417 -0
- package/dist/src/setup/profile.js.map +1 -0
- package/dist/src/setup/replay.js +240 -0
- package/dist/src/setup/replay.js.map +1 -0
- package/dist/src/setup/replaybuild.js +311 -0
- package/dist/src/setup/replaybuild.js.map +1 -0
- package/dist/src/setup/replayrun.js +806 -0
- package/dist/src/setup/replayrun.js.map +1 -0
- package/dist/src/setup/replaysource.js +169 -0
- package/dist/src/setup/replaysource.js.map +1 -0
- package/dist/src/setup/retune.js +333 -0
- package/dist/src/setup/retune.js.map +1 -0
- package/dist/src/setup/retunelog.js +51 -0
- package/dist/src/setup/retunelog.js.map +1 -0
- package/dist/src/setup/scan.js +725 -0
- package/dist/src/setup/scan.js.map +1 -0
- package/dist/src/setup/scanbuild.js +133 -0
- package/dist/src/setup/scanbuild.js.map +1 -0
- package/dist/src/setup/scanfacts.js +96 -0
- package/dist/src/setup/scanfacts.js.map +1 -0
- package/dist/src/setup/scanlease.js +251 -0
- package/dist/src/setup/scanlease.js.map +1 -0
- package/dist/src/setup/scanreport.js +289 -0
- package/dist/src/setup/scanreport.js.map +1 -0
- package/dist/src/setup/scanrun.js +1733 -0
- package/dist/src/setup/scanrun.js.map +1 -0
- package/dist/src/setup/scantree.js +257 -0
- package/dist/src/setup/scantree.js.map +1 -0
- package/dist/src/setup/stage2.js +617 -0
- package/dist/src/setup/stage2.js.map +1 -0
- package/dist/src/setup/stage2request.js +259 -0
- package/dist/src/setup/stage2request.js.map +1 -0
- package/dist/src/setup/survey.js +307 -0
- package/dist/src/setup/survey.js.map +1 -0
- package/dist/src/setup/surveyrun.js +549 -0
- package/dist/src/setup/surveyrun.js.map +1 -0
- package/dist/src/setup/textsniff.js +89 -0
- package/dist/src/setup/textsniff.js.map +1 -0
- package/dist/src/setup/trackersink.js +395 -0
- package/dist/src/setup/trackersink.js.map +1 -0
- package/dist/src/setup/triage.js +636 -0
- package/dist/src/setup/triage.js.map +1 -0
- package/dist/src/setup/tuning.js +585 -0
- package/dist/src/setup/tuning.js.map +1 -0
- package/dist/src/setup/unitcut.js +437 -0
- package/dist/src/setup/unitcut.js.map +1 -0
- package/dist/src/singleflight.js +72 -0
- package/dist/src/singleflight.js.map +1 -0
- package/dist/src/slack/announce.js +725 -0
- package/dist/src/slack/announce.js.map +1 -0
- package/dist/src/slack/api.js +227 -0
- package/dist/src/slack/api.js.map +1 -0
- package/dist/src/slack/messages.js +158 -0
- package/dist/src/slack/messages.js.map +1 -0
- package/dist/src/slack/origin.js +550 -0
- package/dist/src/slack/origin.js.map +1 -0
- package/dist/src/slack/policy.js +225 -0
- package/dist/src/slack/policy.js.map +1 -0
- package/dist/src/slack/reply.js +153 -0
- package/dist/src/slack/reply.js.map +1 -0
- package/dist/src/slack/router.js +189 -0
- package/dist/src/slack/router.js.map +1 -0
- package/dist/src/slack/socket.js +323 -0
- package/dist/src/slack/socket.js.map +1 -0
- package/dist/src/slack/types.js +11 -0
- package/dist/src/slack/types.js.map +1 -0
- package/dist/src/sqlitehint.js +30 -0
- package/dist/src/sqlitehint.js.map +1 -0
- package/dist/src/ssh-observation.js +25 -0
- package/dist/src/ssh-observation.js.map +1 -0
- package/dist/src/state.js +5869 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/statemachine.js +220 -0
- package/dist/src/statemachine.js.map +1 -0
- package/dist/src/stubs.js +52 -0
- package/dist/src/stubs.js.map +1 -0
- package/dist/src/takeover.js +506 -0
- package/dist/src/takeover.js.map +1 -0
- package/dist/src/takeoverpi.js +249 -0
- package/dist/src/takeoverpi.js.map +1 -0
- package/dist/src/terminal-failure.js +26 -0
- package/dist/src/terminal-failure.js.map +1 -0
- package/dist/src/thread-resolution-refusal.js +4 -0
- package/dist/src/thread-resolution-refusal.js.map +1 -0
- package/dist/src/tmux.js +126 -0
- package/dist/src/tmux.js.map +1 -0
- package/dist/src/trackerid.js +43 -0
- package/dist/src/trackerid.js.map +1 -0
- package/dist/src/transcriptindex.js +512 -0
- package/dist/src/transcriptindex.js.map +1 -0
- package/dist/src/tui/actions.js +404 -0
- package/dist/src/tui/actions.js.map +1 -0
- package/dist/src/tui/alerts.js +523 -0
- package/dist/src/tui/alerts.js.map +1 -0
- package/dist/src/tui/app.js +1835 -0
- package/dist/src/tui/app.js.map +1 -0
- package/dist/src/tui/archiveops.js +164 -0
- package/dist/src/tui/archiveops.js.map +1 -0
- package/dist/src/tui/autofile.js +191 -0
- package/dist/src/tui/autofile.js.map +1 -0
- package/dist/src/tui/board.js +1143 -0
- package/dist/src/tui/board.js.map +1 -0
- package/dist/src/tui/candidates.js +699 -0
- package/dist/src/tui/candidates.js.map +1 -0
- package/dist/src/tui/cellwidth.js +151 -0
- package/dist/src/tui/cellwidth.js.map +1 -0
- package/dist/src/tui/changelog-store.js +404 -0
- package/dist/src/tui/changelog-store.js.map +1 -0
- package/dist/src/tui/chord.js +26 -0
- package/dist/src/tui/chord.js.map +1 -0
- package/dist/src/tui/clipboard.js +81 -0
- package/dist/src/tui/clipboard.js.map +1 -0
- package/dist/src/tui/cmdbar.js +253 -0
- package/dist/src/tui/cmdbar.js.map +1 -0
- package/dist/src/tui/companion.js +1275 -0
- package/dist/src/tui/companion.js.map +1 -0
- package/dist/src/tui/companionplan.js +200 -0
- package/dist/src/tui/companionplan.js.map +1 -0
- package/dist/src/tui/companiontab.js +61 -0
- package/dist/src/tui/companiontab.js.map +1 -0
- package/dist/src/tui/confighome.js +105 -0
- package/dist/src/tui/confighome.js.map +1 -0
- package/dist/src/tui/connect.js +44 -0
- package/dist/src/tui/connect.js.map +1 -0
- package/dist/src/tui/deploy-ceremony.js +75 -0
- package/dist/src/tui/deploy-ceremony.js.map +1 -0
- package/dist/src/tui/deploy-drain.js +93 -0
- package/dist/src/tui/deploy-drain.js.map +1 -0
- package/dist/src/tui/deploy-process.js +234 -0
- package/dist/src/tui/deploy-process.js.map +1 -0
- package/dist/src/tui/deploytab.js +1263 -0
- package/dist/src/tui/deploytab.js.map +1 -0
- package/dist/src/tui/design.js +111 -0
- package/dist/src/tui/design.js.map +1 -0
- package/dist/src/tui/digesttab.js +148 -0
- package/dist/src/tui/digesttab.js.map +1 -0
- package/dist/src/tui/emptystate.js +32 -0
- package/dist/src/tui/emptystate.js.map +1 -0
- package/dist/src/tui/evidence.js +790 -0
- package/dist/src/tui/evidence.js.map +1 -0
- package/dist/src/tui/feed.js +415 -0
- package/dist/src/tui/feed.js.map +1 -0
- package/dist/src/tui/feedbacktab.js +907 -0
- package/dist/src/tui/feedbacktab.js.map +1 -0
- package/dist/src/tui/filters.js +72 -0
- package/dist/src/tui/filters.js.map +1 -0
- package/dist/src/tui/ghread.js +272 -0
- package/dist/src/tui/ghread.js.map +1 -0
- package/dist/src/tui/historypull.js +61 -0
- package/dist/src/tui/historypull.js.map +1 -0
- package/dist/src/tui/inboxpull.js +197 -0
- package/dist/src/tui/inboxpull.js.map +1 -0
- package/dist/src/tui/index.js +1425 -0
- package/dist/src/tui/index.js.map +1 -0
- package/dist/src/tui/issuesink.js +206 -0
- package/dist/src/tui/issuesink.js.map +1 -0
- package/dist/src/tui/jsonfile.js +32 -0
- package/dist/src/tui/jsonfile.js.map +1 -0
- package/dist/src/tui/keys.js +97 -0
- package/dist/src/tui/keys.js.map +1 -0
- package/dist/src/tui/layout.js +70 -0
- package/dist/src/tui/layout.js.map +1 -0
- package/dist/src/tui/learning.js +469 -0
- package/dist/src/tui/learning.js.map +1 -0
- package/dist/src/tui/lessonswitch.js +103 -0
- package/dist/src/tui/lessonswitch.js.map +1 -0
- package/dist/src/tui/livecontrol.js +167 -0
- package/dist/src/tui/livecontrol.js.map +1 -0
- package/dist/src/tui/memforward.js +310 -0
- package/dist/src/tui/memforward.js.map +1 -0
- package/dist/src/tui/memincident.js +287 -0
- package/dist/src/tui/memincident.js.map +1 -0
- package/dist/src/tui/memory.js +485 -0
- package/dist/src/tui/memory.js.map +1 -0
- package/dist/src/tui/memtab.js +513 -0
- package/dist/src/tui/memtab.js.map +1 -0
- package/dist/src/tui/metricstab.js +979 -0
- package/dist/src/tui/metricstab.js.map +1 -0
- package/dist/src/tui/model.js +104 -0
- package/dist/src/tui/model.js.map +1 -0
- package/dist/src/tui/mouse.js +83 -0
- package/dist/src/tui/mouse.js.map +1 -0
- package/dist/src/tui/msg.js +196 -0
- package/dist/src/tui/msg.js.map +1 -0
- package/dist/src/tui/nativecompanion.js +321 -0
- package/dist/src/tui/nativecompanion.js.map +1 -0
- package/dist/src/tui/openurl.js +17 -0
- package/dist/src/tui/openurl.js.map +1 -0
- package/dist/src/tui/paint.js +501 -0
- package/dist/src/tui/paint.js.map +1 -0
- package/dist/src/tui/panes.js +74 -0
- package/dist/src/tui/panes.js.map +1 -0
- package/dist/src/tui/pipane.js +166 -0
- package/dist/src/tui/pipane.js.map +1 -0
- package/dist/src/tui/poll-hooks.js +11 -0
- package/dist/src/tui/poll-hooks.js.map +1 -0
- package/dist/src/tui/poll.js +113 -0
- package/dist/src/tui/poll.js.map +1 -0
- package/dist/src/tui/proposals.js +715 -0
- package/dist/src/tui/proposals.js.map +1 -0
- package/dist/src/tui/prpause.js +132 -0
- package/dist/src/tui/prpause.js.map +1 -0
- package/dist/src/tui/reload.js +351 -0
- package/dist/src/tui/reload.js.map +1 -0
- package/dist/src/tui/remote.js +312 -0
- package/dist/src/tui/remote.js.map +1 -0
- package/dist/src/tui/rollover.js +767 -0
- package/dist/src/tui/rollover.js.map +1 -0
- package/dist/src/tui/scantab.js +1053 -0
- package/dist/src/tui/scantab.js.map +1 -0
- package/dist/src/tui/setupentry.js +81 -0
- package/dist/src/tui/setupentry.js.map +1 -0
- package/dist/src/tui/sidebar.js +602 -0
- package/dist/src/tui/sidebar.js.map +1 -0
- package/dist/src/tui/sshtriage.js +393 -0
- package/dist/src/tui/sshtriage.js.map +1 -0
- package/dist/src/tui/startup.js +172 -0
- package/dist/src/tui/startup.js.map +1 -0
- package/dist/src/tui/tabs.js +41 -0
- package/dist/src/tui/tabs.js.map +1 -0
- package/dist/src/tui/termmirror.js +217 -0
- package/dist/src/tui/termmirror.js.map +1 -0
- package/dist/src/tui/theme.js +95 -0
- package/dist/src/tui/theme.js.map +1 -0
- package/dist/src/tui/ticketstab.js +224 -0
- package/dist/src/tui/ticketstab.js.map +1 -0
- package/dist/src/tui/tmuxsocket.js +101 -0
- package/dist/src/tui/tmuxsocket.js.map +1 -0
- package/dist/src/tui/trackertickets.js +77 -0
- package/dist/src/tui/trackertickets.js.map +1 -0
- package/dist/src/tui/unwedge.js +245 -0
- package/dist/src/tui/unwedge.js.map +1 -0
- package/dist/src/tui/viewer.js +479 -0
- package/dist/src/tui/viewer.js.map +1 -0
- package/dist/src/tui/viewerinput.js +125 -0
- package/dist/src/tui/viewerinput.js.map +1 -0
- package/dist/src/updatenotice.js +143 -0
- package/dist/src/updatenotice.js.map +1 -0
- package/dist/src/usage.js +1007 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/verdicts.js +3848 -0
- package/dist/src/verdicts.js.map +1 -0
- package/dist/src/version.js +58 -0
- package/dist/src/version.js.map +1 -0
- package/dist/src/wakes.js +411 -0
- package/dist/src/wakes.js.map +1 -0
- package/dist/src/webhook.js +725 -0
- package/dist/src/webhook.js.map +1 -0
- package/dist/src/webhookheal.js +238 -0
- package/dist/src/webhookheal.js.map +1 -0
- package/dist/src/wire.js +1801 -0
- package/dist/src/wire.js.map +1 -0
- package/dist/src/worker-session-host.js +531 -0
- package/dist/src/worker-session-host.js.map +1 -0
- package/dist/src/workerbreaker.js +112 -0
- package/dist/src/workerbreaker.js.map +1 -0
- package/dist/src/workercontrol.js +49 -0
- package/dist/src/workercontrol.js.map +1 -0
- package/dist/src/workerlease.js +351 -0
- package/dist/src/workerlease.js.map +1 -0
- package/dist/src/workerliveness.js +84 -0
- package/dist/src/workerliveness.js.map +1 -0
- package/dist/src/workerlog.js +65 -0
- package/dist/src/workerlog.js.map +1 -0
- package/dist/src/workerruntime.js +10 -0
- package/dist/src/workerruntime.js.map +1 -0
- package/dist/src/workersupervisor.js +1206 -0
- package/dist/src/workersupervisor.js.map +1 -0
- package/dist/src/workerusage.js +23 -0
- package/dist/src/workerusage.js.map +1 -0
- package/dist/src/workerworld.js +332 -0
- package/dist/src/workerworld.js.map +1 -0
- package/dist/src/worktrees.js +984 -0
- package/dist/src/worktrees.js.map +1 -0
- package/docker/Dockerfile +284 -0
- package/docker/Dockerfile.dockerignore +35 -0
- package/docker/README.md +221 -0
- package/docker/companion/Dockerfile +139 -0
- package/docker/companion/agents/evidence.md +34 -0
- package/docker/companion/agents/explore.md +26 -0
- package/docker/companion/agents/pass-survey.md +49 -0
- package/docker/companion/agents/study.md +26 -0
- package/docker/companion/entrypoint.sh +343 -0
- package/docker/companion/memory-health.mjs +269 -0
- package/docker/entrypoint.sh +782 -0
- package/docker/memory/Dockerfile +191 -0
- package/docker/memory/Dockerfile.dockerignore +20 -0
- package/docker/memory/README.md +60 -0
- package/docker/memory/entrypoint.sh +163 -0
- package/docker/setid/qcr-setid.c +109 -0
- package/docker/tunnel/Dockerfile.cloudflared +51 -0
- package/docker/tunnel/Dockerfile.ngrok +40 -0
- package/docker/tunnel/sidecar.mjs +411 -0
- package/docker/vm/tmux.conf +26 -0
- package/docker-compose.yml +348 -0
- package/extensions/cli/bash-guard-ambient.ts +71 -0
- package/extensions/cli/bash-guard.ts +12 -0
- package/extensions/cli/companion-guard.ts +8 -0
- package/extensions/cli/debug-round.ts +9 -0
- package/extensions/cli/inject-bridge.ts +8 -0
- package/extensions/cli/ledger-pin.ts +13 -0
- package/extensions/cli/lens.ts +22 -0
- package/extensions/cli/muted-theme.ts +9 -0
- package/extensions/cli/onboarding.ts +8 -0
- package/extensions/cli/setup.ts +8 -0
- package/extensions/cli/tui-view.ts +8 -0
- package/extensions/companion-guard.ts +411 -0
- package/extensions/debug-round.ts +281 -0
- package/extensions/inject-bridge.ts +630 -0
- package/extensions/inline-bash.ts +102 -0
- package/extensions/muted-theme.json +68 -0
- package/extensions/muted-theme.ts +23 -0
- package/extensions/onboarding.ts +233 -0
- package/extensions/setup-replay.ts +336 -0
- package/extensions/setup-scan.ts +1236 -0
- package/extensions/setup-triage.ts +190 -0
- package/extensions/setup.ts +1428 -0
- package/extensions/tui-view.ts +489 -0
- package/extensions/vendor/openviking-pi/LICENSE +201 -0
- package/extensions/vendor/openviking-pi/NOTICE +68 -0
- package/extensions/vendor/openviking-pi/README.md +550 -0
- package/extensions/vendor/openviking-pi/client.ts +588 -0
- package/extensions/vendor/openviking-pi/config.json +28 -0
- package/extensions/vendor/openviking-pi/config.ts +271 -0
- package/extensions/vendor/openviking-pi/index.ts +523 -0
- package/extensions/vendor/openviking-pi/lib/capture-adapter.d.mts +10 -0
- package/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +88 -0
- package/extensions/vendor/openviking-pi/lib/takeover-core.d.mts +80 -0
- package/extensions/vendor/openviking-pi/lib/takeover-core.mjs +406 -0
- package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.d.mts +1 -0
- package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +42 -0
- package/extensions/vendor/openviking-pi/patches/0000-vendoring-and-build-conformance.patch +726 -0
- package/extensions/vendor/openviking-pi/patches/0001-remove-session-before-compact-handler.patch +101 -0
- package/extensions/vendor/openviking-pi/patches/0002-no-ambient-work-in-subagent-leaves.patch +202 -0
- package/extensions/vendor/openviking-pi/patches/0003-never-auto-create-openviking-sessions.patch +120 -0
- package/extensions/vendor/openviking-pi/patches/0004-viking-write-durable-batch-write.patch +521 -0
- package/extensions/vendor/openviking-pi/patches/0005-second-memory-namespace.patch +786 -0
- package/extensions/vendor/openviking-pi/patches/0006-writes-that-stick-and-honest-errors.patch +996 -0
- package/extensions/vendor/openviking-pi/patches/0007-bounded-recall-no-commit.patch +794 -0
- package/extensions/vendor/openviking-pi/patches/0008-pr-derived-recall.patch +379 -0
- package/extensions/vendor/openviking-pi/patches/0009-pr-session-tools-in-round-areas.patch +375 -0
- package/extensions/vendor/openviking-pi/patches/0010-typed-lessons.patch +448 -0
- package/extensions/vendor/openviking-pi/patches/0011-review-session-memory-boundary.patch +382 -0
- package/extensions/vendor/openviking-pi/patches/0012-typecheck-against-the-real-pi.patch +129 -0
- package/extensions/vendor/openviking-pi/patches/0013-createopenviking-factory.patch +1168 -0
- package/extensions/vendor/openviking-pi/qcr-hint.ts +313 -0
- package/extensions/vendor/openviking-pi/qcr-honest.ts +183 -0
- package/extensions/vendor/openviking-pi/qcr-hooks.ts +87 -0
- package/extensions/vendor/openviking-pi/qcr-leaf.ts +78 -0
- package/extensions/vendor/openviking-pi/qcr-lesson.ts +231 -0
- package/extensions/vendor/openviking-pi/qcr-namespaces.ts +198 -0
- package/extensions/vendor/openviking-pi/qcr-recall.ts +288 -0
- package/extensions/vendor/openviking-pi/qcr-shim.ts +142 -0
- package/extensions/vendor/openviking-pi/qcr-write.ts +427 -0
- package/extensions/vendor/openviking-pi/recall.ts +149 -0
- package/extensions/vendor/openviking-pi/shared/capture-utils.d.mts +9 -0
- package/extensions/vendor/openviking-pi/shared/capture-utils.mjs +515 -0
- package/extensions/vendor/openviking-pi/shared/credentials.d.mts +14 -0
- package/extensions/vendor/openviking-pi/shared/credentials.mjs +228 -0
- package/extensions/vendor/openviking-pi/shared/pending-queue.d.mts +19 -0
- package/extensions/vendor/openviking-pi/shared/pending-queue.mjs +458 -0
- package/extensions/vendor/openviking-pi/shared/profile-inject.d.mts +17 -0
- package/extensions/vendor/openviking-pi/shared/profile-inject.mjs +294 -0
- package/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +195 -0
- package/extensions/vendor/openviking-pi/shared/recall-core.d.mts +13 -0
- package/extensions/vendor/openviking-pi/shared/recall-core.mjs +609 -0
- package/extensions/vendor/openviking-pi/shared/retryable.mjs +9 -0
- package/extensions/vendor/openviking-pi/shared/session-model.d.mts +3 -0
- package/extensions/vendor/openviking-pi/shared/session-model.mjs +57 -0
- package/extensions/vendor/openviking-pi/shared/uri-guard.mjs +56 -0
- package/extensions/vendor/openviking-pi/shared/workspace-peer.d.mts +5 -0
- package/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
- package/extensions/vendor/openviking-pi/sync.ts +163 -0
- package/extensions/vendor/openviking-pi/takeover.ts +36 -0
- package/extensions/vendor/openviking-pi/tools.ts +479 -0
- package/extensions/vendor/openviking-pi/upstream.json +48 -0
- package/package.json +138 -4
- package/profiles/default/README.md +21 -0
- package/profiles/default/addenda/.gitkeep +0 -0
- package/profiles/default/profile.toml +17 -0
- package/profiles/example-django/README.md +21 -0
- package/profiles/example-django/addenda/10-migration-discipline.md +13 -0
- package/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
- package/profiles/example-django/addenda/30-test-doctrine.md +12 -0
- package/profiles/example-django/addenda/40-orm-usage.md +19 -0
- package/profiles/example-django/addenda/50-declared-shape.md +10 -0
- package/profiles/example-django/addenda/60-config-placement.md +14 -0
- package/profiles/example-django/heuristics.toml +24 -0
- package/profiles/example-django/profile.toml +9 -0
- package/profiles/fast/README.md +24 -0
- package/profiles/fast/profile.toml +59 -0
- package/profiles/minimal/README.md +17 -0
- package/profiles/minimal/addenda/.gitkeep +0 -0
- package/profiles/minimal/profile.toml +8 -0
- package/profiles/snappy/README.md +24 -0
- package/profiles/snappy/addenda/10-race-as-question.md +6 -0
- package/profiles/snappy/profile.toml +46 -0
- package/prompts/companion-day-open.md +46 -0
- package/prompts/companion-first-run.md +104 -0
- package/prompts/companion.md +821 -0
- package/prompts/conversation-system.md +117 -0
- package/prompts/eod-sweep.md +120 -0
- package/prompts/onboarding.md +74 -0
- package/prompts/pass-common.md +52 -0
- package/prompts/pass-quality-common.md +22 -0
- package/prompts/round-first.md +874 -0
- package/prompts/round-follow-up.md +991 -0
- package/prompts/round-re-review.md +889 -0
- package/prompts/round-recheck.md +196 -0
- package/prompts/round-staleness.md +856 -0
- package/schemas/review-result.v1.json +391 -0
- package/schemas/scan-result.v1.json +421 -0
- package/schemas/version.v1.json +57 -0
- package/scripts/approve-pr.sh +16 -0
- package/scripts/build-stamp.mjs +67 -0
- package/scripts/commit-verdict.sh +155 -0
- package/scripts/companion-e2e.sh +250 -0
- package/scripts/companion-memory-forward.mjs +1966 -0
- package/scripts/config-fingerprint.sh +28 -0
- package/scripts/deploy-manifest.mjs +141 -0
- package/scripts/deploy.sh +1435 -0
- package/scripts/directory-lock.mjs +193 -0
- package/scripts/heartbeat-fresh.mjs +54 -0
- package/scripts/install-locator.sh +328 -0
- package/scripts/install-wrapper.sh +76 -0
- package/scripts/memory-search-check.mjs +98 -0
- package/scripts/mint-token.sh +212 -0
- package/scripts/openviking-embedder.py +392 -0
- package/scripts/openviking-ov-conf.mjs +161 -0
- package/scripts/openviking-provision.mjs +1122 -0
- package/scripts/openviking-reembed.mjs +1154 -0
- package/scripts/post-review.sh +16 -0
- package/scripts/pr-checks.sh +256 -0
- package/scripts/pr-context.py +174 -0
- package/scripts/pr-context.sh +63 -0
- package/scripts/pr-self.sh +144 -0
- package/scripts/pr-threads.sh +145 -0
- package/scripts/qcr +15 -0
- package/scripts/qcr-host.sh +769 -0
- package/scripts/render-review.sh +24 -0
- package/scripts/reply-thread.sh +16 -0
- package/scripts/required-passes.py +112 -0
- package/scripts/smoke-companion.sh +211 -0
- package/scripts/tui-deploy-runner.mjs +61 -0
- package/scripts/verify-dist.mjs +201 -0
- package/scripts/vm-ghcr-login.sh +188 -0
- package/seeds/agents/pass-baseline.md +61 -0
- package/seeds/agents/pass-contracts.md +30 -0
- package/seeds/agents/pass-data-safety.md +54 -0
- package/seeds/agents/pass-delivery.md +37 -0
- package/seeds/agents/pass-prod-risk.md +24 -0
- package/seeds/agents/pass-security.md +31 -0
- package/seeds/agents/pass-statelessness.md +27 -0
- package/seeds/agents/pass-ui.md +23 -0
- package/seeds/agents/pass-validate.md +50 -0
- package/seeds/prompts/conversation-system.md +117 -0
- package/seeds/prompts/eod-sweep.md +120 -0
- package/seeds/prompts/pass-common.md +52 -0
- package/skills/companion/adjudicate/SKILL.md +247 -0
- package/skills/companion/evidence/SKILL.md +150 -0
- package/skills/companion/land/SKILL.md +200 -0
- package/skills/companion/replay/SKILL.md +138 -0
- package/skills/companion/setup/SKILL.md +167 -0
- package/skills/companion/survey/SKILL.md +160 -0
- package/skills/selfimprove/README.md +34 -0
- package/skills/selfimprove/SKILL.md +127 -0
- package/skills/selfimprove/agents/openai.yaml +2 -0
- package/skills/selfimprove/commands/selfimprove.md +6 -0
- package/skills/selfimprove/references/fix-loop.md +49 -0
- package/skills/selfimprove/references/ledger.md +20 -0
- package/skills/selfimprove/references/setup.md +40 -0
- package/src/anomalies.ts +566 -0
- package/src/answerworker.ts +251 -0
- package/src/appstamp.ts +65 -0
- package/src/archive/admission.ts +3 -0
- package/src/archive/archivedb.ts +548 -0
- package/src/archive/archiver.ts +1963 -0
- package/src/archive/archivewrite.ts +279 -0
- package/src/archive/bundle.ts +333 -0
- package/src/archive/dbmove.ts +911 -0
- package/src/archive/duplicatecounts.ts +98 -0
- package/src/archive/eligibility.ts +181 -0
- package/src/archive/extract.ts +342 -0
- package/src/archive/fsutil.ts +415 -0
- package/src/archive/gzfile.ts +370 -0
- package/src/archive/kinds.ts +21 -0
- package/src/archive/livecheckpoint.ts +217 -0
- package/src/archive/liveretention.ts +180 -0
- package/src/archive/manifest.ts +519 -0
- package/src/archive/overseer.ts +336 -0
- package/src/archive/piglobal.ts +174 -0
- package/src/archive/plan.ts +612 -0
- package/src/archive/prarchive.ts +984 -0
- package/src/archive/prfiles.ts +406 -0
- package/src/archive/report.ts +252 -0
- package/src/archive/restore.ts +330 -0
- package/src/archive/rows.ts +187 -0
- package/src/archive/seal.ts +630 -0
- package/src/archive/status.ts +719 -0
- package/src/archive/storepass.ts +581 -0
- package/src/backfill.ts +362 -0
- package/src/bench/cost.ts +113 -0
- package/src/bench/exec.ts +90 -0
- package/src/bench/values.ts +13 -0
- package/src/botcontrol.ts +176 -0
- package/src/botworker.ts +953 -0
- package/src/capture.ts +321 -0
- package/src/cases.ts +1789 -0
- package/src/channelfs.ts +26 -0
- package/src/checklist.ts +506 -0
- package/src/ciretry.ts +141 -0
- package/src/cli.ts +2271 -0
- package/src/cliops.ts +384 -0
- package/src/clonedir.ts +15 -0
- package/src/commandeffects.ts +57 -0
- package/src/commands.ts +1678 -0
- package/src/companioninbox.ts +239 -0
- package/src/compare.ts +957 -0
- package/src/config.ts +2050 -0
- package/src/configcheck.ts +133 -0
- package/src/coreext.ts +47 -0
- package/src/daemon.ts +1302 -0
- package/src/debuground.ts +927 -0
- package/src/debugroundpi.ts +262 -0
- package/src/diagnostics.ts +14 -0
- package/src/digest.ts +183 -0
- package/src/dispatch.ts +2250 -0
- package/src/engine/answerguard.ts +27 -0
- package/src/engine/botsdk.ts +336 -0
- package/src/engine/caserunner-sdk.ts +423 -0
- package/src/engine/debugpi.ts +235 -0
- package/src/eod.ts +669 -0
- package/src/eodpinwatch.ts +556 -0
- package/src/escalation.ts +1253 -0
- package/src/eventloop.ts +1045 -0
- package/src/execboundary.ts +633 -0
- package/src/feedback.ts +1162 -0
- package/src/findingscheck.ts +298 -0
- package/src/fsdenied.ts +24 -0
- package/src/ghread.ts +242 -0
- package/src/github.ts +1497 -0
- package/src/homes.ts +175 -0
- package/src/hostroute.ts +53 -0
- package/src/improvements.ts +787 -0
- package/src/install.ts +1103 -0
- package/src/installkind.ts +50 -0
- package/src/ipc/capability.ts +333 -0
- package/src/ipc/client.ts +269 -0
- package/src/ipc/control.ts +90 -0
- package/src/ipc/ghreadshim.ts +94 -0
- package/src/ipc/launcher.ts +65 -0
- package/src/ipc/permits.ts +362 -0
- package/src/ipc/postshim.ts +164 -0
- package/src/ipc/protocol.ts +342 -0
- package/src/ipc/server.ts +371 -0
- package/src/ipc/workerspec.ts +218 -0
- package/src/learner/learner.ts +1655 -0
- package/src/learner/miner.ts +1290 -0
- package/src/learner/packet.ts +340 -0
- package/src/learner/receipt.ts +168 -0
- package/src/learner/status.ts +107 -0
- package/src/ledgercheck.ts +157 -0
- package/src/lessonledger.ts +703 -0
- package/src/lessonselect.ts +888 -0
- package/src/lessonshadow.ts +810 -0
- package/src/levers.ts +393 -0
- package/src/live/projection.ts +491 -0
- package/src/live/publish.ts +92 -0
- package/src/live/read.ts +86 -0
- package/src/livecli.ts +336 -0
- package/src/loginhint.ts +56 -0
- package/src/loginrefresh/atomic.ts +76 -0
- package/src/loginrefresh/lock.ts +104 -0
- package/src/loginrefresh/main.ts +59 -0
- package/src/loginrefresh/pirefresh.ts +57 -0
- package/src/loginrefresh/refresher.ts +326 -0
- package/src/loginrefresh/supervisor.ts +273 -0
- package/src/loginrefresh/types.ts +35 -0
- package/src/memory/context.ts +385 -0
- package/src/memory/datafence.ts +9 -0
- package/src/memory/delivery.ts +171 -0
- package/src/memory/eventtags.ts +80 -0
- package/src/memory/health.ts +953 -0
- package/src/memory/import.ts +664 -0
- package/src/memory/layout.ts +261 -0
- package/src/memory/legacy-file.ts +104 -0
- package/src/memory/lessons.ts +447 -0
- package/src/memory/ovsession.ts +601 -0
- package/src/memory/relayout.ts +335 -0
- package/src/memory/roundactivity.ts +209 -0
- package/src/memory/selection.ts +989 -0
- package/src/memory/shadowstatus.ts +125 -0
- package/src/memory/store.ts +275 -0
- package/src/memory/textidentity.ts +8 -0
- package/src/metrics.ts +1056 -0
- package/src/missingconfig.ts +7 -0
- package/src/npmverbs.ts +264 -0
- package/src/onboard/choices.ts +222 -0
- package/src/onboard/engine.ts +518 -0
- package/src/onboard/launch.ts +115 -0
- package/src/onboard/steps/appslug.ts +265 -0
- package/src/onboard/steps/bringup.ts +482 -0
- package/src/onboard/steps/compose.ts +459 -0
- package/src/onboard/steps/configdelivery.ts +58 -0
- package/src/onboard/steps/configfill.ts +1352 -0
- package/src/onboard/steps/deploysh.ts +105 -0
- package/src/onboard/steps/embeddings.ts +550 -0
- package/src/onboard/steps/escalation.ts +640 -0
- package/src/onboard/steps/githubapp.ts +1414 -0
- package/src/onboard/steps/golive.ts +577 -0
- package/src/onboard/steps/index.ts +71 -0
- package/src/onboard/steps/ingress.ts +907 -0
- package/src/onboard/steps/inventory.ts +526 -0
- package/src/onboard/steps/llmauth.ts +1095 -0
- package/src/onboard/steps/memory.ts +1290 -0
- package/src/onboard/steps/memorykeys.ts +85 -0
- package/src/onboard/steps/preflight.ts +191 -0
- package/src/onboard/steps/proof.ts +624 -0
- package/src/onboard/steps/ssh.ts +377 -0
- package/src/onboard/steps/topology.ts +337 -0
- package/src/onboard/steps/transport.ts +665 -0
- package/src/onboard/steps/verifyup.ts +448 -0
- package/src/onboard/steps/vmdeploy.ts +442 -0
- package/src/onboard/toml.ts +50 -0
- package/src/opencli.ts +591 -0
- package/src/openpr.ts +568 -0
- package/src/openviking/client.ts +1131 -0
- package/src/openviking/context.ts +263 -0
- package/src/openviking/embedder.ts +516 -0
- package/src/openviking/gapsink.ts +63 -0
- package/src/openviking/health.ts +83 -0
- package/src/openviking/index.ts +119 -0
- package/src/openviking/lessonfind.ts +92 -0
- package/src/openviking/probe.ts +129 -0
- package/src/openviking/proc.ts +110 -0
- package/src/openviking/provisionreport.ts +160 -0
- package/src/openviking/reembed.ts +412 -0
- package/src/openviking/relayout.ts +177 -0
- package/src/openviking/sidecar-main.ts +38 -0
- package/src/openviking/sidecar.ts +106 -0
- package/src/openviking/store.ts +2025 -0
- package/src/openviking/supervisor.ts +474 -0
- package/src/openviking/types.ts +174 -0
- package/src/operator-recovery.ts +237 -0
- package/src/operatormessage.ts +52 -0
- package/src/outcomes.ts +1670 -0
- package/src/piauthcopy.ts +12 -0
- package/src/picli.ts +49 -0
- package/src/picwd.ts +25 -0
- package/src/pilogin.ts +37 -0
- package/src/pingpong.ts +1339 -0
- package/src/posting-journal.ts +549 -0
- package/src/posting-outbox.ts +802 -0
- package/src/posting-receipt.ts +324 -0
- package/src/posting.ts +603 -0
- package/src/postops.ts +832 -0
- package/src/pr-activity.ts +20 -0
- package/src/pr-author.ts +6 -0
- package/src/pr-conversation.ts +432 -0
- package/src/pr-session-lifecycle.ts +631 -0
- package/src/preflight.ts +391 -0
- package/src/priorresolve.ts +343 -0
- package/src/qcrbin.ts +186 -0
- package/src/recheck.ts +1775 -0
- package/src/reconcile.ts +1030 -0
- package/src/recordedfindings.ts +27 -0
- package/src/regression.ts +833 -0
- package/src/replay.ts +816 -0
- package/src/requiredpasses.ts +148 -0
- package/src/resumecheck.ts +246 -0
- package/src/reviewcli.ts +787 -0
- package/src/reviewcontext.ts +35 -0
- package/src/reviewdetach.ts +734 -0
- package/src/reviewhistory.ts +427 -0
- package/src/reviewhome.ts +36 -0
- package/src/reviewmemory.ts +133 -0
- package/src/reviewrendercli.ts +139 -0
- package/src/round-work.ts +42 -0
- package/src/runhost.ts +108 -0
- package/src/scanauth.ts +36 -0
- package/src/scanbox.ts +1081 -0
- package/src/scancli.ts +1296 -0
- package/src/scandriverlog.ts +32 -0
- package/src/session-bridge.ts +139 -0
- package/src/session-command-payload.ts +35 -0
- package/src/session-command-supervisor.ts +1142 -0
- package/src/sessionjsonl.ts +88 -0
- package/src/sessions.ts +659 -0
- package/src/setup/adapters.ts +335 -0
- package/src/setup/baselines.ts +110 -0
- package/src/setup/caserunner.ts +611 -0
- package/src/setup/configcommit.ts +443 -0
- package/src/setup/engine.ts +1001 -0
- package/src/setup/facts.ts +627 -0
- package/src/setup/findings.ts +1264 -0
- package/src/setup/history.ts +443 -0
- package/src/setup/interview.ts +243 -0
- package/src/setup/ledger.ts +324 -0
- package/src/setup/pins.ts +66 -0
- package/src/setup/portrait.ts +635 -0
- package/src/setup/presentation.ts +18 -0
- package/src/setup/profile.ts +508 -0
- package/src/setup/replay.ts +370 -0
- package/src/setup/replaybuild.ts +392 -0
- package/src/setup/replayrun.ts +1096 -0
- package/src/setup/replaysource.ts +198 -0
- package/src/setup/retune.ts +415 -0
- package/src/setup/retunelog.ts +95 -0
- package/src/setup/scan.ts +1033 -0
- package/src/setup/scanbuild.ts +168 -0
- package/src/setup/scanfacts.ts +107 -0
- package/src/setup/scanlease.ts +297 -0
- package/src/setup/scanreport.ts +373 -0
- package/src/setup/scanrun.ts +2268 -0
- package/src/setup/scantree.ts +259 -0
- package/src/setup/stage2.ts +926 -0
- package/src/setup/stage2request.ts +347 -0
- package/src/setup/survey.ts +458 -0
- package/src/setup/surveyrun.ts +758 -0
- package/src/setup/textsniff.ts +95 -0
- package/src/setup/trackersink.ts +433 -0
- package/src/setup/triage.ts +768 -0
- package/src/setup/tuning.ts +766 -0
- package/src/setup/unitcut.ts +523 -0
- package/src/singleflight.ts +86 -0
- package/src/slack/announce.ts +911 -0
- package/src/slack/api.ts +303 -0
- package/src/slack/messages.ts +288 -0
- package/src/slack/origin.ts +599 -0
- package/src/slack/policy.ts +354 -0
- package/src/slack/reply.ts +201 -0
- package/src/slack/router.ts +285 -0
- package/src/slack/socket.ts +384 -0
- package/src/slack/types.ts +108 -0
- package/src/sqlitehint.ts +32 -0
- package/src/ssh-observation.ts +30 -0
- package/src/state.ts +8243 -0
- package/src/statemachine.ts +369 -0
- package/src/stubs.ts +68 -0
- package/src/takeover.ts +634 -0
- package/src/takeoverpi.ts +293 -0
- package/src/terminal-failure.ts +41 -0
- package/src/thread-resolution-refusal.ts +2 -0
- package/src/tmux.ts +153 -0
- package/src/trackerid.ts +41 -0
- package/src/transcriptindex.ts +665 -0
- package/src/tui/actions.ts +484 -0
- package/src/tui/alerts.ts +666 -0
- package/src/tui/app.ts +1979 -0
- package/src/tui/archiveops.ts +192 -0
- package/src/tui/autofile.ts +233 -0
- package/src/tui/board.ts +1432 -0
- package/src/tui/candidates.ts +889 -0
- package/src/tui/cellwidth.ts +148 -0
- package/src/tui/changelog-store.ts +503 -0
- package/src/tui/chord.ts +25 -0
- package/src/tui/clipboard.ts +92 -0
- package/src/tui/cmdbar.ts +296 -0
- package/src/tui/companion.ts +1427 -0
- package/src/tui/companionplan.ts +247 -0
- package/src/tui/companiontab.ts +83 -0
- package/src/tui/confighome.ts +155 -0
- package/src/tui/connect.ts +68 -0
- package/src/tui/deploy-ceremony.ts +104 -0
- package/src/tui/deploy-drain.ts +129 -0
- package/src/tui/deploy-process.ts +265 -0
- package/src/tui/deploytab.ts +1523 -0
- package/src/tui/design.ts +125 -0
- package/src/tui/digesttab.ts +214 -0
- package/src/tui/emptystate.ts +39 -0
- package/src/tui/evidence.ts +999 -0
- package/src/tui/feed.ts +604 -0
- package/src/tui/feedbacktab.ts +1077 -0
- package/src/tui/filters.ts +90 -0
- package/src/tui/ghread.ts +321 -0
- package/src/tui/historypull.ts +81 -0
- package/src/tui/inboxpull.ts +236 -0
- package/src/tui/index.ts +1565 -0
- package/src/tui/issuesink.ts +238 -0
- package/src/tui/jsonfile.ts +42 -0
- package/src/tui/keys.ts +109 -0
- package/src/tui/layout.ts +108 -0
- package/src/tui/learning.ts +627 -0
- package/src/tui/lessonswitch.ts +118 -0
- package/src/tui/livecontrol.ts +228 -0
- package/src/tui/memforward.ts +443 -0
- package/src/tui/memincident.ts +363 -0
- package/src/tui/memory.ts +620 -0
- package/src/tui/memtab.ts +606 -0
- package/src/tui/metricstab.ts +1177 -0
- package/src/tui/model.ts +131 -0
- package/src/tui/mouse.ts +98 -0
- package/src/tui/msg.ts +268 -0
- package/src/tui/nativecompanion.ts +417 -0
- package/src/tui/openurl.ts +21 -0
- package/src/tui/paint.ts +511 -0
- package/src/tui/panes.ts +98 -0
- package/src/tui/pipane.ts +219 -0
- package/src/tui/poll-hooks.ts +12 -0
- package/src/tui/poll.ts +147 -0
- package/src/tui/proposals.ts +838 -0
- package/src/tui/prpause.ts +150 -0
- package/src/tui/reload.ts +436 -0
- package/src/tui/remote.ts +413 -0
- package/src/tui/rollover.ts +969 -0
- package/src/tui/scantab.ts +1290 -0
- package/src/tui/setupentry.ts +93 -0
- package/src/tui/sidebar.ts +749 -0
- package/src/tui/sshtriage.ts +510 -0
- package/src/tui/startup.ts +231 -0
- package/src/tui/tabs.ts +45 -0
- package/src/tui/termmirror.ts +224 -0
- package/src/tui/theme.ts +95 -0
- package/src/tui/ticketstab.ts +310 -0
- package/src/tui/tmuxsocket.ts +129 -0
- package/src/tui/trackertickets.ts +84 -0
- package/src/tui/unwedge.ts +286 -0
- package/src/tui/viewer.ts +575 -0
- package/src/tui/viewerinput.ts +173 -0
- package/src/updatenotice.ts +178 -0
- package/src/usage.ts +1154 -0
- package/src/verdicts.ts +4689 -0
- package/src/version.ts +80 -0
- package/src/wakes.ts +476 -0
- package/src/webhook.ts +942 -0
- package/src/webhookheal.ts +276 -0
- package/src/wire.ts +2153 -0
- package/src/worker-session-host.ts +602 -0
- package/src/workerbreaker.ts +156 -0
- package/src/workercontrol.ts +104 -0
- package/src/workerlease.ts +441 -0
- package/src/workerliveness.ts +103 -0
- package/src/workerlog.ts +79 -0
- package/src/workerruntime.ts +30 -0
- package/src/workersupervisor.ts +1538 -0
- package/src/workerusage.ts +42 -0
- package/src/workerworld.ts +427 -0
- package/src/worktrees.ts +1138 -0
- package/tests/fixtures/regression-cases.json +795 -0
- package/tests/fixtures/seed-manifest.json +19 -0
|
@@ -0,0 +1,3848 @@
|
|
|
1
|
+
import { enqueueRoundWork, pendingRoundWork, finishRoundWork, } from "./round-work.js";
|
|
2
|
+
// Verdict watcher. The PR session writes
|
|
3
|
+
// verdict/pr-<n>-round-<k>.json at round end; the daemon owns everything that
|
|
4
|
+
// touches GitHub from there: labels off/on, the escalation review request
|
|
5
|
+
// (redundancy-skipped inside the adapter), the round record, the state
|
|
6
|
+
// transition.
|
|
7
|
+
//
|
|
8
|
+
// Two anomaly paths, both explicitly no-approve: a malformed/unparseable
|
|
9
|
+
// verdict file, and a round that blows its deadline. Both mark the round and
|
|
10
|
+
// write the companion inbox. There is no path here that touches an existing review:
|
|
11
|
+
// the invariant against retracting an approval is structural, not prompted
|
|
12
|
+
// (AGENTS.md invariant 2).
|
|
13
|
+
import { normalizePassPhase, normalizeSweepClaim, sweepSkippedOnApprove, sweepStatusOf, } from "@quickchatai/pi-quickreview/src/sweepstatus.js";
|
|
14
|
+
import { pendingPosting } from "./posting-outbox.js";
|
|
15
|
+
export { sweepStatusOf };
|
|
16
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, unlinkSync, watch, } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { checkOutcome, classifyCi, latestRunPerName, } from "@quickchatai/pi-quickreview/src/cigate.js";
|
|
19
|
+
import { deliverEscalations, resumeEscalations, evaluateEscalationRules, normalizeSessionEscalations, } from "./escalation.js";
|
|
20
|
+
import { DEFAULT_MAX_PROMPT_REDELIVERIES, DEFAULT_MAX_SELF_REQUEUES, DEFAULT_POSTING_RECEIPT_GRACE_S, DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN, DEFAULT_ROUND_STALL_GRACE_MIN, githubCloneDir, sandboxRoot, } from "./config.js";
|
|
21
|
+
import { fingerprintRoundIntegrity, gitConfigChangeMessage, INTEGRITY_BUDGET_MS, roundIntegrityOpts, integrityChanges, policySurfaceChangeMessage, splitIntegrityChanges, } from "./execboundary.js";
|
|
22
|
+
import { estimateUsd } from "./usage.js";
|
|
23
|
+
import { TranscriptIndex } from "./transcriptindex.js";
|
|
24
|
+
import { withinBudget } from "@quickchatai/pi-quickreview/src/budget.js";
|
|
25
|
+
import { applyEvent, restoreStandingLifecycle } from "./statemachine.js";
|
|
26
|
+
import { isLoudCancelCause } from "./state.js";
|
|
27
|
+
import { enforceLedgerFreshness } from "./ledgercheck.js";
|
|
28
|
+
import { findingsRecordPath, readFindingsRecord, } from "@quickchatai/pi-quickreview/src/findingsrecord.js";
|
|
29
|
+
import { enforceFindingsRecord, ingestRepairedFindingsRecords, resumeFindingsRecordedHooks, } from "./findingscheck.js";
|
|
30
|
+
import { normalizeVerdict, } from "@quickchatai/pi-quickreview/src/types.js";
|
|
31
|
+
import { guardedNoopMatchesMark, postingMarker, readPostingReceipt, receiptMatchesVerdict, retrySoftenedApproval, } from "./posting-receipt.js";
|
|
32
|
+
import { snapshotMark, standingReview, standingVerdict, } from "@quickchatai/pi-quickreview/src/standing-review.js";
|
|
33
|
+
import { flushHeldWakes, pauseDefersAllHeldWakes } from "./wakes.js";
|
|
34
|
+
import { prWorktreeRel } from "./worktrees.js";
|
|
35
|
+
import { describeGap, failedPasses, parsePlan, readRoundProgress, requiredPassGap, } from "./requiredpasses.js";
|
|
36
|
+
import { readSettledJson, resolvePartialWritePolicy, } from "@quickchatai/pi-quickreview/src/settledread.js";
|
|
37
|
+
/**
|
|
38
|
+
* How long a round end waits for its transcripts. The reads are
|
|
39
|
+
* async and chunked, so the event loop is free throughout; this bound is for a
|
|
40
|
+
* read that never returns at all. Past it the usage is recorded unknown, the
|
|
41
|
+
* audit incomplete, and the round end goes on — a verdict is never held
|
|
42
|
+
* behind its own accounting.
|
|
43
|
+
*/
|
|
44
|
+
export const USAGE_BUDGET_MS = 120_000;
|
|
45
|
+
/** How many unreadable paths the `round_usage_unknown` event names. */
|
|
46
|
+
const UNREADABLE_LISTED = 10;
|
|
47
|
+
/**
|
|
48
|
+
* How old an owed measurement's window may be and still be re-read after a
|
|
49
|
+
* restart. Past it the window is not read at all and the usage is recorded
|
|
50
|
+
* unknown: the pi-global archiver moves store
|
|
51
|
+
* files a day old, and a re-read that silently missed one would record a
|
|
52
|
+
* partial sum as the round's cost. A restart is minutes; this is generous.
|
|
53
|
+
*/
|
|
54
|
+
export const OWED_MEASUREMENT_RESUME_MAX_MS = 12 * 3_600_000;
|
|
55
|
+
/**
|
|
56
|
+
* A staged verdict or recheck outcome (`verdict/<name>.tmp`)
|
|
57
|
+
* that was never committed is removed once it is this old. A session stages
|
|
58
|
+
* and commits within one turn of a round that lasts at most its deadline, so
|
|
59
|
+
* a day-old staging file belongs to a session that died between the two.
|
|
60
|
+
*/
|
|
61
|
+
export const STAGED_FILE_MAX_AGE_MS = 24 * 3_600_000;
|
|
62
|
+
/** The staging names `scripts/commit-verdict.sh` publishes from: the two
|
|
63
|
+
* watched names (VERDICT_FILE_RE here, RECHECK_FILE_RE in recheck.ts) plus
|
|
64
|
+
* `.tmp`, and nothing else. */
|
|
65
|
+
const STAGED_FILE_RE = /^pr-(\d+)-(?:round-\d+|recheck-[0-9a-f]{7,40})\.json\.tmp$/i;
|
|
66
|
+
/** A receipt settlement's measurement mode: inline from the verdict sweep (a
|
|
67
|
+
* verdict file, or a malformed one), background from the deadline tick and the
|
|
68
|
+
* round-stage supervisor. */
|
|
69
|
+
function measureModeFor(via) {
|
|
70
|
+
return via === "verdict" || via === "malformed_verdict" ? "inline" : "background";
|
|
71
|
+
}
|
|
72
|
+
/** A round-stage limit's answer for a posting failure the stage tried to
|
|
73
|
+
* open: a verdict that owns the round is the sweep's (retry the limit later),
|
|
74
|
+
* a round that ended is settled, a failure opened is loud. */
|
|
75
|
+
function stageFailure(outcome) {
|
|
76
|
+
return outcome === "verdict" ? "retryable" : outcome === "ended" ? "settled" : "failed_loud";
|
|
77
|
+
}
|
|
78
|
+
/** A self-requeue's measurement mode: the two that come from a verdict file
|
|
79
|
+
* are the sweep's; the rest come from the deadline tick or the supervisor. */
|
|
80
|
+
function measureModeForRequeue(mode) {
|
|
81
|
+
return mode === "head_moved" ||
|
|
82
|
+
mode === "needs_human_incomplete" ||
|
|
83
|
+
mode === "required_pass_missing"
|
|
84
|
+
? "inline"
|
|
85
|
+
: "background";
|
|
86
|
+
}
|
|
87
|
+
const SELF_REQUEUE_CAUSE = {
|
|
88
|
+
dead: "deadline_overrun",
|
|
89
|
+
alive_stuck: "deadline_overrun",
|
|
90
|
+
needs_human_incomplete: "incomplete_round",
|
|
91
|
+
lost_prompt: "lost_prompt",
|
|
92
|
+
head_moved: "head_moved",
|
|
93
|
+
required_pass_missing: "incomplete_round",
|
|
94
|
+
infrastructure_active: "infrastructure_failure",
|
|
95
|
+
infrastructure_settled: "infrastructure_failure",
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* The merits decision a confirmed posting receipt proves, or null
|
|
99
|
+
* when it proves only that something was posted.
|
|
100
|
+
*
|
|
101
|
+
* The receipt records what the session asked the posting script for
|
|
102
|
+
* (`intended_event`) beside what the guards let through (`actual_event`), and
|
|
103
|
+
* the scripts' shapes are fixed: `approve-pr.sh` only ever records
|
|
104
|
+
* APPROVE→APPROVE, and `post-review.sh` takes REQUEST_CHANGES or COMMENT, with
|
|
105
|
+
* guard 1 softening REQUEST_CHANGES to COMMENT when a non-author human has
|
|
106
|
+
* already approved. So:
|
|
107
|
+
*
|
|
108
|
+
* - REQUEST_CHANGES asked for is a REQUEST_CHANGES decision whatever was
|
|
109
|
+
* posted — exactly what `receiptMatchesVerdict` accepts from a verdict file;
|
|
110
|
+
* - APPROVE posted is an APPROVE;
|
|
111
|
+
* - a COMMENT the session asked for is, by the round templates' own contract,
|
|
112
|
+
* the withheld-approval shape and nothing else: "red CI and no blockers ⇒
|
|
113
|
+
* post the full review as a COMMENT … write `"verdict": "APPROVE"` with
|
|
114
|
+
* `"approval_withheld": true`" (step 5's CI gate). Mechanical
|
|
115
|
+
* comments (the migration pre-check, an escalation) go through the daemon's
|
|
116
|
+
* gate and never write a session receipt, and a softened REQUEST_CHANGES
|
|
117
|
+
* says so in `intended_event`. Settling it as APPROVE + withheld records
|
|
118
|
+
* the withheld-approval mark, so CI turning green re-queues the PR and the
|
|
119
|
+
* SESSION decides again (cigate.ts) — the reading can never approve
|
|
120
|
+
* anything by itself.
|
|
121
|
+
*
|
|
122
|
+
* Not decided — null — is everything else: a receipt the marker reconcile
|
|
123
|
+
* synthesised from GitHub (`reconciled`: its `intended_event` is only the
|
|
124
|
+
* posted event, so a COMMENT there may be a softened REQUEST_CHANGES), and any
|
|
125
|
+
* pairing no posting script writes. Those keep the loud paths.
|
|
126
|
+
*
|
|
127
|
+
* This is the receipt's reading. The reviewed-head mark's `posted_event`
|
|
128
|
+
* COMMENT carries no `intended_event` and stays never-guessed
|
|
129
|
+
* (standing-review.ts rule 4).
|
|
130
|
+
*/
|
|
131
|
+
export function decidedVerdictFromReceipt(receipt) {
|
|
132
|
+
// The daemon's deferred-posting retry delivered the session's
|
|
133
|
+
// APPROVE and guard 4 softened it on the gate — the withheld-approval shape.
|
|
134
|
+
if (retrySoftenedApproval(receipt))
|
|
135
|
+
return { verdict: "APPROVE", approval_withheld: true };
|
|
136
|
+
if (receipt.intended_event === "REQUEST_CHANGES" &&
|
|
137
|
+
(receipt.actual_event === "REQUEST_CHANGES" || receipt.actual_event === "COMMENT"))
|
|
138
|
+
return { verdict: "REQUEST_CHANGES" };
|
|
139
|
+
if (receipt.intended_event === "APPROVE" && receipt.actual_event === "APPROVE")
|
|
140
|
+
return { verdict: "APPROVE" };
|
|
141
|
+
if (receipt.intended_event === "COMMENT" &&
|
|
142
|
+
receipt.actual_event === "COMMENT" &&
|
|
143
|
+
receipt.reconciled !== true)
|
|
144
|
+
return { verdict: "APPROVE", approval_withheld: true };
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/** The planned lens set the round-end findings check stamps against:
|
|
148
|
+
* the round's plan, plus the sweep's lenses when the sweep RAN —
|
|
149
|
+
* their findings are planned work, never `unplanned`. */
|
|
150
|
+
export function tailPlannedLenses(round, sweepStatus) {
|
|
151
|
+
const base = round.lenses ?? "";
|
|
152
|
+
if (sweepStatus !== "ran" || !round.sweep_plan)
|
|
153
|
+
return base;
|
|
154
|
+
try {
|
|
155
|
+
const plan = JSON.parse(round.sweep_plan);
|
|
156
|
+
if (!Array.isArray(plan))
|
|
157
|
+
return base;
|
|
158
|
+
const lenses = new Set(base.split(",").filter(Boolean));
|
|
159
|
+
for (const row of plan) {
|
|
160
|
+
const lens = row?.lens;
|
|
161
|
+
if (typeof lens === "string" && lens)
|
|
162
|
+
lenses.add(lens);
|
|
163
|
+
}
|
|
164
|
+
return [...lenses].join(",");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return base;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function allowsTier(policy, tier) {
|
|
171
|
+
return (!!tier &&
|
|
172
|
+
[policy.tier, ...(policy.raisable_to ?? []).map((agent) => agent.split("--").at(-1))].includes(tier));
|
|
173
|
+
}
|
|
174
|
+
/** Match executions to distinct rows before judging any excess retries. */
|
|
175
|
+
function plannedTierMismatches(passes, rows) {
|
|
176
|
+
const byLens = new Map();
|
|
177
|
+
for (const row of rows)
|
|
178
|
+
byLens.set(row.lens, [...(byLens.get(row.lens) ?? []), row]);
|
|
179
|
+
const wrong = [];
|
|
180
|
+
for (const [lens, policies] of byLens) {
|
|
181
|
+
const runs = passes.filter((pass) => pass.name === lens);
|
|
182
|
+
const assigned = new Map();
|
|
183
|
+
const place = (run, seen) => {
|
|
184
|
+
for (let row = 0; row < policies.length; row++) {
|
|
185
|
+
if (seen.has(row) || !allowsTier(policies[row], runs[run].tier))
|
|
186
|
+
continue;
|
|
187
|
+
seen.add(row);
|
|
188
|
+
const previous = assigned.get(row);
|
|
189
|
+
if (previous === undefined || place(previous, seen)) {
|
|
190
|
+
assigned.set(row, run);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
};
|
|
196
|
+
const excess = runs.filter((_pass, run) => !place(run, new Set()));
|
|
197
|
+
for (const pass of excess)
|
|
198
|
+
if (assigned.size < policies.length ||
|
|
199
|
+
!policies.some((policy) => allowsTier(policy, pass.tier)))
|
|
200
|
+
wrong.push(lens);
|
|
201
|
+
}
|
|
202
|
+
return wrong;
|
|
203
|
+
}
|
|
204
|
+
function comparePlan(passes, plan, addable, legacyPlanned, checkMissing) {
|
|
205
|
+
const executed = passes.map((p) => p.name);
|
|
206
|
+
const out = {
|
|
207
|
+
planned: legacyPlanned,
|
|
208
|
+
missing: [],
|
|
209
|
+
unknownAdditions: [],
|
|
210
|
+
missingReason: [],
|
|
211
|
+
wrongTier: [],
|
|
212
|
+
allowedAdditions: [],
|
|
213
|
+
};
|
|
214
|
+
if (plan && addable) {
|
|
215
|
+
out.planned = plan.map((p) => p.lens);
|
|
216
|
+
const byAddable = new Map();
|
|
217
|
+
for (const policy of addable)
|
|
218
|
+
byAddable.set(policy.lens, [...(byAddable.get(policy.lens) ?? []), policy]);
|
|
219
|
+
out.wrongTier = plannedTierMismatches(passes, plan);
|
|
220
|
+
const plannedNames = new Set(plan.map((p) => p.lens));
|
|
221
|
+
for (const pass of passes) {
|
|
222
|
+
if (plannedNames.has(pass.name))
|
|
223
|
+
continue;
|
|
224
|
+
const policies = byAddable.get(pass.name);
|
|
225
|
+
if (!policies) {
|
|
226
|
+
out.unknownAdditions.push(pass.name);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const optional = policies.filter((policy) => policy.optional);
|
|
230
|
+
if (optional.length > 0) {
|
|
231
|
+
if (optional.some((policy) => allowsTier(policy, pass.tier)))
|
|
232
|
+
out.allowedAdditions.push({
|
|
233
|
+
name: pass.name,
|
|
234
|
+
tier: pass.tier,
|
|
235
|
+
reason: pass.reason ?? "optional row",
|
|
236
|
+
});
|
|
237
|
+
else
|
|
238
|
+
out.wrongTier.push(pass.name);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const policy = policies[0];
|
|
242
|
+
if (!pass.tier || pass.tier !== policy.tier)
|
|
243
|
+
out.wrongTier.push(pass.name);
|
|
244
|
+
if (!pass.reason)
|
|
245
|
+
out.missingReason.push(pass.name);
|
|
246
|
+
if (pass.tier === policy.tier && pass.reason) {
|
|
247
|
+
out.allowedAdditions.push({ name: pass.name, tier: pass.tier, reason: pass.reason });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
// Rows with no structured plan (in flight across a migration) compare
|
|
253
|
+
// by lens name only.
|
|
254
|
+
out.unknownAdditions = [...new Set(executed.filter((lens) => !out.planned.includes(lens)))];
|
|
255
|
+
}
|
|
256
|
+
if (checkMissing) {
|
|
257
|
+
const remaining = new Map();
|
|
258
|
+
for (const lens of executed)
|
|
259
|
+
remaining.set(lens, (remaining.get(lens) ?? 0) + 1);
|
|
260
|
+
out.missing = out.planned.filter((lens) => {
|
|
261
|
+
const count = remaining.get(lens) ?? 0;
|
|
262
|
+
if (count === 0)
|
|
263
|
+
return true;
|
|
264
|
+
remaining.set(lens, count - 1);
|
|
265
|
+
return false;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
export const VERDICT_FILE_RE = /^pr-(\d+)-round-(\d+)\.json$/;
|
|
271
|
+
/** A round's verdict file name — the one spelling VERDICT_FILE_RE reads,
|
|
272
|
+
* the watcher's slot key and every presence check use. */
|
|
273
|
+
export function verdictFileName(pr, roundNo) {
|
|
274
|
+
return `pr-${pr}-round-${roundNo}.json`;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Tolerant normalization of the executed-pass telemetry. LLM-written
|
|
278
|
+
* files: digit-string counts coerce, `lens` is accepted for `name`,
|
|
279
|
+
* `candidates`/`candidate_findings` for `findings`. Anything unusable —
|
|
280
|
+
* a non-array, an entry with no name — degrades to unknown (undefined) or is
|
|
281
|
+
* dropped with a note; nothing here can fail the verdict.
|
|
282
|
+
*/
|
|
283
|
+
export function normalizePasses(raw) {
|
|
284
|
+
if (raw === undefined || raw === null)
|
|
285
|
+
return { note: "absent" };
|
|
286
|
+
if (!Array.isArray(raw))
|
|
287
|
+
return { note: `not an array (${typeof raw})` };
|
|
288
|
+
const count = (val) => {
|
|
289
|
+
if (typeof val === "number" && Number.isFinite(val))
|
|
290
|
+
return Math.trunc(val);
|
|
291
|
+
if (typeof val === "string" && /^\d+$/.test(val))
|
|
292
|
+
return Number(val);
|
|
293
|
+
return null;
|
|
294
|
+
};
|
|
295
|
+
const passes = [];
|
|
296
|
+
let dropped = 0;
|
|
297
|
+
let badPhase = 0;
|
|
298
|
+
for (const entry of raw) {
|
|
299
|
+
if (typeof entry !== "object" || entry === null) {
|
|
300
|
+
dropped++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const e = entry;
|
|
304
|
+
const name = typeof e.name === "string" && e.name.trim()
|
|
305
|
+
? e.name.trim()
|
|
306
|
+
: typeof e.lens === "string" && e.lens.trim()
|
|
307
|
+
? e.lens.trim()
|
|
308
|
+
: null;
|
|
309
|
+
if (!name) {
|
|
310
|
+
dropped++;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
passes.push({
|
|
314
|
+
name,
|
|
315
|
+
tier: typeof e.tier === "string" && e.tier.trim() ? e.tier.trim() : null,
|
|
316
|
+
...(typeof e.reason === "string" && e.reason.trim() ? { reason: e.reason.trim() } : {}),
|
|
317
|
+
findings: count(e.findings) ?? count(e.candidates) ?? count(e.candidate_findings),
|
|
318
|
+
survived: count(e.survived),
|
|
319
|
+
killed: count(e.killed),
|
|
320
|
+
downgraded: count(e.downgraded),
|
|
321
|
+
merged: count(e.merged),
|
|
322
|
+
folded: count(e.folded),
|
|
323
|
+
});
|
|
324
|
+
// Which stage of a follow-up round launched the pass. Additive
|
|
325
|
+
// and tolerant — absent stays absent (a delta-stage pass, or a round that
|
|
326
|
+
// is not a follow-up), an unusable spelling is dropped with a note; the
|
|
327
|
+
// entry itself always survives.
|
|
328
|
+
if (e.phase !== undefined && e.phase !== null) {
|
|
329
|
+
const phase = normalizePassPhase(e.phase);
|
|
330
|
+
if (phase)
|
|
331
|
+
passes[passes.length - 1].phase = phase;
|
|
332
|
+
else
|
|
333
|
+
badPhase++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const notes = [
|
|
337
|
+
dropped > 0 ? `${dropped} malformed entr${dropped === 1 ? "y" : "ies"} dropped` : "",
|
|
338
|
+
badPhase > 0 ? `${badPhase} unusable phase${badPhase === 1 ? "" : "s"} ignored` : "",
|
|
339
|
+
].filter(Boolean);
|
|
340
|
+
return { passes, ...(notes.length > 0 ? { note: notes.join("; ") } : {}) };
|
|
341
|
+
}
|
|
342
|
+
/** The verdict's `extent`: what the session did with the follow-up
|
|
343
|
+
* plan's optional rows — `ran` | `partial` | `skipped`, case forgiven;
|
|
344
|
+
* anything else is unusable (null). */
|
|
345
|
+
export function normalizeExtentClaim(raw) {
|
|
346
|
+
if (typeof raw !== "string")
|
|
347
|
+
return null;
|
|
348
|
+
const word = raw.trim().toLowerCase();
|
|
349
|
+
return word === "ran" || word === "partial" || word === "skipped" ? word : null;
|
|
350
|
+
}
|
|
351
|
+
/** Shape validation for VerdictFile. Anything off → the anomaly path. */
|
|
352
|
+
export function validateVerdict(raw) {
|
|
353
|
+
if (typeof raw !== "object" || raw === null)
|
|
354
|
+
return { ok: false, error: "not an object" };
|
|
355
|
+
const v = raw;
|
|
356
|
+
const bad = (m) => ({ ok: false, error: m });
|
|
357
|
+
// LLM-written files: coerce unambiguous digit-strings ("5263" → 5263) before
|
|
358
|
+
// shape-checking.
|
|
359
|
+
for (const k of ["pr", "round_no"]) {
|
|
360
|
+
const val = v[k];
|
|
361
|
+
if (typeof val === "string" && /^\d+$/.test(val))
|
|
362
|
+
v[k] = Number(val);
|
|
363
|
+
}
|
|
364
|
+
if (typeof v.pr !== "number" || !Number.isInteger(v.pr))
|
|
365
|
+
return bad("pr must be an integer");
|
|
366
|
+
if (typeof v.round_no !== "number" || !Number.isInteger(v.round_no))
|
|
367
|
+
return bad("round_no must be an integer");
|
|
368
|
+
// Guard-5 landing: `outcome: "head_moved"` (or, belt-and-braces, verdict
|
|
369
|
+
// spelled HEAD_MOVED) is a verdict-less round
|
|
370
|
+
// ending — the PR advanced mid-round and the guard refused the stale post.
|
|
371
|
+
// Only pr/round_no/head_sha/finished_at are required; the daemon cancels
|
|
372
|
+
// the round and self-requeues instead of applying a verdict.
|
|
373
|
+
const outcome = typeof v.outcome === "string" ? v.outcome.trim().toLowerCase() : "";
|
|
374
|
+
const verdictSpelled = typeof v.verdict === "string" ? v.verdict.trim().toUpperCase() : "";
|
|
375
|
+
// The envelope every outcome carries: the head the round reviewed and when
|
|
376
|
+
// it finished. spend_usd is a legacy self-reported field — accepted in any
|
|
377
|
+
// shape and IGNORED (models copy the template's example figure; real usage
|
|
378
|
+
// is measured from the session JSONLs) — deleted so no consumer can store
|
|
379
|
+
// it.
|
|
380
|
+
const badEnvelope = () => {
|
|
381
|
+
if (typeof v.head_sha !== "string" || v.head_sha.length === 0)
|
|
382
|
+
return bad("head_sha must be a non-empty string");
|
|
383
|
+
if (typeof v.finished_at !== "string" || v.finished_at.length === 0)
|
|
384
|
+
return bad("finished_at must be a non-empty string");
|
|
385
|
+
delete v.spend_usd;
|
|
386
|
+
return null;
|
|
387
|
+
};
|
|
388
|
+
if (outcome === "head_moved" || verdictSpelled === "HEAD_MOVED") {
|
|
389
|
+
v.outcome = "head_moved";
|
|
390
|
+
delete v.verdict;
|
|
391
|
+
return badEnvelope() ?? { ok: true, verdict: raw };
|
|
392
|
+
}
|
|
393
|
+
if (outcome)
|
|
394
|
+
return bad(`bad outcome: ${String(v.outcome)}`);
|
|
395
|
+
// normalizeVerdict also folds the retired "ESCALATE" spelling into
|
|
396
|
+
// NEEDS_HUMAN — the frozen conversation-system.md seed still emits it.
|
|
397
|
+
const verdict = normalizeVerdict(v.verdict);
|
|
398
|
+
if (verdict === null)
|
|
399
|
+
return bad(`bad verdict: ${String(v.verdict)}`);
|
|
400
|
+
v.verdict = verdict;
|
|
401
|
+
const envelope = badEnvelope();
|
|
402
|
+
if (envelope)
|
|
403
|
+
return envelope;
|
|
404
|
+
if (v.ai_approved !== undefined && typeof v.ai_approved !== "boolean")
|
|
405
|
+
return bad("ai_approved must be a boolean");
|
|
406
|
+
// LLM-written booleans arrive as strings often enough to coerce the
|
|
407
|
+
// two unambiguous spellings rather than fail the whole file over them.
|
|
408
|
+
if (v.approval_withheld === "true")
|
|
409
|
+
v.approval_withheld = true;
|
|
410
|
+
if (v.approval_withheld === "false")
|
|
411
|
+
v.approval_withheld = false;
|
|
412
|
+
if (v.approval_withheld !== undefined && typeof v.approval_withheld !== "boolean")
|
|
413
|
+
return bad("approval_withheld must be a boolean");
|
|
414
|
+
if (v.ledger_snapshot !== undefined && typeof v.ledger_snapshot !== "string")
|
|
415
|
+
return bad("ledger_snapshot must be a string");
|
|
416
|
+
if (v.summary !== undefined && typeof v.summary !== "string")
|
|
417
|
+
return bad("summary must be a string");
|
|
418
|
+
if (typeof v.summary === "string")
|
|
419
|
+
v.summary = v.summary.trim().slice(0, 800);
|
|
420
|
+
// Executed-pass telemetry: tolerant by design — a session may omit it and
|
|
421
|
+
// malformed shapes degrade to unknown, never to a rejected verdict.
|
|
422
|
+
const norm = normalizePasses(v.passes);
|
|
423
|
+
if (norm.passes)
|
|
424
|
+
v.passes = norm.passes;
|
|
425
|
+
else
|
|
426
|
+
delete v.passes;
|
|
427
|
+
// Session-judgement escalations: the same tolerant discipline, for
|
|
428
|
+
// the same reason and one stronger one — escalation is notification beside a
|
|
429
|
+
// verdict, so a malformed `escalations` array must never be able to reject
|
|
430
|
+
// the verdict it rides on. Unreadable ⇒ dropped with a note.
|
|
431
|
+
const esc = normalizeSessionEscalations(v.escalations);
|
|
432
|
+
if (esc.entries.length > 0)
|
|
433
|
+
v.escalations = esc.entries;
|
|
434
|
+
else
|
|
435
|
+
delete v.escalations;
|
|
436
|
+
// A follow-up round's pre-approval sweep claim — the same tolerant
|
|
437
|
+
// discipline: an unusable claim is dropped with a note (the daemon then
|
|
438
|
+
// records the sweep unknown), never a rejected verdict.
|
|
439
|
+
let sweepNote;
|
|
440
|
+
if (v.sweep !== undefined) {
|
|
441
|
+
const claim = normalizeSweepClaim(v.sweep);
|
|
442
|
+
if (claim)
|
|
443
|
+
v.sweep = claim;
|
|
444
|
+
else {
|
|
445
|
+
sweepNote = `sweep claim unusable (${JSON.stringify(v.sweep).slice(0, 40)}) ignored`;
|
|
446
|
+
delete v.sweep;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// The optional rows' extent — the same tolerant discipline.
|
|
450
|
+
let extentNote;
|
|
451
|
+
if (v.extent !== undefined) {
|
|
452
|
+
const extent = normalizeExtentClaim(v.extent);
|
|
453
|
+
if (extent)
|
|
454
|
+
v.extent = extent;
|
|
455
|
+
else {
|
|
456
|
+
extentNote = `extent claim unusable (${JSON.stringify(v.extent).slice(0, 40)}) ignored`;
|
|
457
|
+
delete v.extent;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const k of ["sweep_reason", "extent_reason"])
|
|
461
|
+
if (v[k] !== undefined && typeof v[k] !== "string")
|
|
462
|
+
delete v[k];
|
|
463
|
+
const notes = [norm.note, esc.note, sweepNote, extentNote].filter(Boolean).join("; ");
|
|
464
|
+
return {
|
|
465
|
+
ok: true,
|
|
466
|
+
verdict: raw,
|
|
467
|
+
...(notes ? { passes_note: notes } : {}),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
export class VerdictWatcher {
|
|
471
|
+
deps;
|
|
472
|
+
watcher = null;
|
|
473
|
+
inFlight = new Set();
|
|
474
|
+
processedDir;
|
|
475
|
+
/** Exec-policy violations found while measuring usage, kept only so an
|
|
476
|
+
* inbox item that happens anyway can carry a line about them —
|
|
477
|
+
* violations alone never create inject spam. */
|
|
478
|
+
execViolations = new Map();
|
|
479
|
+
/** Waits logged once per round per process, not once per supervisor scan
|
|
480
|
+
* or deadline tick: the posted-tail wait at a stage limit (`stage:<id>`)
|
|
481
|
+
* and at the deadline (`deadline:<id>`), and an ambiguous POST a stage limit
|
|
482
|
+
* is waiting out (`ambiguous:<id>`). */
|
|
483
|
+
loggedOnce = new Set();
|
|
484
|
+
/** Rounds whose owed measurement this process is running or about to run —
|
|
485
|
+
* the boot resume never measures one of them a second time. */
|
|
486
|
+
measuring = new Set();
|
|
487
|
+
working = new Set();
|
|
488
|
+
/** Background round work, tracked until it ends. */
|
|
489
|
+
background = new Set();
|
|
490
|
+
/** The once-per-watcher resume of measurements an earlier process owed. */
|
|
491
|
+
owedResumed = false;
|
|
492
|
+
/** `checkDeadlines` never overlaps itself. */
|
|
493
|
+
deadlinesInFlight = false;
|
|
494
|
+
/** Round transcripts, read off the event loop and kept by offset. */
|
|
495
|
+
transcripts;
|
|
496
|
+
stopped = false;
|
|
497
|
+
escalationRecoveryInFlight = false;
|
|
498
|
+
constructor(deps) {
|
|
499
|
+
this.deps = deps;
|
|
500
|
+
this.processedDir = join(deps.verdictDir, "processed");
|
|
501
|
+
this.transcripts = deps.usage?.transcripts ?? new TranscriptIndex();
|
|
502
|
+
}
|
|
503
|
+
now() {
|
|
504
|
+
return (this.deps.now ?? (() => new Date()))();
|
|
505
|
+
}
|
|
506
|
+
log(msg, extra) {
|
|
507
|
+
(this.deps.log ?? (() => { }))(msg, extra);
|
|
508
|
+
}
|
|
509
|
+
/** Startup sweep + fs.watch. fs.watch is the fast path only; the sweep (also
|
|
510
|
+
* run by the daemon's periodic tick) is the reliable one. */
|
|
511
|
+
start() {
|
|
512
|
+
this.stopped = false;
|
|
513
|
+
mkdirSync(this.deps.verdictDir, { recursive: true });
|
|
514
|
+
void this.sweep();
|
|
515
|
+
this.watcher = watch(this.deps.verdictDir, (_event, filename) => {
|
|
516
|
+
if (!filename)
|
|
517
|
+
return;
|
|
518
|
+
const name = String(filename);
|
|
519
|
+
if (!VERDICT_FILE_RE.test(name))
|
|
520
|
+
return;
|
|
521
|
+
void this.handleFile(name).catch((err) => this.log(`verdicts: ${name} failed`, err));
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
stop() {
|
|
525
|
+
this.stopped = true;
|
|
526
|
+
this.watcher?.close();
|
|
527
|
+
this.watcher = null;
|
|
528
|
+
}
|
|
529
|
+
/** Resolves once this watcher's background work ends. The daemon does not
|
|
530
|
+
* wait here during shutdown: durable obligations resume at the next start.
|
|
531
|
+
* Tests use it before observing background round results. */
|
|
532
|
+
async idle() {
|
|
533
|
+
while (this.background.size > 0)
|
|
534
|
+
await Promise.allSettled([...this.background]);
|
|
535
|
+
}
|
|
536
|
+
/** Process every verdict file sitting in the directory.
|
|
537
|
+
*
|
|
538
|
+
* One PR's files in name order; different PRs side by side. A file still
|
|
539
|
+
* being written is waited out for seconds, so a sweep that walked every file
|
|
540
|
+
* in one line would hold every other PR's verdict behind it — the fs.watch
|
|
541
|
+
* path handles PRs concurrently too. */
|
|
542
|
+
async sweep() {
|
|
543
|
+
this.resumeRoundWork();
|
|
544
|
+
this.detach(() => resumeFindingsRecordedHooks({
|
|
545
|
+
store: this.deps.store,
|
|
546
|
+
...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
|
|
547
|
+
log: this.log.bind(this),
|
|
548
|
+
}), "recorded findings hooks");
|
|
549
|
+
this.resumeEscalationDeliveries();
|
|
550
|
+
if (!existsSync(this.deps.verdictDir))
|
|
551
|
+
return [];
|
|
552
|
+
const byPr = new Map();
|
|
553
|
+
const listed = readdirSync(this.deps.verdictDir).sort();
|
|
554
|
+
for (const name of listed) {
|
|
555
|
+
const match = VERDICT_FILE_RE.exec(name);
|
|
556
|
+
if (!match)
|
|
557
|
+
continue;
|
|
558
|
+
byPr.set(match[1], [...(byPr.get(match[1]) ?? []), name]);
|
|
559
|
+
}
|
|
560
|
+
this.pruneStaleStaging(listed);
|
|
561
|
+
const done = [];
|
|
562
|
+
await Promise.all([...byPr.values()].map(async (names) => {
|
|
563
|
+
for (const name of names) {
|
|
564
|
+
try {
|
|
565
|
+
if (await this.handleFile(name))
|
|
566
|
+
done.push(name);
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
this.log(`verdicts: ${name} failed`, err);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}));
|
|
573
|
+
try {
|
|
574
|
+
await this.resumeInterruptedSettlements();
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
this.log("verdicts: resuming interrupted receipt settlements failed", err);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
this.resumeOwedMeasurements();
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
this.log("verdicts: resuming owed round measurements failed", err);
|
|
584
|
+
}
|
|
585
|
+
// A findings record the round tail nudged for lands after that tail ran.
|
|
586
|
+
if (this.deps.findingsDir) {
|
|
587
|
+
try {
|
|
588
|
+
ingestRepairedFindingsRecords({
|
|
589
|
+
store: this.deps.store,
|
|
590
|
+
findingsDir: this.deps.findingsDir,
|
|
591
|
+
log: this.log.bind(this),
|
|
592
|
+
...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
|
|
593
|
+
}, this.now());
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
this.log("verdicts: storing repaired findings records failed", err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return done.sort();
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Remove staging files (`verdict/<name>.tmp`) that were never committed.
|
|
603
|
+
* Nothing reads them — the watchers' names end
|
|
604
|
+
* in `.json` — so one left by a session that died between its write and
|
|
605
|
+
* `scripts/commit-verdict.sh` would sit in verdict/ forever. Only the two
|
|
606
|
+
* staging shapes, only once a day old, and never a directory.
|
|
607
|
+
*/
|
|
608
|
+
pruneStaleStaging(names) {
|
|
609
|
+
const cutoff = this.now().getTime() - STAGED_FILE_MAX_AGE_MS;
|
|
610
|
+
for (const name of names) {
|
|
611
|
+
const match = STAGED_FILE_RE.exec(name);
|
|
612
|
+
if (!match)
|
|
613
|
+
continue;
|
|
614
|
+
const path = join(this.deps.verdictDir, name);
|
|
615
|
+
try {
|
|
616
|
+
const st = lstatSync(path);
|
|
617
|
+
if (st.isDirectory() || st.mtimeMs > cutoff)
|
|
618
|
+
continue;
|
|
619
|
+
unlinkSync(path);
|
|
620
|
+
this.deps.store.logEvent("verdict_staging_pruned", Number(match[1]), {
|
|
621
|
+
file: name,
|
|
622
|
+
mtime: new Date(st.mtimeMs).toISOString(),
|
|
623
|
+
});
|
|
624
|
+
this.log(`verdicts: removed ${name}, staged and never committed`);
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
// Gone already, or unreadable: the next sweep looks again.
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** The round `roundNo` names, whatever its status — never another round:
|
|
632
|
+
* a lookup that names a round this PR does not have answers undefined. With
|
|
633
|
+
* no `roundNo`, the PR's newest running round. */
|
|
634
|
+
runningRound(pr, roundNo) {
|
|
635
|
+
if (roundNo !== undefined) {
|
|
636
|
+
return this.deps.store.db
|
|
637
|
+
.prepare("SELECT * FROM rounds WHERE pr = ? AND round_no = ? ORDER BY id DESC LIMIT 1")
|
|
638
|
+
.get(pr, roundNo);
|
|
639
|
+
}
|
|
640
|
+
return this.deps.store.db
|
|
641
|
+
.prepare("SELECT * FROM rounds WHERE pr = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
642
|
+
.get(pr);
|
|
643
|
+
}
|
|
644
|
+
/** A file reaching a cancelled round is not applied over the cancel:
|
|
645
|
+
* recorded, archived by the caller, nothing else touched. */
|
|
646
|
+
ignoreAfterCancel(round, name, detail = {}) {
|
|
647
|
+
this.deps.store.logEvent("verdict_after_cancel_ignored", round.pr, {
|
|
648
|
+
round_id: round.id,
|
|
649
|
+
round_no: round.round_no,
|
|
650
|
+
cancel_cause: round.cancel_cause,
|
|
651
|
+
file: name,
|
|
652
|
+
...detail,
|
|
653
|
+
});
|
|
654
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} was already ${round.status}${round.cancel_cause ? ` (${round.cancel_cause})` : ""} — ${name} is not applied over it`);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* The orphan sweep cancels a round because its session did not survive a
|
|
658
|
+
* restart — not because anything decided against the round. A verdict file
|
|
659
|
+
* the session committed before the restart, which the boot sweep had not
|
|
660
|
+
* settled yet (still waiting on its posting receipt), is that round's
|
|
661
|
+
* decision and still applies — the posted review is settled, never re-run —
|
|
662
|
+
* as long as no later round has started on the PR.
|
|
663
|
+
*/
|
|
664
|
+
orphanVerdictOwed(round) {
|
|
665
|
+
return (round.status === "cancelled" &&
|
|
666
|
+
round.cancel_cause === "orphan_sweep" &&
|
|
667
|
+
!this.deps.store.hasLaterRound(round.pr, round.round_no));
|
|
668
|
+
}
|
|
669
|
+
/** A done round's interrupted apply that a later round of its PR has
|
|
670
|
+
* overtaken: nothing more of the old verdict lands — no transition, no
|
|
671
|
+
* reviewed-head or withheld mark, no successor settled — and it is recorded
|
|
672
|
+
* settled, so it is not resumed again. Its usage is owed over its own
|
|
673
|
+
* window, which ended at its claim (`finished_at`), never now — the later
|
|
674
|
+
* round's transcript is not this one's. The caller awaits the reads. */
|
|
675
|
+
settleSupersededResume(round, name) {
|
|
676
|
+
this.deps.store.logEvent("verdict_resume_superseded", round.pr, {
|
|
677
|
+
round_id: round.id,
|
|
678
|
+
round_no: round.round_no,
|
|
679
|
+
file: name,
|
|
680
|
+
});
|
|
681
|
+
const owed = this.deps.store.db.transaction(() => {
|
|
682
|
+
const measurement = this.oweMeasurement(round, round.finished_at);
|
|
683
|
+
this.deps.store.logEvent("verdict_settled", round.pr, {
|
|
684
|
+
round_id: round.id,
|
|
685
|
+
round_no: round.round_no,
|
|
686
|
+
superseded: true,
|
|
687
|
+
});
|
|
688
|
+
return measurement;
|
|
689
|
+
})();
|
|
690
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no}'s interrupted apply is overtaken by a later round — ${name} lands nothing more`);
|
|
691
|
+
return owed;
|
|
692
|
+
}
|
|
693
|
+
/** A verdict file names a round this PR does not have: archived inert,
|
|
694
|
+
* recorded, and nothing else touched — no round settled, no lifecycle moved,
|
|
695
|
+
* no failure opened against whatever round IS running. */
|
|
696
|
+
ignoreUnknownRound(pr, roundNo, name, malformed) {
|
|
697
|
+
this.deps.store.logEvent("verdict_round_unknown", pr, {
|
|
698
|
+
round_no: roundNo,
|
|
699
|
+
file: name,
|
|
700
|
+
...(malformed !== undefined ? { malformed } : {}),
|
|
701
|
+
});
|
|
702
|
+
this.log(`verdicts: #${pr} has no round ${roundNo} — ${name} is not applied to any round`);
|
|
703
|
+
}
|
|
704
|
+
/** Whether the apply of this round's verdict ran to its last durable step. */
|
|
705
|
+
verdictSettled(pr, roundId) {
|
|
706
|
+
return (this.deps.store.db
|
|
707
|
+
.prepare("SELECT 1 FROM events WHERE kind = 'verdict_settled' AND pr = ? AND json_extract(payload, '$.round_id') = ? LIMIT 1")
|
|
708
|
+
.get(pr, roundId) !== undefined);
|
|
709
|
+
}
|
|
710
|
+
archive(name) {
|
|
711
|
+
mkdirSync(this.processedDir, { recursive: true });
|
|
712
|
+
try {
|
|
713
|
+
renameSync(join(this.deps.verdictDir, name), join(this.processedDir, `${this.now().toISOString().replace(/[:.]/g, "-")}-${name}`));
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
this.log(`verdicts: could not archive ${name}`, err);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** Read + validate + apply one verdict file. Returns true when the file was
|
|
720
|
+
* consumed (applied or quarantined). */
|
|
721
|
+
async handleFile(name) {
|
|
722
|
+
const match = VERDICT_FILE_RE.exec(name);
|
|
723
|
+
if (!match)
|
|
724
|
+
return false;
|
|
725
|
+
if (this.inFlight.has(name))
|
|
726
|
+
return false;
|
|
727
|
+
this.inFlight.add(name);
|
|
728
|
+
try {
|
|
729
|
+
const pr = Number(match[1]);
|
|
730
|
+
const roundNo = Number(match[2]);
|
|
731
|
+
const path = join(this.deps.verdictDir, name);
|
|
732
|
+
if (!existsSync(path))
|
|
733
|
+
return false;
|
|
734
|
+
// Partial writes: an unparseable file is waited out while its bytes
|
|
735
|
+
// are still moving — by its mtime or between our own looks — and
|
|
736
|
+
// declared only once it has sat still, or after a hard window. A session
|
|
737
|
+
// may write in place (open O_TRUNC, then the bytes), and on a
|
|
738
|
+
// CPU-starved container the gap between the two can be long. A file
|
|
739
|
+
// that parses is complete; its shape is judged at once.
|
|
740
|
+
const read = await readSettledJson(path, resolvePartialWritePolicy(this.deps.partialWrite));
|
|
741
|
+
if (read.kind === "missing")
|
|
742
|
+
return false;
|
|
743
|
+
if (read.reads > 1) {
|
|
744
|
+
this.deps.store.logEvent("verdict_read_waited", pr, {
|
|
745
|
+
file: name,
|
|
746
|
+
round_no: roundNo,
|
|
747
|
+
reads: read.reads,
|
|
748
|
+
waited_ms: Math.round(read.waited_ms),
|
|
749
|
+
outcome: read.kind,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
const parsed = read.kind === "parsed"
|
|
753
|
+
? validateVerdict(read.value)
|
|
754
|
+
: {
|
|
755
|
+
ok: false,
|
|
756
|
+
error: `unparseable: ${read.error}` +
|
|
757
|
+
(read.why === "no_tolerance"
|
|
758
|
+
? ""
|
|
759
|
+
: ` (still unparseable after ${read.reads} read(s) over ${(read.waited_ms / 1000).toFixed(1)}s — ${read.why === "settled" ? "the file had stopped changing" : "the partial-write window ran out"})`),
|
|
760
|
+
};
|
|
761
|
+
if (!parsed.ok || !parsed.verdict) {
|
|
762
|
+
await this.handleMalformed(pr, roundNo, name, `malformed verdict file ${name}: ${parsed.error ?? "unknown"}`);
|
|
763
|
+
this.archive(name);
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
// The payload must be the file's own round — the same binding
|
|
767
|
+
// RecheckDecisionWatcher enforces on its filenames. A payload naming
|
|
768
|
+
// another PR or round would otherwise act on that other lifecycle while
|
|
769
|
+
// this round never gets its verdict; it is this round's malformed verdict
|
|
770
|
+
// instead.
|
|
771
|
+
if (parsed.verdict.pr !== pr || parsed.verdict.round_no !== roundNo) {
|
|
772
|
+
await this.handleMalformed(pr, roundNo, name, `malformed verdict file ${name}: payload names PR #${parsed.verdict.pr} round ${parsed.verdict.round_no}, not the filename's`);
|
|
773
|
+
this.archive(name);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
if (parsed.passes_note)
|
|
777
|
+
this.log(`verdicts: ${name} passes telemetry: ${parsed.passes_note}`);
|
|
778
|
+
const applied = await this.apply(parsed.verdict, name);
|
|
779
|
+
if (applied === "waiting")
|
|
780
|
+
return false;
|
|
781
|
+
this.archive(name);
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
finally {
|
|
785
|
+
this.inFlight.delete(name);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* A verdict file that cannot be applied. Before failing loud it asks
|
|
790
|
+
* whether the round's review already reached GitHub — a posted review is
|
|
791
|
+
* the evidence, and failing it would hold a PR over "no review was
|
|
792
|
+
* produced".
|
|
793
|
+
*
|
|
794
|
+
* - The round's verdict already settled (a replay of a done round, or a
|
|
795
|
+
* round guard 2 already closed): inert, like any replay.
|
|
796
|
+
* - A receipt settlement a restart interrupted (the round is done but its
|
|
797
|
+
* apply never reached `verdict_settled`): resumed, never failed.
|
|
798
|
+
* - Running, with a confirmed receipt that proves the decision
|
|
799
|
+
* (`decidedVerdictFromReceipt`): settled from the receipt — the posted
|
|
800
|
+
* review is the evidence. No FAILED_LOUD, no self-requeue, so no second
|
|
801
|
+
* posting.
|
|
802
|
+
* - Running, refused by guard 2 (a `skipped` record): the guarded no-op.
|
|
803
|
+
* - Anything else — no receipt, an ambiguous or undecidable one, a legacy
|
|
804
|
+
* round: the malformed anomaly. A missing receipt still fails loud.
|
|
805
|
+
*/
|
|
806
|
+
async handleMalformed(pr, roundNo, name, message) {
|
|
807
|
+
const round = this.runningRound(pr, roundNo);
|
|
808
|
+
if (!round) {
|
|
809
|
+
this.ignoreUnknownRound(pr, roundNo, name, message);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (round.status !== "running") {
|
|
813
|
+
if (this.roundTerminallySettled(round)) {
|
|
814
|
+
this.deps.store.logEvent("verdict_replay_ignored", pr, {
|
|
815
|
+
round_id: round.id,
|
|
816
|
+
round_no: roundNo,
|
|
817
|
+
file: name,
|
|
818
|
+
malformed: message,
|
|
819
|
+
});
|
|
820
|
+
this.log(`verdicts: #${pr} round ${roundNo} already settled (${round.status}) — malformed ${name} replays inertly`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// The file is still in verdict/ because the round's receipt settlement
|
|
824
|
+
// was cut short (archive runs only after it): finish that settlement.
|
|
825
|
+
if (await this.resumeReceiptSettlement(round))
|
|
826
|
+
return;
|
|
827
|
+
// A cancelled round is closed for good, as in `apply`: a malformed file
|
|
828
|
+
// reaching it afterwards opens no failure over whatever replaced it (an
|
|
829
|
+
// operator's `u` stays undone by nothing the old session writes).
|
|
830
|
+
if (round.status === "cancelled") {
|
|
831
|
+
this.ignoreAfterCancel(round, name, { malformed: message });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
let anomaly = message;
|
|
836
|
+
if (round && round.status === "running" && round.posting_key) {
|
|
837
|
+
const proof = await this.postingProof(round);
|
|
838
|
+
if (proof.kind === "confirmed" && proof.receipt) {
|
|
839
|
+
const decision = decidedVerdictFromReceipt(proof.receipt);
|
|
840
|
+
if (decision) {
|
|
841
|
+
await this.settleFromReceipt(round, proof.receipt, decision, "malformed_verdict", message);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
// Posted, but nothing on the receipt says what was decided: the loud
|
|
845
|
+
// path, saying so.
|
|
846
|
+
anomaly = `${message} (its review was posted — ${proof.receipt.actual_event}${proof.receipt.gh_review_id !== undefined ? `, GitHub review ${proof.receipt.gh_review_id}` : ""} — but the posting receipt does not say what was decided)`;
|
|
847
|
+
}
|
|
848
|
+
else if (proof.kind === "skipped" && proof.noop) {
|
|
849
|
+
await this.settleGuardedNoop(round, proof.noop, "malformed_verdict");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
else if (proof.kind === "waiting" &&
|
|
853
|
+
pendingPosting(this.postingReceiptDir(), pr, roundNo, round.posting_key)) {
|
|
854
|
+
// The malformed tail cannot cancel independently owned delivery.
|
|
855
|
+
// Its confirmed receipt will settle the decision on a later tick.
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
await this.handleAnomaly(pr, roundNo, anomaly, "malformed");
|
|
860
|
+
}
|
|
861
|
+
/** Has this (non-running) round been closed for good — a verdict (or a
|
|
862
|
+
* receipt settlement) applied to its last durable step, or a guard-2
|
|
863
|
+
* no-op? */
|
|
864
|
+
roundTerminallySettled(round) {
|
|
865
|
+
if (round.status === "done")
|
|
866
|
+
return this.verdictSettled(round.pr, round.id);
|
|
867
|
+
return round.status === "cancelled" && round.cancel_cause === "no_new_evidence";
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Hold the round's verdict-file slot — the one `handleFile` holds while it
|
|
871
|
+
* reads, waits out a partial write and applies — for a settlement that does
|
|
872
|
+
* not come from the file. A receipt settlement and a verdict
|
|
873
|
+
* landing in the same moment can then never both apply. Busy ⇒ undefined:
|
|
874
|
+
* the caller's next tick decides again.
|
|
875
|
+
*/
|
|
876
|
+
async withVerdictSlot(pr, roundNo, fn) {
|
|
877
|
+
const name = verdictFileName(pr, roundNo);
|
|
878
|
+
if (this.inFlight.has(name))
|
|
879
|
+
return undefined;
|
|
880
|
+
this.inFlight.add(name);
|
|
881
|
+
try {
|
|
882
|
+
return await fn();
|
|
883
|
+
}
|
|
884
|
+
finally {
|
|
885
|
+
this.inFlight.delete(name);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
verdictFilePresent(pr, roundNo) {
|
|
889
|
+
return existsSync(join(this.deps.verdictDir, verdictFileName(pr, roundNo)));
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Close a running round from its confirmed posting receipt:
|
|
893
|
+
* the review is on GitHub and the verdict file is malformed, never came, or
|
|
894
|
+
* did not come within the posted-tail grace. Never posts anything — the
|
|
895
|
+
* whole point is that the review already went out once.
|
|
896
|
+
*
|
|
897
|
+
* Only for a receipt that proves the decision (`decidedVerdictFromReceipt`);
|
|
898
|
+
* an undecidable one stays on the caller's loud path. The round is applied
|
|
899
|
+
* exactly as its verdict file would have been — the one `apply` path, with
|
|
900
|
+
* the posting already proven — so the labels, the transition, the
|
|
901
|
+
* reviewed-head mark, the withheld-approval mark, the successor settlement,
|
|
902
|
+
* the usage, the ledger and findings-record checks and the held wakes all
|
|
903
|
+
* happen as usual. Its summary says where the verdict came from; its
|
|
904
|
+
* executed-pass telemetry is honestly unknown.
|
|
905
|
+
*
|
|
906
|
+
* The claim has two halves. A fresh read of the round, followed with no await
|
|
907
|
+
* between by `apply`'s own read: a round the operator cancelled, a newer
|
|
908
|
+
* round superseded, or another path settled while the caller awaited GitHub
|
|
909
|
+
* is never applied over. And `apply`'s `finishRound` is a compare-and-set on
|
|
910
|
+
* the status that read saw: one that changed while `apply` itself awaited
|
|
911
|
+
* the label calls is not applied over either — the apply stops there
|
|
912
|
+
* (`verdict_apply_claim_lost`). The `verdict_settled_from_receipt` event is
|
|
913
|
+
* written before the apply and carries the verdict it applies, so a restart
|
|
914
|
+
* in the middle of it is resumed (`resumeReceiptSettlement`), never failed.
|
|
915
|
+
*/
|
|
916
|
+
async settleFromReceipt(round, receipt, decision, via, why) {
|
|
917
|
+
const { store } = this.deps;
|
|
918
|
+
const ghId = receipt.gh_review_id ?? null;
|
|
919
|
+
const posted = `${receipt.actual_event}${ghId !== null ? `, GitHub review ${ghId}` : ""}`;
|
|
920
|
+
const plain = via === "malformed_verdict"
|
|
921
|
+
? "its verdict file could not be read"
|
|
922
|
+
: via === "deadline"
|
|
923
|
+
? "no verdict file arrived before the round deadline"
|
|
924
|
+
: via === "settled_without_verdict"
|
|
925
|
+
? "the session finished without writing its verdict file"
|
|
926
|
+
: "the session did not write its verdict file within the grace after posting";
|
|
927
|
+
const event = {
|
|
928
|
+
round_id: round.id,
|
|
929
|
+
round_no: round.round_no,
|
|
930
|
+
via,
|
|
931
|
+
why,
|
|
932
|
+
posting_key: round.posting_key,
|
|
933
|
+
head_sha: receipt.head_sha,
|
|
934
|
+
intended_event: receipt.intended_event,
|
|
935
|
+
actual_event: receipt.actual_event,
|
|
936
|
+
gh_review_id: ghId,
|
|
937
|
+
confirmed_at: receipt.confirmed_at ?? null,
|
|
938
|
+
decided_verdict: decision.verdict,
|
|
939
|
+
approval_withheld: decision.approval_withheld === true,
|
|
940
|
+
};
|
|
941
|
+
// ---- the claim: fresh read → apply, with no await between ---------------
|
|
942
|
+
const fresh = this.runningRound(round.pr, round.round_no);
|
|
943
|
+
if (!fresh || fresh.id !== round.id || fresh.status !== "running") {
|
|
944
|
+
store.logEvent("verdict_settled_from_receipt_replayed", round.pr, {
|
|
945
|
+
...event,
|
|
946
|
+
round_status: fresh?.id === round.id ? fresh.status : "missing",
|
|
947
|
+
});
|
|
948
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} is no longer running (${fresh?.id === round.id ? fresh.status : "missing"}) — its receipt settlement (${via}) is not applied`);
|
|
949
|
+
return "replayed";
|
|
950
|
+
}
|
|
951
|
+
const reading = decision.approval_withheld
|
|
952
|
+
? "a COMMENT the session asked for is the round templates' withheld-approval shape, so it is APPROVE with the approval withheld until CI is green at this head"
|
|
953
|
+
: `the receipt proves ${decision.verdict}`;
|
|
954
|
+
const v = {
|
|
955
|
+
pr: round.pr,
|
|
956
|
+
round_no: round.round_no,
|
|
957
|
+
verdict: decision.verdict,
|
|
958
|
+
head_sha: receipt.head_sha,
|
|
959
|
+
...(decision.approval_withheld ? { approval_withheld: true } : {}),
|
|
960
|
+
summary: `This round's review was posted (${posted}), but ${plain}, so its verdict was settled from the posted review: ${reading}. ` +
|
|
961
|
+
"The session's own summary and pass telemetry were not available.",
|
|
962
|
+
finished_at: this.now().toISOString(),
|
|
963
|
+
};
|
|
964
|
+
store.logEvent("verdict_settled_from_receipt", round.pr, { ...event, verdict_file: v });
|
|
965
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} settled from its posting receipt (${posted}) as ${decision.verdict}${decision.approval_withheld ? " (approval withheld)" : ""} — ${plain}`);
|
|
966
|
+
await this.apply(v, verdictFileName(round.pr, round.round_no), { via, gh_review_id: ghId });
|
|
967
|
+
return "applied";
|
|
968
|
+
}
|
|
969
|
+
/** The receipt settlement this round started (its `verdict_settled_from_receipt`
|
|
970
|
+
* event, with the verdict it was applying), if any. */
|
|
971
|
+
receiptSettlementStarted(round) {
|
|
972
|
+
const row = this.deps.store.db
|
|
973
|
+
.prepare("SELECT payload FROM events WHERE kind = 'verdict_settled_from_receipt' AND pr = ? AND json_extract(payload, '$.round_id') = ? ORDER BY id DESC LIMIT 1")
|
|
974
|
+
.get(round.pr, round.id);
|
|
975
|
+
if (!row)
|
|
976
|
+
return undefined;
|
|
977
|
+
let payload;
|
|
978
|
+
try {
|
|
979
|
+
payload = JSON.parse(row.payload);
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
return undefined;
|
|
983
|
+
}
|
|
984
|
+
const parsed = validateVerdict(payload.verdict_file);
|
|
985
|
+
if (!parsed.ok || !parsed.verdict)
|
|
986
|
+
return undefined;
|
|
987
|
+
if (parsed.verdict.pr !== round.pr || parsed.verdict.round_no !== round.round_no)
|
|
988
|
+
return undefined;
|
|
989
|
+
if (parsed.verdict.verdict !== "APPROVE" && parsed.verdict.verdict !== "REQUEST_CHANGES")
|
|
990
|
+
return undefined;
|
|
991
|
+
return {
|
|
992
|
+
verdict: parsed.verdict,
|
|
993
|
+
via: typeof payload.via === "string" ? payload.via : "deadline",
|
|
994
|
+
gh_review_id: typeof payload.gh_review_id === "number" ? payload.gh_review_id : null,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Finish a receipt settlement a restart cut short. `apply` finishes the
|
|
999
|
+
* round row before its last durable steps (the transition, the marks, the
|
|
1000
|
+
* successor) and records `verdict_settled` only after them; a verdict file
|
|
1001
|
+
* survives a restart in that window and resumes the apply, but
|
|
1002
|
+
* a receipt settlement has no file — and on the malformed path the file it
|
|
1003
|
+
* does have is the broken one, which would turn a posted review into
|
|
1004
|
+
* FAILED_LOUD. So the settlement's own event carries the verdict it applies,
|
|
1005
|
+
* and a done round whose apply never settled is re-applied from it, the same
|
|
1006
|
+
* way a surviving file is. Returns false when there is nothing to resume.
|
|
1007
|
+
* The caller holds the round's verdict slot.
|
|
1008
|
+
*/
|
|
1009
|
+
async resumeReceiptSettlement(round) {
|
|
1010
|
+
if (round.status !== "done" || this.verdictSettled(round.pr, round.id))
|
|
1011
|
+
return false;
|
|
1012
|
+
const started = this.receiptSettlementStarted(round);
|
|
1013
|
+
if (!started)
|
|
1014
|
+
return false;
|
|
1015
|
+
this.deps.store.logEvent("verdict_settled_from_receipt_resumed", round.pr, {
|
|
1016
|
+
round_id: round.id,
|
|
1017
|
+
round_no: round.round_no,
|
|
1018
|
+
via: started.via,
|
|
1019
|
+
verdict: started.verdict.verdict,
|
|
1020
|
+
});
|
|
1021
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} — resuming its interrupted receipt settlement (${started.via})`);
|
|
1022
|
+
await this.apply(started.verdict, verdictFileName(round.pr, round.round_no), {
|
|
1023
|
+
via: started.via,
|
|
1024
|
+
gh_review_id: started.gh_review_id,
|
|
1025
|
+
});
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The sweep's half of the resume: receipt settlements with no verdict file
|
|
1030
|
+
* (the deadline and stage-limit paths) that a restart cut short. Bounded to
|
|
1031
|
+
* the last two days of settlements — a restart is minutes, and a done round
|
|
1032
|
+
* older than that is not touched on the strength of an old event.
|
|
1033
|
+
*/
|
|
1034
|
+
async resumeInterruptedSettlements() {
|
|
1035
|
+
const { store } = this.deps;
|
|
1036
|
+
const cutoff = new Date(this.now().getTime() - 2 * 86_400_000).toISOString();
|
|
1037
|
+
const rows = store.db
|
|
1038
|
+
.prepare("SELECT DISTINCT pr, json_extract(payload, '$.round_id') AS round_id FROM events WHERE kind = 'verdict_settled_from_receipt' AND ts >= ?")
|
|
1039
|
+
.all(cutoff);
|
|
1040
|
+
for (const row of rows) {
|
|
1041
|
+
if (row.round_id === null)
|
|
1042
|
+
continue;
|
|
1043
|
+
const round = store.roundsFor(row.pr).find((r) => r.id === row.round_id);
|
|
1044
|
+
if (!round || round.status !== "done" || this.verdictSettled(round.pr, round.id))
|
|
1045
|
+
continue;
|
|
1046
|
+
// A file still in verdict/ resumes through handleFile.
|
|
1047
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
1048
|
+
continue;
|
|
1049
|
+
try {
|
|
1050
|
+
await this.withVerdictSlot(round.pr, round.round_no, () => this.resumeReceiptSettlement(round));
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
this.log(`verdicts: resuming #${round.pr} round ${round.round_no} failed`, err);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
postingReceiptDir() {
|
|
1058
|
+
return (this.deps.postingReceiptDir ??
|
|
1059
|
+
join(this.deps.root ?? sandboxRoot(), "state", "posting-receipts"));
|
|
1060
|
+
}
|
|
1061
|
+
async postingProof(round, verdict) {
|
|
1062
|
+
if (!round.posting_key)
|
|
1063
|
+
return { kind: "legacy" };
|
|
1064
|
+
const expectedHead = verdict?.head_sha ?? round.posting_key.split(":").slice(3).join(":");
|
|
1065
|
+
const receipt = readPostingReceipt(this.postingReceiptDir(), round.pr, round.round_no);
|
|
1066
|
+
const receiptIdentityOk = receipt !== undefined &&
|
|
1067
|
+
receipt.posting_key === round.posting_key &&
|
|
1068
|
+
receipt.pr === round.pr &&
|
|
1069
|
+
receipt.round_no === round.round_no &&
|
|
1070
|
+
receipt.head_sha.toLowerCase() === expectedHead.toLowerCase();
|
|
1071
|
+
// The shell recorded a guard-2 refusal for this exact identity.
|
|
1072
|
+
// It is answered only after the live marker reconcile below has had its
|
|
1073
|
+
// say: the record claims no POST was attempted, and a bot review carrying
|
|
1074
|
+
// this round's marker at this head would prove it wrong — GitHub's truth
|
|
1075
|
+
// wins over the file, exactly as it wins over a missing file.
|
|
1076
|
+
let noop;
|
|
1077
|
+
let noopModeMismatch = false;
|
|
1078
|
+
if (receiptIdentityOk && receipt && receipt.status === "skipped") {
|
|
1079
|
+
// Same rule a `dryrun` receipt lives under: a record written in the
|
|
1080
|
+
// other mode came from a script whose config disagrees with this
|
|
1081
|
+
// daemon's, and that disagreement is not something to settle quietly.
|
|
1082
|
+
if ((receipt.mode === "dryrun") === this.deps.cfg.dry_run)
|
|
1083
|
+
noop = receipt;
|
|
1084
|
+
else
|
|
1085
|
+
noopModeMismatch = true;
|
|
1086
|
+
}
|
|
1087
|
+
// A deferred POST is posting IN FLIGHT for its whole retry
|
|
1088
|
+
// window. The daemon's retry owns it — the marker read included, so this
|
|
1089
|
+
// proof makes no GitHub read of its own that could race the retry's POST
|
|
1090
|
+
// — and no stage limit, deadline or verdict apply may requeue, fail or
|
|
1091
|
+
// kill the round meanwhile (a rerun would post a second review).
|
|
1092
|
+
if (receiptIdentityOk &&
|
|
1093
|
+
receipt &&
|
|
1094
|
+
receipt.status === "deferred" &&
|
|
1095
|
+
!this.deps.cfg.dry_run &&
|
|
1096
|
+
pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key))
|
|
1097
|
+
return { kind: "waiting" };
|
|
1098
|
+
if (receiptIdentityOk && receipt && receipt.status !== "skipped") {
|
|
1099
|
+
if (receipt.status === "confirmed" && (!verdict || receiptMatchesVerdict(receipt, verdict))) {
|
|
1100
|
+
const persisted = this.deps.store.confirmRoundPosting(round.id, {
|
|
1101
|
+
posting_key: receipt.posting_key,
|
|
1102
|
+
actual_event: receipt.actual_event,
|
|
1103
|
+
gh_review_id: receipt.gh_review_id,
|
|
1104
|
+
gh_commit_id: receipt.gh_commit_id,
|
|
1105
|
+
confirmed_at: receipt.confirmed_at,
|
|
1106
|
+
});
|
|
1107
|
+
return persisted
|
|
1108
|
+
? { kind: "confirmed", receipt }
|
|
1109
|
+
: {
|
|
1110
|
+
kind: "failed",
|
|
1111
|
+
detail: `conflicting posting linkage for ${round.posting_key}`,
|
|
1112
|
+
failure: "contradicted",
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
if (receipt.status === "dryrun" &&
|
|
1116
|
+
this.deps.cfg.dry_run &&
|
|
1117
|
+
(!verdict || receiptMatchesVerdict(receipt, verdict))) {
|
|
1118
|
+
const persisted = this.deps.store.confirmRoundPosting(round.id, {
|
|
1119
|
+
posting_key: receipt.posting_key,
|
|
1120
|
+
actual_event: receipt.actual_event,
|
|
1121
|
+
gh_review_id: null,
|
|
1122
|
+
gh_commit_id: receipt.head_sha,
|
|
1123
|
+
confirmed_at: receipt.confirmed_at ?? receipt.prepared_at,
|
|
1124
|
+
});
|
|
1125
|
+
return persisted
|
|
1126
|
+
? { kind: "confirmed", receipt }
|
|
1127
|
+
: {
|
|
1128
|
+
kind: "failed",
|
|
1129
|
+
detail: `conflicting dry-run posting linkage for ${round.posting_key}`,
|
|
1130
|
+
failure: "contradicted",
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
if ((receipt.status === "confirmed" || receipt.status === "dryrun") && verdict) {
|
|
1134
|
+
return {
|
|
1135
|
+
kind: "failed",
|
|
1136
|
+
detail: `posting receipt ${round.posting_key} does not match verdict/head`,
|
|
1137
|
+
failure: "contradicted",
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
// The POST may have reached GitHub before the shell could persist its
|
|
1142
|
+
// response. Reconcile by the inert marker; never retry a mutation on an
|
|
1143
|
+
// ambiguous transport outcome.
|
|
1144
|
+
// Whether GitHub was actually read for the marker: a prepared receipt
|
|
1145
|
+
// past its grace is "not posted" only when it was — a read that threw, or
|
|
1146
|
+
// no way to read, leaves it unknown.
|
|
1147
|
+
let reconciled = false;
|
|
1148
|
+
if (!this.deps.cfg.dry_run && this.deps.adapter.listReviewsFull) {
|
|
1149
|
+
try {
|
|
1150
|
+
const marker = postingMarker(round.posting_key);
|
|
1151
|
+
const bot = this.deps.cfg.github.bot_login;
|
|
1152
|
+
const reviews = await this.deps.adapter.listReviewsFull(round.pr);
|
|
1153
|
+
reconciled = true;
|
|
1154
|
+
const matches = reviews.filter((review) => (review.user === bot || review.user === `${this.deps.cfg.github.app_slug}[bot]`) &&
|
|
1155
|
+
review.commit_id?.toLowerCase() === expectedHead.toLowerCase() &&
|
|
1156
|
+
review.body.includes(marker));
|
|
1157
|
+
if (matches.length > 1)
|
|
1158
|
+
return {
|
|
1159
|
+
kind: "failed",
|
|
1160
|
+
detail: `multiple bot reviews match ${round.posting_key}`,
|
|
1161
|
+
failure: "contradicted",
|
|
1162
|
+
};
|
|
1163
|
+
if (matches.length === 1) {
|
|
1164
|
+
const match = matches[0];
|
|
1165
|
+
// Only the three submitted states say what was posted. Any other —
|
|
1166
|
+
// a review a person retracted, whose state no longer says whether
|
|
1167
|
+
// it approved or blocked, or one still pending — is evidence the
|
|
1168
|
+
// round's posting cannot be read off, never a COMMENT (the posting
|
|
1169
|
+
// outbox reads them the same way).
|
|
1170
|
+
const state = match.state.toUpperCase();
|
|
1171
|
+
const actualEvent = state === "APPROVED"
|
|
1172
|
+
? "APPROVE"
|
|
1173
|
+
: state === "CHANGES_REQUESTED"
|
|
1174
|
+
? "REQUEST_CHANGES"
|
|
1175
|
+
: state === "COMMENTED"
|
|
1176
|
+
? "COMMENT"
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (!actualEvent)
|
|
1179
|
+
return {
|
|
1180
|
+
kind: "failed",
|
|
1181
|
+
detail: `GitHub review ${match.id} carries ${round.posting_key} in state ${match.state}, which does not say what was posted`,
|
|
1182
|
+
failure: "contradicted",
|
|
1183
|
+
};
|
|
1184
|
+
const verifiedIntent = receiptIdentityOk && receipt && receipt.status !== "skipped" ? receipt : undefined;
|
|
1185
|
+
const synthetic = {
|
|
1186
|
+
version: 1,
|
|
1187
|
+
posting_key: round.posting_key,
|
|
1188
|
+
marker,
|
|
1189
|
+
pr: round.pr,
|
|
1190
|
+
round_no: round.round_no,
|
|
1191
|
+
head_sha: expectedHead,
|
|
1192
|
+
intended_event: verifiedIntent?.intended_event ?? actualEvent,
|
|
1193
|
+
actual_event: actualEvent,
|
|
1194
|
+
...(verifiedIntent?.status === "deferred"
|
|
1195
|
+
? { delivered_by: "daemon_retry" }
|
|
1196
|
+
: {}),
|
|
1197
|
+
status: "confirmed",
|
|
1198
|
+
payload_sha256: "0".repeat(64),
|
|
1199
|
+
prepared_at: match.submitted_at ?? this.now().toISOString(),
|
|
1200
|
+
confirmed_at: match.submitted_at ?? this.now().toISOString(),
|
|
1201
|
+
gh_review_id: match.id,
|
|
1202
|
+
gh_commit_id: match.commit_id,
|
|
1203
|
+
gh_state: match.state,
|
|
1204
|
+
...(verifiedIntent ? {} : { reconciled: true }),
|
|
1205
|
+
};
|
|
1206
|
+
if (verdict && !receiptMatchesVerdict(synthetic, verdict)) {
|
|
1207
|
+
return {
|
|
1208
|
+
kind: "failed",
|
|
1209
|
+
detail: `GitHub review ${match.id} event does not match verdict`,
|
|
1210
|
+
failure: "contradicted",
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const persisted = this.deps.store.confirmRoundPosting(round.id, {
|
|
1214
|
+
posting_key: round.posting_key,
|
|
1215
|
+
actual_event: actualEvent,
|
|
1216
|
+
gh_review_id: match.id,
|
|
1217
|
+
gh_commit_id: match.commit_id,
|
|
1218
|
+
confirmed_at: synthetic.confirmed_at,
|
|
1219
|
+
});
|
|
1220
|
+
if (!persisted)
|
|
1221
|
+
return {
|
|
1222
|
+
kind: "failed",
|
|
1223
|
+
detail: `conflicting reconciled posting linkage for ${round.posting_key}`,
|
|
1224
|
+
failure: "contradicted",
|
|
1225
|
+
};
|
|
1226
|
+
this.deps.store.logEvent("posting_receipt_reconciled", round.pr, {
|
|
1227
|
+
round_no: round.round_no,
|
|
1228
|
+
posting_key: round.posting_key,
|
|
1229
|
+
gh_review_id: match.id,
|
|
1230
|
+
});
|
|
1231
|
+
if (noop) {
|
|
1232
|
+
// The file said "never posted"; GitHub says otherwise. The posted
|
|
1233
|
+
// review is the truth and is linked above; the record is noted as
|
|
1234
|
+
// contradicted so the audit trail explains the disagreement.
|
|
1235
|
+
this.deps.store.logEvent("posting_guarded_noop_contradicted", round.pr, {
|
|
1236
|
+
round_no: round.round_no,
|
|
1237
|
+
posting_key: round.posting_key,
|
|
1238
|
+
gh_review_id: match.id,
|
|
1239
|
+
skipped_at: noop.skipped_at,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
return { kind: "confirmed", receipt: synthetic };
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
this.log(`posting receipt reconciliation failed for #${round.pr} round ${round.round_no}`, error);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (noop)
|
|
1250
|
+
return { kind: "skipped", noop };
|
|
1251
|
+
if (noopModeMismatch) {
|
|
1252
|
+
return {
|
|
1253
|
+
kind: "failed",
|
|
1254
|
+
detail: `guarded no-op record for ${round.posting_key} was written in ${this.deps.cfg.dry_run ? "live" : "dry-run"} mode; this daemon is not`,
|
|
1255
|
+
failure: "contradicted",
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
// Delivery debt outlives the session and its deadline. The independent
|
|
1259
|
+
// outbox worker reconciles/retries it; never rerun lenses or archive a
|
|
1260
|
+
// waiting verdict merely because GitHub's outage outlasted the grace.
|
|
1261
|
+
if (!this.deps.cfg.dry_run &&
|
|
1262
|
+
pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key))
|
|
1263
|
+
return { kind: "waiting" };
|
|
1264
|
+
// A deferred POST whose retry is over (the outbox job is failed or gone)
|
|
1265
|
+
// and whose marker the read above did not find: attempted, never
|
|
1266
|
+
// confirmed. Loud on every path — never a requeue.
|
|
1267
|
+
if (receiptIdentityOk && receipt && receipt.status === "deferred") {
|
|
1268
|
+
const ledger = this.deps.store.postingRetry(round.posting_key);
|
|
1269
|
+
return {
|
|
1270
|
+
kind: "failed",
|
|
1271
|
+
detail: `deferred posting for ${round.posting_key} was not delivered${ledger ? ` (${ledger.status} after ${ledger.attempts} attempt(s)${ledger.last_error ? `: ${ledger.last_error}` : ""})` : " (no retry on record)"}`,
|
|
1272
|
+
failure: "exhausted",
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const origin = receiptIdentityOk && receipt && receipt.status !== "skipped"
|
|
1276
|
+
? receipt.prepared_at
|
|
1277
|
+
: round.started_at;
|
|
1278
|
+
const graceS = this.deps.postingReceiptGraceS ??
|
|
1279
|
+
this.deps.cfg.daemon.posting_receipt_grace_s ??
|
|
1280
|
+
DEFAULT_POSTING_RECEIPT_GRACE_S;
|
|
1281
|
+
if (this.now().getTime() - Date.parse(origin) < graceS * 1000)
|
|
1282
|
+
return { kind: "waiting" };
|
|
1283
|
+
const postMayHaveLanded = !this.deps.cfg.dry_run &&
|
|
1284
|
+
!reconciled &&
|
|
1285
|
+
receiptIdentityOk &&
|
|
1286
|
+
receipt !== undefined &&
|
|
1287
|
+
receipt.status === "prepared";
|
|
1288
|
+
return {
|
|
1289
|
+
kind: "failed",
|
|
1290
|
+
detail: `no confirmed posting receipt for ${round.posting_key} after ${graceS}s`,
|
|
1291
|
+
failure: postMayHaveLanded ? "ambiguous" : "unconfirmed",
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Posting guard 2 refused this round's review — the head was
|
|
1296
|
+
* already reviewed and no author reply had landed since — and the shell
|
|
1297
|
+
* recorded that refusal as an identity-bound no-op before exiting. This is
|
|
1298
|
+
* successful terminal bookkeeping, not a verdict and not a failure:
|
|
1299
|
+
*
|
|
1300
|
+
* - the round closes as `cancelled` with cause `no_new_evidence` — never
|
|
1301
|
+
* `done` with the verdict the session wrote, because that verdict was
|
|
1302
|
+
* never posted, and never `infrastructure_failure`, because nothing
|
|
1303
|
+
* failed;
|
|
1304
|
+
* - the PR returns to the state its standing review put it in, read off
|
|
1305
|
+
* the live mark through the one standing-review interpretation; the
|
|
1306
|
+
* mark itself, GitHub and the withheld-approval mark are left exactly
|
|
1307
|
+
* as they were;
|
|
1308
|
+
* - round-end housekeeping runs as after a verdict: a fresh-head
|
|
1309
|
+
* successor is promoted, otherwise the wakes held during the round
|
|
1310
|
+
* flow;
|
|
1311
|
+
* - no `review_failures` row, no retry budget, no Slack FAILED_LOUD, no
|
|
1312
|
+
* replacement round, no escalation.
|
|
1313
|
+
*
|
|
1314
|
+
* Consumed once: the claim is a fresh read of the round row followed
|
|
1315
|
+
* synchronously by the cancel — no await between them, so the deadline
|
|
1316
|
+
* sweep and the verdict sweep (separate timers, each of which may have
|
|
1317
|
+
* awaited a GitHub read in `postingProof`) cannot both win. The loser sees
|
|
1318
|
+
* a round that is no longer running and archives its file without touching
|
|
1319
|
+
* anything.
|
|
1320
|
+
*
|
|
1321
|
+
* Returns `failed` — after handing the round to the ordinary loud path —
|
|
1322
|
+
* when the record's standing mark no longer describes the live one. That is
|
|
1323
|
+
* state changing under the record, which is ambiguous, and ambiguous stays
|
|
1324
|
+
* loud.
|
|
1325
|
+
*/
|
|
1326
|
+
async settleGuardedNoop(round, noop, via, verdict) {
|
|
1327
|
+
const { store, cfg, adapter } = this.deps;
|
|
1328
|
+
// ---- the claim: fresh read → cancel, synchronously -----------------------
|
|
1329
|
+
const fresh = this.runningRound(round.pr, round.round_no);
|
|
1330
|
+
if (!fresh || fresh.id !== round.id || fresh.status !== "running") {
|
|
1331
|
+
store.logEvent("posting_guarded_noop_replayed", round.pr, {
|
|
1332
|
+
round_id: round.id,
|
|
1333
|
+
round_no: round.round_no,
|
|
1334
|
+
posting_key: round.posting_key,
|
|
1335
|
+
via,
|
|
1336
|
+
round_status: fresh?.status ?? "missing",
|
|
1337
|
+
});
|
|
1338
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} guarded no-op already settled (${fresh?.status ?? "missing"}) — ${via} ignored`);
|
|
1339
|
+
return "replayed";
|
|
1340
|
+
}
|
|
1341
|
+
const measure = measureModeFor(via);
|
|
1342
|
+
const mark = store.reviewedHead(round.pr);
|
|
1343
|
+
if (!guardedNoopMatchesMark(noop, mark)) {
|
|
1344
|
+
await this.failPostingProof(round, `guarded no-op record for ${round.posting_key} no longer matches the live reviewed-head mark`, "posting_receipt", measure);
|
|
1345
|
+
return "failed";
|
|
1346
|
+
}
|
|
1347
|
+
const standingInfo = standingReview(mark);
|
|
1348
|
+
const standing = standingInfo.kind === "decided" ? standingInfo.verdict : null;
|
|
1349
|
+
const head7 = noop.head_sha.slice(0, 7);
|
|
1350
|
+
const { owed, restored } = store.db.transaction(() => {
|
|
1351
|
+
store.markRoundCancelled(round.id, `posting refused by guard 2 — ${head7} already reviewed with no author reply since; ` +
|
|
1352
|
+
`standing ${standing ?? "review (verdict unknown)"} stands, nothing was posted`, "no_new_evidence");
|
|
1353
|
+
// ---- claimed; the restoration below lands in this same turn --------------
|
|
1354
|
+
// The round ran a full review and burned its tokens: measured like every
|
|
1355
|
+
// other terminal path, never hidden behind a benign cause. Its window
|
|
1356
|
+
// closes here, and the measurement is owed from here, durably; the reads
|
|
1357
|
+
// (async — seconds, at most the usage budget) run last, after the
|
|
1358
|
+
// restored lifecycle and the held-wake flush, so they
|
|
1359
|
+
// never widen the window before either: no round dispatched while they
|
|
1360
|
+
// run can receive this round's held wakes. A restart while they run
|
|
1361
|
+
// resumes them.
|
|
1362
|
+
//
|
|
1363
|
+
// The restored lifecycle and the settlement event land synchronously
|
|
1364
|
+
// with the cancel, in one SQLite transaction: a replay of a cancelled round
|
|
1365
|
+
// is inert, so a restart or failed write must never expose partial settlement
|
|
1366
|
+
// (the PR never stranded in `reviewing`). The successor is promoted only
|
|
1367
|
+
// once this round's in-progress label is off, so that removal can never
|
|
1368
|
+
// strip the label the successor's dispatch adds; a restart between the
|
|
1369
|
+
// two leaves the durable successor obligation to reconcile's
|
|
1370
|
+
// successor-obligations step.
|
|
1371
|
+
const owed = this.oweMeasurement(round);
|
|
1372
|
+
if (standingInfo.kind === "unknown") {
|
|
1373
|
+
// A COMMENT-only mark: guard 2 was right to refuse, and nothing here
|
|
1374
|
+
// may guess what that comment decided. Tracked, never an approve, with
|
|
1375
|
+
// an anomaly naming the shape.
|
|
1376
|
+
store.logAnomaly("standing_review_unknown", round.pr, round.round_no, `round ${round.round_no}'s review was refused by posting guard 2 at ${head7} (already reviewed, no author reply since), ` +
|
|
1377
|
+
`but the reviewed-head mark carries no verdict (posted_event=${standingInfo.posted_event ?? "null"}). ` +
|
|
1378
|
+
`Nothing was posted and no verdict was guessed; the PR is tracked until the author pushes or replies.`);
|
|
1379
|
+
}
|
|
1380
|
+
const restored = restoreStandingLifecycle(store, round.pr, standing, {
|
|
1381
|
+
botLogin: cfg.github.bot_login,
|
|
1382
|
+
now: () => this.now(),
|
|
1383
|
+
reason: standing
|
|
1384
|
+
? `posting-guarded-noop:${standing}`
|
|
1385
|
+
: "posting-guarded-noop:standing-unknown",
|
|
1386
|
+
});
|
|
1387
|
+
// The receipt proves this head already reviewed: a successor owed at it
|
|
1388
|
+
// is answered (as a verdict's apply and dispatch's skip answer it), a
|
|
1389
|
+
// fresher one stays for the promotion below.
|
|
1390
|
+
store.settleSuccessor(round.pr, noop.head_sha);
|
|
1391
|
+
store.logEvent("posting_guarded_noop", round.pr, {
|
|
1392
|
+
stage: "posting",
|
|
1393
|
+
via,
|
|
1394
|
+
round_id: round.id,
|
|
1395
|
+
round_no: round.round_no,
|
|
1396
|
+
posting_key: round.posting_key,
|
|
1397
|
+
head_sha: noop.head_sha,
|
|
1398
|
+
guard: noop.skip_reason,
|
|
1399
|
+
skipped_at: noop.skipped_at,
|
|
1400
|
+
mode: noop.mode,
|
|
1401
|
+
intended_event: noop.intended_event,
|
|
1402
|
+
unposted_verdict: verdict?.verdict ?? null,
|
|
1403
|
+
standing_verdict: standing,
|
|
1404
|
+
standing_source: standingInfo.kind === "decided" ? standingInfo.source : null,
|
|
1405
|
+
restored_state: restored,
|
|
1406
|
+
trigger_ts: round.trigger_ts,
|
|
1407
|
+
mark: snapshotMark(mark),
|
|
1408
|
+
});
|
|
1409
|
+
return { owed, restored };
|
|
1410
|
+
})();
|
|
1411
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} closed as a guarded no-op — guard 2 refused at ${head7}, ` +
|
|
1412
|
+
`standing ${standing ?? "unknown"} stands, PR ${restored} (via ${via})`);
|
|
1413
|
+
// ---- everything below may await ------------------------------------------
|
|
1414
|
+
await this.safe(() => adapter.removeLabel(round.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${round.pr}`);
|
|
1415
|
+
// Round-end housekeeping, the same as a verdict's: a fresh-head successor
|
|
1416
|
+
// takes the session slot; otherwise the wakes held during the round flow.
|
|
1417
|
+
const successorPromoted = store.promoteSuccessor(round.pr);
|
|
1418
|
+
if (successorPromoted)
|
|
1419
|
+
this.log(`verdicts: #${round.pr} preserved fresh-head successor after skipped round ${round.round_no}`);
|
|
1420
|
+
if (!successorPromoted)
|
|
1421
|
+
await this.flushWakes(round.pr);
|
|
1422
|
+
await this.closeMeasurement(owed, measure);
|
|
1423
|
+
return "settled";
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* A round whose posting cannot be proven: cancelled, and its review owed as
|
|
1427
|
+
* a FAILED_LOUD incident. The claim is a fresh read that the round is still
|
|
1428
|
+
* running, followed synchronously by the cancel and the incident — every
|
|
1429
|
+
* caller has awaited GitHub (the posting proof) since it read the round, and
|
|
1430
|
+
* a round some other path ended meanwhile (an operator's `u`, a verdict, a
|
|
1431
|
+
* receipt settlement) owes nothing here: false, and nothing is touched.
|
|
1432
|
+
*
|
|
1433
|
+
* `resumed`: the caller is a done round's interrupted apply whose resumed
|
|
1434
|
+
* posting proof failed. That round is done but never settled, its PR left
|
|
1435
|
+
* where the interrupted apply stopped, and nothing else will repair it — so
|
|
1436
|
+
* it stays loud: the incident opens, with nothing to cancel.
|
|
1437
|
+
*/
|
|
1438
|
+
async failPostingProof(round, detail, stage = "posting_receipt", measure = "inline", resumed = false) {
|
|
1439
|
+
const { store, cfg, adapter } = this.deps;
|
|
1440
|
+
const fresh = this.runningRound(round.pr, round.round_no);
|
|
1441
|
+
const resumedDone = resumed &&
|
|
1442
|
+
fresh?.id === round.id &&
|
|
1443
|
+
fresh.status === "done" &&
|
|
1444
|
+
!this.verdictSettled(round.pr, round.id);
|
|
1445
|
+
if (!resumedDone && !this.stillRunning(round)) {
|
|
1446
|
+
store.logEvent("posting_failure_round_ended", round.pr, {
|
|
1447
|
+
round_id: round.id,
|
|
1448
|
+
round_no: round.round_no,
|
|
1449
|
+
round_status: fresh?.id === round.id ? fresh.status : "missing",
|
|
1450
|
+
stage,
|
|
1451
|
+
detail,
|
|
1452
|
+
});
|
|
1453
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} ended while its posting was proven — ${stage} opens no failure over it`);
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
const { owed, failure } = store.db
|
|
1457
|
+
.transaction(() => {
|
|
1458
|
+
if (!resumedDone)
|
|
1459
|
+
store.markRoundCancelled(round.id, `posting receipt failure — ${detail}`, "infrastructure_failure");
|
|
1460
|
+
// This round ran a full review and burned its tokens — the receipt is
|
|
1461
|
+
// what failed, not the work. It stays in `SUM(est_usd) FROM rounds` like
|
|
1462
|
+
// every other row, so it must be measured here too. Its window closes now
|
|
1463
|
+
// and no successor is running, so nothing double-counts. The cancel and
|
|
1464
|
+
// the FAILED_LOUD incident land in one turn — a restart inside any await
|
|
1465
|
+
// below must not leave a cancelled round with no loud failure behind it —
|
|
1466
|
+
// and the label call and the reads (async) run after them, the reads
|
|
1467
|
+
// owed durably from here, so a restart while they run resumes them.
|
|
1468
|
+
const owed = this.oweMeasurement(round);
|
|
1469
|
+
const command = store.sessionCommandForRound(round.pr, round.round_no);
|
|
1470
|
+
const successor = store.successorFor(round.pr);
|
|
1471
|
+
const failure = store.openReviewFailure({
|
|
1472
|
+
pr: round.pr,
|
|
1473
|
+
command_id: command?.id ?? null,
|
|
1474
|
+
round_id: round.id,
|
|
1475
|
+
round_no: round.round_no,
|
|
1476
|
+
head_sha: successor?.head_sha ?? store.getPr(round.pr)?.head_sha ?? "",
|
|
1477
|
+
stage,
|
|
1478
|
+
detail,
|
|
1479
|
+
attempts: command?.attempt ?? 1,
|
|
1480
|
+
retry_cap: 0,
|
|
1481
|
+
});
|
|
1482
|
+
store.logAnomaly("review_failed_loud", round.pr, round.round_no, `posting receipt: ${detail}; work remains owed as ${failure.incident_id}`);
|
|
1483
|
+
return { owed, failure };
|
|
1484
|
+
})
|
|
1485
|
+
.immediate();
|
|
1486
|
+
await this.safe(() => adapter.removeLabel(round.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${round.pr}`);
|
|
1487
|
+
await this.announceSafe((a) => a.anomaly({
|
|
1488
|
+
pr: round.pr,
|
|
1489
|
+
round_no: round.round_no,
|
|
1490
|
+
kind: "failed_loud",
|
|
1491
|
+
deadline_min: cfg.daemon.round_deadline_min,
|
|
1492
|
+
incident_id: failure.incident_id,
|
|
1493
|
+
}));
|
|
1494
|
+
await this.closeMeasurement(owed, measure);
|
|
1495
|
+
return true;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* `failPostingProof` for the sweeps that are not the verdict's own path —
|
|
1499
|
+
* the round deadline and the round-stage limits — after their posting
|
|
1500
|
+
* proof awaited GitHub. Inside the verdict-file slot, so a verdict that
|
|
1501
|
+
* arrived meanwhile wins: one on disk, or one the watcher is applying (the
|
|
1502
|
+
* slot busy), is left to the watcher and nothing is failed under it.
|
|
1503
|
+
* `verdict`: a verdict owns the round; `ended`: the round ended; `failed`:
|
|
1504
|
+
* FAILED_LOUD.
|
|
1505
|
+
*/
|
|
1506
|
+
async failPostingProofUnlessVerdict(round, detail, stage) {
|
|
1507
|
+
const outcome = await this.withVerdictSlot(round.pr, round.round_no, async () => {
|
|
1508
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
1509
|
+
return "verdict";
|
|
1510
|
+
return (await this.failPostingProof(round, detail, stage, "background"))
|
|
1511
|
+
? "failed"
|
|
1512
|
+
: "ended";
|
|
1513
|
+
});
|
|
1514
|
+
return outcome ?? "verdict";
|
|
1515
|
+
}
|
|
1516
|
+
/** The handoff: posting receipt, labels, round record, transition.
|
|
1517
|
+
*
|
|
1518
|
+
* `proven`: the caller already holds this round's confirmed posting
|
|
1519
|
+
* receipt and built `v` from it (`settleFromReceipt`, or the resume
|
|
1520
|
+
* of one a restart cut short). The receipt is not proven a second time — a
|
|
1521
|
+
* second marker reconcile could fail on a transient GitHub read and turn a
|
|
1522
|
+
* posted review into a loud failure — and the durable events say where the
|
|
1523
|
+
* verdict came from. */
|
|
1524
|
+
async apply(v, name, proven) {
|
|
1525
|
+
const { cfg, store, adapter } = this.deps;
|
|
1526
|
+
const round = this.runningRound(v.pr, v.round_no);
|
|
1527
|
+
// The status this apply claims the round in: `finishRound` below lands
|
|
1528
|
+
// only while the row still has it, so an operator cancel or another
|
|
1529
|
+
// terminal path that lands while this awaits GitHub is never overwritten.
|
|
1530
|
+
const claimedStatus = round?.status;
|
|
1531
|
+
// A receipt settlement from the deadline tick or the round-stage supervisor
|
|
1532
|
+
// hands its measurement to the background; a verdict file's is awaited.
|
|
1533
|
+
const measure = proven ? measureModeFor(proven.via) : "inline";
|
|
1534
|
+
// A file naming a round this PR never had is nobody's verdict: it may
|
|
1535
|
+
// neither settle another round nor move the PR's lifecycle.
|
|
1536
|
+
if (!round) {
|
|
1537
|
+
this.ignoreUnknownRound(v.pr, v.round_no, name);
|
|
1538
|
+
return "applied";
|
|
1539
|
+
}
|
|
1540
|
+
// A round already cancelled is closed for good, whoever cancelled it — an
|
|
1541
|
+
// operator's `u`, a self-requeue that queued its replacement, a
|
|
1542
|
+
// supersession, the orphan sweep. A verdict file that reaches it
|
|
1543
|
+
// afterwards (an archive that failed, a session finishing behind the
|
|
1544
|
+
// cancel) is inert: finishing the round would apply the old verdict over
|
|
1545
|
+
// whatever replaced it. The same rule `finishRound`'s claim enforces for a
|
|
1546
|
+
// cancel landing DURING the apply. A guard-2 no-op (`no_new_evidence`)
|
|
1547
|
+
// keeps its own replay path below, and so does the one cancel that is not
|
|
1548
|
+
// a decision about the round (`orphanVerdictOwed`).
|
|
1549
|
+
if (round.status === "cancelled" &&
|
|
1550
|
+
round.cancel_cause !== "no_new_evidence" &&
|
|
1551
|
+
!this.orphanVerdictOwed(round)) {
|
|
1552
|
+
this.ignoreAfterCancel(round, name, {
|
|
1553
|
+
head_sha: v.head_sha,
|
|
1554
|
+
outcome: v.outcome ?? null,
|
|
1555
|
+
verdict: v.verdict ?? null,
|
|
1556
|
+
});
|
|
1557
|
+
return "applied";
|
|
1558
|
+
}
|
|
1559
|
+
// A done round whose apply a restart cut short resumes below — unless a
|
|
1560
|
+
// later round of the PR has started since: that round owns the lifecycle,
|
|
1561
|
+
// the marks and the successor now, and the old verdict is not applied
|
|
1562
|
+
// over it.
|
|
1563
|
+
if (round.status === "done" &&
|
|
1564
|
+
!this.verdictSettled(v.pr, round.id) &&
|
|
1565
|
+
store.hasLaterRound(v.pr, v.round_no)) {
|
|
1566
|
+
await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
|
|
1567
|
+
return "applied";
|
|
1568
|
+
}
|
|
1569
|
+
// Guard-5 landing: the session was refused at posting time because the
|
|
1570
|
+
// PR advanced mid-round, and said so explicitly instead of idling to
|
|
1571
|
+
// deadline expiry. Mapped exactly like the needs_human_incomplete redo:
|
|
1572
|
+
// immediate cancel + capped self-requeue (the same per-head counter,
|
|
1573
|
+
// keyed on the fresh head the PR row now carries) — and the session is
|
|
1574
|
+
// not killed: it is healthy, it did the
|
|
1575
|
+
// right thing. No verdict applies, no label lands, no reviewed-head mark
|
|
1576
|
+
// is stamped (nothing was reviewed-and-decided at the live head). Past
|
|
1577
|
+
// the cap the anomaly path terminalizes — never an approve.
|
|
1578
|
+
if (v.outcome === "head_moved") {
|
|
1579
|
+
// The round already ended — this file is a replay of a report that was
|
|
1580
|
+
// handled (or of a round some other path closed). Nothing is owed, and
|
|
1581
|
+
// nothing is reported again.
|
|
1582
|
+
if (round.status !== "running") {
|
|
1583
|
+
store.logEvent("verdict_replay_ignored", v.pr, {
|
|
1584
|
+
round_id: round.id,
|
|
1585
|
+
round_no: v.round_no,
|
|
1586
|
+
round_status: round.status,
|
|
1587
|
+
outcome: "head_moved",
|
|
1588
|
+
file: name,
|
|
1589
|
+
});
|
|
1590
|
+
return "applied";
|
|
1591
|
+
}
|
|
1592
|
+
store.logEvent("head_moved_reported", v.pr, {
|
|
1593
|
+
round_id: round.id,
|
|
1594
|
+
round_no: v.round_no,
|
|
1595
|
+
reviewed_head: v.head_sha,
|
|
1596
|
+
file: name,
|
|
1597
|
+
});
|
|
1598
|
+
if (await this.selfRequeue(round, "head_moved")) {
|
|
1599
|
+
await this.safe(() => adapter.removeLabel(v.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${v.pr}`);
|
|
1600
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} ended head_moved — cancelled and requeued at the fresh head`);
|
|
1601
|
+
return "applied";
|
|
1602
|
+
}
|
|
1603
|
+
await this.handleAnomaly(v.pr, v.round_no, `round ${v.round_no} reported head_moved (reviewed ${v.head_sha.slice(0, 7)}, the PR advanced mid-round) but could not be self-requeued — cap exhausted or no running round`, "overdue");
|
|
1604
|
+
return "applied";
|
|
1605
|
+
}
|
|
1606
|
+
// A required pass (pass-baseline or a risk lens, marked by the plan
|
|
1607
|
+
// builder) that failed and never completed: the posting route refused to
|
|
1608
|
+
// post, so the round is redone through the capped self-requeue instead
|
|
1609
|
+
// of applying a verdict reached without it.
|
|
1610
|
+
if (round && round.status === "running") {
|
|
1611
|
+
const requeued = await this.requeueOnRequiredPassGap(v, round, proven !== undefined);
|
|
1612
|
+
if (requeued)
|
|
1613
|
+
return "applied";
|
|
1614
|
+
}
|
|
1615
|
+
// NEEDS_HUMAN is never an escape hatch for an unfinished review. One with
|
|
1616
|
+
// no executed-pass evidence (e.g. "the configured passes were
|
|
1617
|
+
// unavailable") did not do the review; redo it through the capped
|
|
1618
|
+
// self-requeue. Past the cap (or with no running round to cancel) the
|
|
1619
|
+
// verdict applies below like any other — the cap guarantees this can
|
|
1620
|
+
// never loop. No reviewed-head mark is recorded on
|
|
1621
|
+
// this path, so the redo round can dispatch and post at the same head.
|
|
1622
|
+
// A round whose review already went out — a posting receipt other than a
|
|
1623
|
+
// guard-2 refusal, confirmed or still owned by the daemon's delivery — is
|
|
1624
|
+
// not redone (a redo would post again): it applies below through its
|
|
1625
|
+
// posting proof, as the required-pass gap does.
|
|
1626
|
+
if (v.verdict === "NEEDS_HUMAN" &&
|
|
1627
|
+
round.status === "running" &&
|
|
1628
|
+
(!v.passes || v.passes.length === 0)) {
|
|
1629
|
+
const posting = this.reviewWentOut(v.pr, v.round_no, proven !== undefined);
|
|
1630
|
+
store.logEvent("needs_human_incomplete", v.pr, {
|
|
1631
|
+
round_id: round.id,
|
|
1632
|
+
round_no: v.round_no,
|
|
1633
|
+
head_sha: v.head_sha,
|
|
1634
|
+
passes_reported: v.passes ? 0 : null,
|
|
1635
|
+
...(posting ? { posting_receipt: posting } : {}),
|
|
1636
|
+
});
|
|
1637
|
+
if (!posting && (await this.selfRequeue(round, "needs_human_incomplete"))) {
|
|
1638
|
+
await this.safe(() => adapter.removeLabel(v.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${v.pr}`);
|
|
1639
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} NEEDS_HUMAN without executed passes — requeued, not parked`);
|
|
1640
|
+
return "applied";
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// A round already done has had its verdict applied: this file is a
|
|
1644
|
+
// replay — an archive that failed and left it in verdict/,
|
|
1645
|
+
// or a session re-emitting an old round's file. Applying it again would
|
|
1646
|
+
// re-run every side effect below and stamp the old head over a newer
|
|
1647
|
+
// reviewed-head mark. Inert, exactly like the guarded-noop replay.
|
|
1648
|
+
//
|
|
1649
|
+
// "Done" alone is not proof: finishRound runs before the rest of the
|
|
1650
|
+
// apply (the transition, the reviewed-head and withheld marks, the
|
|
1651
|
+
// successor), across several awaits. A daemon killed in that window leaves
|
|
1652
|
+
// a done round with the file still in verdict/, and resuming it is the only
|
|
1653
|
+
// way the rest ever lands. So the replay is inert only once the apply
|
|
1654
|
+
// recorded that it settled (`verdict_settled`, written after the last
|
|
1655
|
+
// durable step); a done round without it resumes.
|
|
1656
|
+
if (round &&
|
|
1657
|
+
round.round_no === v.round_no &&
|
|
1658
|
+
round.status === "done" &&
|
|
1659
|
+
this.verdictSettled(v.pr, round.id)) {
|
|
1660
|
+
store.logEvent("verdict_replay_ignored", v.pr, {
|
|
1661
|
+
round_id: round.id,
|
|
1662
|
+
round_no: v.round_no,
|
|
1663
|
+
head_sha: v.head_sha,
|
|
1664
|
+
file: name,
|
|
1665
|
+
});
|
|
1666
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} is already done — ${name} replays inertly`);
|
|
1667
|
+
return "applied";
|
|
1668
|
+
}
|
|
1669
|
+
if (round && !proven) {
|
|
1670
|
+
const proof = await this.postingProof(round, v);
|
|
1671
|
+
if (proof.kind === "waiting") {
|
|
1672
|
+
store.logEvent("verdict_waiting_posting_receipt", v.pr, {
|
|
1673
|
+
round_no: v.round_no,
|
|
1674
|
+
posting_key: round.posting_key,
|
|
1675
|
+
});
|
|
1676
|
+
return "waiting";
|
|
1677
|
+
}
|
|
1678
|
+
if (proof.kind === "skipped") {
|
|
1679
|
+
// Guard 2 refused the post. The verdict in this file was never
|
|
1680
|
+
// published and is never applied — no label, no transition from it, no
|
|
1681
|
+
// reviewed-head mark, no escalation. The file is archived by the caller.
|
|
1682
|
+
return (await this.settleGuardedNoop(round, proof.noop, "verdict", v)) === "failed"
|
|
1683
|
+
? "failed"
|
|
1684
|
+
: "applied";
|
|
1685
|
+
}
|
|
1686
|
+
if (proof.kind === "failed") {
|
|
1687
|
+
// An orphan-swept round's verdict that its posting cannot prove is
|
|
1688
|
+
// not applied — and opens no incident over the requeue that stands.
|
|
1689
|
+
if (round.status === "cancelled") {
|
|
1690
|
+
this.ignoreAfterCancel(round, name, {
|
|
1691
|
+
reason: `posting proof failed: ${proof.detail ?? "?"}`,
|
|
1692
|
+
});
|
|
1693
|
+
return "applied";
|
|
1694
|
+
}
|
|
1695
|
+
// A round ended while the proof awaited GitHub (an operator's `u`)
|
|
1696
|
+
// owes nothing here: the claim inside refuses it.
|
|
1697
|
+
return (await this.failPostingProof(round, proof.detail ?? "posting receipt invalid", "posting_receipt", "inline", claimedStatus === "done"))
|
|
1698
|
+
? "failed"
|
|
1699
|
+
: "applied";
|
|
1700
|
+
}
|
|
1701
|
+
// The daemon's retry delivered this round's APPROVE after the
|
|
1702
|
+
// session finished, and guard 4 softened it to a COMMENT on the gate.
|
|
1703
|
+
// The posted review withheld the approval, so the verdict does too —
|
|
1704
|
+
// what the session would have written had the softening happened
|
|
1705
|
+
// under it; the withheld mark then re-queues on green CI (cigate.ts).
|
|
1706
|
+
if (proof.kind === "confirmed" &&
|
|
1707
|
+
proof.receipt &&
|
|
1708
|
+
retrySoftenedApproval(proof.receipt) &&
|
|
1709
|
+
v.verdict === "APPROVE" &&
|
|
1710
|
+
v.approval_withheld !== true) {
|
|
1711
|
+
v = { ...v, approval_withheld: true };
|
|
1712
|
+
store.logEvent("posting_retry_approval_withheld", v.pr, {
|
|
1713
|
+
round_no: v.round_no,
|
|
1714
|
+
posting_key: round.posting_key,
|
|
1715
|
+
gh_review_id: proof.receipt.gh_review_id ?? null,
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
if (proof.kind === "legacy")
|
|
1719
|
+
store.logEvent("posting_receipt_legacy_unknown", v.pr, { round_no: v.round_no });
|
|
1720
|
+
}
|
|
1721
|
+
// Collect optional CI evidence before claiming the apply. Once the
|
|
1722
|
+
// lifecycle is terminal, all current-decision marks publish in one turn;
|
|
1723
|
+
// a later round must never be overwritten after an old network await.
|
|
1724
|
+
let withheldCi = null;
|
|
1725
|
+
if (v.head_sha && v.approval_withheld && v.verdict === "APPROVE" && adapter.listChecks) {
|
|
1726
|
+
try {
|
|
1727
|
+
withheldCi = classifyCi(await adapter.listChecks(v.head_sha));
|
|
1728
|
+
}
|
|
1729
|
+
catch (err) {
|
|
1730
|
+
this.log(`verdicts: checks read for withheld mark failed on #${v.pr}`, err);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
const reportLostClaim = () => {
|
|
1734
|
+
if (!round)
|
|
1735
|
+
return;
|
|
1736
|
+
const now = this.runningRound(v.pr, v.round_no);
|
|
1737
|
+
const nowStatus = now?.id === round.id ? now.status : "missing";
|
|
1738
|
+
store.logEvent("verdict_apply_claim_lost", v.pr, {
|
|
1739
|
+
round_id: round.id,
|
|
1740
|
+
round_no: v.round_no,
|
|
1741
|
+
claimed_status: claimedStatus ?? null,
|
|
1742
|
+
round_status: nowStatus,
|
|
1743
|
+
verdict: v.verdict,
|
|
1744
|
+
head_sha: v.head_sha,
|
|
1745
|
+
file: name,
|
|
1746
|
+
...(proven
|
|
1747
|
+
? { source: "posting_receipt", via: proven.via, gh_review_id: proven.gh_review_id }
|
|
1748
|
+
: {}),
|
|
1749
|
+
});
|
|
1750
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} went ${claimedStatus ?? "?"} → ${nowStatus} while its verdict was being applied — ${v.verdict} is not applied over it`);
|
|
1751
|
+
};
|
|
1752
|
+
const ownsLabelAdmission = () => !round ||
|
|
1753
|
+
(this.runningRound(v.pr, v.round_no)?.status === claimedStatus &&
|
|
1754
|
+
(claimedStatus === "running" || !store.hasLaterRound(v.pr, v.round_no)));
|
|
1755
|
+
const stopLostLabelApply = async () => {
|
|
1756
|
+
if (round && claimedStatus !== "running" && store.hasLaterRound(v.pr, v.round_no)) {
|
|
1757
|
+
if (claimedStatus === "done")
|
|
1758
|
+
await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
|
|
1759
|
+
else
|
|
1760
|
+
this.ignoreAfterCancel(round, name, {
|
|
1761
|
+
reason: "a later round started while the verdict was applied",
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
else
|
|
1765
|
+
reportLostClaim();
|
|
1766
|
+
};
|
|
1767
|
+
if (!ownsLabelAdmission()) {
|
|
1768
|
+
await stopLostLabelApply();
|
|
1769
|
+
return "applied";
|
|
1770
|
+
}
|
|
1771
|
+
await this.safe(() => adapter.removeLabel(v.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${v.pr}`);
|
|
1772
|
+
if (!ownsLabelAdmission()) {
|
|
1773
|
+
await stopLostLabelApply();
|
|
1774
|
+
return "applied";
|
|
1775
|
+
}
|
|
1776
|
+
await this.safe(() => adapter.addLabel(v.pr, cfg.github.reviewed_label), `add ${cfg.github.reviewed_label} on #${v.pr}`);
|
|
1777
|
+
// spend_usd from the file is fiction (template echo) — never stored; the
|
|
1778
|
+
// measured figures land via recordRoundUsage below.
|
|
1779
|
+
// Captured before applyEvent: the transition context writes the verdict's
|
|
1780
|
+
// (possibly stale) head back onto pr_state, which would mask a mid-round
|
|
1781
|
+
// advance (see the chain below).
|
|
1782
|
+
const headBeforeApply = store.getPr(v.pr)?.head_sha ?? null;
|
|
1783
|
+
// The claim: finished only while the round still has the status this apply
|
|
1784
|
+
// read before its awaits (the posting proof, the two label calls). Lost ⇒
|
|
1785
|
+
// whatever landed meanwhile — an operator's `u`, a supersession — stands,
|
|
1786
|
+
// and nothing below runs. Before the successor record, so a lost claim
|
|
1787
|
+
// leaves no obligation behind either.
|
|
1788
|
+
//
|
|
1789
|
+
// An orphan-swept round's claim is `cancelled`, and a resumed apply's is
|
|
1790
|
+
// `done` — neither of which a cancel landing meanwhile can change — but a
|
|
1791
|
+
// round of the PR can have started while this awaited (the requeue the
|
|
1792
|
+
// sweep queued, a promoted successor). Re-asked here, in the claim's own
|
|
1793
|
+
// turn: a later round owns the PR, and the old verdict is not applied over it.
|
|
1794
|
+
if (round && claimedStatus !== "running" && store.hasLaterRound(v.pr, v.round_no)) {
|
|
1795
|
+
if (claimedStatus === "done")
|
|
1796
|
+
await this.closeMeasurement(this.settleSupersededResume(round, name), measure);
|
|
1797
|
+
else
|
|
1798
|
+
this.ignoreAfterCancel(round, name, {
|
|
1799
|
+
reason: "a later round started while the verdict was applied",
|
|
1800
|
+
});
|
|
1801
|
+
return "applied";
|
|
1802
|
+
}
|
|
1803
|
+
if (round && !store.finishRound(round.id, v.verdict, null, v.summary ?? null, claimedStatus)) {
|
|
1804
|
+
reportLostClaim();
|
|
1805
|
+
return "applied";
|
|
1806
|
+
}
|
|
1807
|
+
if (round && v.head_sha && headBeforeApply && headBeforeApply !== v.head_sha) {
|
|
1808
|
+
store.recordSuccessor({
|
|
1809
|
+
pr: v.pr,
|
|
1810
|
+
predecessor_round_id: round.id,
|
|
1811
|
+
head_sha: headBeforeApply,
|
|
1812
|
+
provenance: "system",
|
|
1813
|
+
reason: "verdict-observed-head-advance",
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
// Executed-pass telemetry: persist what actually ran beside the plan, and
|
|
1817
|
+
// flag plan≠executed. Best-effort side channel — nothing in it may disturb
|
|
1818
|
+
// the verdict.
|
|
1819
|
+
// A follow-up round's pre-approval sweep — what became of it, read from
|
|
1820
|
+
// the claim and the executed telemetry — before the pass
|
|
1821
|
+
// telemetry, which compares the sweep's passes only when it ran.
|
|
1822
|
+
const sweepStatus = round ? this.recordSweepTelemetry(v, round, proven !== undefined) : null;
|
|
1823
|
+
// The verdict's lifecycle event lands in the same turn as the claim above:
|
|
1824
|
+
// nothing may await between them, or a round the pump starts meanwhile
|
|
1825
|
+
// would be moved under its feet. The telemetry comes after.
|
|
1826
|
+
applyEvent(store, v.pr, { type: "verdict", verdict: v.verdict }, { head_sha: v.head_sha }, { botLogin: cfg.github.bot_login, now: () => this.now(), reason: `verdict:${v.verdict}` });
|
|
1827
|
+
store.logEvent("verdict_applied", v.pr, {
|
|
1828
|
+
file: name,
|
|
1829
|
+
round_id: round?.id ?? null,
|
|
1830
|
+
round_no: v.round_no,
|
|
1831
|
+
head_sha: v.head_sha,
|
|
1832
|
+
verdict: v.verdict,
|
|
1833
|
+
ai_approved: v.ai_approved ?? null,
|
|
1834
|
+
...(proven
|
|
1835
|
+
? {
|
|
1836
|
+
source: "posting_receipt",
|
|
1837
|
+
via: proven.via,
|
|
1838
|
+
gh_review_id: proven.gh_review_id,
|
|
1839
|
+
}
|
|
1840
|
+
: {}),
|
|
1841
|
+
});
|
|
1842
|
+
// The reviewed-head mark: this head has now been reviewed and decided.
|
|
1843
|
+
// Read by the no-new-evidence guard at dispatch and at posting time, and
|
|
1844
|
+
// by the post-approval delta recheck.
|
|
1845
|
+
if (v.head_sha) {
|
|
1846
|
+
const posted = store.reviewedHead(v.pr);
|
|
1847
|
+
if (posted?.head_sha.toLowerCase() === v.head_sha.toLowerCase() &&
|
|
1848
|
+
posted.round_no === v.round_no &&
|
|
1849
|
+
posted.posted_event !== null)
|
|
1850
|
+
store.recordReviewedVerdict(v.pr, v.head_sha, v.verdict);
|
|
1851
|
+
else
|
|
1852
|
+
store.recordReviewedHead(v.pr, {
|
|
1853
|
+
head_sha: v.head_sha,
|
|
1854
|
+
verdict: v.verdict,
|
|
1855
|
+
round_no: v.round_no,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
store.recoverReviewFailure(v.pr, "verdict-applied", {
|
|
1859
|
+
round_no: v.round_no,
|
|
1860
|
+
head_sha: v.head_sha,
|
|
1861
|
+
});
|
|
1862
|
+
// The withheld-approval mark, session route. The session posted
|
|
1863
|
+
// its review as a COMMENT because CI was not green at this head; the mark
|
|
1864
|
+
// is what notices when CI goes green and re-queues the PR (cigate.ts).
|
|
1865
|
+
// Anything else clears it: the verdict here supersedes whatever earlier
|
|
1866
|
+
// round withheld an approval.
|
|
1867
|
+
if (v.head_sha && v.approval_withheld && v.verdict === "APPROVE") {
|
|
1868
|
+
store.recordApprovalWithheld(v.pr, {
|
|
1869
|
+
head_sha: v.head_sha,
|
|
1870
|
+
reason: "red-ci",
|
|
1871
|
+
ci_state: withheldCi,
|
|
1872
|
+
});
|
|
1873
|
+
store.logEvent("approval_withheld_red_ci", v.pr, {
|
|
1874
|
+
head_sha: v.head_sha,
|
|
1875
|
+
source: proven ? "posting_receipt" : "verdict",
|
|
1876
|
+
ci_state: withheldCi,
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
else {
|
|
1880
|
+
store.clearApprovalWithheld(v.pr);
|
|
1881
|
+
}
|
|
1882
|
+
if (round)
|
|
1883
|
+
await this.recordPassTelemetry(v, round, sweepStatus);
|
|
1884
|
+
// Slack, in-thread and never broadcast. duration_s is computed
|
|
1885
|
+
// inside finishRound, so it is read back off the row rather than recomputed.
|
|
1886
|
+
const finished = round ? store.roundsFor(v.pr).find((r) => r.id === round.id) : undefined;
|
|
1887
|
+
await this.announceSafe((a) => a.verdict({
|
|
1888
|
+
pr: v.pr,
|
|
1889
|
+
round_no: v.round_no,
|
|
1890
|
+
verdict: v.verdict,
|
|
1891
|
+
...(v.verdict === "APPROVE" && v.approval_withheld ? { approval_withheld: true } : {}),
|
|
1892
|
+
summary: finished?.summary ?? v.summary ?? null,
|
|
1893
|
+
duration_s: finished?.duration_s ?? null,
|
|
1894
|
+
}));
|
|
1895
|
+
// A head that advanced while this round ran was recorded as a
|
|
1896
|
+
// durable successor obligation before terminalization. An old-head verdict
|
|
1897
|
+
// can never erase it; a verdict covering the owed head settles it. Queue
|
|
1898
|
+
// publication and obligation retention are owned by StateStore.
|
|
1899
|
+
if (v.head_sha && headBeforeApply && headBeforeApply !== v.head_sha) {
|
|
1900
|
+
store.logEvent("mid_round_head_advance", v.pr, {
|
|
1901
|
+
round_no: v.round_no,
|
|
1902
|
+
reviewed_head: v.head_sha,
|
|
1903
|
+
current_head: headBeforeApply,
|
|
1904
|
+
verdict: v.verdict,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
if (v.head_sha)
|
|
1908
|
+
store.settleSuccessor(v.pr, v.head_sha);
|
|
1909
|
+
const successorPromoted = round ? store.promoteSuccessor(v.pr) : false;
|
|
1910
|
+
if (successorPromoted) {
|
|
1911
|
+
this.log(`verdicts: #${v.pr} preserved fresh-head successor after round ${v.round_no}`);
|
|
1912
|
+
}
|
|
1913
|
+
// Every durable step of the apply has landed: from here a replay of this
|
|
1914
|
+
// file is inert (the done-round guard above reads this).
|
|
1915
|
+
//
|
|
1916
|
+
// The round's usage window closed at its claim (`finished_at`), and its
|
|
1917
|
+
// measurement is owed from here — in the same synchronous turn as
|
|
1918
|
+
// `verdict_settled`. Both writes share one SQLite transaction. Its reads (async — seconds, at most the usage
|
|
1919
|
+
// budget) run at the very end of the apply, after the compaction request
|
|
1920
|
+
// and the held-wake flush: a round dispatched while they run must never
|
|
1921
|
+
// receive this round's held wakes (rule 9), and nothing in between reads
|
|
1922
|
+
// the tokens. A restart while they run leaves the row owed, and the next
|
|
1923
|
+
// start measures it.
|
|
1924
|
+
let owed;
|
|
1925
|
+
if (round) {
|
|
1926
|
+
// Debt and replay suppression share a commit. A persistence failure
|
|
1927
|
+
// leaves the done apply eligible for replay, never silently unmeasured.
|
|
1928
|
+
owed = store.db.transaction(() => {
|
|
1929
|
+
const measurement = this.oweMeasurement(round, finished?.finished_at);
|
|
1930
|
+
this.enqueueTail(v.pr, v.round_no, round.id, v.head_sha, tailPlannedLenses(round, sweepStatus));
|
|
1931
|
+
if (cfg.escalation.enabled)
|
|
1932
|
+
enqueueRoundWork(store, {
|
|
1933
|
+
key: `escalations:${round.id}`,
|
|
1934
|
+
kind: "escalations",
|
|
1935
|
+
pr: v.pr,
|
|
1936
|
+
round_no: v.round_no,
|
|
1937
|
+
payload: v,
|
|
1938
|
+
due_at: this.now().toISOString(),
|
|
1939
|
+
});
|
|
1940
|
+
store.logEvent("verdict_settled", v.pr, { round_id: round.id, round_no: v.round_no });
|
|
1941
|
+
return measurement;
|
|
1942
|
+
})();
|
|
1943
|
+
}
|
|
1944
|
+
// Escalation — off unless the operator switched it on, and it runs here,
|
|
1945
|
+
// after the verdict has been applied, the label moved and the
|
|
1946
|
+
// PR transitioned. That ordering is the invariant, not a convenience: an
|
|
1947
|
+
// escalation is additive notification beside a decided review, so it can
|
|
1948
|
+
// neither withhold, defer nor replace one. `deliverEscalations` returns a
|
|
1949
|
+
// delivery record and nothing else — there is no channel back into the
|
|
1950
|
+
// verdict — and the whole call is wrapped, so even a throw lands as a log
|
|
1951
|
+
// line on an already-applied verdict.
|
|
1952
|
+
if (round && cfg.escalation.enabled)
|
|
1953
|
+
await this.runRoundWorkKey(`escalations:${round.id}`);
|
|
1954
|
+
else
|
|
1955
|
+
await this.safe(() => this.deliverEscalations(v), `escalations pr-${v.pr}`);
|
|
1956
|
+
// (The worker compacts its own conversation before it exits.)
|
|
1957
|
+
// Ledger freshness: after compaction, so ledger-pin's mirror has had its
|
|
1958
|
+
// chance — and delayed, because the compaction is only requested: it is
|
|
1959
|
+
// still running when apply continues. Best-effort fire-and-forget —
|
|
1960
|
+
// nothing here may disturb the verdict.
|
|
1961
|
+
//
|
|
1962
|
+
// The reasoning-record check rides the same settle window, for the same
|
|
1963
|
+
// reason: it may nudge. It runs after the ledger check and is likewise
|
|
1964
|
+
// fire-and-forget — by the time either runs the verdict is applied, the
|
|
1965
|
+
// round row is finished and the PR has transitioned, so a record that
|
|
1966
|
+
// never arrives costs one anomaly row and one nudge and cannot hold the
|
|
1967
|
+
// round open.
|
|
1968
|
+
await this.scheduleRoundTail(v.pr, v.round_no, round?.id ?? null, v.head_sha, round ? tailPlannedLenses(round, sweepStatus) : "");
|
|
1969
|
+
// The round is over. Fresh-head review work wins the session command slot;
|
|
1970
|
+
// held conversational wakes stay behind it and flush at the successor's
|
|
1971
|
+
// terminal path. Without a successor, ordinary FIFO wake delivery resumes.
|
|
1972
|
+
if (!successorPromoted)
|
|
1973
|
+
await this.flushWakes(v.pr);
|
|
1974
|
+
if (owed)
|
|
1975
|
+
await this.closeMeasurement(owed, measure);
|
|
1976
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} → ${v.verdict}`);
|
|
1977
|
+
return "applied";
|
|
1978
|
+
}
|
|
1979
|
+
/** The round-end ledger and findings-record checks, after the settle
|
|
1980
|
+
* delay — shared by a verdict and a receipt settlement. */
|
|
1981
|
+
tailDelayMs() {
|
|
1982
|
+
return this.deps.ledgerCheckDelayMs ?? 90_000;
|
|
1983
|
+
}
|
|
1984
|
+
enqueueTail(pr, roundNo, roundId, headSha, lenses) {
|
|
1985
|
+
const key = `tail:${roundId ?? `${pr}:${roundNo}`}`;
|
|
1986
|
+
enqueueRoundWork(this.deps.store, {
|
|
1987
|
+
key,
|
|
1988
|
+
kind: "tail",
|
|
1989
|
+
pr,
|
|
1990
|
+
round_no: roundNo,
|
|
1991
|
+
payload: { roundId, headSha, lenses },
|
|
1992
|
+
due_at: new Date(this.now().getTime() + Math.max(0, this.tailDelayMs())).toISOString(),
|
|
1993
|
+
});
|
|
1994
|
+
return key;
|
|
1995
|
+
}
|
|
1996
|
+
async scheduleRoundTail(pr, roundNo, roundId, headSha, lenses) {
|
|
1997
|
+
const key = this.enqueueTail(pr, roundNo, roundId, headSha, lenses);
|
|
1998
|
+
const delay = this.tailDelayMs();
|
|
1999
|
+
if (delay <= 0)
|
|
2000
|
+
await this.runRoundWorkKey(key);
|
|
2001
|
+
else {
|
|
2002
|
+
const timer = setTimeout(() => {
|
|
2003
|
+
this.detach(() => this.runRoundWorkKey(key), `round tail ${key}`);
|
|
2004
|
+
}, delay);
|
|
2005
|
+
timer.unref?.();
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
async runRoundWorkKey(key) {
|
|
2009
|
+
for (const kind of ["tail", "escalations"]) {
|
|
2010
|
+
const work = pendingRoundWork(this.deps.store, kind, this.now().toISOString()).find((row) => row.key === key);
|
|
2011
|
+
if (work)
|
|
2012
|
+
await this.runRoundWork(work);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
async runRoundWork(work) {
|
|
2016
|
+
if (this.working.has(work.key))
|
|
2017
|
+
return;
|
|
2018
|
+
this.working.add(work.key);
|
|
2019
|
+
try {
|
|
2020
|
+
if (work.kind === "tail") {
|
|
2021
|
+
const payload = JSON.parse(work.payload);
|
|
2022
|
+
await this.checkLedger(work.pr, work.round_no, payload.headSha);
|
|
2023
|
+
await this.checkFindingsRecord(work.pr, work.round_no, payload.roundId, payload.headSha, payload.lenses.split(",").filter(Boolean));
|
|
2024
|
+
}
|
|
2025
|
+
else {
|
|
2026
|
+
const parsed = validateVerdict(JSON.parse(work.payload));
|
|
2027
|
+
if (!parsed.ok || !parsed.verdict || parsed.verdict.outcome === "head_moved")
|
|
2028
|
+
throw new Error(`unreadable escalation intent ${work.key}`);
|
|
2029
|
+
await this.deliverEscalations(parsed.verdict);
|
|
2030
|
+
}
|
|
2031
|
+
finishRoundWork(this.deps.store, work.key, this.now().toISOString());
|
|
2032
|
+
}
|
|
2033
|
+
catch (err) {
|
|
2034
|
+
this.log(`verdicts: owed ${work.kind} for #${work.pr} round ${work.round_no} failed`, err);
|
|
2035
|
+
}
|
|
2036
|
+
finally {
|
|
2037
|
+
this.working.delete(work.key);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
resumeRoundWork() {
|
|
2041
|
+
for (const kind of ["tail", "escalations"])
|
|
2042
|
+
for (const work of pendingRoundWork(this.deps.store, kind, this.now().toISOString()))
|
|
2043
|
+
this.detach(() => this.runRoundWork(work), `owed ${kind} ${work.key}`);
|
|
2044
|
+
}
|
|
2045
|
+
// ---- escalation (off by default) ---------------------------------------
|
|
2046
|
+
/**
|
|
2047
|
+
* Assemble the round's facts, evaluate the operator's deterministic rules,
|
|
2048
|
+
* add whatever the session asked for, and hand the lot to the one delivery
|
|
2049
|
+
* path.
|
|
2050
|
+
*
|
|
2051
|
+
* Everything here is a no-op when `[escalation] enabled = false`: the first
|
|
2052
|
+
* line returns before a single fact is read, so a default install makes no
|
|
2053
|
+
* extra API call, writes no row and logs no event.
|
|
2054
|
+
*/
|
|
2055
|
+
escalationDeliveryDeps() {
|
|
2056
|
+
const { adapter, store, cfg } = this.deps;
|
|
2057
|
+
if (!cfg.escalation.enabled || !adapter.createReview)
|
|
2058
|
+
return null;
|
|
2059
|
+
const bot = cfg.github.bot_login.toLowerCase();
|
|
2060
|
+
return {
|
|
2061
|
+
cfg: cfg.escalation,
|
|
2062
|
+
store,
|
|
2063
|
+
dryRun: cfg.dry_run,
|
|
2064
|
+
isDraining: () => this.stopped,
|
|
2065
|
+
adapter: {
|
|
2066
|
+
createReview: adapter.createReview.bind(adapter),
|
|
2067
|
+
...(adapter.requestEscalationReview
|
|
2068
|
+
? { requestEscalationReview: adapter.requestEscalationReview.bind(adapter) }
|
|
2069
|
+
: {}),
|
|
2070
|
+
...(adapter.listReviewsFull
|
|
2071
|
+
? {
|
|
2072
|
+
findEscalationComment: async (pr, head, marker, body) => {
|
|
2073
|
+
const reviews = await adapter.listReviewsFull(pr);
|
|
2074
|
+
const found = reviews.find((review) => review.user.toLowerCase() === bot &&
|
|
2075
|
+
review.commit_id.toLowerCase() === head.toLowerCase() &&
|
|
2076
|
+
review.state === "COMMENTED" &&
|
|
2077
|
+
typeof review.submitted_at === "string" &&
|
|
2078
|
+
review.submitted_at.length > 0 &&
|
|
2079
|
+
Number.isSafeInteger(review.id) &&
|
|
2080
|
+
review.id > 0 &&
|
|
2081
|
+
review.body === body &&
|
|
2082
|
+
review.body.split(/\r?\n/).includes(marker));
|
|
2083
|
+
return found ? { id: found.id } : null;
|
|
2084
|
+
},
|
|
2085
|
+
}
|
|
2086
|
+
: {}),
|
|
2087
|
+
},
|
|
2088
|
+
log: this.log.bind(this),
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
/** Optional recovery never holds a fresh verdict behind a GitHub read. */
|
|
2092
|
+
resumeEscalationDeliveries() {
|
|
2093
|
+
if (this.stopped || this.escalationRecoveryInFlight)
|
|
2094
|
+
return;
|
|
2095
|
+
const deps = this.escalationDeliveryDeps();
|
|
2096
|
+
if (!deps || deps.dryRun)
|
|
2097
|
+
return;
|
|
2098
|
+
this.escalationRecoveryInFlight = true;
|
|
2099
|
+
this.detach(async () => {
|
|
2100
|
+
try {
|
|
2101
|
+
await resumeEscalations(deps);
|
|
2102
|
+
}
|
|
2103
|
+
finally {
|
|
2104
|
+
this.escalationRecoveryInFlight = false;
|
|
2105
|
+
}
|
|
2106
|
+
}, "escalation delivery recovery");
|
|
2107
|
+
}
|
|
2108
|
+
async deliverEscalations(v) {
|
|
2109
|
+
const cfg = this.deps.cfg.escalation;
|
|
2110
|
+
if (!cfg.enabled)
|
|
2111
|
+
return;
|
|
2112
|
+
const { store, adapter } = this.deps;
|
|
2113
|
+
if (!adapter.createReview)
|
|
2114
|
+
return; // no gated posting route ⇒ nothing to deliver through
|
|
2115
|
+
// --- the facts. Every read is best-effort: a fact we cannot see means
|
|
2116
|
+
// the rules keyed on it do not fire, never that the round is disturbed.
|
|
2117
|
+
const facts = {
|
|
2118
|
+
changed_paths: await this.escalationPaths(v.pr),
|
|
2119
|
+
labels: await this.escalationLabels(v.pr),
|
|
2120
|
+
findings: this.escalationFindings(v.pr, v.round_no, cfg.rules.some((r) => r.min_severity !== "")),
|
|
2121
|
+
failing_checks: await this.escalationFailingChecks(v.pr, v.head_sha),
|
|
2122
|
+
};
|
|
2123
|
+
// The two groups stay separate because they have different trust: the
|
|
2124
|
+
// first is the operator's configured rules, the second is whatever the
|
|
2125
|
+
// model wrote in its verdict file. Flattening them into one list and
|
|
2126
|
+
// re-deriving trust from a field inside the payload would let a session
|
|
2127
|
+
// relabel itself `origin: "rule"`.
|
|
2128
|
+
//
|
|
2129
|
+
// A rule that fired but produced nothing deliverable is logged, never
|
|
2130
|
+
// silent: the operator configured it, it matched, and they would otherwise
|
|
2131
|
+
// have no way to see why nobody was told.
|
|
2132
|
+
const ruleRequests = evaluateEscalationRules(cfg, facts, (rule, refusal) => {
|
|
2133
|
+
store.logEvent("escalation_rule_refused", v.pr, {
|
|
2134
|
+
rule: rule.name,
|
|
2135
|
+
to: rule.to,
|
|
2136
|
+
refusal,
|
|
2137
|
+
round_no: v.round_no,
|
|
2138
|
+
});
|
|
2139
|
+
this.log(`escalation: rule "${rule.name}" fired on #${v.pr} but produced nothing — ${refusal}`);
|
|
2140
|
+
});
|
|
2141
|
+
const sessionCandidates = cfg.session_judgement ? (v.escalations ?? []) : [];
|
|
2142
|
+
if (ruleRequests.length === 0 && sessionCandidates.length === 0)
|
|
2143
|
+
return;
|
|
2144
|
+
await deliverEscalations(this.escalationDeliveryDeps(), {
|
|
2145
|
+
pr: v.pr,
|
|
2146
|
+
verdict: v.verdict,
|
|
2147
|
+
head_sha: v.head_sha,
|
|
2148
|
+
round_no: v.round_no,
|
|
2149
|
+
// The path-in-diff check's input. An unreadable
|
|
2150
|
+
// listing is `[]`, which refuses every path-bearing scope rather than
|
|
2151
|
+
// waving it through — the safe direction, same as every other fact.
|
|
2152
|
+
changed_paths: facts.changed_paths,
|
|
2153
|
+
// Whether the round's own review actually reached GitHub, from the
|
|
2154
|
+
// posting receipt: the note must not claim a posting no confirmed
|
|
2155
|
+
// receipt shows (a dry run, a legacy round, a review the marker
|
|
2156
|
+
// reconcile proved without a local receipt). A guard-2 refusal never
|
|
2157
|
+
// gets here — it settles as a guarded no-op, with no escalation.
|
|
2158
|
+
review_posted: this.roundReviewConfirmed(v.pr, v.round_no, v.head_sha),
|
|
2159
|
+
ruleRequests,
|
|
2160
|
+
sessionCandidates,
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Did the round's own review confirm as posted (posting receipt)? Read-only,
|
|
2165
|
+
* total: an unreadable or absent receipt is `false`, and the note then says
|
|
2166
|
+
* "decided" without claiming "posted".
|
|
2167
|
+
*/
|
|
2168
|
+
roundReviewConfirmed(pr, roundNo, headSha) {
|
|
2169
|
+
try {
|
|
2170
|
+
const receipt = readPostingReceipt(this.postingReceiptDir(), pr, roundNo);
|
|
2171
|
+
if (!receipt || receipt.pr !== pr || receipt.round_no !== roundNo)
|
|
2172
|
+
return false;
|
|
2173
|
+
if (receipt.head_sha.toLowerCase() !== headSha.toLowerCase())
|
|
2174
|
+
return false;
|
|
2175
|
+
return receipt.status === "confirmed";
|
|
2176
|
+
}
|
|
2177
|
+
catch {
|
|
2178
|
+
return false;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
async escalationPaths(pr) {
|
|
2182
|
+
if (!this.deps.adapter.listPrFiles)
|
|
2183
|
+
return [];
|
|
2184
|
+
try {
|
|
2185
|
+
return (await this.deps.adapter.listPrFiles(pr)).map((f) => f.filename).filter(Boolean);
|
|
2186
|
+
}
|
|
2187
|
+
catch (err) {
|
|
2188
|
+
this.log(`escalation: file listing failed on #${pr}`, err);
|
|
2189
|
+
return [];
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
async escalationLabels(pr) {
|
|
2193
|
+
if (!this.deps.adapter.getPr)
|
|
2194
|
+
return [];
|
|
2195
|
+
try {
|
|
2196
|
+
return (await this.deps.adapter.getPr(pr)).labels ?? [];
|
|
2197
|
+
}
|
|
2198
|
+
catch (err) {
|
|
2199
|
+
this.log(`escalation: PR read failed on #${pr}`, err);
|
|
2200
|
+
return [];
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* The findings the round posted, from the reasoning record. Absent or
|
|
2205
|
+
* unreadable ⇒ severity-keyed rules simply do not fire.
|
|
2206
|
+
*
|
|
2207
|
+
* That silence is logged when it matters: a `needed`
|
|
2208
|
+
* caller — one whose config actually has a `min_severity` rule — gets an
|
|
2209
|
+
* `escalation_findings_unavailable` event, so a severity rule that never
|
|
2210
|
+
* fires is visible in the event log instead of being invisible. Without a
|
|
2211
|
+
* severity rule the record is not consulted for anything and the absence is
|
|
2212
|
+
* not worth a line.
|
|
2213
|
+
*/
|
|
2214
|
+
escalationFindings(pr, roundNo, needed) {
|
|
2215
|
+
const dir = this.deps.findingsDir;
|
|
2216
|
+
if (!dir) {
|
|
2217
|
+
if (needed) {
|
|
2218
|
+
this.deps.store.logEvent("escalation_findings_unavailable", pr, {
|
|
2219
|
+
round_no: roundNo,
|
|
2220
|
+
reason: "no findings dir configured",
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
return [];
|
|
2224
|
+
}
|
|
2225
|
+
const parsed = readFindingsRecord(findingsRecordPath(dir, pr, roundNo), pr, roundNo);
|
|
2226
|
+
if (!parsed.record) {
|
|
2227
|
+
if (needed) {
|
|
2228
|
+
this.log(`escalation: no reasoning record for #${pr} round ${roundNo} — severity rules cannot fire`);
|
|
2229
|
+
this.deps.store.logEvent("escalation_findings_unavailable", pr, {
|
|
2230
|
+
round_no: roundNo,
|
|
2231
|
+
reason: parsed.status,
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
return [];
|
|
2235
|
+
}
|
|
2236
|
+
return parsed.record.findings
|
|
2237
|
+
.filter((f) => f.disposition === "posted" && f.severity)
|
|
2238
|
+
.map((f) => ({ severity: String(f.severity), path: f.path, title: f.title ?? f.reason }));
|
|
2239
|
+
}
|
|
2240
|
+
/** Check names whose latest run at the head has the classified outcome
|
|
2241
|
+
* "failed" (pending and inert runs are not listed) — the same
|
|
2242
|
+
* latest-run-per-name rule the CI gate uses, so an auto-cancelled
|
|
2243
|
+
* superseded run cannot escalate to a person forever. */
|
|
2244
|
+
async escalationFailingChecks(pr, head) {
|
|
2245
|
+
if (!this.deps.adapter.listChecks || !head)
|
|
2246
|
+
return [];
|
|
2247
|
+
try {
|
|
2248
|
+
return latestRunPerName(await this.deps.adapter.listChecks(head))
|
|
2249
|
+
.filter((c) => checkOutcome(c) === "failed")
|
|
2250
|
+
.map((c) => c.name);
|
|
2251
|
+
}
|
|
2252
|
+
catch (err) {
|
|
2253
|
+
this.log(`escalation: checks read failed on #${pr}`, err);
|
|
2254
|
+
return [];
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
// ---- ledger freshness ------------------------------------------------
|
|
2258
|
+
ledgerPathFor(pr) {
|
|
2259
|
+
const custom = this.deps.ledgerPathFor;
|
|
2260
|
+
if (custom)
|
|
2261
|
+
return custom(pr);
|
|
2262
|
+
const root = this.deps.root ?? sandboxRoot();
|
|
2263
|
+
return join(root, "state", "ledgers", `pr-${pr}.md`);
|
|
2264
|
+
}
|
|
2265
|
+
/** Enforce the end-of-round ledger rewrite: header must read
|
|
2266
|
+
* `LEDGER PR #<n> @ <head>`. Stale ⇒ anomaly + one capped re-nudge. */
|
|
2267
|
+
async checkLedger(pr, roundNo, headSha) {
|
|
2268
|
+
if (!headSha)
|
|
2269
|
+
return;
|
|
2270
|
+
await enforceLedgerFreshness({
|
|
2271
|
+
store: this.deps.store,
|
|
2272
|
+
ledgerPathFor: (n) => this.ledgerPathFor(n),
|
|
2273
|
+
inject: (n, message) => this.deps.hooks.inject(n, message),
|
|
2274
|
+
log: this.log.bind(this),
|
|
2275
|
+
}, { pr, roundNo, headSha });
|
|
2276
|
+
}
|
|
2277
|
+
// ---- the durable reasoning record ------------------------------------
|
|
2278
|
+
/** Parse `state/findings/pr-<n>-round-<k>.json` into rows. Missing or
|
|
2279
|
+
* unparseable ⇒ anomaly + exactly one re-nudge, never a held-open round. */
|
|
2280
|
+
async checkFindingsRecord(pr, roundNo, roundId, headSha, planned) {
|
|
2281
|
+
const findingsDir = this.deps.findingsDir;
|
|
2282
|
+
if (!findingsDir)
|
|
2283
|
+
return;
|
|
2284
|
+
await enforceFindingsRecord({
|
|
2285
|
+
store: this.deps.store,
|
|
2286
|
+
findingsDir,
|
|
2287
|
+
inject: (n, message) => this.deps.hooks.inject(n, message),
|
|
2288
|
+
log: this.log.bind(this),
|
|
2289
|
+
...(this.deps.onFindingsRecorded ? { onRecorded: this.deps.onFindingsRecorded } : {}),
|
|
2290
|
+
}, { pr, roundNo, roundId, headSha, planned });
|
|
2291
|
+
}
|
|
2292
|
+
// ---- follow-up sweep telemetry ---------------------------------------
|
|
2293
|
+
/**
|
|
2294
|
+
* Record what became of a follow-up round's pre-approval sweep
|
|
2295
|
+
* (`rounds.sweep_status`) and return it; null on a round that is not a
|
|
2296
|
+
* follow-up (nothing written). `ran` needs both the session's claim and
|
|
2297
|
+
* sweep-phase passes in its executed telemetry — a claim with no telemetry
|
|
2298
|
+
* behind it is `unknown`, as is a verdict settled from the posting receipt
|
|
2299
|
+
* (no telemetry at all): an unknown sweep never counts as a full look, so
|
|
2300
|
+
* the next round owes it again. An idempotent overwrite after the
|
|
2301
|
+
* `finishRound` claim, like the pass telemetry, so a resumed apply redoes
|
|
2302
|
+
* it. An APPROVE with the sweep owed and not run is the quiet
|
|
2303
|
+
* `followup_sweep_skipped` anomaly — the review is already on GitHub, so
|
|
2304
|
+
* this is telemetry, never a veto. Wrapped: nothing here may disturb the
|
|
2305
|
+
* verdict.
|
|
2306
|
+
*/
|
|
2307
|
+
recordSweepTelemetry(v, round, receiptSettled) {
|
|
2308
|
+
const { store } = this.deps;
|
|
2309
|
+
try {
|
|
2310
|
+
if (round.scope !== "delta" || round.sweep_owed === null)
|
|
2311
|
+
return null;
|
|
2312
|
+
const owed = round.sweep_owed === 1;
|
|
2313
|
+
const claim = v.sweep ?? null;
|
|
2314
|
+
const sweepPasses = v.passes ? v.passes.filter((p) => p.phase === "sweep").length : null;
|
|
2315
|
+
const { status, note } = sweepStatusOf({
|
|
2316
|
+
owed,
|
|
2317
|
+
claim,
|
|
2318
|
+
sweepPasses,
|
|
2319
|
+
receiptSettled,
|
|
2320
|
+
verdict: v.verdict,
|
|
2321
|
+
});
|
|
2322
|
+
store.setRoundMeta(round.id, { sweep_status: status });
|
|
2323
|
+
store.logEvent("followup_sweep_recorded", v.pr, {
|
|
2324
|
+
round_id: round.id,
|
|
2325
|
+
round_no: v.round_no,
|
|
2326
|
+
owed,
|
|
2327
|
+
claimed: claim,
|
|
2328
|
+
sweep_passes: sweepPasses,
|
|
2329
|
+
status,
|
|
2330
|
+
...(note ? { note } : {}),
|
|
2331
|
+
...(v.sweep_reason ? { reason: v.sweep_reason.slice(0, 500) } : {}),
|
|
2332
|
+
});
|
|
2333
|
+
if (note)
|
|
2334
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} sweep ${status}: ${note}`);
|
|
2335
|
+
// What the session did with the optional rows, and why.
|
|
2336
|
+
if (v.extent !== undefined)
|
|
2337
|
+
store.logEvent("followup_extent_decided", v.pr, {
|
|
2338
|
+
round_id: round.id,
|
|
2339
|
+
round_no: v.round_no,
|
|
2340
|
+
extent: v.extent,
|
|
2341
|
+
...(v.extent_reason ? { reason: v.extent_reason.slice(0, 500) } : {}),
|
|
2342
|
+
});
|
|
2343
|
+
// A sweep the session decided to skip is a decision, not an anomaly:
|
|
2344
|
+
// only an owed sweep left un-run without one is.
|
|
2345
|
+
if (sweepSkippedOnApprove(v.verdict, owed, status)) {
|
|
2346
|
+
store.logAnomaly("followup_sweep_skipped", v.pr, v.round_no, `APPROVE with the pre-approval sweep owed and ${status}` +
|
|
2347
|
+
`${claim ? ` (claimed ${claim})` : " (no claim)"}` +
|
|
2348
|
+
`${sweepPasses === null ? "; executed passes unknown" : `; ${sweepPasses} sweep-phase passes`}`);
|
|
2349
|
+
}
|
|
2350
|
+
return status;
|
|
2351
|
+
}
|
|
2352
|
+
catch (err) {
|
|
2353
|
+
this.log(`verdicts: sweep telemetry failed for #${v.pr}`, err);
|
|
2354
|
+
return null;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
// ---- executed-pass telemetry -----------------------------------------
|
|
2358
|
+
/**
|
|
2359
|
+
* Persist the verdict's executed-pass report and compare it with the plan
|
|
2360
|
+
* (`rounds.lenses`). Missing telemetry ⇒ logged as unknown; plan≠executed ⇒
|
|
2361
|
+
* logged anomaly + companion-inbox item. Never blocks the verdict — everything
|
|
2362
|
+
* here is wrapped, and the verdict was already applied by the caller.
|
|
2363
|
+
*/
|
|
2364
|
+
async recordPassTelemetry(v, round, sweepStatus = null) {
|
|
2365
|
+
const { store } = this.deps;
|
|
2366
|
+
try {
|
|
2367
|
+
if (!v.passes) {
|
|
2368
|
+
// The session reported nothing usable: the record stays NULL =
|
|
2369
|
+
// unknown, distinct from [] = "ran zero passes".
|
|
2370
|
+
store.logEvent("verdict_passes_unknown", v.pr, {
|
|
2371
|
+
round_id: round.id,
|
|
2372
|
+
round_no: v.round_no,
|
|
2373
|
+
});
|
|
2374
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} executed passes unknown (no telemetry in verdict)`);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
store.recordExecutedPasses(round.id, v.passes);
|
|
2378
|
+
const executed = v.passes.map((p) => p.name);
|
|
2379
|
+
const legacyPlanned = round.lenses ? round.lenses.split(",").filter(Boolean) : [];
|
|
2380
|
+
const plan = round.pass_plan ? JSON.parse(round.pass_plan) : null;
|
|
2381
|
+
const addable = round.addable_passes
|
|
2382
|
+
? JSON.parse(round.addable_passes)
|
|
2383
|
+
: null;
|
|
2384
|
+
// A follow-up round runs two stages. The delta stage's passes (phase
|
|
2385
|
+
// "delta", or none) answer to `pass_plan`; the sweep's (phase "sweep")
|
|
2386
|
+
// to `sweep_plan` — and a sweep lens is missing only when the sweep ran,
|
|
2387
|
+
// so a round that decided before the sweep raises nothing. Every other
|
|
2388
|
+
// round compares every pass against its plan.
|
|
2389
|
+
const followUp = round.scope === "delta";
|
|
2390
|
+
const deltaPasses = followUp ? v.passes.filter((p) => p.phase !== "sweep") : v.passes;
|
|
2391
|
+
const sweepPasses = followUp ? v.passes.filter((p) => p.phase === "sweep") : [];
|
|
2392
|
+
const main = comparePlan(deltaPasses, plan, addable, legacyPlanned, true);
|
|
2393
|
+
let sweep = null;
|
|
2394
|
+
if (sweepPasses.length > 0 || sweepStatus === "ran") {
|
|
2395
|
+
const sweepPlan = round.sweep_plan ? JSON.parse(round.sweep_plan) : [];
|
|
2396
|
+
// What the sweep may add: the round's addable set and the delta's own
|
|
2397
|
+
// plan rows, minus what the sweep already plans.
|
|
2398
|
+
const inSweep = new Set(sweepPlan.map((p) => p.lens));
|
|
2399
|
+
const sweepAddable = [
|
|
2400
|
+
...(addable ?? []),
|
|
2401
|
+
...(plan ?? []).map((p) => ({ lens: p.lens, tier: p.tier })),
|
|
2402
|
+
].filter((p) => !inSweep.has(p.lens));
|
|
2403
|
+
sweep = comparePlan(sweepPasses, sweepPlan, sweepAddable, [], sweepStatus === "ran");
|
|
2404
|
+
}
|
|
2405
|
+
const tag = (names) => names.map((n) => `sweep:${n}`);
|
|
2406
|
+
const planned = main.planned;
|
|
2407
|
+
const missing = [...main.missing, ...tag(sweep?.missing ?? [])];
|
|
2408
|
+
const unknownAdditions = [...main.unknownAdditions, ...tag(sweep?.unknownAdditions ?? [])];
|
|
2409
|
+
const missingReason = [...main.missingReason, ...tag(sweep?.missingReason ?? [])];
|
|
2410
|
+
const wrongTier = [...main.wrongTier, ...tag(sweep?.wrongTier ?? [])];
|
|
2411
|
+
const allowedAdditions = [
|
|
2412
|
+
...main.allowedAdditions,
|
|
2413
|
+
...(sweep?.allowedAdditions ?? []).map((a) => ({ ...a, name: `sweep:${a.name}` })),
|
|
2414
|
+
];
|
|
2415
|
+
if (allowedAdditions.length > 0) {
|
|
2416
|
+
store.logEvent("pass_plan_conformant_addition", v.pr, {
|
|
2417
|
+
round_id: round.id,
|
|
2418
|
+
round_no: v.round_no,
|
|
2419
|
+
additions: allowedAdditions,
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
const violations = missing.length + unknownAdditions.length + missingReason.length + wrongTier.length;
|
|
2423
|
+
if (violations === 0)
|
|
2424
|
+
return;
|
|
2425
|
+
store.logEvent("pass_plan_mismatch", v.pr, {
|
|
2426
|
+
round_id: round.id,
|
|
2427
|
+
round_no: v.round_no,
|
|
2428
|
+
planned,
|
|
2429
|
+
executed,
|
|
2430
|
+
missing,
|
|
2431
|
+
added: unknownAdditions,
|
|
2432
|
+
unknown_additions: unknownAdditions,
|
|
2433
|
+
missing_reason: missingReason,
|
|
2434
|
+
wrong_tier: wrongTier,
|
|
2435
|
+
allowed_additions: allowedAdditions,
|
|
2436
|
+
...(sweep ? { sweep_planned: sweep.planned, sweep_status: sweepStatus } : {}),
|
|
2437
|
+
});
|
|
2438
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} pass plan mismatch`, {
|
|
2439
|
+
planned,
|
|
2440
|
+
executed,
|
|
2441
|
+
missing,
|
|
2442
|
+
unknownAdditions,
|
|
2443
|
+
missingReason,
|
|
2444
|
+
wrongTier,
|
|
2445
|
+
});
|
|
2446
|
+
store.logAnomaly("pass_plan_mismatch", v.pr, v.round_no, `pass policy violated` +
|
|
2447
|
+
`${missing.length ? `; missing ${missing.join(",")}` : ""}` +
|
|
2448
|
+
`${unknownAdditions.length ? `; unknown ${unknownAdditions.join(",")}` : ""}` +
|
|
2449
|
+
`${missingReason.length ? `; no reason ${missingReason.join(",")}` : ""}` +
|
|
2450
|
+
`${wrongTier.length ? `; wrong tier ${wrongTier.join(",")}` : ""}`, [
|
|
2451
|
+
`PASS-PLAN MISMATCH — PR #${v.pr} round ${v.round_no}`,
|
|
2452
|
+
`Planned: ${planned.join(", ") || "(none)"}`,
|
|
2453
|
+
...(sweep
|
|
2454
|
+
? [
|
|
2455
|
+
`Pre-approval sweep (${sweepStatus ?? "unknown"}): ${sweep.planned.join(", ") || "(none planned)"}`,
|
|
2456
|
+
]
|
|
2457
|
+
: []),
|
|
2458
|
+
`Executed: ${executed.join(", ") || "(none — solo/degraded round)"}`,
|
|
2459
|
+
...(missing.length ? [`Missing planned passes: ${missing.join(", ")}`] : []),
|
|
2460
|
+
...(unknownAdditions.length
|
|
2461
|
+
? [`Not in the dispatched addable set: ${unknownAdditions.join(", ")}`]
|
|
2462
|
+
: []),
|
|
2463
|
+
...(missingReason.length
|
|
2464
|
+
? [`Allowed additions missing structured reason: ${missingReason.join(", ")}`]
|
|
2465
|
+
: []),
|
|
2466
|
+
...(wrongTier.length ? [`Passes at a non-authorized tier: ${wrongTier.join(", ")}`] : []),
|
|
2467
|
+
"The verdict stands — this is telemetry, not a veto.",
|
|
2468
|
+
].join("\n"));
|
|
2469
|
+
}
|
|
2470
|
+
catch (err) {
|
|
2471
|
+
this.log(`verdicts: pass telemetry failed for #${v.pr}`, err);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
// ---- measured usage --------------------------------------------------
|
|
2475
|
+
sessionDirFor(pr) {
|
|
2476
|
+
const custom = this.deps.usage?.sessionDirFor;
|
|
2477
|
+
if (custom)
|
|
2478
|
+
return custom(pr);
|
|
2479
|
+
const root = this.deps.root ?? sandboxRoot();
|
|
2480
|
+
return join(root, this.deps.cfg.pi.session_dir, `pr-${pr}`);
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Did git's configuration change while this round ran? And did this
|
|
2484
|
+
* system's own policy surface (config.toml, scripts/, dist/, extensions/,
|
|
2485
|
+
* the seeds)?
|
|
2486
|
+
*
|
|
2487
|
+
* The write veto in the bash guard reads command strings, and five write
|
|
2488
|
+
* primitives on the guard's own read-only allowlist go straight past it —
|
|
2489
|
+
* `T=.git/config; cat p >> $T` can never be seen by a token scan, because
|
|
2490
|
+
* the target is a variable. This check does not look at commands at all: the
|
|
2491
|
+
* fingerprint was taken when `dispatch` prepared the worktree, and any
|
|
2492
|
+
* difference now is a write the daemon did not make. The two families raise
|
|
2493
|
+
* different anomalies, because they mean different things: a git change
|
|
2494
|
+
* taints this round; a policy change rewrites the boundary every later round
|
|
2495
|
+
* in the container runs under.
|
|
2496
|
+
*
|
|
2497
|
+
* Runs inside `measureUsage`, which is deliberately the one place that fires
|
|
2498
|
+
* on both the verdict path and the anomaly/overdue path — a round that ends
|
|
2499
|
+
* badly is exactly when this most wants checking.
|
|
2500
|
+
*
|
|
2501
|
+
* Never throws and never fails a round: an unavailable fingerprint at either
|
|
2502
|
+
* end leaves the round unchecked, and a NULL baseline is never read as
|
|
2503
|
+
* "changed" (unknown is not evidence of loss). The re-hash is async with a
|
|
2504
|
+
* loop turn after every file — it walks dist/, scripts/ and the
|
|
2505
|
+
* rest of the policy surface — and bounded by `integrityBudgetMs`: a read
|
|
2506
|
+
* that never returns leaves the round unchecked (`round_integrity_unchecked`),
|
|
2507
|
+
* never a round end that does not finish.
|
|
2508
|
+
*/
|
|
2509
|
+
async checkRoundIntegrity(pr, roundId) {
|
|
2510
|
+
const { cfg, store } = this.deps;
|
|
2511
|
+
try {
|
|
2512
|
+
const before = this.integrityBaseline(roundId);
|
|
2513
|
+
if (!before)
|
|
2514
|
+
return; // no baseline — never judged
|
|
2515
|
+
const root = this.deps.root ?? sandboxRoot();
|
|
2516
|
+
const budgetMs = Math.max(0, this.deps.integrityBudgetMs ?? INTEGRITY_BUDGET_MS);
|
|
2517
|
+
const rehash = await withinBudget((this.deps.integrityFingerprint ?? fingerprintRoundIntegrity)(roundIntegrityOpts(githubCloneDir(cfg.github), root, prWorktreeRel(pr))), budgetMs);
|
|
2518
|
+
if (rehash.kind === "budget") {
|
|
2519
|
+
// Unchecked, never "unchanged": the baseline stays, nothing is judged.
|
|
2520
|
+
store.logEvent("round_integrity_unchecked", pr, {
|
|
2521
|
+
round_id: roundId,
|
|
2522
|
+
stage: "round_end",
|
|
2523
|
+
reason: "budget",
|
|
2524
|
+
budget_ms: budgetMs,
|
|
2525
|
+
});
|
|
2526
|
+
this.log(`verdicts: #${pr} round integrity UNCHECKED — the re-hash did not finish within ${budgetMs} ms`);
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
const after = rehash.value;
|
|
2530
|
+
const { git, policy } = splitIntegrityChanges(integrityChanges(before, after), root);
|
|
2531
|
+
if (git.length > 0) {
|
|
2532
|
+
store.logEvent("git_config_modified", pr, { round_id: roundId, changes: git });
|
|
2533
|
+
const message = gitConfigChangeMessage(pr, git);
|
|
2534
|
+
store.logAnomaly("git_config_modified", pr, null, message, message);
|
|
2535
|
+
this.log(`verdicts: #${pr} GIT CONFIG CHANGED DURING THE ROUND`, git);
|
|
2536
|
+
}
|
|
2537
|
+
if (policy.length > 0) {
|
|
2538
|
+
store.logEvent("policy_surface_modified", pr, { round_id: roundId, changes: policy });
|
|
2539
|
+
const message = policySurfaceChangeMessage(pr, policy);
|
|
2540
|
+
store.logAnomaly("policy_surface_modified", pr, null, message, message);
|
|
2541
|
+
this.log(`verdicts: #${pr} POLICY SURFACE CHANGED DURING THE ROUND`, policy);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
catch (err) {
|
|
2545
|
+
this.log(`verdicts: #${pr} round integrity check unavailable`, err);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
/** Measure real token usage for the round window from the pi session JSONLs,
|
|
2549
|
+
* estimate USD from config pricing, and store both. Also the post-hoc
|
|
2550
|
+
* exec-policy audit. Best-effort: no failure here disturbs the verdict.
|
|
2551
|
+
*
|
|
2552
|
+
* The transcripts are read once, async and chunked, through the watcher's
|
|
2553
|
+
* `TranscriptIndex` (each file resumed from where the last round end left
|
|
2554
|
+
* it; a session file older than the round not read at all), and the
|
|
2555
|
+
* integrity re-hash is async too, so neither stalls the event loop. An
|
|
2556
|
+
* in-scope transcript that cannot be read, or a read past `budgetMs`,
|
|
2557
|
+
* leaves the usage unknown
|
|
2558
|
+
* (nothing recorded — the columns stay NULL, never a partial sum) with a
|
|
2559
|
+
* `round_usage_unknown` event, and the audit reports what it did read. */
|
|
2560
|
+
async measureUsage(pr, roundId, startedAt, finishedAt, integrity = "check") {
|
|
2561
|
+
const { cfg, store } = this.deps;
|
|
2562
|
+
let persisted = false;
|
|
2563
|
+
try {
|
|
2564
|
+
const bounded = await withinBudget(this.transcripts.measure({
|
|
2565
|
+
sessionDir: this.sessionDirFor(pr),
|
|
2566
|
+
startedAt,
|
|
2567
|
+
finishedAt,
|
|
2568
|
+
}), this.usageBudgetMs());
|
|
2569
|
+
const measured = bounded.kind === "done" ? bounded.value : "budget";
|
|
2570
|
+
const usage = measured === "budget" ? null : measured.usage;
|
|
2571
|
+
if (usage !== null) {
|
|
2572
|
+
// pi's own per-record usage.cost.total is the primary source (verified
|
|
2573
|
+
// live on the VM); the config price table is only the fallback for
|
|
2574
|
+
// windows whose records carried no cost object.
|
|
2575
|
+
const pricing = this.deps.usage?.pricing ?? cfg.models.pricing ?? {};
|
|
2576
|
+
// The worker's own total (session, in-process lenses, warm-ups) when it closed
|
|
2577
|
+
// its report; the transcript's figure for a crashed or re-run worker and for
|
|
2578
|
+
// the tmux routes, which report nothing.
|
|
2579
|
+
const reported = store.workerReportedUsd(roundId);
|
|
2580
|
+
const est = reported ?? usage.cost_usd ?? estimateUsd(usage.models, pricing);
|
|
2581
|
+
store.recordRoundUsage(roundId, {
|
|
2582
|
+
tokens_in: usage.tokens_in,
|
|
2583
|
+
tokens_out: usage.tokens_out,
|
|
2584
|
+
tokens_cache_read: usage.tokens_cache_read,
|
|
2585
|
+
est_usd: est,
|
|
2586
|
+
});
|
|
2587
|
+
this.log(`verdicts: #${pr} round usage ${usage.tokens_in}in/${usage.tokens_out}out/${usage.tokens_cache_read}cache` +
|
|
2588
|
+
` est=${est === null ? "unpriced" : `$${est.toFixed(2)}`}${usage.passes_included ? " (+passes)" : ""}${usage.children_included ? " (+children)" : ""}`);
|
|
2589
|
+
}
|
|
2590
|
+
else if (measured === "budget") {
|
|
2591
|
+
store.logEvent("round_usage_unknown", pr, {
|
|
2592
|
+
round_id: roundId,
|
|
2593
|
+
reason: "budget",
|
|
2594
|
+
budget_ms: this.usageBudgetMs(),
|
|
2595
|
+
audit: "incomplete",
|
|
2596
|
+
});
|
|
2597
|
+
this.log(`verdicts: #${pr} round usage UNKNOWN — the transcripts were not read within ${this.usageBudgetMs()} ms; nothing recorded, the exec-policy audit did not run`);
|
|
2598
|
+
}
|
|
2599
|
+
else {
|
|
2600
|
+
store.logEvent("round_usage_unknown", pr, {
|
|
2601
|
+
round_id: roundId,
|
|
2602
|
+
reason: "unreadable",
|
|
2603
|
+
unreadable: measured.unreadable.slice(0, UNREADABLE_LISTED),
|
|
2604
|
+
unreadable_count: measured.unreadable.length,
|
|
2605
|
+
audit: "incomplete",
|
|
2606
|
+
});
|
|
2607
|
+
this.log(`verdicts: #${pr} round usage UNKNOWN — ${measured.unreadable.length} transcript path(s) unreadable; nothing recorded, the exec-policy audit covers only what was read`, measured.unreadable.slice(0, UNREADABLE_LISTED));
|
|
2608
|
+
}
|
|
2609
|
+
persisted = true;
|
|
2610
|
+
// Classified, so the durable event says which policy was crossed.
|
|
2611
|
+
if (integrity === "check")
|
|
2612
|
+
await this.checkRoundIntegrity(pr, roundId);
|
|
2613
|
+
else
|
|
2614
|
+
this.integrityInterrupted(pr, roundId);
|
|
2615
|
+
const violations = measured === "budget" ? [] : measured.violations;
|
|
2616
|
+
if (violations.length > 0) {
|
|
2617
|
+
const commands = violations.map((v) => v.command);
|
|
2618
|
+
store.logEvent("exec_policy_violation", pr, { commands, violations });
|
|
2619
|
+
this.execViolations.set(pr, violations.map((v) => `${v.kind}: ${v.command}`));
|
|
2620
|
+
this.log(`verdicts: #${pr} exec-policy violations`, violations);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
catch (err) {
|
|
2624
|
+
this.log(`verdicts: usage measurement failed for #${pr}`, err);
|
|
2625
|
+
}
|
|
2626
|
+
return persisted;
|
|
2627
|
+
}
|
|
2628
|
+
/**
|
|
2629
|
+
* A round end fixes its usage window and owes the measurement. The reads
|
|
2630
|
+
* can take minutes, and a deploy's drain sees `running_rounds == 0` the
|
|
2631
|
+
* moment the round row is terminal, so the obligation must be durable: a
|
|
2632
|
+
* restart mid-read would otherwise leave NULL tokens with no
|
|
2633
|
+
* `round_usage_unknown`, reading as "never measured" rather than "unknown".
|
|
2634
|
+
* `rounds.usage_owed_to` carries the window's end from the same synchronous
|
|
2635
|
+
* turn as the round end's terminal write until the measurement has
|
|
2636
|
+
* finished, whatever it found, and the next start resumes what is still owed
|
|
2637
|
+
* (`resumeOwedMeasurements`). The durable write must succeed before a
|
|
2638
|
+
* terminal replay marker is published; a failure leaves apply replayable.
|
|
2639
|
+
*/
|
|
2640
|
+
oweMeasurement(round,
|
|
2641
|
+
/** The window's end when it is not now: the round's terminal write
|
|
2642
|
+
* landed turns earlier (a verdict's apply stamps `finished_at` at its
|
|
2643
|
+
* claim and owes the measurement only once every durable step landed). */
|
|
2644
|
+
endedAt) {
|
|
2645
|
+
const measuredTo = endedAt ?? this.now().toISOString();
|
|
2646
|
+
this.deps.store.oweRoundMeasurement(round.id, measuredTo);
|
|
2647
|
+
this.measuring.add(round.id);
|
|
2648
|
+
return {
|
|
2649
|
+
pr: round.pr,
|
|
2650
|
+
roundId: round.id,
|
|
2651
|
+
roundNo: round.round_no,
|
|
2652
|
+
startedAt: round.started_at,
|
|
2653
|
+
measuredTo,
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
/** Run an owed measurement now (`inline`) or hand it to the background. */
|
|
2657
|
+
async closeMeasurement(owed, mode) {
|
|
2658
|
+
if (mode === "background") {
|
|
2659
|
+
this.detach(() => this.runMeasurement(owed, "check"), `measurement of #${owed.pr} round ${owed.roundNo}`);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
await this.runMeasurement(owed, "check");
|
|
2663
|
+
}
|
|
2664
|
+
/** The measurement, then the obligation cleared — whatever it found
|
|
2665
|
+
* (`measureUsage` explicitly reports that usage or unknown was persisted). */
|
|
2666
|
+
async runMeasurement(owed, integrity) {
|
|
2667
|
+
try {
|
|
2668
|
+
const persisted = await this.measureUsage(owed.pr, owed.roundId, owed.startedAt, owed.measuredTo, integrity);
|
|
2669
|
+
if (persisted)
|
|
2670
|
+
this.deps.store.settleRoundMeasurement(owed.roundId);
|
|
2671
|
+
}
|
|
2672
|
+
catch (err) {
|
|
2673
|
+
this.log(`verdicts: #${owed.pr} round ${owed.roundNo} — could not settle its owed measurement`, err);
|
|
2674
|
+
}
|
|
2675
|
+
finally {
|
|
2676
|
+
this.measuring.delete(owed.roundId);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
detach(fn, what) {
|
|
2680
|
+
const job = fn()
|
|
2681
|
+
.catch((err) => this.log(`verdicts: ${what} failed`, err))
|
|
2682
|
+
.finally(() => this.background.delete(job));
|
|
2683
|
+
this.background.add(job);
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Measurements an earlier process owed and did not finish — a restart,
|
|
2687
|
+
* usually a deploy, cut them short. Once
|
|
2688
|
+
* per watcher, from its first sweep; this process's own are in `measuring`.
|
|
2689
|
+
*
|
|
2690
|
+
* Each is measured again over its stored window, in the background: the
|
|
2691
|
+
* transcripts are append-only and the window's end is fixed, so the answer
|
|
2692
|
+
* is the one the interrupted read would have given. The integrity re-check
|
|
2693
|
+
* is not re-run — a deploy rewrites dist/ and scripts/, the policy surface,
|
|
2694
|
+
* so a re-hash after one would blame the round for the deploy; the round is
|
|
2695
|
+
* recorded unchecked (`round_integrity_unchecked`, `interrupted`) instead.
|
|
2696
|
+
* A window older than `OWED_MEASUREMENT_RESUME_MAX_MS` is not re-read at
|
|
2697
|
+
* all — the pi-global archiver may have moved its store files since, and a
|
|
2698
|
+
* partial sum must never read as the round's cost — so its usage, if it was
|
|
2699
|
+
* not recorded before the restart, is recorded unknown.
|
|
2700
|
+
*/
|
|
2701
|
+
resumeOwedMeasurements() {
|
|
2702
|
+
if (this.owedResumed)
|
|
2703
|
+
return;
|
|
2704
|
+
const { store } = this.deps;
|
|
2705
|
+
const owed = store.owedRoundMeasurements();
|
|
2706
|
+
this.owedResumed = true;
|
|
2707
|
+
for (const round of owed) {
|
|
2708
|
+
try {
|
|
2709
|
+
this.resumeOwedMeasurement(round);
|
|
2710
|
+
}
|
|
2711
|
+
catch (err) {
|
|
2712
|
+
this.log(`verdicts: resuming #${round.pr} round ${round.round_no}'s measurement failed`, err);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
resumeOwedMeasurement(round) {
|
|
2717
|
+
const { store } = this.deps;
|
|
2718
|
+
if (round.usage_owed_to === null || this.measuring.has(round.id))
|
|
2719
|
+
return;
|
|
2720
|
+
const job = {
|
|
2721
|
+
pr: round.pr,
|
|
2722
|
+
roundId: round.id,
|
|
2723
|
+
roundNo: round.round_no,
|
|
2724
|
+
startedAt: round.started_at,
|
|
2725
|
+
measuredTo: round.usage_owed_to,
|
|
2726
|
+
};
|
|
2727
|
+
const age = this.now().getTime() - Date.parse(job.measuredTo);
|
|
2728
|
+
if (!Number.isFinite(age) || age > OWED_MEASUREMENT_RESUME_MAX_MS) {
|
|
2729
|
+
if (round.tokens_in === null) {
|
|
2730
|
+
store.logEvent("round_usage_unknown", round.pr, {
|
|
2731
|
+
round_id: round.id,
|
|
2732
|
+
reason: "interrupted",
|
|
2733
|
+
measured_to: job.measuredTo,
|
|
2734
|
+
audit: "incomplete",
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
this.integrityInterrupted(round.pr, round.id);
|
|
2738
|
+
store.settleRoundMeasurement(round.id);
|
|
2739
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} — its measurement was cut short by a restart and its window (to ${job.measuredTo}) is too old to re-read; ${round.tokens_in === null ? "usage UNKNOWN" : "usage as recorded"}, audit incomplete`);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
this.measuring.add(round.id);
|
|
2743
|
+
store.logEvent("round_usage_resumed", round.pr, {
|
|
2744
|
+
round_id: round.id,
|
|
2745
|
+
round_no: round.round_no,
|
|
2746
|
+
started_at: job.startedAt,
|
|
2747
|
+
measured_to: job.measuredTo,
|
|
2748
|
+
});
|
|
2749
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} — resuming the measurement a restart cut short (window to ${job.measuredTo})`);
|
|
2750
|
+
this.detach(() => this.runMeasurement(job, "interrupted"), `resumed measurement of #${round.pr} round ${round.round_no}`);
|
|
2751
|
+
}
|
|
2752
|
+
/** The round's integrity baseline, when dispatch took one that can be
|
|
2753
|
+
* judged against; undefined otherwise — such a round is never judged. */
|
|
2754
|
+
integrityBaseline(roundId) {
|
|
2755
|
+
const before = this.deps.store.roundGitConfig(roundId);
|
|
2756
|
+
return before && typeof before.digest === "string" ? before : undefined;
|
|
2757
|
+
}
|
|
2758
|
+
/** The round-end integrity re-check did not run for this round because a
|
|
2759
|
+
* restart cut its measurement short — recorded, never read as "unchanged".
|
|
2760
|
+
* Only a round with a baseline could have been judged at all. */
|
|
2761
|
+
integrityInterrupted(pr, roundId) {
|
|
2762
|
+
if (!this.integrityBaseline(roundId))
|
|
2763
|
+
return;
|
|
2764
|
+
this.deps.store.logEvent("round_integrity_unchecked", pr, {
|
|
2765
|
+
round_id: roundId,
|
|
2766
|
+
stage: "round_end",
|
|
2767
|
+
reason: "interrupted",
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
/** How long a round end waits for its transcripts. A measure cut by it
|
|
2771
|
+
* goes on in the background (its reads fill the index for the next round
|
|
2772
|
+
* end); its late answer is dropped. */
|
|
2773
|
+
usageBudgetMs() {
|
|
2774
|
+
return Math.max(0, this.deps.usage?.budgetMs ?? USAGE_BUDGET_MS);
|
|
2775
|
+
}
|
|
2776
|
+
/** Malformed verdict or blown deadline: inbox anomaly, round marked, the
|
|
2777
|
+
* PR's lifecycle returned to its standing-verdict state (never an approve,
|
|
2778
|
+
* never a park), and the review still OWED as a FAILED_LOUD incident
|
|
2779
|
+
* (`openReviewFailure`) that holds the PR out of the pump — and its held
|
|
2780
|
+
* wakes behind it — until the operator presses `u`.
|
|
2781
|
+
*
|
|
2782
|
+
* Only a round still running owes that: every caller awaited something
|
|
2783
|
+
* since it read the round (a liveness probe, a posting proof, a requeue
|
|
2784
|
+
* that lost its cancel), and a round a verdict, a receipt settlement or an
|
|
2785
|
+
* operator ended meanwhile is left as it stands — no transition, no
|
|
2786
|
+
* incident over whatever replaced it. The read, the mark, the transition
|
|
2787
|
+
* and the incident land in one turn, before the first await. */
|
|
2788
|
+
async handleAnomaly(pr, roundNo, message, kind) {
|
|
2789
|
+
const { cfg, store, adapter } = this.deps;
|
|
2790
|
+
const round = this.runningRound(pr, roundNo ?? undefined);
|
|
2791
|
+
if (round && round.status !== "running") {
|
|
2792
|
+
store.logEvent("anomaly_round_ended", pr, {
|
|
2793
|
+
round_id: round.id,
|
|
2794
|
+
round_no: round.round_no,
|
|
2795
|
+
round_status: round.status,
|
|
2796
|
+
kind,
|
|
2797
|
+
message,
|
|
2798
|
+
});
|
|
2799
|
+
this.log(`verdicts: #${pr} round ${round.round_no} is already ${round.status} — the ${kind} anomaly opens nothing over it`);
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
let owed = null;
|
|
2803
|
+
if (round) {
|
|
2804
|
+
store.markRoundOverdue(round.id);
|
|
2805
|
+
// Measure the round's tokens on this path too, not only on the verdict
|
|
2806
|
+
// path: `markRoundOverdue` deliberately keeps the row in spend, and the
|
|
2807
|
+
// stranded-round operator message says "Its spend still counts." — an
|
|
2808
|
+
// unmeasured overdue or malformed round would contribute zero to every
|
|
2809
|
+
// aggregate. The window ends now, which is the `finished_at`
|
|
2810
|
+
// markRoundOverdue just stamped. The reads are async (seconds, at most
|
|
2811
|
+
// the usage budget), so they run below, after the transition and the
|
|
2812
|
+
// FAILED_LOUD incident — a restart while they run must never leave an
|
|
2813
|
+
// overdue round under a PR still `reviewing` with nothing owed — and
|
|
2814
|
+
// before the anomaly, whose inbox item carries the violations they
|
|
2815
|
+
// find. Owed durably from here; awaited on every path, the deadline
|
|
2816
|
+
// tick's included, because that inject reads what it finds.
|
|
2817
|
+
owed = this.oweMeasurement(round);
|
|
2818
|
+
}
|
|
2819
|
+
const standing = standingVerdict(store.reviewedHead(pr));
|
|
2820
|
+
applyEvent(store, pr, { type: "round_overdue", standing }, {}, { botLogin: cfg.github.bot_login, now: () => this.now(), reason: `anomaly:${kind}` });
|
|
2821
|
+
const command = round ? store.sessionCommandForRound(pr, round.round_no) : undefined;
|
|
2822
|
+
const successor = store.successorFor(pr);
|
|
2823
|
+
const failure = store.openReviewFailure({
|
|
2824
|
+
pr,
|
|
2825
|
+
command_id: command?.id ?? null,
|
|
2826
|
+
round_id: round?.id ?? null,
|
|
2827
|
+
round_no: roundNo,
|
|
2828
|
+
head_sha: successor?.head_sha ?? store.getPr(pr)?.head_sha ?? "",
|
|
2829
|
+
stage: kind === "overdue" ? "round_deadline" : "malformed_verdict",
|
|
2830
|
+
detail: message,
|
|
2831
|
+
attempts: command?.attempt ?? 1,
|
|
2832
|
+
retry_cap: cfg.daemon.max_self_requeues ?? DEFAULT_MAX_SELF_REQUEUES,
|
|
2833
|
+
});
|
|
2834
|
+
store.logEvent(kind === "overdue" ? "round_overdue" : "verdict_malformed", pr, {
|
|
2835
|
+
round_id: round?.id ?? null,
|
|
2836
|
+
round_no: roundNo,
|
|
2837
|
+
message,
|
|
2838
|
+
});
|
|
2839
|
+
// The in-progress label must not stick, but the "Automatic Review" label is
|
|
2840
|
+
// deliberately not applied — no review was soundly produced.
|
|
2841
|
+
await this.safe(() => adapter.removeLabel(pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${pr}`);
|
|
2842
|
+
if (owed)
|
|
2843
|
+
await this.closeMeasurement(owed, "inline");
|
|
2844
|
+
// Piggyback pending exec-policy findings on a wake that happens anyway;
|
|
2845
|
+
// violations by themselves never trigger an inject (no spam).
|
|
2846
|
+
const pendingViolations = this.execViolations.get(pr);
|
|
2847
|
+
this.execViolations.delete(pr);
|
|
2848
|
+
// The durable anomaly record — and the one wake funnel: both kinds here
|
|
2849
|
+
// are WAKE classes, so the store's sink writes the
|
|
2850
|
+
// inbox item (dedup'd per kind+pr) from the message passed along.
|
|
2851
|
+
store.logAnomaly(kind, pr, roundNo, message, [
|
|
2852
|
+
`ANOMALY (${kind}) — PR #${pr}${roundNo !== null ? ` round ${roundNo}` : ""}`,
|
|
2853
|
+
message,
|
|
2854
|
+
`No review was produced: work remains owed as ${failure.incident_id} (never an approve). No reviewer was requested on GitHub.`,
|
|
2855
|
+
"Press u on the board to clear the FAILED_LOUD hold and rerun, or restart the session deliberately.",
|
|
2856
|
+
...(pendingViolations && pendingViolations.length > 0
|
|
2857
|
+
? [
|
|
2858
|
+
`Also: exec-policy violations were audited on this PR — ${pendingViolations.join(" · ")}`,
|
|
2859
|
+
]
|
|
2860
|
+
: []),
|
|
2861
|
+
].join("\n"));
|
|
2862
|
+
await this.announceSafe((a) => a.anomaly({
|
|
2863
|
+
pr,
|
|
2864
|
+
round_no: roundNo,
|
|
2865
|
+
kind,
|
|
2866
|
+
deadline_min: cfg.daemon.round_deadline_min,
|
|
2867
|
+
incident_id: failure.incident_id,
|
|
2868
|
+
}));
|
|
2869
|
+
// FAILED_LOUD review debt owns the session ordering until an operator
|
|
2870
|
+
// presses u. Held conversation cannot overtake work that is still owed.
|
|
2871
|
+
this.log(`verdicts: anomaly (${kind}) on #${pr}`, message);
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Deadline-time recovery for a round whose prompt provably never arrived:
|
|
2875
|
+
* re-deliver the order and give the round a fresh deadline.
|
|
2876
|
+
*
|
|
2877
|
+
* The extension is what makes this a recovery rather than a formality — a
|
|
2878
|
+
* round already past its deadline would otherwise be re-judged on the very
|
|
2879
|
+
* next tick, before the session could possibly have finished. The clock is
|
|
2880
|
+
* reset from now, because the review has not started yet: none of the
|
|
2881
|
+
* elapsed time was spent reviewing.
|
|
2882
|
+
*
|
|
2883
|
+
* False ⇒ nothing was delivered (budget spent, or no saved prompt), and the
|
|
2884
|
+
* caller escalates.
|
|
2885
|
+
*/
|
|
2886
|
+
async recoverNeverDelivered(round) {
|
|
2887
|
+
return (await this.redeliverPrompt(round, "deadline")) === "redelivered";
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Re-deliver a round's own dispatched order — same round, same session,
|
|
2891
|
+
* the exact bytes — bounded by `max_prompt_redeliveries`. The one
|
|
2892
|
+
* redelivery of both the deadline path and the pre-deadline lost-prompt
|
|
2893
|
+
* net. A delivered order gets a fresh deadline from now, wherever it was
|
|
2894
|
+
* caught: the review has not started, so none of the elapsed time was spent
|
|
2895
|
+
* reviewing, and a prompt caught minutes before its deadline must not be
|
|
2896
|
+
* judged overdue before the session could have finished. The silent mark
|
|
2897
|
+
* is cleared, so the next escalation must be re-earned.
|
|
2898
|
+
*
|
|
2899
|
+
* `unavailable`: no hook, or the budget is spent — nothing attempted.
|
|
2900
|
+
* `failed`: attempted (the attempt counted), nothing delivered.
|
|
2901
|
+
*/
|
|
2902
|
+
async redeliverPrompt(round, at) {
|
|
2903
|
+
const { cfg, store } = this.deps;
|
|
2904
|
+
const hooks = this.deps.hooks;
|
|
2905
|
+
const cap = cfg.daemon.max_prompt_redeliveries ?? DEFAULT_MAX_PROMPT_REDELIVERIES;
|
|
2906
|
+
if (!hooks.redeliverRound || round.redeliveries >= cap)
|
|
2907
|
+
return "unavailable";
|
|
2908
|
+
const attempt = store.countRedelivery(round.id);
|
|
2909
|
+
let redelivered = false;
|
|
2910
|
+
try {
|
|
2911
|
+
redelivered = await hooks.redeliverRound.call(hooks, round.pr, round.round_no);
|
|
2912
|
+
}
|
|
2913
|
+
catch (err) {
|
|
2914
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} redelivery (${at}) failed`, err);
|
|
2915
|
+
return "failed";
|
|
2916
|
+
}
|
|
2917
|
+
if (!redelivered) {
|
|
2918
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} has no saved prompt to redeliver (${at})`);
|
|
2919
|
+
return "failed";
|
|
2920
|
+
}
|
|
2921
|
+
store.extendRoundDeadline(round.id, cfg.daemon.round_deadline_min);
|
|
2922
|
+
store.clearRoundSilent(round.id);
|
|
2923
|
+
store.logEvent("round_prompt_redelivered", round.pr, {
|
|
2924
|
+
round_id: round.id,
|
|
2925
|
+
round_no: round.round_no,
|
|
2926
|
+
attempt,
|
|
2927
|
+
cap,
|
|
2928
|
+
at,
|
|
2929
|
+
deadline_extended_min: cfg.daemon.round_deadline_min,
|
|
2930
|
+
});
|
|
2931
|
+
this.log(`verdicts: REDELIVER (${at}) #${round.pr} round ${round.round_no} attempt ${attempt}/${cap} — the prompt never reached the session; same round, same session, nothing cancelled or killed`);
|
|
2932
|
+
return "redelivered";
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Deliver wakes that are held with no round left to flush them.
|
|
2936
|
+
*
|
|
2937
|
+
* `flushWakes` runs at a round's settled end — a verdict landing or a
|
|
2938
|
+
* guarded no-op — on the assumption that every round ends through one of
|
|
2939
|
+
* them. Three cancellations do not: `superseded`, a startup orphan that is
|
|
2940
|
+
* not requeued (the requeue needs `state === "reviewing"` and no later
|
|
2941
|
+
* round), and a self-requeue whose successor is cancelled the same way. The
|
|
2942
|
+
* wakes held while such a round was in flight — author replies included —
|
|
2943
|
+
* then sit in `pending_wakes` with nothing to flush them, and that table has
|
|
2944
|
+
* no expiry.
|
|
2945
|
+
*
|
|
2946
|
+
* A PR holding wakes with no round running and no review owed is
|
|
2947
|
+
* unambiguous: the reason to hold is gone, so deliver. A PR whose review
|
|
2948
|
+
* is owed FAILED_LOUD (the anomaly path, an exhausted recovery) keeps them
|
|
2949
|
+
* held — conversation never overtakes owed review work; the operator's `u`
|
|
2950
|
+
* resolves the debt, and the next sweep delivers them. A paused PR holding
|
|
2951
|
+
* only deferred rechecks keeps them too, until the pause lifts. Runs on the
|
|
2952
|
+
* deadline tick, which is already the daemon's "is anything stuck?" sweep.
|
|
2953
|
+
*/
|
|
2954
|
+
async flushStrandedWakes() {
|
|
2955
|
+
const { store } = this.deps;
|
|
2956
|
+
for (const pr of store.prsWithStrandedWakes()) {
|
|
2957
|
+
if (store.getPr(pr)?.state === "closed") {
|
|
2958
|
+
// The session is archived — there is nothing to wake. Drop them rather
|
|
2959
|
+
// than leave rows nothing will ever read.
|
|
2960
|
+
const dropped = store.dropHeldWakes(pr);
|
|
2961
|
+
if (dropped > 0)
|
|
2962
|
+
store.logEvent("wakes_dropped_closed", pr, { dropped });
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
// Owed FAILED_LOUD review work keeps its wakes behind it.
|
|
2966
|
+
if (store.openReviewFailure(pr))
|
|
2967
|
+
continue;
|
|
2968
|
+
if (store.blockingSessionCommand(`pr-${pr}`))
|
|
2969
|
+
continue;
|
|
2970
|
+
// A takeover holds the session: its release flushes what was parked.
|
|
2971
|
+
if (store.hostLease(pr))
|
|
2972
|
+
continue;
|
|
2973
|
+
// A paused PR holding only deferred rechecks waits for the resume,
|
|
2974
|
+
// quietly: the next tick after the pause lifts delivers them.
|
|
2975
|
+
if (pauseDefersAllHeldWakes(store, pr))
|
|
2976
|
+
continue;
|
|
2977
|
+
store.logEvent("wakes_stranded", pr, { held: store.heldWakes(pr).length });
|
|
2978
|
+
this.log(`verdicts: #${pr} held wakes with no round running — flushing`);
|
|
2979
|
+
await this.flushWakes(pr);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
/** Deliver the wakes held while this PR's round was in flight. */
|
|
2983
|
+
async flushWakes(pr) {
|
|
2984
|
+
await this.safe(() => flushHeldWakes({
|
|
2985
|
+
store: this.deps.store,
|
|
2986
|
+
inject: (n, message) => this.deps.hooks.inject(n, message),
|
|
2987
|
+
log: this.log.bind(this),
|
|
2988
|
+
}, pr), `flush held wakes on #${pr}`);
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* Stuck-round triage: is this overdue round one the daemon should requeue
|
|
2992
|
+
* itself? "Stuck" is overdue (the caller already established that) and
|
|
2993
|
+
* session dead (or live with a kill hook) and no verdict file on disk.
|
|
2994
|
+
* - no `sessionAlive` hook or a hook error ⇒ `not_stuck`: the anomaly path
|
|
2995
|
+
* stands.
|
|
2996
|
+
* - session dead but the round's verdict file is sitting unconsumed ⇒
|
|
2997
|
+
* `verdict_pending`: the sweep will consume it — neither a requeue nor an
|
|
2998
|
+
* anomaly may race a real verdict (the restart-orphan/self-requeue
|
|
2999
|
+
* idempotency hinges on never double-owning a round that already decided).
|
|
3000
|
+
*/
|
|
3001
|
+
async stuckDisposition(round) {
|
|
3002
|
+
const alive = this.deps.hooks.sessionAlive;
|
|
3003
|
+
if (!alive)
|
|
3004
|
+
return "not_stuck";
|
|
3005
|
+
let dead;
|
|
3006
|
+
try {
|
|
3007
|
+
dead = !(await alive.call(this.deps.hooks, round.pr));
|
|
3008
|
+
}
|
|
3009
|
+
catch {
|
|
3010
|
+
return "not_stuck";
|
|
3011
|
+
}
|
|
3012
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
3013
|
+
return "verdict_pending";
|
|
3014
|
+
if (dead)
|
|
3015
|
+
return "dead";
|
|
3016
|
+
// A live session that never received the order is not wedged — it is
|
|
3017
|
+
// idle, waiting for work that was lost in transit. Killing it destroys a
|
|
3018
|
+
// healthy conversation to fix a file that failed to arrive, so the receipt
|
|
3019
|
+
// is checked before `alive_stuck` is concluded.
|
|
3020
|
+
if (round.prompt_marker && this.deps.hooks.roundPromptAccepted) {
|
|
3021
|
+
try {
|
|
3022
|
+
const startedMs = Date.parse(round.started_at);
|
|
3023
|
+
const accepted = Number.isFinite(startedMs)
|
|
3024
|
+
? await this.deps.hooks.roundPromptAccepted.call(this.deps.hooks, round.pr, round.round_no, startedMs)
|
|
3025
|
+
: null;
|
|
3026
|
+
if (accepted === false)
|
|
3027
|
+
return "never_delivered";
|
|
3028
|
+
}
|
|
3029
|
+
catch {
|
|
3030
|
+
// Unreadable evidence falls through to the judgement below.
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
// Live but past the deadline with nothing on disk: recover, don't park —
|
|
3034
|
+
// the session is killed and the round requeued through the same capped
|
|
3035
|
+
// path. Without a kill hook the anomaly path stands — never inject a
|
|
3036
|
+
// fresh round into a wedged live session.
|
|
3037
|
+
return this.deps.hooks.killSession ? "alive_stuck" : "not_stuck";
|
|
3038
|
+
}
|
|
3039
|
+
/** Feed command-stage infrastructure failures into the same
|
|
3040
|
+
* capped cancellation→queue lifecycle as overdue/dead rounds. There is no
|
|
3041
|
+
* merits verdict and no GitHub mutation. A transient inability to stop an
|
|
3042
|
+
* active session is retryable; budget exhaustion is distinct durable fact. */
|
|
3043
|
+
async recoverInfrastructureFailure(pr, roundNo, stage, detail) {
|
|
3044
|
+
const round = this.runningRound(pr, roundNo);
|
|
3045
|
+
if (!round || round.status !== "running")
|
|
3046
|
+
return "requeued";
|
|
3047
|
+
if (round.posting_key) {
|
|
3048
|
+
const answered = await this.receiptAwareStage(round, stage, detail);
|
|
3049
|
+
if (answered)
|
|
3050
|
+
return answered;
|
|
3051
|
+
// The posting proof awaited GitHub: a round an operator or a verdict
|
|
3052
|
+
// ended meanwhile is not recovered — and its session, which may now be
|
|
3053
|
+
// the replacement round's, is not killed.
|
|
3054
|
+
if (!this.stillRunning(round))
|
|
3055
|
+
return "requeued";
|
|
3056
|
+
}
|
|
3057
|
+
this.deps.store.logEvent("round_infrastructure_failure", pr, {
|
|
3058
|
+
round_id: round.id,
|
|
3059
|
+
round_no: roundNo,
|
|
3060
|
+
stage,
|
|
3061
|
+
detail,
|
|
3062
|
+
});
|
|
3063
|
+
const head = this.deps.store.getPr(pr)?.head_sha ?? "";
|
|
3064
|
+
const cap = this.selfRequeueCap();
|
|
3065
|
+
if (this.selfRequeueSpent(pr, head)) {
|
|
3066
|
+
const mode = stage === "settled_without_verdict" ? "infrastructure_settled" : "infrastructure_active";
|
|
3067
|
+
if (mode === "infrastructure_active" && !this.deps.hooks.killSession)
|
|
3068
|
+
return "retryable";
|
|
3069
|
+
// A session that could not be stopped, or a verdict that won before the
|
|
3070
|
+
// fence, leaves the round to the next check and the sweep.
|
|
3071
|
+
if ((await this.fenceForRecovery(round, mode)) !== "fenced")
|
|
3072
|
+
return "retryable";
|
|
3073
|
+
const command = this.deps.store.sessionCommandForRound(pr, roundNo);
|
|
3074
|
+
if (!command)
|
|
3075
|
+
return "retryable";
|
|
3076
|
+
const successor = this.deps.store.successorFor(pr);
|
|
3077
|
+
const reason = `round ${roundNo} failed a bounded daemon→Pi infrastructure stage before any merits verdict`;
|
|
3078
|
+
const failure = this.deps.store.terminalizeInfrastructureReviewFailure({
|
|
3079
|
+
pr,
|
|
3080
|
+
command_id: command.id,
|
|
3081
|
+
round_id: round.id,
|
|
3082
|
+
round_no: roundNo,
|
|
3083
|
+
head_sha: successor?.head_sha ?? this.deps.store.getPr(pr)?.head_sha ?? head,
|
|
3084
|
+
stage,
|
|
3085
|
+
detail,
|
|
3086
|
+
attempts: cap,
|
|
3087
|
+
retry_cap: cap,
|
|
3088
|
+
summary: `infrastructure recovery exhausted after ${cap} self-requeue(s) — ${reason}`,
|
|
3089
|
+
});
|
|
3090
|
+
if (!failure) {
|
|
3091
|
+
// An operator/verdict/concurrent recovery won the exact terminal CAS.
|
|
3092
|
+
return this.deps.store.reviewFailureForCommand(command.id)?.work_owed === 1
|
|
3093
|
+
? "exhausted"
|
|
3094
|
+
: "requeued";
|
|
3095
|
+
}
|
|
3096
|
+
// The round's window closes now, and its measurement is owed from now;
|
|
3097
|
+
// it runs after the incident's durable rows, so no await sits between
|
|
3098
|
+
// the terminal CAS and them (the measurement reads its transcripts
|
|
3099
|
+
// async), and in the background: this is the round-stage supervisor's
|
|
3100
|
+
// serialized scan.
|
|
3101
|
+
const owed = this.oweMeasurement(round);
|
|
3102
|
+
this.deps.store.logEvent("self_requeue_cap_exhausted", pr, {
|
|
3103
|
+
round_id: round.id,
|
|
3104
|
+
round_no: roundNo,
|
|
3105
|
+
head_sha: head,
|
|
3106
|
+
attempts: cap,
|
|
3107
|
+
cap,
|
|
3108
|
+
mode,
|
|
3109
|
+
});
|
|
3110
|
+
this.deps.store.logEvent("round_infrastructure_recovery_exhausted", pr, {
|
|
3111
|
+
round_id: round.id,
|
|
3112
|
+
round_no: roundNo,
|
|
3113
|
+
head_sha: head,
|
|
3114
|
+
attempts: cap,
|
|
3115
|
+
mode,
|
|
3116
|
+
});
|
|
3117
|
+
this.deps.store.logAnomaly("review_failed_loud", pr, roundNo, `${stage}: ${detail}; work remains owed as ${failure.incident_id}; ${failure.next_action === "retry_wait" && failure.next_retry_at
|
|
3118
|
+
? `automatic retry scheduled for ${failure.next_retry_at} (u retries immediately)`
|
|
3119
|
+
: "press u to recover"}`);
|
|
3120
|
+
// No round owns the review any more: the in-progress label comes off,
|
|
3121
|
+
// as on every other terminal path.
|
|
3122
|
+
await this.safe(() => this.deps.adapter.removeLabel(pr, this.deps.cfg.github.in_progress_label), `remove ${this.deps.cfg.github.in_progress_label} on #${pr}`);
|
|
3123
|
+
await this.closeMeasurement(owed, "background");
|
|
3124
|
+
await this.announceSafe((a) => a.anomaly({
|
|
3125
|
+
pr,
|
|
3126
|
+
round_no: roundNo,
|
|
3127
|
+
kind: "failed_loud",
|
|
3128
|
+
deadline_min: this.deps.cfg.daemon.round_deadline_min,
|
|
3129
|
+
incident_id: failure.incident_id,
|
|
3130
|
+
}));
|
|
3131
|
+
return "exhausted";
|
|
3132
|
+
}
|
|
3133
|
+
if (stage !== "settled_without_verdict" && !this.deps.hooks.killSession)
|
|
3134
|
+
return "retryable";
|
|
3135
|
+
const recovered = await this.selfRequeue(round, stage === "settled_without_verdict" ? "infrastructure_settled" : "infrastructure_active", this.preFanoutRecovery(round, stage));
|
|
3136
|
+
return recovered ? "requeued" : "retryable";
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Every stage limit asks the posting receipt first. The limits are
|
|
3140
|
+
* wall-clock bounds on a session that is supposed to be reviewing; once its
|
|
3141
|
+
* review is confirmed on GitHub, it is not — it is writing its ledger, its
|
|
3142
|
+
* findings record and its verdict file, and on a CPU-starved container that
|
|
3143
|
+
* tail can outlive the finalize grace. Cancelling and requeueing it then
|
|
3144
|
+
* would post a second review on the same head.
|
|
3145
|
+
*
|
|
3146
|
+
* So, for a round whose review is on GitHub (or was refused by guard 2 —
|
|
3147
|
+
* equally final):
|
|
3148
|
+
* - a parent that has settled will write nothing more: settle now;
|
|
3149
|
+
* - otherwise the tail gets `[daemon] round_posted_tail_grace_min` from the
|
|
3150
|
+
* posting — `posted_wait`: nothing is cancelled, killed or requeued, and
|
|
3151
|
+
* a verdict file that lands in that window is applied by the sweep as
|
|
3152
|
+
* usual;
|
|
3153
|
+
* - past it, settle from the receipt when it proves the decision
|
|
3154
|
+
* (`decidedVerdictFromReceipt`), and fail loud (`posted_without_verdict`,
|
|
3155
|
+
* the shape a posted round without a verdict always had) when it does
|
|
3156
|
+
* not. Never a requeue, so never a second posting.
|
|
3157
|
+
* A POST still inside its ambiguous transport window (a `prepared` receipt
|
|
3158
|
+
* within `posting_receipt_grace_s`) is waited out too: killing the poster
|
|
3159
|
+
* there is how a review lands on GitHub with nothing local to say so.
|
|
3160
|
+
*
|
|
3161
|
+
* Before any of that, and whatever the receipt says — receipt or none — a
|
|
3162
|
+
* verdict file already on disk (or one the watcher is still reading) defers
|
|
3163
|
+
* the limit to the sweep: the session has finished, and cancelling the round
|
|
3164
|
+
* under its own verdict would only make that verdict land on a cancelled
|
|
3165
|
+
* round. The deferral is bounded by the sweep, which consumes the file within
|
|
3166
|
+
* its partial-write window (applied, or malformed and archived) every
|
|
3167
|
+
* `VERDICT_SWEEP_S`.
|
|
3168
|
+
*
|
|
3169
|
+
* Returns null for everything else — no receipt, an unreadable or
|
|
3170
|
+
* mismatched one, a legacy round — and the caller's own path runs:
|
|
3171
|
+
* unknown is not evidence of a posting.
|
|
3172
|
+
*/
|
|
3173
|
+
async receiptAwareStage(round, stage, detail) {
|
|
3174
|
+
const { store, cfg } = this.deps;
|
|
3175
|
+
// A verdict file on disk (or one the watcher is still reading) is the
|
|
3176
|
+
// sweep's, whatever the receipt says.
|
|
3177
|
+
if (this.verdictFilePresent(round.pr, round.round_no) ||
|
|
3178
|
+
this.inFlight.has(verdictFileName(round.pr, round.round_no)))
|
|
3179
|
+
return "retryable";
|
|
3180
|
+
const proof = await this.postingProof(round);
|
|
3181
|
+
if (proof.kind === "confirmed" || proof.kind === "skipped") {
|
|
3182
|
+
const graceMin = cfg.daemon.round_posted_tail_grace_min ?? DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN;
|
|
3183
|
+
if (stage !== "settled_without_verdict" &&
|
|
3184
|
+
this.inPostedTailGrace(round, proof, stage, detail))
|
|
3185
|
+
return "posted_wait";
|
|
3186
|
+
const settled = await this.withVerdictSlot(round.pr, round.round_no, async () => {
|
|
3187
|
+
// The verdict may have landed while this awaited GitHub: it wins.
|
|
3188
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
3189
|
+
return "retryable";
|
|
3190
|
+
if (proof.kind === "skipped") {
|
|
3191
|
+
// Refused by guard 2 and no verdict file. The round is over
|
|
3192
|
+
// and nothing is owed; the delivered command stays as it is.
|
|
3193
|
+
return (await this.settleGuardedNoop(round, proof.noop, stage)) === "failed"
|
|
3194
|
+
? "failed_loud"
|
|
3195
|
+
: "settled";
|
|
3196
|
+
}
|
|
3197
|
+
const receipt = proof.receipt;
|
|
3198
|
+
const why = stage === "settled_without_verdict"
|
|
3199
|
+
? detail
|
|
3200
|
+
: `${detail}; no verdict file ${graceMin} min after the review was posted`;
|
|
3201
|
+
const decision = decidedVerdictFromReceipt(receipt);
|
|
3202
|
+
if (decision) {
|
|
3203
|
+
await this.settleFromReceipt(round, receipt, decision, stage, why);
|
|
3204
|
+
return "settled";
|
|
3205
|
+
}
|
|
3206
|
+
// Posted, the session has written nothing, and the receipt does not
|
|
3207
|
+
// say what was decided: loud, as a posted round without a verdict
|
|
3208
|
+
// always was — and never a requeue, which would post a second time.
|
|
3209
|
+
if (!this.stillRunning(round))
|
|
3210
|
+
return "settled";
|
|
3211
|
+
await this.failPostingProof(round, `GitHub review ${receipt.gh_review_id ?? "confirmed"} was posted (${receipt.actual_event}) but ${stage === "settled_without_verdict" ? "the session settled before writing its verdict" : `the session wrote no verdict within ${graceMin} min of posting`}, and the posting receipt does not say what was decided`, "posted_without_verdict", "background");
|
|
3212
|
+
return "failed_loud";
|
|
3213
|
+
});
|
|
3214
|
+
return settled ?? "retryable";
|
|
3215
|
+
}
|
|
3216
|
+
if (stage === "settled_without_verdict") {
|
|
3217
|
+
// The parent stopped, so an ambiguous receipt gets its grace and then
|
|
3218
|
+
// fails loud — never a requeue that could post a second time. Nothing
|
|
3219
|
+
// went out (`unconfirmed`: no receipt, or GitHub read and no marker) is
|
|
3220
|
+
// the caller's capped requeue, as at every other stage: a session that
|
|
3221
|
+
// stopped on a failed pass, posting nothing, is a retry, not an incident.
|
|
3222
|
+
if (proof.kind === "waiting")
|
|
3223
|
+
return "retryable";
|
|
3224
|
+
// The proof awaited GitHub: a round an operator ended meanwhile is
|
|
3225
|
+
// theirs, as on the fail-loud path below.
|
|
3226
|
+
if (proof.kind === "failed" && proof.failure === "unconfirmed")
|
|
3227
|
+
return this.stillRunning(round) ? null : "settled";
|
|
3228
|
+
if (proof.kind === "failed") {
|
|
3229
|
+
return stageFailure(await this.failPostingProofUnlessVerdict(round, proof.detail ?? "settled without a posting receipt", "posting_receipt"));
|
|
3230
|
+
}
|
|
3231
|
+
return null;
|
|
3232
|
+
}
|
|
3233
|
+
if (proof.kind === "waiting" &&
|
|
3234
|
+
(this.postInFlight(round) ||
|
|
3235
|
+
(round.posting_key &&
|
|
3236
|
+
pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key)))) {
|
|
3237
|
+
store.logEvent("round_stage_posting_in_flight", round.pr, {
|
|
3238
|
+
round_id: round.id,
|
|
3239
|
+
round_no: round.round_no,
|
|
3240
|
+
stage,
|
|
3241
|
+
detail,
|
|
3242
|
+
});
|
|
3243
|
+
return "retryable";
|
|
3244
|
+
}
|
|
3245
|
+
if (proof.kind === "failed" && proof.failure === "ambiguous") {
|
|
3246
|
+
// A POST for exactly this round may have landed — its receipt is still
|
|
3247
|
+
// `prepared` past its grace — and GitHub could not be read for its
|
|
3248
|
+
// marker. A requeue here could post twice; so could failing loud, whose
|
|
3249
|
+
// `u` reruns a round that cannot see the posting either. So the limit
|
|
3250
|
+
// waits for a read that can decide: the next check finds the marker (and
|
|
3251
|
+
// settles from it) or finds none (and requeues). Bounded by the round
|
|
3252
|
+
// deadline, where the same evidence fails loud.
|
|
3253
|
+
if (!this.loggedOnce.has(`ambiguous:${round.id}`)) {
|
|
3254
|
+
this.loggedOnce.add(`ambiguous:${round.id}`);
|
|
3255
|
+
store.logEvent("round_stage_posting_ambiguous", round.pr, {
|
|
3256
|
+
round_id: round.id,
|
|
3257
|
+
round_no: round.round_no,
|
|
3258
|
+
stage,
|
|
3259
|
+
detail,
|
|
3260
|
+
posting_key: round.posting_key,
|
|
3261
|
+
});
|
|
3262
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} hit ${stage} with a POST whose outcome is unknown (receipt still prepared, GitHub unreadable) — waiting for a read that can decide; nothing cancelled, killed or requeued`);
|
|
3263
|
+
}
|
|
3264
|
+
return "retryable";
|
|
3265
|
+
}
|
|
3266
|
+
if (proof.kind === "failed" &&
|
|
3267
|
+
(proof.failure === "contradicted" || proof.failure === "exhausted")) {
|
|
3268
|
+
// Evidence of a posting that disagrees with the round (conflicting
|
|
3269
|
+
// linkage, several matching reviews): a requeue could only add another.
|
|
3270
|
+
// Loud, as the verdict and deadline paths are on the same evidence.
|
|
3271
|
+
return stageFailure(await this.failPostingProofUnlessVerdict(round, proof.detail ?? "posting receipt contradicted", "posting_receipt"));
|
|
3272
|
+
}
|
|
3273
|
+
// Nothing says a review went out (no receipt, or GitHub read and no
|
|
3274
|
+
// marker): the caller's own path.
|
|
3275
|
+
return null;
|
|
3276
|
+
}
|
|
3277
|
+
/** Is this the same round, still running? A fresh read, for the paths that
|
|
3278
|
+
* awaited GitHub since they found it. */
|
|
3279
|
+
stillRunning(round) {
|
|
3280
|
+
const fresh = this.runningRound(round.pr, round.round_no);
|
|
3281
|
+
return fresh !== undefined && fresh.id === round.id && fresh.status === "running";
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Inside the posted-tail grace: the round's review is on GitHub (or guard
|
|
3285
|
+
* 2 refused it), and the session is still writing its ledger, findings
|
|
3286
|
+
* record and verdict file — not wedged. True while `now` is before the
|
|
3287
|
+
* posting + `[daemon] round_posted_tail_grace_min`, logged once per round
|
|
3288
|
+
* and limit. Shared by the stage limits and the round deadline, so neither
|
|
3289
|
+
* settles a round (and lets the supervisor kill the session) mid-tail.
|
|
3290
|
+
*/
|
|
3291
|
+
inPostedTailGrace(round, proof, limit, detail) {
|
|
3292
|
+
const postedAt = proof.kind === "confirmed"
|
|
3293
|
+
? (proof.receipt?.confirmed_at ?? proof.receipt?.prepared_at)
|
|
3294
|
+
: proof.noop?.skipped_at;
|
|
3295
|
+
if (postedAt === undefined)
|
|
3296
|
+
return false;
|
|
3297
|
+
const graceMin = this.deps.cfg.daemon.round_posted_tail_grace_min ?? DEFAULT_ROUND_POSTED_TAIL_GRACE_MIN;
|
|
3298
|
+
const until = Date.parse(postedAt) + graceMin * 60_000;
|
|
3299
|
+
if (!Number.isFinite(until) || this.now().getTime() >= until)
|
|
3300
|
+
return false;
|
|
3301
|
+
const key = `${limit === "round_deadline" ? "deadline" : "stage"}:${round.id}`;
|
|
3302
|
+
if (!this.loggedOnce.has(key)) {
|
|
3303
|
+
this.loggedOnce.add(key);
|
|
3304
|
+
this.deps.store.logEvent("round_stage_posted_wait", round.pr, {
|
|
3305
|
+
round_id: round.id,
|
|
3306
|
+
round_no: round.round_no,
|
|
3307
|
+
stage: limit,
|
|
3308
|
+
detail,
|
|
3309
|
+
posting: proof.kind,
|
|
3310
|
+
posted_at: postedAt,
|
|
3311
|
+
wait_until: new Date(until).toISOString(),
|
|
3312
|
+
});
|
|
3313
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} hit ${limit} after its review was ${proof.kind === "confirmed" ? "posted" : "refused by guard 2"} — ` +
|
|
3314
|
+
`not a failure; the session's tail has until ${new Date(until).toISOString()}, nothing cancelled or killed`);
|
|
3315
|
+
}
|
|
3316
|
+
return true;
|
|
3317
|
+
}
|
|
3318
|
+
/** A `prepared` receipt for exactly this round: a POST whose outcome is not
|
|
3319
|
+
* known yet. */
|
|
3320
|
+
postInFlight(round) {
|
|
3321
|
+
const receipt = readPostingReceipt(this.postingReceiptDir(), round.pr, round.round_no);
|
|
3322
|
+
return (receipt !== undefined &&
|
|
3323
|
+
receipt.status === "prepared" &&
|
|
3324
|
+
receipt.posting_key === round.posting_key &&
|
|
3325
|
+
receipt.pr === round.pr &&
|
|
3326
|
+
receipt.round_no === round.round_no);
|
|
3327
|
+
}
|
|
3328
|
+
/** Only exact command-correlated pre-fanout failures get a changed-behavior
|
|
3329
|
+
* successor instruction. The retry epoch follows an automatic predecessor;
|
|
3330
|
+
* ordinary fresh work naturally starts again at epoch zero. */
|
|
3331
|
+
preFanoutRecovery(round, stage) {
|
|
3332
|
+
if (stage !== "first_pass_start")
|
|
3333
|
+
return undefined;
|
|
3334
|
+
const command = this.deps.store.sessionCommandForRound(round.pr, round.round_no);
|
|
3335
|
+
if (!command)
|
|
3336
|
+
return undefined;
|
|
3337
|
+
return {
|
|
3338
|
+
stage,
|
|
3339
|
+
source_command_id: command.id,
|
|
3340
|
+
retry_epoch: this.deps.store.roundRecoveryInstruction(round.pr, round.round_no)?.retry_epoch ?? 0,
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
3343
|
+
/**
|
|
3344
|
+
* Whether this round's review already went out: a posting the caller proved
|
|
3345
|
+
* from GitHub (`proven`), or a posting receipt other than a guard-2 refusal
|
|
3346
|
+
* — confirmed, or still owned by the daemon's delivery. A round whose
|
|
3347
|
+
* review went out is never redone, whatever else is wrong with it: a redo
|
|
3348
|
+
* would post a second review. The evidence's name (`proven` or the
|
|
3349
|
+
* receipt's status), or undefined when nothing went out.
|
|
3350
|
+
*/
|
|
3351
|
+
reviewWentOut(pr, roundNo, proven) {
|
|
3352
|
+
if (proven)
|
|
3353
|
+
return "proven";
|
|
3354
|
+
const receipt = readPostingReceipt(this.postingReceiptDir(), pr, roundNo);
|
|
3355
|
+
return receipt !== undefined && receipt.status !== "skipped" ? receipt.status : undefined;
|
|
3356
|
+
}
|
|
3357
|
+
/**
|
|
3358
|
+
* The verdict-time belt for required passes (src/requiredpasses.ts). Every
|
|
3359
|
+
* failed pass of the round is recorded (`round_pass_failures`) — the gap is
|
|
3360
|
+
* internal, never on the PR. When a required pass is missing and nothing
|
|
3361
|
+
* was posted for the round, the round is cancelled and requeued within
|
|
3362
|
+
* `max_self_requeues` (true: the caller returns); past the cap it ends on
|
|
3363
|
+
* the anomaly path, never with a verdict applied over the gap. A gap whose
|
|
3364
|
+
* review already went out (`reviewWentOut`) is logged and applied as
|
|
3365
|
+
* usual — a requeue would post a second review. Unknown evidence never requeues.
|
|
3366
|
+
*/
|
|
3367
|
+
async requeueOnRequiredPassGap(v, round, proven) {
|
|
3368
|
+
const { cfg, store, adapter } = this.deps;
|
|
3369
|
+
const progress = readRoundProgress(this.deps.root ?? sandboxRoot(), v.pr);
|
|
3370
|
+
const failures = failedPasses(progress, v.round_no);
|
|
3371
|
+
if (failures.length > 0)
|
|
3372
|
+
store.logEvent("round_pass_failures", v.pr, {
|
|
3373
|
+
round_id: round.id,
|
|
3374
|
+
round_no: v.round_no,
|
|
3375
|
+
failed: failures,
|
|
3376
|
+
});
|
|
3377
|
+
const gap = requiredPassGap(parsePlan(round.pass_plan), progress, v.round_no, parsePlan(round.sweep_plan));
|
|
3378
|
+
if (gap.state !== "missing")
|
|
3379
|
+
return false;
|
|
3380
|
+
const missing = describeGap(gap.missing);
|
|
3381
|
+
const posted = this.reviewWentOut(v.pr, v.round_no, proven);
|
|
3382
|
+
if (posted) {
|
|
3383
|
+
store.logEvent("required_pass_missing_after_post", v.pr, {
|
|
3384
|
+
round_id: round.id,
|
|
3385
|
+
round_no: v.round_no,
|
|
3386
|
+
receipt: posted,
|
|
3387
|
+
missing,
|
|
3388
|
+
});
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
store.logEvent("round_required_pass_missing", v.pr, {
|
|
3392
|
+
round_id: round.id,
|
|
3393
|
+
round_no: v.round_no,
|
|
3394
|
+
head_sha: v.head_sha,
|
|
3395
|
+
missing,
|
|
3396
|
+
});
|
|
3397
|
+
if (await this.selfRequeue(round, "required_pass_missing")) {
|
|
3398
|
+
await this.safe(() => adapter.removeLabel(v.pr, cfg.github.in_progress_label), `remove ${cfg.github.in_progress_label} on #${v.pr}`);
|
|
3399
|
+
this.log(`verdicts: #${v.pr} round ${v.round_no} missing required pass(es) ${missing} — requeued, nothing posted`);
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
await this.handleAnomaly(v.pr, v.round_no, `round ${v.round_no} is missing required pass(es) ${missing} and could not be self-requeued — cap exhausted or no running round; nothing was posted`, "overdue");
|
|
3403
|
+
return true;
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Stop a round's session before recovering it, then look for a verdict
|
|
3407
|
+
* that won while it was being stopped — the one fencing protocol the
|
|
3408
|
+
* self-requeue and the exhausted infrastructure recovery share. A wedged
|
|
3409
|
+
* (`alive_stuck`) or active-infrastructure session is killed; any other
|
|
3410
|
+
* has nothing left to stop. After a kill the read is definitive: no verdict
|
|
3411
|
+
* can appear once the session is gone; a settled parent's writes are over.
|
|
3412
|
+
* - `kill_failed`: the session could not be stopped (logged) — nothing
|
|
3413
|
+
* may be recovered over a session that may still write;
|
|
3414
|
+
* - `verdict_won`: a verdict file is on disk (logged) — it is the sweep's;
|
|
3415
|
+
* - `posting_won`: posting or its durable retry won; the ordinary receipt
|
|
3416
|
+
* settlement sweep owns it, never a replacement review;
|
|
3417
|
+
* - `fenced`: recover.
|
|
3418
|
+
*/
|
|
3419
|
+
async fenceForRecovery(round, mode) {
|
|
3420
|
+
const { store } = this.deps;
|
|
3421
|
+
const kill = this.deps.hooks.killSession;
|
|
3422
|
+
if ((mode === "alive_stuck" || mode === "infrastructure_active") && kill) {
|
|
3423
|
+
try {
|
|
3424
|
+
await kill.call(this.deps.hooks, round.pr);
|
|
3425
|
+
}
|
|
3426
|
+
catch (error) {
|
|
3427
|
+
store.logEvent("session_recovery_kill_failed", round.pr, {
|
|
3428
|
+
round_id: round.id,
|
|
3429
|
+
round_no: round.round_no,
|
|
3430
|
+
mode,
|
|
3431
|
+
error: String(error),
|
|
3432
|
+
});
|
|
3433
|
+
return "kill_failed";
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
const fencedModes = [
|
|
3437
|
+
"alive_stuck",
|
|
3438
|
+
"infrastructure_active",
|
|
3439
|
+
"infrastructure_settled",
|
|
3440
|
+
];
|
|
3441
|
+
if (fencedModes.includes(mode) && this.verdictFilePresent(round.pr, round.round_no)) {
|
|
3442
|
+
store.logEvent("round_stage_verdict_won", round.pr, {
|
|
3443
|
+
round_id: round.id,
|
|
3444
|
+
round_no: round.round_no,
|
|
3445
|
+
mode,
|
|
3446
|
+
});
|
|
3447
|
+
return "verdict_won";
|
|
3448
|
+
}
|
|
3449
|
+
if (fencedModes.includes(mode) && round.posting_key) {
|
|
3450
|
+
const proof = await this.postingProof(round);
|
|
3451
|
+
if (proof.kind === "confirmed" ||
|
|
3452
|
+
proof.kind === "skipped" ||
|
|
3453
|
+
(proof.kind === "waiting" &&
|
|
3454
|
+
(this.postInFlight(round) ||
|
|
3455
|
+
pendingPosting(this.postingReceiptDir(), round.pr, round.round_no, round.posting_key))) ||
|
|
3456
|
+
(proof.kind === "failed" &&
|
|
3457
|
+
(proof.failure === "ambiguous" ||
|
|
3458
|
+
proof.failure === "contradicted" ||
|
|
3459
|
+
proof.failure === "exhausted"))) {
|
|
3460
|
+
store.logEvent("round_stage_posting_won", round.pr, {
|
|
3461
|
+
round_id: round.id,
|
|
3462
|
+
round_no: round.round_no,
|
|
3463
|
+
mode,
|
|
3464
|
+
proof: proof.kind,
|
|
3465
|
+
});
|
|
3466
|
+
return "posting_won";
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
return "fenced";
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Self-requeue a stuck round. Returns true when the round was handled here
|
|
3473
|
+
* (cancelled + PR re-queued for a fresh dispatch through the normal pump —
|
|
3474
|
+
* same move as the overseer's rerun-round and the startup orphan requeue);
|
|
3475
|
+
* false when the per-head cap is exhausted (or the old session could not be
|
|
3476
|
+
* stopped) and the caller must take the anomaly path instead.
|
|
3477
|
+
*
|
|
3478
|
+
* Session-side only by construction: no label, no review, no gate call —
|
|
3479
|
+
* dry-run and live behave identically. The cancelled round keeps a NULL
|
|
3480
|
+
* verdict, so `priorRoundCount`/review_no never age (a failed round must
|
|
3481
|
+
* not age the PR). Held wakes stay held: the requeued round is about to
|
|
3482
|
+
* dispatch, and its own round-end paths flush them.
|
|
3483
|
+
*/
|
|
3484
|
+
selfRequeueCap() {
|
|
3485
|
+
return this.deps.cfg.daemon.max_self_requeues ?? DEFAULT_MAX_SELF_REQUEUES;
|
|
3486
|
+
}
|
|
3487
|
+
/** True when the per-head self-requeue budget has no attempt left. */
|
|
3488
|
+
selfRequeueSpent(pr, head) {
|
|
3489
|
+
return this.deps.store.selfRequeueCount(pr, head) >= this.selfRequeueCap();
|
|
3490
|
+
}
|
|
3491
|
+
async selfRequeue(round, mode = "dead", recovery) {
|
|
3492
|
+
const { store } = this.deps;
|
|
3493
|
+
const row = store.getPr(round.pr);
|
|
3494
|
+
const head = row?.head_sha ?? "";
|
|
3495
|
+
const cap = this.selfRequeueCap();
|
|
3496
|
+
const attempt = store.selfRequeueCount(round.pr, head) + 1;
|
|
3497
|
+
const exhausted = this.selfRequeueSpent(round.pr, head);
|
|
3498
|
+
const infrastructure = mode === "infrastructure_active" || mode === "infrastructure_settled";
|
|
3499
|
+
if (exhausted) {
|
|
3500
|
+
store.logEvent("self_requeue_cap_exhausted", round.pr, {
|
|
3501
|
+
round_id: round.id,
|
|
3502
|
+
round_no: round.round_no,
|
|
3503
|
+
head_sha: head,
|
|
3504
|
+
attempts: attempt - 1,
|
|
3505
|
+
cap,
|
|
3506
|
+
mode,
|
|
3507
|
+
});
|
|
3508
|
+
return false;
|
|
3509
|
+
}
|
|
3510
|
+
const reason = mode === "dead"
|
|
3511
|
+
? `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict and its session is dead`
|
|
3512
|
+
: mode === "alive_stuck"
|
|
3513
|
+
? `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict; its session was alive but wedged and has been killed for a fresh rebirth`
|
|
3514
|
+
: mode === "lost_prompt"
|
|
3515
|
+
? `round ${round.round_no}'s prompt left the inject dir but its receipt never appeared in the session transcript since the round started (${round.started_at}) — the prompt was dropped in delivery and redelivery is spent or impossible; redispatching`
|
|
3516
|
+
: mode === "required_pass_missing"
|
|
3517
|
+
? `round ${round.round_no} ended with a required pass (pass-baseline or a risk lens) failed and never completed — the session posted nothing; redispatching`
|
|
3518
|
+
: mode === "head_moved"
|
|
3519
|
+
? `round ${round.round_no} ended head_moved (guard 5): the PR advanced mid-round and the session correctly posted nothing — redispatching against the fresh head; the session is healthy and was not killed`
|
|
3520
|
+
: mode === "infrastructure_active" || mode === "infrastructure_settled"
|
|
3521
|
+
? `round ${round.round_no} failed a bounded daemon→Pi infrastructure stage before any merits verdict — redispatching through the ordinary pump`
|
|
3522
|
+
: `round ${round.round_no} verdicted NEEDS_HUMAN without executing any passes — the review was not done, so it is redone rather than handed to a person`;
|
|
3523
|
+
if (mode === "infrastructure_active" && !this.deps.hooks.killSession) {
|
|
3524
|
+
store.logEvent("session_recovery_kill_unavailable", round.pr, {
|
|
3525
|
+
round_id: round.id,
|
|
3526
|
+
round_no: round.round_no,
|
|
3527
|
+
mode,
|
|
3528
|
+
});
|
|
3529
|
+
return false;
|
|
3530
|
+
}
|
|
3531
|
+
const fence = await this.fenceForRecovery(round, mode);
|
|
3532
|
+
if (fence === "kill_failed") {
|
|
3533
|
+
this.log(`verdicts: refusing ${mode} requeue for #${round.pr}; old session could not be stopped`);
|
|
3534
|
+
return false;
|
|
3535
|
+
}
|
|
3536
|
+
// The verdict that won the fence is the 10s sweep's, never cancelled or
|
|
3537
|
+
// quarantined here. A wedged session's (`alive_stuck`) is handled — the
|
|
3538
|
+
// deadline path neither requeues nor fails the round; an infrastructure
|
|
3539
|
+
// stage's is not, and the stage is checked again.
|
|
3540
|
+
if (fence === "verdict_won" || fence === "posting_won")
|
|
3541
|
+
return mode === "alive_stuck";
|
|
3542
|
+
if (infrastructure && this.runningRound(round.pr, round.round_no)?.status !== "running")
|
|
3543
|
+
return true;
|
|
3544
|
+
const cause = SELF_REQUEUE_CAUSE[mode];
|
|
3545
|
+
const { publication, owed } = store.db
|
|
3546
|
+
.transaction(() => {
|
|
3547
|
+
const publication = store.cancelRoundAndQueueRecovery({
|
|
3548
|
+
pr: round.pr,
|
|
3549
|
+
round_id: round.id,
|
|
3550
|
+
round_no: round.round_no,
|
|
3551
|
+
summary: `self-requeue ${attempt}/${cap} — ${reason}`,
|
|
3552
|
+
cause,
|
|
3553
|
+
fallback_head_sha: head,
|
|
3554
|
+
trigger_ts: this.now().toISOString(),
|
|
3555
|
+
...(recovery
|
|
3556
|
+
? {
|
|
3557
|
+
recovery: {
|
|
3558
|
+
stage: recovery.stage,
|
|
3559
|
+
source_command_id: recovery.source_command_id,
|
|
3560
|
+
immediate_attempt: attempt,
|
|
3561
|
+
retry_epoch: recovery.retry_epoch,
|
|
3562
|
+
},
|
|
3563
|
+
}
|
|
3564
|
+
: {}),
|
|
3565
|
+
});
|
|
3566
|
+
// The cancel and the owed measurement commit together: a kill between
|
|
3567
|
+
// them cannot leave a cancelled round that nothing ever measures.
|
|
3568
|
+
return { publication, owed: publication ? this.oweMeasurement(round) : null };
|
|
3569
|
+
})
|
|
3570
|
+
.immediate();
|
|
3571
|
+
if (!publication || !owed)
|
|
3572
|
+
return false;
|
|
3573
|
+
// A cancelled round burned real tokens and stays in every spend aggregate
|
|
3574
|
+
// (`SUM(est_usd) FROM rounds` has no status filter), so it must be
|
|
3575
|
+
// measured. The window's end is fixed here, and the measurement owed from
|
|
3576
|
+
// here; the successor's window begins later, so the two never overlap.
|
|
3577
|
+
// The reads (async) run after this requeue's durable rows below, so a
|
|
3578
|
+
// successor dispatched while they run never sees the cancel without its
|
|
3579
|
+
// `round_self_requeued` row. From the deadline tick and the round-stage
|
|
3580
|
+
// supervisor they run in the background.
|
|
3581
|
+
if (isLoudCancelCause(cause)) {
|
|
3582
|
+
// The durable twin of the inject below. A round that stopped dead
|
|
3583
|
+
// inside its deadline window is a real defect; the redeploy orphan
|
|
3584
|
+
// sweep writes nothing here. `round_stopped_dead` is deliberately absent
|
|
3585
|
+
// from anomalies.ts WAKE_KINDS: the bespoke SELF-REQUEUE inject a few
|
|
3586
|
+
// lines down is this record's wake, and listing it there would inject
|
|
3587
|
+
// the same event twice.
|
|
3588
|
+
store.logAnomaly("round_stopped_dead", round.pr, round.round_no, `round ${round.round_no} stopped dead inside its deadline window (${round.started_at} → ${round.deadline_at}) and was cancelled; self-requeue attempt ${attempt}/${cap}`);
|
|
3589
|
+
}
|
|
3590
|
+
store.logEvent("round_self_requeued", round.pr, {
|
|
3591
|
+
cancel_cause: cause,
|
|
3592
|
+
round_id: round.id,
|
|
3593
|
+
round_no: round.round_no,
|
|
3594
|
+
head_sha: head,
|
|
3595
|
+
attempt,
|
|
3596
|
+
cap,
|
|
3597
|
+
mode,
|
|
3598
|
+
reason,
|
|
3599
|
+
});
|
|
3600
|
+
await this.closeMeasurement(owed, measureModeForRequeue(mode));
|
|
3601
|
+
if (!publication.queued) {
|
|
3602
|
+
store.logEvent("round_self_requeue_terminal", round.pr, {
|
|
3603
|
+
round_id: round.id,
|
|
3604
|
+
round_no: round.round_no,
|
|
3605
|
+
reason: "pr_closed",
|
|
3606
|
+
});
|
|
3607
|
+
return true;
|
|
3608
|
+
}
|
|
3609
|
+
this.log(`verdicts: SELF-REQUEUE #${round.pr} round ${round.round_no} attempt ${attempt}/${cap} — ${reason}`);
|
|
3610
|
+
await this.safe(async () => void (await this.deps.writeInbox?.([
|
|
3611
|
+
`SELF-REQUEUE (recovery) — PR #${round.pr} round ${round.round_no}, attempt ${attempt}/${cap}`,
|
|
3612
|
+
reason,
|
|
3613
|
+
"The daemon cancelled the round and re-queued the PR for a fresh dispatch through the normal path. No GitHub state was touched.",
|
|
3614
|
+
"This is a recovery path, not a normal one — if it recurs on the same PR, investigate the cause the line above names.",
|
|
3615
|
+
].join("\n"), "anomaly")), "inbox self-requeue");
|
|
3616
|
+
return true;
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Recover a round whose prompt never reached its session.
|
|
3620
|
+
*
|
|
3621
|
+
* Loss is judged from the receipt, never inferred from activity: a prompt
|
|
3622
|
+
* is dropped precisely because the agent is busy, and a busy agent always
|
|
3623
|
+
* has activity after round start. `roundPromptAccepted` is a fact (the
|
|
3624
|
+
* dispatched receipt token, echoed into the session JSONL only when pi
|
|
3625
|
+
* accepted the message), so `false` means the prompt is not there. It does
|
|
3626
|
+
* not depend on the head's self-requeue budget either: a `head_moved`
|
|
3627
|
+
* requeue spends one seconds before dispatching the round most likely to be
|
|
3628
|
+
* lost.
|
|
3629
|
+
*
|
|
3630
|
+
* Recovery is cheap first. A missing prompt is a delivery fault, so the
|
|
3631
|
+
* remedy is to deliver it again — same round, same session, same
|
|
3632
|
+
* conversation — and only a redelivery that also fails to land escalates to
|
|
3633
|
+
* the expensive path (a cancelled round, and for a live session a kill):
|
|
3634
|
+
* killing a healthy session because a file did not arrive destroys a live
|
|
3635
|
+
* conversation.
|
|
3636
|
+
*/
|
|
3637
|
+
async maybeRequeueLostPrompt(round, nowMs) {
|
|
3638
|
+
const { cfg, store } = this.deps;
|
|
3639
|
+
const hooks = this.deps.hooks;
|
|
3640
|
+
if (!hooks.roundPromptAccepted || !hooks.sessionAlive)
|
|
3641
|
+
return;
|
|
3642
|
+
// A round dispatched before receipts existed carries no marker, so its
|
|
3643
|
+
// delivery is unknowable — never guess at it.
|
|
3644
|
+
if (!round.prompt_marker)
|
|
3645
|
+
return;
|
|
3646
|
+
const graceMs = (cfg.daemon.round_stall_grace_min ?? DEFAULT_ROUND_STALL_GRACE_MIN) * 60_000;
|
|
3647
|
+
const startedMs = Date.parse(round.started_at);
|
|
3648
|
+
if (!Number.isFinite(startedMs) || nowMs - startedMs < graceMs)
|
|
3649
|
+
return;
|
|
3650
|
+
if (store.hasLaterRound(round.pr, round.round_no))
|
|
3651
|
+
return; // superseded — the deadline sweep owns that case
|
|
3652
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
3653
|
+
return;
|
|
3654
|
+
const clearSilent = () => {
|
|
3655
|
+
if (round.silent_since)
|
|
3656
|
+
store.clearRoundSilent(round.id);
|
|
3657
|
+
};
|
|
3658
|
+
try {
|
|
3659
|
+
const accepted = await hooks.roundPromptAccepted.call(hooks, round.pr, round.round_no, startedMs);
|
|
3660
|
+
if (accepted !== false) {
|
|
3661
|
+
// true = delivered and the session owns it; null = no evidence either
|
|
3662
|
+
// way. Neither is loss.
|
|
3663
|
+
clearSilent();
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
catch {
|
|
3668
|
+
clearSilent(); // an unreadable signal is not evidence
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
// Two consecutive observations before acting. The receipt is a fact, but
|
|
3672
|
+
// the window around it is not: a prompt delivered a moment ago may not yet
|
|
3673
|
+
// be flushed to the JSONL, and re-sending a round the session has already
|
|
3674
|
+
// started is worse than waiting one more tick.
|
|
3675
|
+
if (!round.silent_since) {
|
|
3676
|
+
store.markRoundSilent(round.id);
|
|
3677
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} shows no prompt receipt — marked; recovering on the next sweep if it is still missing`);
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
store.logEvent("round_prompt_lost", round.pr, {
|
|
3681
|
+
round_id: round.id,
|
|
3682
|
+
round_no: round.round_no,
|
|
3683
|
+
started_at: round.started_at,
|
|
3684
|
+
silent_since: round.silent_since,
|
|
3685
|
+
redeliveries: round.redeliveries,
|
|
3686
|
+
});
|
|
3687
|
+
// ---- cheap recovery first: deliver the same order again ----------------
|
|
3688
|
+
if ((await this.redeliverPrompt(round, "before_deadline")) === "redelivered")
|
|
3689
|
+
return;
|
|
3690
|
+
// ---- redelivery is spent or impossible: the expensive path -------------
|
|
3691
|
+
await this.selfRequeue(round, "lost_prompt");
|
|
3692
|
+
}
|
|
3693
|
+
/** Deadline sweep — a running round past `deadline_at` is an anomaly.
|
|
3694
|
+
* A superseded round (a newer round exists on the same PR) is cancelled
|
|
3695
|
+
* silently instead: its PR state, labels and reviewer requests belong to
|
|
3696
|
+
* the newer round, and yanking them here would corrupt live reviews.
|
|
3697
|
+
* An overdue round with no verdict whose session is dead (or alive and
|
|
3698
|
+
* wedged) is self-requeued, capped per head.
|
|
3699
|
+
*
|
|
3700
|
+
* Never overlaps itself: a tick still running
|
|
3701
|
+
* — it awaits GitHub reads and, on the anomaly path, the round's measurement
|
|
3702
|
+
* — makes the next one a no-op, as the verdict sweep's per-file guards do
|
|
3703
|
+
* for it. */
|
|
3704
|
+
async checkDeadlines() {
|
|
3705
|
+
if (this.deadlinesInFlight)
|
|
3706
|
+
return [];
|
|
3707
|
+
this.deadlinesInFlight = true;
|
|
3708
|
+
try {
|
|
3709
|
+
return await this.checkDeadlinesOnce();
|
|
3710
|
+
}
|
|
3711
|
+
finally {
|
|
3712
|
+
this.deadlinesInFlight = false;
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
async checkDeadlinesOnce() {
|
|
3716
|
+
const overdue = [];
|
|
3717
|
+
const nowMs = this.now().getTime();
|
|
3718
|
+
const { store } = this.deps;
|
|
3719
|
+
await this.flushStrandedWakes();
|
|
3720
|
+
for (const round of store.runningRounds()) {
|
|
3721
|
+
if (Date.parse(round.deadline_at) > nowMs) {
|
|
3722
|
+
// Lost-prompt net, well before the deadline: the round's inject was
|
|
3723
|
+
// consumed (nothing pending, so the bridge is not merely retrying),
|
|
3724
|
+
// no verdict is on disk, and the session's JSONL carries no receipt
|
|
3725
|
+
// for the prompt. Redelivered first, then requeued through the same
|
|
3726
|
+
// capped path (`maybeRequeueLostPrompt`).
|
|
3727
|
+
await this.maybeRequeueLostPrompt(round, nowMs);
|
|
3728
|
+
continue;
|
|
3729
|
+
}
|
|
3730
|
+
if (store.hasLaterRound(round.pr, round.round_no)) {
|
|
3731
|
+
store.markRoundCancelled(round.id, `superseded — round ${round.round_no} outlived by a newer round`, "superseded");
|
|
3732
|
+
store.logEvent("round_superseded", round.pr, {
|
|
3733
|
+
round_id: round.id,
|
|
3734
|
+
round_no: round.round_no,
|
|
3735
|
+
});
|
|
3736
|
+
// Slack: "read the newest one" — no existing review is touched, ever
|
|
3737
|
+
// (AGENTS.md invariant 2); the wording invariant lives in messages.ts.
|
|
3738
|
+
const newest = store
|
|
3739
|
+
.roundsFor(round.pr)
|
|
3740
|
+
.reduce((a, b) => (b.round_no > a.round_no ? b : a), round);
|
|
3741
|
+
await this.announceSafe((a) => a.superseded({
|
|
3742
|
+
pr: round.pr,
|
|
3743
|
+
old_round_no: round.round_no,
|
|
3744
|
+
new_round_no: newest.round_no,
|
|
3745
|
+
head_sha: store.getPr(round.pr)?.head_sha ?? "",
|
|
3746
|
+
}));
|
|
3747
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} cancelled (superseded)`);
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
if (round.posting_key && !this.verdictFilePresent(round.pr, round.round_no)) {
|
|
3751
|
+
const proof = await this.postingProof(round);
|
|
3752
|
+
if (proof.kind === "waiting")
|
|
3753
|
+
continue;
|
|
3754
|
+
// The deadline honours the posted-tail grace the stage limits give:
|
|
3755
|
+
// settling minutes after the post would end the round under a session
|
|
3756
|
+
// still writing its tail, and the supervisor's terminal-round path
|
|
3757
|
+
// would then kill it, losing its summary and pass telemetry. Past the
|
|
3758
|
+
// grace it settles; a verdict file that lands within it is the
|
|
3759
|
+
// sweep's.
|
|
3760
|
+
if ((proof.kind === "skipped" || proof.kind === "confirmed") &&
|
|
3761
|
+
this.inPostedTailGrace(round, proof, "round_deadline", `round ${round.round_no} passed its deadline (${round.deadline_at}) with no verdict file`))
|
|
3762
|
+
continue;
|
|
3763
|
+
if (proof.kind === "skipped") {
|
|
3764
|
+
// Refused by guard 2, no verdict file by the deadline. Not
|
|
3765
|
+
// stuck, not failed — settled, and a late verdict file replays inertly.
|
|
3766
|
+
await this.settleGuardedNoop(round, proof.noop, "deadline");
|
|
3767
|
+
continue;
|
|
3768
|
+
}
|
|
3769
|
+
if (proof.kind === "confirmed" && proof.receipt) {
|
|
3770
|
+
// The review is on GitHub; the missing verdict file is settled from
|
|
3771
|
+
// the receipt, never re-run (a rerun would post a second review). A
|
|
3772
|
+
// receipt that does not say what was decided keeps the loud path.
|
|
3773
|
+
const receipt = proof.receipt;
|
|
3774
|
+
const decision = decidedVerdictFromReceipt(receipt);
|
|
3775
|
+
if (!decision) {
|
|
3776
|
+
await this.failPostingProofUnlessVerdict(round, `GitHub review ${receipt.gh_review_id ?? "confirmed"} was posted (${receipt.actual_event}) but no verdict file arrived before the round deadline, and the posting receipt does not say what was decided`, "posted_without_verdict");
|
|
3777
|
+
continue;
|
|
3778
|
+
}
|
|
3779
|
+
await this.withVerdictSlot(round.pr, round.round_no, async () => {
|
|
3780
|
+
if (this.verdictFilePresent(round.pr, round.round_no))
|
|
3781
|
+
return;
|
|
3782
|
+
await this.settleFromReceipt(round, receipt, decision, "deadline", `round ${round.round_no} passed its deadline (${round.deadline_at}) with its review posted and no verdict file`);
|
|
3783
|
+
});
|
|
3784
|
+
continue;
|
|
3785
|
+
}
|
|
3786
|
+
if (proof.kind === "failed") {
|
|
3787
|
+
await this.failPostingProofUnlessVerdict(round, proof.detail ?? "round ended without posting confirmation", "posting_receipt");
|
|
3788
|
+
continue;
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
const stuck = await this.stuckDisposition(round);
|
|
3792
|
+
if (stuck === "verdict_pending") {
|
|
3793
|
+
// A real verdict is on disk — the sweep consumes it; do nothing here.
|
|
3794
|
+
this.log(`verdicts: #${round.pr} round ${round.round_no} overdue but its verdict file is pending — leaving it to the sweep`);
|
|
3795
|
+
continue;
|
|
3796
|
+
}
|
|
3797
|
+
// A live session that provably never got the order: deliver it again
|
|
3798
|
+
// rather than kill a healthy conversation. Only when redelivery
|
|
3799
|
+
// is spent or impossible does this fall through to the expensive path.
|
|
3800
|
+
if (stuck === "never_delivered" && (await this.recoverNeverDelivered(round)))
|
|
3801
|
+
continue;
|
|
3802
|
+
const expensive = stuck === "dead" || stuck === "alive_stuck" || stuck === "never_delivered";
|
|
3803
|
+
if (expensive &&
|
|
3804
|
+
(await this.selfRequeue(round, stuck === "never_delivered" ? "lost_prompt" : stuck)))
|
|
3805
|
+
continue;
|
|
3806
|
+
overdue.push(round.pr);
|
|
3807
|
+
const spent = expensive && this.selfRequeueSpent(round.pr, store.getPr(round.pr)?.head_sha ?? "");
|
|
3808
|
+
await this.handleAnomaly(round.pr, round.round_no, `round ${round.round_no} passed its deadline (${round.deadline_at}); started ${round.started_at}` +
|
|
3809
|
+
(spent
|
|
3810
|
+
? " — the self-requeue cap is exhausted; parking"
|
|
3811
|
+
: expensive
|
|
3812
|
+
? " — recovery could not proceed (session not stopped or no recovery hook); parking"
|
|
3813
|
+
: ""), "overdue");
|
|
3814
|
+
}
|
|
3815
|
+
return overdue;
|
|
3816
|
+
}
|
|
3817
|
+
/** Slack is a side channel: absent announcer ⇒ nothing happens, and a Slack
|
|
3818
|
+
* failure is logged, never propagated into the verdict path. */
|
|
3819
|
+
async announceSafe(fn) {
|
|
3820
|
+
const announcer = this.deps.announce;
|
|
3821
|
+
if (!announcer)
|
|
3822
|
+
return;
|
|
3823
|
+
try {
|
|
3824
|
+
await fn(announcer);
|
|
3825
|
+
}
|
|
3826
|
+
catch (err) {
|
|
3827
|
+
this.log("verdicts: slack announcement failed", err);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
async safe(fn, what) {
|
|
3831
|
+
try {
|
|
3832
|
+
return await fn();
|
|
3833
|
+
}
|
|
3834
|
+
catch (err) {
|
|
3835
|
+
this.log(`verdicts: ${what} failed`, err);
|
|
3836
|
+
// A side channel's failure record must not become a failure of its
|
|
3837
|
+
// own: the delayed round tail can outlive the store it writes to.
|
|
3838
|
+
try {
|
|
3839
|
+
this.deps.store.logEvent("verdict_action_failed", null, { what, error: String(err) });
|
|
3840
|
+
}
|
|
3841
|
+
catch (logErr) {
|
|
3842
|
+
this.log(`verdicts: ${what} failure not recorded`, logErr);
|
|
3843
|
+
}
|
|
3844
|
+
return undefined;
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
//# sourceMappingURL=verdicts.js.map
|