@repo-os/repoos 0.5.45
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.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background watchdog for `active` and `review` tasks whose agent session is
|
|
3
|
+
* dead or stalled (#0180/#0286).
|
|
4
|
+
*
|
|
5
|
+
* ACTIVE tasks (#0180): a task sitting silently in `active` — no live agent
|
|
6
|
+
* process, no recent activity — is detected, the reason it stopped is recorded
|
|
7
|
+
* in the task transcript (agent crashed / exited without a handoff / never
|
|
8
|
+
* started), and it is surfaced by auto-transitioning to a visible state.
|
|
9
|
+
*
|
|
10
|
+
* A task is considered stuck for the ACTIVE path when:
|
|
11
|
+
* - status === 'active'
|
|
12
|
+
* - No agent process is running (`runner.isRunning(id)` === false) and no
|
|
13
|
+
* server-side handoff is finalizing
|
|
14
|
+
* - Not already flagged `needsInput` (the human is on it)
|
|
15
|
+
* - Not deliberately paused (0070) — `runner.isPaused(id)` is true
|
|
16
|
+
* - No activity since the most recent `status →active` transition (or no
|
|
17
|
+
* activity at all) for longer than the staleness threshold
|
|
18
|
+
*
|
|
19
|
+
* REVIEW tasks (#0286): a task sitting in `review` whose REVIEWER agent session
|
|
20
|
+
* died silently — no report, no completion log entry, no running process — is
|
|
21
|
+
* detected the same way. The recovery differs: a fresh reviewer run is kicked
|
|
22
|
+
* off (the same action as the human's "Review again"), leaving an Activity
|
|
23
|
+
* trail. If the auto-retry is exhausted and the task is still stuck (no report,
|
|
24
|
+
* no running review), it is escalated to `needsInput` for a human rather than
|
|
25
|
+
* looped.
|
|
26
|
+
*
|
|
27
|
+
* On detection of an ACTIVE task the watchdog:
|
|
28
|
+
* 1. Classifies the failure reason — `never-started` (no session ever began),
|
|
29
|
+
* `crashed` (the persisted handoff log shows an interrupted turn), or
|
|
30
|
+
* `exited-without-handoff` (a session ran, its process ended, but no clean
|
|
31
|
+
* handoff signal followed).
|
|
32
|
+
* 2. Records that reason in the task's Activity log.
|
|
33
|
+
* 3. Surfaces the task by auto-transitioning it out of the silent `active`
|
|
34
|
+
* state (`watchdog.autoTransition`, default on): to `review` when the
|
|
35
|
+
* worktree holds work (committed or uncommitted — reviewable, never to be
|
|
36
|
+
* silently discarded, the #0172 lesson), else back to `ready` where it can
|
|
37
|
+
* be picked up again. The write goes through the live index, so the board
|
|
38
|
+
* updates and the existing ntfy status-change notification fires. When
|
|
39
|
+
* auto-transition is disabled it falls back to `needsInput` (the #0156
|
|
40
|
+
* escalation), which fires the existing needs-input notification.
|
|
41
|
+
*
|
|
42
|
+
* Guards:
|
|
43
|
+
* - Never fires while the server is mid-reload (`canRun` is observed every
|
|
44
|
+
* scan — the reload manager reports `isReloading()` during handover).
|
|
45
|
+
* - Never marks a task whose agent is legitimately paused.
|
|
46
|
+
* - Bounded: a task is surfaced/retried at most once per stuck episode
|
|
47
|
+
* (persisted Activity marker, so the bound survives reloads; the in-memory
|
|
48
|
+
* set is only a fast path).
|
|
49
|
+
*/
|
|
50
|
+
import { writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
51
|
+
import { execFileSync } from "node:child_process";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { parseTask, serializeTask, recordChange } from "../core/task.js";
|
|
54
|
+
import { commitTaskFile, worktreeStatus, worktreePathForBranch } from "../core/git.js";
|
|
55
|
+
import { scheduleHandoffSignalRetry } from "./handoff.js";
|
|
56
|
+
const DEFAULT_STALENESS_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
57
|
+
const WATCHDOG_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
|
58
|
+
/** Activity-log marker the watchdog writes/reads to bound surfacing once. */
|
|
59
|
+
const SURFACED_ACTIVITY = /watchdog: auto-surfaced stuck task/;
|
|
60
|
+
const ESCALATED_ACTIVITY = /watchdog: escalated to needs_input/;
|
|
61
|
+
/** Activity-log marker the watchdog writes/reads to bound reviewer retry once. */
|
|
62
|
+
const REVIEW_RETRIED_ACTIVITY = /watchdog: auto-retried dead reviewer session/;
|
|
63
|
+
const HANDOFF_FAILURE_ACTIVITY = /handoff failed · (.+)$/m;
|
|
64
|
+
const HANDOFF_RETAINED = /handoff retained for recovery/;
|
|
65
|
+
const HANDOFF_RECOVERY_ATTEMPTED = /handoff recovery attempted/;
|
|
66
|
+
/**
|
|
67
|
+
* Best-effort next-step suggestion for an escalation note, matched from the
|
|
68
|
+
* captured failure reason. A generic fallback beats a wrong diagnosis.
|
|
69
|
+
*/
|
|
70
|
+
export function suggestNextStep(reason) {
|
|
71
|
+
const r = reason.toLowerCase();
|
|
72
|
+
if (/retained for recovery/.test(r)) {
|
|
73
|
+
return "the handoff request was retained for recovery — it will be finalized automatically on the next server start";
|
|
74
|
+
}
|
|
75
|
+
if (/recovery attempted.*finalization failed/.test(r)) {
|
|
76
|
+
return "handoff recovery was attempted but failed — open the task and resume the session manually to finish and hand off";
|
|
77
|
+
}
|
|
78
|
+
if (/permission|approval|allowed|confirm|prompt/.test(r)) {
|
|
79
|
+
return ("check the transcript for an unanswered permission/approval prompt — see " +
|
|
80
|
+
"docs/adr/0005-agents-use-repoos-apis-for-privileged-operations.md");
|
|
81
|
+
}
|
|
82
|
+
if (/signal|handoff|rendered|ansi|kiro/.test(r)) {
|
|
83
|
+
return ("the handoff signal may not have been emitted on its own line — the agent's " +
|
|
84
|
+
"final line must be exactly `::repoos-handoff-ready::` (see #0154/#0155 for " +
|
|
85
|
+
"signal-line rendering bugs)");
|
|
86
|
+
}
|
|
87
|
+
if (/interrupted|stopped|killed|crash|exit/.test(r)) {
|
|
88
|
+
return "the agent turn was interrupted — open the task and resume the session in its worktree to finish and hand off";
|
|
89
|
+
}
|
|
90
|
+
if (/timeout|stall|hung|hang|busy/.test(r)) {
|
|
91
|
+
return "the agent stalled or timed out — see DEFAULT_STALL_TIMEOUT_MS in src/server/agents.ts";
|
|
92
|
+
}
|
|
93
|
+
return "resume the session manually from the task's worktree and check for uncommitted work";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pure stuck-detection over a task's Activity log. True when the log has a
|
|
97
|
+
* `status →active` transition and no Activity entry after it for at least
|
|
98
|
+
* `stalenessMs` (a task with zero entries after going active is the purest
|
|
99
|
+
* stuck shape — its clock runs from the transition itself).
|
|
100
|
+
*/
|
|
101
|
+
export function isStuckActiveTask(body, stalenessMs, now) {
|
|
102
|
+
const transitionAt = lastStatusTransitionTime(body, "active");
|
|
103
|
+
if (transitionAt === null)
|
|
104
|
+
return false;
|
|
105
|
+
return now - lastActivityTime(body, transitionAt) >= stalenessMs;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Pure stuck-detection over a task's Activity log for the `review` status.
|
|
109
|
+
* Mirrors `isStuckActiveTask` but bounds the episode by the most recent
|
|
110
|
+
* `status →review` transition: a review task is candidate-stuck when no
|
|
111
|
+
* Activity entry after it (or none at all) exists for at least `stalenessMs`.
|
|
112
|
+
*
|
|
113
|
+
* This is deliberately only the Activity-log staleness half of the decision.
|
|
114
|
+
* The "the review actually completed" half (a report file exists) and the
|
|
115
|
+
* "a review is genuinely in flight" half live in `isStuck`, because they need
|
|
116
|
+
* the `ReviewManager` and filesystem, not just the body string.
|
|
117
|
+
*/
|
|
118
|
+
export function isStuckReviewTask(body, stalenessMs, now) {
|
|
119
|
+
const transitionAt = lastStatusTransitionTime(body, "review");
|
|
120
|
+
if (transitionAt === null)
|
|
121
|
+
return false;
|
|
122
|
+
return now - lastActivityTime(body, transitionAt) >= stalenessMs;
|
|
123
|
+
}
|
|
124
|
+
/** Directories never worth scanning for agent activity: irrelevant or huge. */
|
|
125
|
+
const ACTIVITY_SCAN_SKIP = new Set([".git", "node_modules"]);
|
|
126
|
+
/** Bound on files inspected per scan — a stuck-check must never become slow. */
|
|
127
|
+
const ACTIVITY_SCAN_FILE_LIMIT = 2000;
|
|
128
|
+
/**
|
|
129
|
+
* True when any file in `dir` was modified within `withinMs` of `now`.
|
|
130
|
+
* Depth-first, bounded by `ACTIVITY_SCAN_FILE_LIMIT`, returns at the first
|
|
131
|
+
* qualifying file found rather than walking the whole tree.
|
|
132
|
+
*/
|
|
133
|
+
function hasRecentMtime(dir, withinMs, now, budget) {
|
|
134
|
+
let entries;
|
|
135
|
+
try {
|
|
136
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
for (const e of entries) {
|
|
142
|
+
if (budget.left <= 0)
|
|
143
|
+
return false;
|
|
144
|
+
if (ACTIVITY_SCAN_SKIP.has(e.name))
|
|
145
|
+
continue;
|
|
146
|
+
const full = join(dir, e.name);
|
|
147
|
+
if (e.isDirectory()) {
|
|
148
|
+
if (hasRecentMtime(full, withinMs, now, budget))
|
|
149
|
+
return true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!e.isFile())
|
|
153
|
+
continue;
|
|
154
|
+
budget.left--;
|
|
155
|
+
try {
|
|
156
|
+
if (now - statSync(full).mtimeMs <= withinMs)
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* file vanished mid-scan — not evidence either way */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* True when a task's worktree has a file modified within `stalenessMs` of
|
|
167
|
+
* `now` — evidence of a genuinely live agent independent of the in-memory
|
|
168
|
+
* runner registry.
|
|
169
|
+
*
|
|
170
|
+
* The registry (`AgentRunner.isRunning`) is a plain in-memory Map: it goes
|
|
171
|
+
* empty on every server restart, with no record of which PID belonged to
|
|
172
|
+
* which task (#0214). During this repo's own reload-churn-heavy sessions that
|
|
173
|
+
* makes `isRunning()` report false for a still-working agent constantly, not
|
|
174
|
+
* as a rare edge case — the exact false-positive #0203 was filed against
|
|
175
|
+
* ("the agent was never dead: it kept running... and kept writing source
|
|
176
|
+
* files"). The registry cannot be trusted alone; the worktree's own file
|
|
177
|
+
* mtimes are a signal that survives a restart because they live on disk, not
|
|
178
|
+
* in server memory.
|
|
179
|
+
*/
|
|
180
|
+
export function hasRecentWorktreeActivity(root, branch, stalenessMs, now) {
|
|
181
|
+
if (!branch)
|
|
182
|
+
return false;
|
|
183
|
+
const path = worktreePathForBranch(root, branch);
|
|
184
|
+
if (!path)
|
|
185
|
+
return false;
|
|
186
|
+
return hasRecentMtime(path, stalenessMs, now, { left: ACTIVITY_SCAN_FILE_LIMIT });
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Classify why an active task's agent session is dead. The `task` is the
|
|
190
|
+
* freshly-parsed on-disk Task (its Activity log may carry a persisted
|
|
191
|
+
* `handoff failed · …` entry); `runner.output(id)` reveals whether the task
|
|
192
|
+
* ever had a session (in-memory or persisted) at all.
|
|
193
|
+
*/
|
|
194
|
+
export function classifyDeadAgentReason(task, runner) {
|
|
195
|
+
// A persisted handoff failure is the strongest signal: the turn reached the
|
|
196
|
+
// handoff boundary but its process ended uncleanly (or the task could not be
|
|
197
|
+
// resolved) — the "killed mid-turn" shape.
|
|
198
|
+
const failed = extractHandoffFailure(task.body);
|
|
199
|
+
if (failed) {
|
|
200
|
+
// Check if this is a check failure that has been exhausted
|
|
201
|
+
if (/check failed after \d+ automatic retries/.test(failed)) {
|
|
202
|
+
return {
|
|
203
|
+
kind: "crashed",
|
|
204
|
+
reason: `check-failed-after-retries · ${failed}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Recovery was attempted but finalization failed — surface that outcome,
|
|
208
|
+
// not the original "retained for recovery" which is now stale (#0235).
|
|
209
|
+
if (HANDOFF_RECOVERY_ATTEMPTED.test(task.body)) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "crashed",
|
|
212
|
+
reason: "handoff recovery was attempted after an interrupted turn but finalization failed — manual intervention needed",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// A "retained for recovery" failure means the request was persisted and
|
|
216
|
+
// will be re-fired on the next server boot — not truly crashed (#0235).
|
|
217
|
+
if (HANDOFF_RETAINED.test(failed)) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "crashed",
|
|
220
|
+
reason: "agent turn was interrupted with a pending handoff — retained for recovery on next server start",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
kind: "crashed",
|
|
225
|
+
reason: `agent crashed or was interrupted mid-turn — ${failed}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// No session ever existed: the task went active but no agent process was
|
|
229
|
+
// ever launched (or its session was never persisted).
|
|
230
|
+
if (!runner.output(task.id)) {
|
|
231
|
+
return {
|
|
232
|
+
kind: "never-started",
|
|
233
|
+
reason: "agent never started — no session exists for this task",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// A session ran and ended, but the agent never emitted a clean handoff.
|
|
237
|
+
return {
|
|
238
|
+
kind: "exited-without-handoff",
|
|
239
|
+
reason: "agent exited without emitting the handoff signal",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Where a stuck task lands when surfaced: `review` whenever its worktree holds
|
|
244
|
+
* any work (committed branch commits OR uncommitted changes — either way it is
|
|
245
|
+
* reviewable, and moving it to `ready` could discard it on a clean restart),
|
|
246
|
+
* else `ready` to go back in the pile. Without a branch/worktree there is no
|
|
247
|
+
* work to look at, so `ready`.
|
|
248
|
+
*/
|
|
249
|
+
export function autoTransitionTarget(config, task) {
|
|
250
|
+
if (!task.branch)
|
|
251
|
+
return "ready";
|
|
252
|
+
if (task.hotfix) {
|
|
253
|
+
try {
|
|
254
|
+
const out = execFileSync("git", ["status", "--porcelain"], {
|
|
255
|
+
cwd: config.root,
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
timeout: 4000,
|
|
258
|
+
});
|
|
259
|
+
return out.trim() ? "review" : "ready";
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return "ready";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const status = worktreeStatus(config.root, task.branch);
|
|
266
|
+
return status.dirty ? "review" : "ready";
|
|
267
|
+
}
|
|
268
|
+
/** The most recent persisted handoff-failure reason in an Activity log, if any. */
|
|
269
|
+
function extractHandoffFailure(body) {
|
|
270
|
+
const match = body.match(HANDOFF_FAILURE_ACTIVITY);
|
|
271
|
+
return match?.[1]?.trim() || null;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Whether the task's Activity log already shows a watchdog surface/escalation
|
|
275
|
+
* SINCE the most recent `status →active` transition. Scoped the same way
|
|
276
|
+
* `isStuckActiveTask`/`lastActivityTime` already are — an unscoped whole-body
|
|
277
|
+
* match was a confirmed bug (#0271 follow-up, task #0243): a task surfaced
|
|
278
|
+
* once, then restarted to `active` again (by a human or a fresh run), can get
|
|
279
|
+
* stuck a second time — but the OLD marker from the first, already-resolved
|
|
280
|
+
* session never leaves the append-only Activity log, so an unscoped check
|
|
281
|
+
* permanently disables the watchdog for that task for the rest of its life.
|
|
282
|
+
* #0243 sat silently `active` for 4+ days this way. Scanning only entries
|
|
283
|
+
* after the current transition-into-active line fixes it: each active
|
|
284
|
+
* session gets its own fresh "not yet surfaced" state.
|
|
285
|
+
*/
|
|
286
|
+
function alreadySurfaced(body) {
|
|
287
|
+
const lines = body.split("\n");
|
|
288
|
+
const activityIndex = lines.findIndex((line) => line.trim() === "## Activity");
|
|
289
|
+
if (activityIndex === -1)
|
|
290
|
+
return false;
|
|
291
|
+
for (let i = lines.length - 1; i > activityIndex; i--) {
|
|
292
|
+
const line = lines[i];
|
|
293
|
+
if (SURFACED_ACTIVITY.test(line) || ESCALATED_ACTIVITY.test(line))
|
|
294
|
+
return true;
|
|
295
|
+
// The most recent transition INTO active bounds this session — anything
|
|
296
|
+
// above it (further back) belongs to an earlier, already-resolved one.
|
|
297
|
+
if (/^- \d{4}-\d{2}-\d{2}T\S+ · status [a-z_]+→active\b/.test(line))
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Whether the watchdog has already auto-retried a dead REVIEWER since the most
|
|
304
|
+
* recent `status →review` transition. Scoped exactly like `alreadySurfaced`:
|
|
305
|
+
* scanning only entries after the current transition-into-review keeps each
|
|
306
|
+
* review episode's retry budget (at most one) independent of earlier episodes
|
|
307
|
+
* the append-only Activity log never forgets.
|
|
308
|
+
*/
|
|
309
|
+
function alreadyRetriedReview(body) {
|
|
310
|
+
const lines = body.split("\n");
|
|
311
|
+
const activityIndex = lines.findIndex((line) => line.trim() === "## Activity");
|
|
312
|
+
if (activityIndex === -1)
|
|
313
|
+
return false;
|
|
314
|
+
for (let i = lines.length - 1; i > activityIndex; i--) {
|
|
315
|
+
const line = lines[i];
|
|
316
|
+
if (REVIEW_RETRIED_ACTIVITY.test(line))
|
|
317
|
+
return true;
|
|
318
|
+
// The most recent transition INTO review bounds this episode.
|
|
319
|
+
if (/^- \d{4}-\d{2}-\d{2}T\S+ · status [a-z_]+→review\b/.test(line))
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Whether a stored review report belongs to the CURRENT review episode — i.e.
|
|
326
|
+
* its `at` timestamp post-dates the most recent `status →review` transition.
|
|
327
|
+
*
|
|
328
|
+
* The report file is never cleared when a new review round starts (only the
|
|
329
|
+
* session conversation is reset, #0110), so an old report from a PRIOR round
|
|
330
|
+
* still reads back and would otherwise mask a current dead reviewer. Comparing
|
|
331
|
+
* against the episode's transition time makes "a report exists" mean "this
|
|
332
|
+
* round's review really completed" instead of "some report ever existed".
|
|
333
|
+
*/
|
|
334
|
+
function isReportForCurrentEpisode(reportAt, body) {
|
|
335
|
+
const transitionAt = lastStatusTransitionTime(body, "review");
|
|
336
|
+
if (transitionAt === null)
|
|
337
|
+
return true; // no transition on record — assume current
|
|
338
|
+
const t = Date.parse(reportAt);
|
|
339
|
+
if (Number.isNaN(t))
|
|
340
|
+
return false;
|
|
341
|
+
return t >= transitionAt;
|
|
342
|
+
}
|
|
343
|
+
export class TaskWatchdog {
|
|
344
|
+
config;
|
|
345
|
+
index;
|
|
346
|
+
runner;
|
|
347
|
+
autoTransition;
|
|
348
|
+
canRun;
|
|
349
|
+
reviews;
|
|
350
|
+
timer = null;
|
|
351
|
+
stalenessThresholdMs;
|
|
352
|
+
/** Re-entry guard: a scan already in flight is skipped by the next tick. */
|
|
353
|
+
scanning = false;
|
|
354
|
+
constructor(config, index, runner, stalenessThresholdMs = DEFAULT_STALENESS_THRESHOLD_MS, opts = {}) {
|
|
355
|
+
this.config = config;
|
|
356
|
+
this.index = index;
|
|
357
|
+
this.runner = runner;
|
|
358
|
+
this.stalenessThresholdMs = stalenessThresholdMs;
|
|
359
|
+
this.autoTransition = opts.autoTransition !== false;
|
|
360
|
+
this.canRun = opts.canRun;
|
|
361
|
+
this.reviews = opts.reviews;
|
|
362
|
+
}
|
|
363
|
+
start() {
|
|
364
|
+
if (this.timer)
|
|
365
|
+
return;
|
|
366
|
+
this.timer = setInterval(() => {
|
|
367
|
+
void this.checkNow();
|
|
368
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
369
|
+
}
|
|
370
|
+
stop() {
|
|
371
|
+
if (this.timer) {
|
|
372
|
+
clearInterval(this.timer);
|
|
373
|
+
this.timer = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Run one full scan now (used by the timer and by tests). */
|
|
377
|
+
async checkNow() {
|
|
378
|
+
if (this.canRun && !this.canRun())
|
|
379
|
+
return;
|
|
380
|
+
// Re-entry guard: the timer fires every 60s and a scan is async; if an
|
|
381
|
+
// earlier scan somehow overlaps (a slow disk, a blocked write), a second
|
|
382
|
+
// would scan the same tasks concurrently. Skip it — the next tick covers
|
|
383
|
+
// anything new. The scan body itself only does fast file/state work: any
|
|
384
|
+
// long-lived work (a review run, a handoff retry) is fired fire-and-forget,
|
|
385
|
+
// so a scan never blocks on it.
|
|
386
|
+
if (this.scanning)
|
|
387
|
+
return;
|
|
388
|
+
this.scanning = true;
|
|
389
|
+
try {
|
|
390
|
+
// The `active` status is scanned for the dead/stalled ENGINEER shape
|
|
391
|
+
// (#0180). The `review` status is scanned too (#0286): a task whose
|
|
392
|
+
// REVIEWER session died silently sits in `review` forever with no report
|
|
393
|
+
// and no one to nudge it — the same failure mode as a dead engineer, just
|
|
394
|
+
// on the review side where the watchdog never looked before.
|
|
395
|
+
for (const status of ["active", "review"]) {
|
|
396
|
+
for (const task of this.index.getTasks(status)) {
|
|
397
|
+
if (this.isStuck(task)) {
|
|
398
|
+
this.handleStuck(task);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
this.scanning = false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
isStuck(task) {
|
|
408
|
+
// A task the human is already flagged on is not stuck-but-unnoticed: don't
|
|
409
|
+
// auto-surface or re-escalate it.
|
|
410
|
+
if (task.needsInput)
|
|
411
|
+
return false;
|
|
412
|
+
// An agent process is running, or a server-side handoff is finalizing.
|
|
413
|
+
if (this.runner.isRunning(task.id))
|
|
414
|
+
return false;
|
|
415
|
+
if (this.runner.isHandoffInFlight(task.id))
|
|
416
|
+
return false;
|
|
417
|
+
// A deliberately paused agent is legitimate — never disturb it (#0180).
|
|
418
|
+
if (this.runner.isPaused(task.id))
|
|
419
|
+
return false;
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
if (task.status === "review") {
|
|
422
|
+
return this.isStuckReview(task, now);
|
|
423
|
+
}
|
|
424
|
+
// Bounded: never surface a task the watchdog already surfaced.
|
|
425
|
+
if (alreadySurfaced(task.body))
|
|
426
|
+
return false;
|
|
427
|
+
// A task whose handoff was retained for recovery (#0235) is not stuck —
|
|
428
|
+
// the recovery is in progress or will fire on the next boot. But only
|
|
429
|
+
// when no recovery attempt has already been recorded: if recoverPending-
|
|
430
|
+
// Handoffs fired and finalization failed, the Activity log will contain a
|
|
431
|
+
// "handoff recovery attempted" entry and the watchdog must be free to
|
|
432
|
+
// surface/escalate the task again.
|
|
433
|
+
if (HANDOFF_RETAINED.test(task.body) && !HANDOFF_RECOVERY_ATTEMPTED.test(task.body))
|
|
434
|
+
return false;
|
|
435
|
+
if (!isStuckActiveTask(task.body, this.stalenessThresholdMs, now))
|
|
436
|
+
return false;
|
|
437
|
+
// The Activity log looks stale, but `isRunning()` above only reflects the
|
|
438
|
+
// in-memory registry, which is empty after every server restart with no
|
|
439
|
+
// memory of which PID belonged to this task (#0214). Before surfacing,
|
|
440
|
+
// check the worktree's own file mtimes — durable, on-disk evidence a
|
|
441
|
+
// registry reset can't erase. Skips the scan entirely once the log
|
|
442
|
+
// already proves recent activity, so the common case pays nothing extra.
|
|
443
|
+
if (hasRecentWorktreeActivity(this.config.root, task.branch, this.stalenessThresholdMs, now)) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The `review`-status half of stuck detection. A review task is stuck when
|
|
450
|
+
* its reviewer session ended without ever producing a report for THIS round
|
|
451
|
+
* and no review is in flight — the silent-death shape (#0286). A report that
|
|
452
|
+
* EXISTS for the current episode (written after the current `→review`
|
|
453
|
+
* transition) means the review already completed and surfaced itself, so it
|
|
454
|
+
* is not stuck even if it "failed"; only a round that produced nothing at all
|
|
455
|
+
* needs the watchdog.
|
|
456
|
+
*/
|
|
457
|
+
isStuckReview(task, now) {
|
|
458
|
+
if (!this.reviews)
|
|
459
|
+
return false; // no review manager wired — nothing to do
|
|
460
|
+
if (this.reviews.isRunning(task.id))
|
|
461
|
+
return false;
|
|
462
|
+
// A report post-dating this episode's →review transition means this round's
|
|
463
|
+
// review completed. A report from a PRIOR round (older `at`) is stale — the
|
|
464
|
+
// report file is never cleared between rounds — and must not mask a current
|
|
465
|
+
// dead reviewer.
|
|
466
|
+
const report = this.reviews.read(task.id);
|
|
467
|
+
if (report && isReportForCurrentEpisode(report.at, task.body))
|
|
468
|
+
return false;
|
|
469
|
+
if (!isStuckReviewTask(task.body, this.stalenessThresholdMs, now))
|
|
470
|
+
return false;
|
|
471
|
+
// Durable on-disk worktree evidence a live agent (engineer) is still
|
|
472
|
+
// active; a registry reset can't erase mtimes (#0214/#0203). The
|
|
473
|
+
// completed-review `reviews.read` check above already ruled out the
|
|
474
|
+
// finished case, so this is only a false-suppression guard, never a mask.
|
|
475
|
+
if (hasRecentWorktreeActivity(this.config.root, task.branch, this.stalenessThresholdMs, now)) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
handleStuck(task) {
|
|
481
|
+
// Re-read fresh from disk (the index copy can lag the canonical board) and
|
|
482
|
+
// re-check the guards against the on-disk reality.
|
|
483
|
+
const current = this.readCurrent(task);
|
|
484
|
+
if (current.status === "review") {
|
|
485
|
+
this.handleStuckReview(current);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (current.status !== "active")
|
|
489
|
+
return;
|
|
490
|
+
if (alreadySurfaced(current.body))
|
|
491
|
+
return;
|
|
492
|
+
const { kind, reason } = classifyDeadAgentReason(current, this.runner);
|
|
493
|
+
// Check if this is a terminal check failure that should immediately set needs_input
|
|
494
|
+
if (reason.startsWith("check-failed-after-retries")) {
|
|
495
|
+
this.escalateToNeedsInput(current, reason);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// #0271 follow-up: `exited-without-handoff` is the one dead-session shape
|
|
499
|
+
// with a plausible quick fix — the agent may have simply mis-emitted the
|
|
500
|
+
// signal line (#0154/#0155) or just needs a nudge to finish. Give the
|
|
501
|
+
// SAME session a bounded number of automatic nudges before falling back
|
|
502
|
+
// to surfacing (which is still the correct outcome for `never-started` —
|
|
503
|
+
// nothing to resume — and `crashed`, which already has its own recovery
|
|
504
|
+
// path via the persisted-handoff-failure machinery).
|
|
505
|
+
if (kind === "exited-without-handoff" &&
|
|
506
|
+
scheduleHandoffSignalRetry(this.config, current, this.runner, (absPath) => this.index.applyFileChange(absPath, { guarded: true }))) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (this.autoTransition) {
|
|
510
|
+
this.surfaceTask(current, autoTransitionTarget(this.config, current), reason);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Fallback (#0156): keep the task active but flag it for the human.
|
|
514
|
+
this.escalateToNeedsInput(current, reason);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Recover a task whose REVIEWER session died silently mid-review (#0286).
|
|
518
|
+
*
|
|
519
|
+
* Unlike the `active` path (which resumes/restarts the ENGINEER), a dead
|
|
520
|
+
* reviewer is recovered by kicking off a FRESH reviewer run — the same
|
|
521
|
+
* action the human takes manually via "Review again". It never touches
|
|
522
|
+
* engineer state: the task stays in `review` for a human to sign off.
|
|
523
|
+
*
|
|
524
|
+
* Bounded the same way the `active` path is bounded: one automatic retry per
|
|
525
|
+
* review episode (persisted Activity marker, scoped to the current
|
|
526
|
+
* `→review` transition). If the retry is exhausted and the task is still
|
|
527
|
+
* stuck — no report, no running review — it is escalated to `needsInput`
|
|
528
|
+
* for a human instead of looping forever on a genuinely broken reviewer.
|
|
529
|
+
*/
|
|
530
|
+
handleStuckReview(task) {
|
|
531
|
+
if (!this.reviews)
|
|
532
|
+
return;
|
|
533
|
+
const current = this.readCurrent(task);
|
|
534
|
+
if (current.status !== "review")
|
|
535
|
+
return;
|
|
536
|
+
if (current.needsInput)
|
|
537
|
+
return;
|
|
538
|
+
if (this.reviews.isRunning(current.id))
|
|
539
|
+
return;
|
|
540
|
+
// This episode's review completed — only a stale report from a prior round
|
|
541
|
+
// would read back here, and that must not block recovery of a dead reviewer
|
|
542
|
+
// in the current round (#0286 review round 2 fix).
|
|
543
|
+
const report = this.reviews.read(current.id);
|
|
544
|
+
if (report && isReportForCurrentEpisode(report.at, current.body))
|
|
545
|
+
return;
|
|
546
|
+
// One automatic retry is the budget; a second stuck episode is a broken
|
|
547
|
+
// reviewer config, not bad luck — hand it to a human, tracked in the log.
|
|
548
|
+
if (alreadyRetriedReview(current.body)) {
|
|
549
|
+
this.escalateToNeedsInput(current, "reviewer session died and the automatic retry did not recover it — the review agent may be misconfigured");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Record the retry (trail + bound) before kicking off a fresh run, so the
|
|
553
|
+
// attempt survives even a mid-run restart and the retry stays at most one.
|
|
554
|
+
const note = "watchdog: auto-retried dead reviewer session · the reviewer agent produced no report and its session ended — starting a fresh review";
|
|
555
|
+
try {
|
|
556
|
+
recordChange(current, note);
|
|
557
|
+
this.writeTask(current);
|
|
558
|
+
commitTaskFile(this.config.root, current.absPath, `docs(${current.id}): update task`);
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
console.error(`[repoos] watchdog: failed to record review retry for #${task.id}: ${err.message}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// Fire-and-forget, never awaited: a review can run for up to REVIEW_TIMEOUT_MS
|
|
565
|
+
// (15 min), and `checkNow` is a background scan that must stay fast so it can
|
|
566
|
+
// keep detecting OTHER stuck tasks on the same sweep. The retry marker above
|
|
567
|
+
// already bounds it, and `ReviewManager.isRunning` short-circuits re-entry for
|
|
568
|
+
// this same task, so there is nothing to await. Mirrors how the route layer
|
|
569
|
+
// starts a review (`void reviews.run(...)`).
|
|
570
|
+
void this.reviews.run(current);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Move a stuck task out of the silent `active` state: write the status +
|
|
574
|
+
* reason in one Activity entry, then commit so main stays mergeable and the
|
|
575
|
+
* indexing write surfaces the change to the board + ntfy notification.
|
|
576
|
+
*/
|
|
577
|
+
surfaceTask(task, target, reason) {
|
|
578
|
+
const current = this.readCurrent(task);
|
|
579
|
+
if (current.status !== "active")
|
|
580
|
+
return;
|
|
581
|
+
if (alreadySurfaced(current.body))
|
|
582
|
+
return;
|
|
583
|
+
try {
|
|
584
|
+
const note = `watchdog: auto-surfaced stuck task · status active→${target} · ${reason} · next step: ${suggestNextStep(reason)}`;
|
|
585
|
+
current.status = target;
|
|
586
|
+
current.needsInput = false;
|
|
587
|
+
current.needsInputReason = undefined;
|
|
588
|
+
recordChange(current, note);
|
|
589
|
+
this.writeTask(current);
|
|
590
|
+
commitTaskFile(this.config.root, current.absPath, `docs(${current.id}): update task`);
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
console.error(`[repoos] watchdog: failed to surface #${task.id}: ${err.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Escalate a stuck task (#0156 fallback): set `needsInput: true` (which
|
|
598
|
+
* surfaces on the board and fires the existing `notifyNeedsInput`
|
|
599
|
+
* notification) and append an Activity entry with the failure reason and a
|
|
600
|
+
* suggested next step. The task stays `active` — visible, not silent.
|
|
601
|
+
*/
|
|
602
|
+
escalateToNeedsInput(task, failureReason) {
|
|
603
|
+
let current;
|
|
604
|
+
try {
|
|
605
|
+
current = this.readCurrent(task);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
console.error(`[repoos] watchdog: failed to escalate #${task.id}: ${err.message}`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (current.needsInput || alreadySurfaced(current.body))
|
|
612
|
+
return;
|
|
613
|
+
const reason = failureReason ?? extractHandoffFailure(current.body) ?? "handoff signal was not detected";
|
|
614
|
+
const note = `watchdog: escalated to needs_input · ${reason} · next step: ${suggestNextStep(reason)}`;
|
|
615
|
+
try {
|
|
616
|
+
current.needsInput = true;
|
|
617
|
+
current.needsInputReason = "watchdog-stuck";
|
|
618
|
+
recordChange(current, note);
|
|
619
|
+
this.writeTask(current);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
console.error(`[repoos] watchdog: failed to write escalation for #${task.id}: ${err.message}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
readCurrent(task) {
|
|
626
|
+
return parseTask({
|
|
627
|
+
content: readFileSync(task.absPath, "utf8"),
|
|
628
|
+
absPath: task.absPath,
|
|
629
|
+
root: this.config.root,
|
|
630
|
+
defaultStatus: this.config.defaultStatus,
|
|
631
|
+
defaultAssignee: this.config.defaultAssignee,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
writeTask(task) {
|
|
635
|
+
writeFileSync(task.absPath, serializeTask(task));
|
|
636
|
+
// Surface the change to the live index so the board updates and the
|
|
637
|
+
// existing status-change/needs-input notification subscribers fire.
|
|
638
|
+
this.index.applyFileChange(task.absPath);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function lastStatusTransitionTime(body, status) {
|
|
642
|
+
const lines = body.split("\n");
|
|
643
|
+
const activityIndex = lines.findIndex((line) => line.trim() === "## Activity");
|
|
644
|
+
if (activityIndex === -1)
|
|
645
|
+
return null;
|
|
646
|
+
for (let i = lines.length - 1; i > activityIndex; i--) {
|
|
647
|
+
const match = lines[i].match(/^- (\d{4}-\d{2}-\d{2}T[^\s]+) · status [a-z_]+→(\w+)/);
|
|
648
|
+
if (match && match[2] === status) {
|
|
649
|
+
const t = Date.parse(match[1]);
|
|
650
|
+
return Number.isNaN(t) ? null : t;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The timestamp of the most recent Activity entry after the `status →active`
|
|
657
|
+
* transition. Falls back to the transition time itself when there is none.
|
|
658
|
+
*/
|
|
659
|
+
function lastActivityTime(body, afterTime) {
|
|
660
|
+
const lines = body.split("\n");
|
|
661
|
+
const activityIndex = lines.findIndex((line) => line.trim() === "## Activity");
|
|
662
|
+
if (activityIndex === -1)
|
|
663
|
+
return afterTime;
|
|
664
|
+
for (let i = lines.length - 1; i > activityIndex; i--) {
|
|
665
|
+
const line = lines[i].trim();
|
|
666
|
+
if (!line.startsWith("-"))
|
|
667
|
+
continue;
|
|
668
|
+
const match = line.match(/^- (\d{4}-\d{2}-\d{2}T[^\s]+) · /);
|
|
669
|
+
if (!match)
|
|
670
|
+
continue;
|
|
671
|
+
const t = Date.parse(match[1]);
|
|
672
|
+
if (Number.isNaN(t))
|
|
673
|
+
continue;
|
|
674
|
+
if (t > afterTime)
|
|
675
|
+
return t;
|
|
676
|
+
// Entries are append-only, so the first one at-or-before the transition
|
|
677
|
+
// means nothing newer exists.
|
|
678
|
+
return afterTime;
|
|
679
|
+
}
|
|
680
|
+
return afterTime;
|
|
681
|
+
}
|