@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,1299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrates individual close-out jobs through their lifecycle. Each job:
|
|
3
|
+
* 1. Validates the candidate away from live main (temporary worktree/branch)
|
|
4
|
+
* 2. Checks for main SHA drift and rebuilds if needed
|
|
5
|
+
* 3. Publishes only a green candidate while holding the repo lock
|
|
6
|
+
* 4. Cleans up the candidate and task resources
|
|
7
|
+
*
|
|
8
|
+
* Phases track recovery: if interrupted mid-flight, retry resumes from the current phase.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, existsSync, symlinkSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join, relative } from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { currentBranch, runGit, worktreePathForBranch, ensureWorktree, removeWorktree, deleteBranch, pruneWorktrees, isGitRepo, commitDirtyFiles, mergeBranch, dirtyFiles, getDiff, getDiffStats, getChangedFilePaths, GitDirtyCheckError, } from "../core/git.js";
|
|
14
|
+
import { sweepAndWarn } from "../core/worktree-gc.js";
|
|
15
|
+
import { redactSecrets, stripAnsi } from "./done.js";
|
|
16
|
+
import { markTaskReleased } from "./write.js";
|
|
17
|
+
import { saveDiffSnapshot } from "./diff-snapshot.js";
|
|
18
|
+
import { parseTask } from "../core/task.js";
|
|
19
|
+
import { summarizeCheckFailure } from "../core/check-failure-summary.js";
|
|
20
|
+
// Candidate branch prefix. Must be a valid git refname: a leading dot is
|
|
21
|
+
// rejected by git (`'.repoos/integrate/…' is not a valid branch name`), which
|
|
22
|
+
// silently failed every close-out job at worktree creation.
|
|
23
|
+
const CANDIDATE_BRANCH_PREFIX = "repoos/integrate/";
|
|
24
|
+
/** Shared literal for the failed job phase so recovery paths stay consistent. */
|
|
25
|
+
const PHASE_FAILED = "failed";
|
|
26
|
+
/**
|
|
27
|
+
* Cap on consecutive publish-time "main advanced" resyncs (#0386). Every
|
|
28
|
+
* OTHER retry path in the close-out pipeline is capped (#0216's validate
|
|
29
|
+
* retry, handoff.ts's MAX_*_RETRY_ATTEMPTS) — this one wasn't, so on a busy
|
|
30
|
+
* board a task could restart forever, never winning the race to publish.
|
|
31
|
+
* Deliberately generous: a single trivial `docs(<id>): update task` commit
|
|
32
|
+
* for an unrelated task is common and harmless to resync past once or
|
|
33
|
+
* twice; this only fires once retries are clearly not converging.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_PUBLISH_DRIFT_RETRIES = 5;
|
|
36
|
+
function candidateBranchName(taskId) {
|
|
37
|
+
return `${CANDIDATE_BRANCH_PREFIX}${taskId}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The merge that just ran left the candidate at exactly base main — it added
|
|
41
|
+
* nothing. That is legitimate when the feature branch was already fully
|
|
42
|
+
* integrated (a re-run of a close-out, a branch cherry-picked onto main
|
|
43
|
+
* earlier): the close-out is idempotent and the task genuinely has nothing
|
|
44
|
+
* left to land.
|
|
45
|
+
*
|
|
46
|
+
* It is a BUG when the branch still carries commits main lacks and the merge —
|
|
47
|
+
* or, far more often, the conflict auto-resolution in `mergeBranch` — silently
|
|
48
|
+
* dropped them. Left unguarded, the gate then runs `build` + `check` against
|
|
49
|
+
* what is effectively bare main (which trivially passes) and the task publishes
|
|
50
|
+
* as `done` with none of its code integrated. This bit #0306/#0307/#0309/#0312.
|
|
51
|
+
*
|
|
52
|
+
* Returns an actionable failure reason in the bug case, `null` when the no-op
|
|
53
|
+
* is legitimate (or when git errored and a downstream step should surface it).
|
|
54
|
+
*/
|
|
55
|
+
export async function detectDroppedMerge(root, mainBranch, featureBranch, postMergeHead, baseMainSha) {
|
|
56
|
+
if (postMergeHead !== baseMainSha)
|
|
57
|
+
return null;
|
|
58
|
+
// `git diff --quiet A...B` — exit 0: B adds nothing over the merge-base
|
|
59
|
+
// (branch already in main → legit); exit 1: B carries a real delta that the
|
|
60
|
+
// merge failed to bring across; >1: git error, leave it for a later step.
|
|
61
|
+
const delta = await runGit(root, ["diff", "--quiet", `${mainBranch}...${featureBranch}`], 15_000);
|
|
62
|
+
if (delta.status !== 1)
|
|
63
|
+
return null;
|
|
64
|
+
return (`merge of ${featureBranch} produced no change to ${mainBranch}, but the branch still carries ` +
|
|
65
|
+
`commits ${mainBranch} does not have — the merge, or its conflict auto-resolution, dropped them. ` +
|
|
66
|
+
`Rebase ${featureBranch} onto ${mainBranch} and resolve the conflicts there, then retry.`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Make main's `<workDir>/*.md` state authoritative for every task file the
|
|
70
|
+
* merged candidate changed that is NOT the closing task's own file, and commit
|
|
71
|
+
* the restore.
|
|
72
|
+
*
|
|
73
|
+
* A feature branch can carry stale edits to other tasks' files — a concurrent
|
|
74
|
+
* board write, a `repoos` CLI call in the worktree, a partial merge — that git
|
|
75
|
+
* folds in without a conflict (main untouched since the merge-base), so the
|
|
76
|
+
* close-out's conflict-only `autoResolveOurs` never catches them and the stale
|
|
77
|
+
* version publishes to main. This closes that gap: a foreign file modified or
|
|
78
|
+
* deleted by the branch is restored to main's copy; one the branch newly added
|
|
79
|
+
* is removed (a close-out never creates another task's file). Returns the paths
|
|
80
|
+
* touched (empty when there were none); throws only if the git ops fail.
|
|
81
|
+
*
|
|
82
|
+
* Exported for tests.
|
|
83
|
+
*/
|
|
84
|
+
export async function resetForeignWorkFiles(opts) {
|
|
85
|
+
const { candidateWtPath, baseMainSha, workDir, ownWorkFile } = opts;
|
|
86
|
+
const changed = await runGit(candidateWtPath, ["diff", "--name-only", `${baseMainSha}..HEAD`, "--", workDir], 10_000);
|
|
87
|
+
if (changed.status !== 0)
|
|
88
|
+
return [];
|
|
89
|
+
const foreign = changed.stdout
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter((p) => p && p.endsWith(".md") && p !== ownWorkFile);
|
|
93
|
+
if (foreign.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
// Partition by whether the file exists at main's tip: modified/deleted files
|
|
96
|
+
// get main's version back; branch-added files get removed.
|
|
97
|
+
const onMain = [];
|
|
98
|
+
const addedByBranch = [];
|
|
99
|
+
for (const p of foreign) {
|
|
100
|
+
const exists = await runGit(candidateWtPath, ["cat-file", "-e", `${baseMainSha}:${p}`], 5_000);
|
|
101
|
+
(exists.status === 0 ? onMain : addedByBranch).push(p);
|
|
102
|
+
}
|
|
103
|
+
if (onMain.length > 0) {
|
|
104
|
+
const restore = await runGit(candidateWtPath, ["checkout", baseMainSha, "--", ...onMain], 15_000);
|
|
105
|
+
if (restore.status !== 0) {
|
|
106
|
+
throw new Error(`could not restore unrelated task files: ${restore.stderr.trim()}`);
|
|
107
|
+
}
|
|
108
|
+
await runGit(candidateWtPath, ["add", "--", ...onMain], 10_000);
|
|
109
|
+
}
|
|
110
|
+
if (addedByBranch.length > 0) {
|
|
111
|
+
const removed = await runGit(candidateWtPath, ["rm", "-q", "--", ...addedByBranch], 10_000);
|
|
112
|
+
if (removed.status !== 0) {
|
|
113
|
+
throw new Error(`could not drop stray task files: ${removed.stderr.trim()}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const staged = await runGit(candidateWtPath, ["diff", "--cached", "--quiet"], 10_000);
|
|
117
|
+
if (staged.status !== 1)
|
|
118
|
+
return []; // nothing actually differed after all
|
|
119
|
+
await runGit(candidateWtPath, [
|
|
120
|
+
"commit",
|
|
121
|
+
"--no-edit",
|
|
122
|
+
"-m",
|
|
123
|
+
`chore: keep main's copy of ${foreign.length} unrelated task file(s) during close-out`,
|
|
124
|
+
], 10_000);
|
|
125
|
+
return foreign;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a candidate's merged diff is documentation-only, so the expensive
|
|
129
|
+
* build + check gate in `validateCandidate` can be skipped (#0355).
|
|
130
|
+
*
|
|
131
|
+
* Deliberately literal and mechanical: a path qualifies only if it lives under
|
|
132
|
+
* `docs/` or `user-docs/`, or ends in `.md`. The closing task's own
|
|
133
|
+
* `work/<id>-*.md` file is the common `.md` case — it always changes, and
|
|
134
|
+
* `resetForeignWorkFiles` has already restored every OTHER task's work file to
|
|
135
|
+
* main's copy before this runs, so no foreign task file reaches this check.
|
|
136
|
+
* Anything else — `repoos.toml`, `package.json`, `src/`, `scripts/`,
|
|
137
|
+
* `.github/`, … — disqualifies the fast path. No scoring, no "mostly docs".
|
|
138
|
+
*
|
|
139
|
+
* An empty path list is vacuously docs-only: a legitimate no-op merge (the
|
|
140
|
+
* branch was already fully integrated) leaves nothing for build/test to
|
|
141
|
+
* examine, and the dropped-merge guard in `validateCandidate` runs earlier and
|
|
142
|
+
* already rejects an empty merge that actually swallowed real branch work.
|
|
143
|
+
*/
|
|
144
|
+
export function isDocsOnlyChange(paths) {
|
|
145
|
+
return paths.every((p) => p.startsWith("docs/") || p.startsWith("user-docs/") || p.endsWith(".md"));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the repository's actual default branch name.
|
|
149
|
+
* Tries (in order):
|
|
150
|
+
* 1. git symbolic-ref refs/remotes/origin/HEAD (when remote exists)
|
|
151
|
+
* 2. git config --get init.defaultBranch
|
|
152
|
+
* 3. currently checked-out branch
|
|
153
|
+
* 4. fallback to "main"
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* Whether this project is expected to build its own `dist/cli/index.js` at
|
|
157
|
+
* all. True only for RepoOS itself (self-hosting) and any other managed
|
|
158
|
+
* project that happens to declare the same `bin` target — the vast majority
|
|
159
|
+
* of managed projects (a Vue app, a backend service, ...) never produce a
|
|
160
|
+
* CLI there, so a missing local CLI for them is normal, not a regression.
|
|
161
|
+
* Detected via `package.json`'s `bin` field rather than a name check so any
|
|
162
|
+
* project that legitimately ships a `dist/cli/index.js` binary is covered.
|
|
163
|
+
*/
|
|
164
|
+
function expectsOwnCli(wtPath) {
|
|
165
|
+
try {
|
|
166
|
+
const pkg = JSON.parse(readFileSync(join(wtPath, "package.json"), "utf8"));
|
|
167
|
+
const binPaths = typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin ?? {});
|
|
168
|
+
return binPaths.some((p) => p.replace(/^\.\//, "") === "dist/cli/index.js");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function resolveDefaultBranch(root) {
|
|
175
|
+
// Try remote HEAD first (most reliable for cloned repos)
|
|
176
|
+
const remoteHeadRes = await runGit(root, ["symbolic-ref", "refs/remotes/origin/HEAD"], 4000);
|
|
177
|
+
if (remoteHeadRes.status === 0) {
|
|
178
|
+
// Output format: "ref: refs/remotes/origin/main" -> extract "main"
|
|
179
|
+
const match = remoteHeadRes.stdout.trim().match(/refs\/remotes\/origin\/(.+)$/);
|
|
180
|
+
if (match)
|
|
181
|
+
return match[1];
|
|
182
|
+
}
|
|
183
|
+
// Try init.defaultBranch config
|
|
184
|
+
const configRes = await runGit(root, ["config", "--get", "init.defaultBranch"], 4000);
|
|
185
|
+
if (configRes.status === 0 && configRes.stdout.trim()) {
|
|
186
|
+
return configRes.stdout.trim();
|
|
187
|
+
}
|
|
188
|
+
// Fall back to currently checked-out branch
|
|
189
|
+
const currentRes = await runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"], 4000);
|
|
190
|
+
if (currentRes.status === 0 && currentRes.stdout.trim() && currentRes.stdout.trim() !== "HEAD") {
|
|
191
|
+
return currentRes.stdout.trim();
|
|
192
|
+
}
|
|
193
|
+
// Final fallback
|
|
194
|
+
return "main";
|
|
195
|
+
}
|
|
196
|
+
// How much of a failed command's output to keep as the failure reason.
|
|
197
|
+
const TAIL_LINES = 15;
|
|
198
|
+
const TAIL_MAX_CHARS = 800;
|
|
199
|
+
/**
|
|
200
|
+
* Locate a task's file directly on disk by id, independent of the live
|
|
201
|
+
* index's freshness. Used only as a fallback when `getTask` misses — the
|
|
202
|
+
* normal, common path is the index lookup, which is far cheaper than a
|
|
203
|
+
* directory scan. Task ids are unique by filename convention (`<id>-*.md`).
|
|
204
|
+
*/
|
|
205
|
+
function findTaskFileById(root, workDir, taskId) {
|
|
206
|
+
const dir = join(root, workDir);
|
|
207
|
+
let entries;
|
|
208
|
+
try {
|
|
209
|
+
entries = readdirSync(dir);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const match = entries.find((f) => f.startsWith(`${taskId}-`) && f.endsWith(".md"));
|
|
215
|
+
return match ? join(dir, match) : null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The useful part of a failed command's combined output. bun/npm wrap a child
|
|
219
|
+
* failure with a generic trailing line (`error: script "repoos" exited with
|
|
220
|
+
* code 1`) that is useless as a reason on its own, so keep the last several
|
|
221
|
+
* meaningful lines — the real cause (a failing test, a compiler error) sits
|
|
222
|
+
* just above the wrapper.
|
|
223
|
+
*
|
|
224
|
+
* The reason is persisted to `.repoos/integration-jobs/<id>.json` and shown
|
|
225
|
+
* verbatim in the UI, so it must be free of ANSI escapes (the gate's test
|
|
226
|
+
* output is colored) and of anything that looks like a credential. When the
|
|
227
|
+
* excerpt exceeds the character cap it is cut from the front at a WORD
|
|
228
|
+
* boundary — cutting mid-word produced reasons like `check failed: …eletion
|
|
229
|
+
* detected by…` (0215) that read as garbage.
|
|
230
|
+
*/
|
|
231
|
+
export function tailLine(stdout, stderr) {
|
|
232
|
+
const cleaned = redactSecrets(stripAnsi(`${stdout}\n${stderr}`));
|
|
233
|
+
// Vitest's final lines are usually the least useful part of a failure: a
|
|
234
|
+
// partial received/expected diff followed by its generic runner summary.
|
|
235
|
+
// Prefer its explicit FAIL block when present; the full transcript remains
|
|
236
|
+
// available in the task's Debug tab.
|
|
237
|
+
const testFailure = summarizeCheckFailure(cleaned);
|
|
238
|
+
if (testFailure)
|
|
239
|
+
return testFailure;
|
|
240
|
+
const lines = cleaned
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) => l.trim())
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
if (lines.length === 0)
|
|
245
|
+
return "unknown error";
|
|
246
|
+
let tail = lines.slice(-TAIL_LINES).join("\n");
|
|
247
|
+
if (tail.length > TAIL_MAX_CHARS) {
|
|
248
|
+
const cut = tail.length - TAIL_MAX_CHARS;
|
|
249
|
+
const lastWs = Math.max(tail.lastIndexOf(" ", cut), tail.lastIndexOf("\n", cut));
|
|
250
|
+
const start = lastWs >= 0 ? lastWs + 1 : cut;
|
|
251
|
+
return `…${tail.slice(start)}`;
|
|
252
|
+
}
|
|
253
|
+
return tail;
|
|
254
|
+
}
|
|
255
|
+
function runProcess(cmd, args, opts) {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env });
|
|
258
|
+
let stdout = "";
|
|
259
|
+
let stderr = "";
|
|
260
|
+
let timedOut = false;
|
|
261
|
+
let errorCode;
|
|
262
|
+
let settled = false;
|
|
263
|
+
let timer;
|
|
264
|
+
const finish = (status) => {
|
|
265
|
+
if (settled)
|
|
266
|
+
return;
|
|
267
|
+
settled = true;
|
|
268
|
+
if (timer)
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
resolve({ status, stdout, stderr, timedOut, errorCode });
|
|
271
|
+
};
|
|
272
|
+
child.stdout.on("data", (d) => {
|
|
273
|
+
const text = d.toString("utf8");
|
|
274
|
+
stdout += text;
|
|
275
|
+
opts.onChunk?.(text);
|
|
276
|
+
});
|
|
277
|
+
child.stderr.on("data", (d) => {
|
|
278
|
+
const text = d.toString("utf8");
|
|
279
|
+
stderr += text;
|
|
280
|
+
opts.onChunk?.(text);
|
|
281
|
+
});
|
|
282
|
+
child.on("error", (err) => {
|
|
283
|
+
errorCode = err.code;
|
|
284
|
+
finish(null);
|
|
285
|
+
});
|
|
286
|
+
child.on("close", (code) => finish(code));
|
|
287
|
+
timer = setTimeout(() => {
|
|
288
|
+
timedOut = true;
|
|
289
|
+
child.kill("SIGKILL");
|
|
290
|
+
}, opts.timeout);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* True when the command couldn't be launched at all (not installed), as
|
|
295
|
+
* opposed to running and failing. Only then is falling back to another tool
|
|
296
|
+
* (npm instead of bun) useful; a build that ran and failed is the real result.
|
|
297
|
+
*/
|
|
298
|
+
function commandMissing(res) {
|
|
299
|
+
return res.errorCode === "ENOENT" || res.errorCode === "EACCES";
|
|
300
|
+
}
|
|
301
|
+
/** Candidate validation and publication orchestrator for one job. */
|
|
302
|
+
export class CloseOutOrchestrator {
|
|
303
|
+
config;
|
|
304
|
+
coordinator;
|
|
305
|
+
repoLock;
|
|
306
|
+
rootLock;
|
|
307
|
+
getTask;
|
|
308
|
+
onProgress;
|
|
309
|
+
logger;
|
|
310
|
+
onMergeConflict;
|
|
311
|
+
remoteValidator;
|
|
312
|
+
taskChecks;
|
|
313
|
+
onTaskCheckEvent;
|
|
314
|
+
constructor(config, coordinator, repoLock, rootLock, getTask, onProgress, logger,
|
|
315
|
+
/**
|
|
316
|
+
* Fired when `validating` fails on a REAL, named merge conflict (never
|
|
317
|
+
* for the task's own bookkeeping file or generated paths, which
|
|
318
|
+
* auto-resolve; never for infra failures). The job itself is still
|
|
319
|
+
* marked `failed` as before — this is a separate, best-effort trigger
|
|
320
|
+
* to auto-resume the engineer to fix it and resubmit (#0271 follow-up).
|
|
321
|
+
*/
|
|
322
|
+
onMergeConflict,
|
|
323
|
+
/**
|
|
324
|
+
* Runs the expensive half of the gate (build + test) on a cloud VM when
|
|
325
|
+
* `config.remoteValidation.enabled` — see validateCandidate. Undefined
|
|
326
|
+
* disables remote validation regardless of config.
|
|
327
|
+
*/
|
|
328
|
+
remoteValidator,
|
|
329
|
+
/** Records the MTD merge-gate `repoos check` run for the Debug tab (0310). */
|
|
330
|
+
taskChecks, onTaskCheckEvent) {
|
|
331
|
+
this.config = config;
|
|
332
|
+
this.coordinator = coordinator;
|
|
333
|
+
this.repoLock = repoLock;
|
|
334
|
+
this.rootLock = rootLock;
|
|
335
|
+
this.getTask = getTask;
|
|
336
|
+
this.onProgress = onProgress;
|
|
337
|
+
this.logger = logger;
|
|
338
|
+
this.onMergeConflict = onMergeConflict;
|
|
339
|
+
this.remoteValidator = remoteValidator;
|
|
340
|
+
this.taskChecks = taskChecks;
|
|
341
|
+
this.onTaskCheckEvent = onTaskCheckEvent;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Whether the task is, on disk, already `done`. The live index is an
|
|
345
|
+
* in-memory cache that can lag the actual file (a duplicate/stale close-out
|
|
346
|
+
* — #0289 — can run right after the first successful publish, before the
|
|
347
|
+
* index rebuilds), so this reads the authoritative task file directly,
|
|
348
|
+
* falling back to the index only when the file cannot be located or parsed.
|
|
349
|
+
*/
|
|
350
|
+
taskIsDone(taskId) {
|
|
351
|
+
const live = this.getTask?.(taskId);
|
|
352
|
+
const workDir = this.config.workDir;
|
|
353
|
+
const path = live?.absPath ?? (workDir ? findTaskFileById(this.config.root, workDir, taskId) : null);
|
|
354
|
+
if (path && existsSync(path)) {
|
|
355
|
+
try {
|
|
356
|
+
const task = parseTask({
|
|
357
|
+
content: readFileSync(path, "utf8"),
|
|
358
|
+
absPath: path,
|
|
359
|
+
root: this.config.root,
|
|
360
|
+
defaultStatus: this.config.defaultStatus ?? "inbox",
|
|
361
|
+
defaultAssignee: this.config.defaultAssignee ?? "unassigned",
|
|
362
|
+
});
|
|
363
|
+
if (task.status === "done")
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* fall through to the index below */
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return live?.status === "done";
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Record a close-out job failure, OR reconcile it away when it is moot.
|
|
374
|
+
*
|
|
375
|
+
* A failure is moot when the task is already `done`: the failing job is a
|
|
376
|
+
* duplicate/stale enqueue against a task that already finished successfully
|
|
377
|
+
* through an earlier job (#0289). That earlier close-out's cleanup deleted
|
|
378
|
+
* the worktree/branch, which is exactly why a later redundant enqueue fails
|
|
379
|
+
* (e.g. "worktree not found"). There is no gate failure here needing a
|
|
380
|
+
* human's attention, and the failed job record would otherwise sit forever
|
|
381
|
+
* with no path to resolution, so it is dropped instead.
|
|
382
|
+
*
|
|
383
|
+
* Returns the `{ ok, reason }` the caller should return: `ok: true` for a
|
|
384
|
+
* reconciled (moot) failure so the pipeline treats it as a normal completion
|
|
385
|
+
* and never surfaces it as an actionable error; `ok: false` for a genuine
|
|
386
|
+
* failure, which is recorded as `failed` (and `onRecorded`, e.g. the merge-
|
|
387
|
+
* conflict retry, runs) so the UI and status bar can surface it.
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* Tear down the `repoos/integrate/<id>` candidate worktree + branch. Safe to
|
|
391
|
+
* call whether or not the candidate exists. The candidate is a throwaway
|
|
392
|
+
* scratch area rebuilt from scratch on the next run, so its branch is
|
|
393
|
+
* force-deleted (git will not see it as merged after a failed job).
|
|
394
|
+
*
|
|
395
|
+
* Runs on EVERY terminal job outcome — success (`cleanup`), genuine failure,
|
|
396
|
+
* and moot/reconciled — so a candidate never outlives its job. Without this,
|
|
397
|
+
* every failed close-out leaked a worktree that inflated cold-boot `git
|
|
398
|
+
* status` fan-out until someone ran gc.
|
|
399
|
+
*/
|
|
400
|
+
removeCandidate(taskId) {
|
|
401
|
+
const root = this.config.root;
|
|
402
|
+
const branch = candidateBranchName(taskId);
|
|
403
|
+
removeWorktree(root, branch);
|
|
404
|
+
deleteBranch(root, branch, { force: true });
|
|
405
|
+
pruneWorktrees(root);
|
|
406
|
+
}
|
|
407
|
+
failOrReconcile(job, failedPhase, reason, onRecorded) {
|
|
408
|
+
this.removeCandidate(job.taskId);
|
|
409
|
+
if (this.taskIsDone(job.taskId)) {
|
|
410
|
+
this.logger?.integration(job.taskId, "info", "close-out failure is moot — task already done; dropping job", { reason });
|
|
411
|
+
this.coordinator.removeJob(job.taskId);
|
|
412
|
+
return { ok: true };
|
|
413
|
+
}
|
|
414
|
+
this.coordinator.updateJob(job.taskId, { phase: PHASE_FAILED, failedPhase, reason });
|
|
415
|
+
onRecorded?.();
|
|
416
|
+
return { ok: false, reason };
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Process the next job in the queue: validate and publish it.
|
|
420
|
+
* Phases are atomic; a retry at any phase resumes from that phase.
|
|
421
|
+
* May return early if the job transitions back to an earlier phase (e.g., on main drift).
|
|
422
|
+
*/
|
|
423
|
+
async processNext() {
|
|
424
|
+
const job = this.coordinator.peekNext();
|
|
425
|
+
if (!job)
|
|
426
|
+
return { ok: true, reason: "queue empty" };
|
|
427
|
+
return this.processJob(job);
|
|
428
|
+
}
|
|
429
|
+
async processJob(job) {
|
|
430
|
+
const root = this.config.root;
|
|
431
|
+
this.logger?.integration(job.taskId, "info", `Processing job phase: ${job.phase}`, {
|
|
432
|
+
taskId: job.taskId,
|
|
433
|
+
phase: job.phase,
|
|
434
|
+
});
|
|
435
|
+
try {
|
|
436
|
+
if (!isGitRepo(root)) {
|
|
437
|
+
return { ok: false, reason: "not a git repository" };
|
|
438
|
+
}
|
|
439
|
+
// Transition from queued to syncing.
|
|
440
|
+
if (job.phase === "queued") {
|
|
441
|
+
const updated = this.coordinator.updateJob(job.taskId, {
|
|
442
|
+
phase: "syncing",
|
|
443
|
+
startedAt: new Date().toISOString(),
|
|
444
|
+
});
|
|
445
|
+
if (!updated)
|
|
446
|
+
return { ok: false, reason: "job disappeared" };
|
|
447
|
+
job = updated;
|
|
448
|
+
}
|
|
449
|
+
// Syncing phase: ensure the candidate worktree exists and is up to date with main.
|
|
450
|
+
if (job.phase === "syncing") {
|
|
451
|
+
const syncRes = await this.syncCandidate(job);
|
|
452
|
+
if (!syncRes.ok) {
|
|
453
|
+
this.logger?.integration(job.taskId, "error", "sync failed", { reason: syncRes.reason });
|
|
454
|
+
if (syncRes.conflict) {
|
|
455
|
+
// A real conflict caught by the pre-flight (#0358): the candidate
|
|
456
|
+
// worktree was never created, but the outcome is identical to one
|
|
457
|
+
// found during `validating` — same reason format, same
|
|
458
|
+
// non-retryable handling, same repair handoff. Recorded against
|
|
459
|
+
// `validating` so nothing downstream can tell the timing moved.
|
|
460
|
+
return this.failOrReconcile(job, "validating", syncRes.reason, () => this.onMergeConflict?.(job.taskId, syncRes.reason));
|
|
461
|
+
}
|
|
462
|
+
return this.failOrReconcile(job, "syncing", syncRes.reason);
|
|
463
|
+
}
|
|
464
|
+
job = this.coordinator.updateJob(job.taskId, { phase: "validating" });
|
|
465
|
+
}
|
|
466
|
+
// Validating phase: run the full gate (build, check) on the candidate.
|
|
467
|
+
//
|
|
468
|
+
// The gate is retried once before the job is failed (#0216). A false
|
|
469
|
+
// failure here is expensive — the branch is green, but the task is
|
|
470
|
+
// stranded in review and the user cannot tell contention from a real
|
|
471
|
+
// regression. The retry also classifies the failure, which is the part
|
|
472
|
+
// that actually helps: a genuine defect reproduces with the SAME reason
|
|
473
|
+
// (Node-version-dependent breakage did exactly this on #0205, failing
|
|
474
|
+
// identically twice), whereas load-induced failures land on a different
|
|
475
|
+
// test each run (#0211 failed on two unrelated tests). Two attempts is
|
|
476
|
+
// the cap — this must never loop.
|
|
477
|
+
if (job.phase === "validating") {
|
|
478
|
+
let validateRes = await this.validateCandidate(job);
|
|
479
|
+
if (!validateRes.ok && validateRes.retryable === false) {
|
|
480
|
+
// Deterministic by construction — a second run proves nothing and
|
|
481
|
+
// costs the user another full gate cycle.
|
|
482
|
+
this.logger?.integration(job.taskId, "error", "validation failed (non-retryable)", {
|
|
483
|
+
reason: validateRes.reason,
|
|
484
|
+
});
|
|
485
|
+
// A named, real conflict (not the task's own bookkeeping file or a
|
|
486
|
+
// generated path — those auto-resolve inside validateCandidate and
|
|
487
|
+
// never reach here) is the one non-retryable failure with an
|
|
488
|
+
// actionable fix: merge main into the FEATURE branch and resolve it
|
|
489
|
+
// there. Give the engineer a shot at that automatically instead of
|
|
490
|
+
// leaving the job sitting `failed` until a human notices (#0271
|
|
491
|
+
// follow-up).
|
|
492
|
+
const conflict = validateRes.reason?.startsWith("merge conflict in ");
|
|
493
|
+
return this.failOrReconcile(job, "validating", validateRes.reason, conflict ? () => this.onMergeConflict?.(job.taskId, validateRes.reason) : undefined);
|
|
494
|
+
}
|
|
495
|
+
if (!validateRes.ok) {
|
|
496
|
+
const firstReason = validateRes.reason ?? "unknown";
|
|
497
|
+
validateRes = await this.validateCandidate(job);
|
|
498
|
+
if (!validateRes.ok) {
|
|
499
|
+
const secondReason = validateRes.reason ?? "unknown";
|
|
500
|
+
const reason = firstReason === secondReason
|
|
501
|
+
? `${secondReason} — reproduced identically on retry, so this is a real failure in the branch, not machine load`
|
|
502
|
+
: `${secondReason} — NOTE: the first attempt failed differently (${firstReason}). Two unrelated failures point at machine load or infrastructure rather than a regression in this branch; check for stray serve processes and retry.`;
|
|
503
|
+
return this.failOrReconcile(job, "validating", reason);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
job = this.coordinator.updateJob(job.taskId, {
|
|
507
|
+
phase: "publishing",
|
|
508
|
+
candidateSha: validateRes.candidateSha,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
// Publishing phase: merge candidate to live main, holding the repo lock.
|
|
512
|
+
if (job.phase === "publishing") {
|
|
513
|
+
const pubRes = await this.publishCandidate(job);
|
|
514
|
+
if (!pubRes.ok) {
|
|
515
|
+
// Check if the job phase was changed by publishCandidate() (e.g., drift handling).
|
|
516
|
+
// If it was moved back to "syncing" for retry, don't overwrite it to "failed".
|
|
517
|
+
const currentJob = this.coordinator.getJob(job.taskId);
|
|
518
|
+
if (currentJob && currentJob.phase === "syncing") {
|
|
519
|
+
// Drift detected and handled - will retry on next processNext() call
|
|
520
|
+
this.logger?.integration(job.taskId, "info", "main drifted during publish — resyncing", {
|
|
521
|
+
reason: pubRes.reason,
|
|
522
|
+
});
|
|
523
|
+
return pubRes;
|
|
524
|
+
}
|
|
525
|
+
this.logger?.integration(job.taskId, "error", "publish failed", {
|
|
526
|
+
reason: pubRes.reason,
|
|
527
|
+
});
|
|
528
|
+
return this.failOrReconcile(job, "publishing", pubRes.reason);
|
|
529
|
+
}
|
|
530
|
+
job = this.coordinator.updateJob(job.taskId, { phase: "cleanup" });
|
|
531
|
+
}
|
|
532
|
+
// Cleanup phase: remove candidate worktree, delete candidate branch, remove task worktree.
|
|
533
|
+
if (job.phase === "cleanup") {
|
|
534
|
+
const cleanRes = await this.cleanup(job);
|
|
535
|
+
if (!cleanRes.ok) {
|
|
536
|
+
// Log but don't fail: the merge succeeded, so task is done even if cleanup is messy.
|
|
537
|
+
this.logger?.integration(job.taskId, "warn", "cleanup warning", {
|
|
538
|
+
reason: cleanRes.reason,
|
|
539
|
+
});
|
|
540
|
+
console.warn(`Cleanup warning for task ${job.taskId}: ${cleanRes.reason}`);
|
|
541
|
+
}
|
|
542
|
+
job = this.coordinator.updateJob(job.taskId, { phase: "done" });
|
|
543
|
+
this.logger?.integration(job.taskId, "info", "close-out complete — published to main");
|
|
544
|
+
}
|
|
545
|
+
return { ok: true };
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
const reason = err instanceof Error ? err.message : "unknown error";
|
|
549
|
+
this.logger?.integration(job.taskId, "error", "orchestrator error", { reason });
|
|
550
|
+
return this.failOrReconcile(job, job.phase ?? "unknown", `orchestrator error: ${reason}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Cheap, non-destructive pre-flight (#0358): would merging the task's feature
|
|
555
|
+
* branch into current main hit a REAL (non-auto-resolvable) conflict?
|
|
556
|
+
*
|
|
557
|
+
* Runs BEFORE any candidate worktree exists, in the feature branch's own
|
|
558
|
+
* worktree (`worktreePathForBranch`, already checked out and cheap to use)
|
|
559
|
+
* against the CURRENT main SHA, reusing `mergeBranch`'s dry-run mode so the
|
|
560
|
+
* classification is literally the real merge's — never a second, drifting
|
|
561
|
+
* definition of "a real conflict". Returns the conflict reason in the exact
|
|
562
|
+
* format `validateCandidate` uses, or `null` when there is no real conflict.
|
|
563
|
+
*
|
|
564
|
+
* Fail-open by construction: a missing feature worktree, any git error, or a
|
|
565
|
+
* dry-run that cannot run cleanly all return `null`, so the caller falls
|
|
566
|
+
* through to the existing sync/validate flow rather than blocking on a broken
|
|
567
|
+
* optimization.
|
|
568
|
+
*/
|
|
569
|
+
async preflightConflict(job, baseMainSha) {
|
|
570
|
+
const root = this.config.root;
|
|
571
|
+
const featureBranch = job.branch ?? job.taskId;
|
|
572
|
+
// No worktree to run against: let the normal flow report the real failure
|
|
573
|
+
// ("feature branch … worktree not found") rather than guessing here.
|
|
574
|
+
const featureWtPath = worktreePathForBranch(root, featureBranch);
|
|
575
|
+
if (!featureWtPath)
|
|
576
|
+
return null;
|
|
577
|
+
const task = this.getTask?.(job.taskId);
|
|
578
|
+
const autoResolve = ["dist/", "screenshots/", ...(task ? [relative(root, task.absPath)] : [])];
|
|
579
|
+
// Same semantics as `validateCandidate`: unrelated task files keep main's
|
|
580
|
+
// side, everything else in `autoResolve` takes the branch's side. Only the
|
|
581
|
+
// real-conflict classification is needed here, so the direction is moot.
|
|
582
|
+
const autoResolveOurs = ["work/"];
|
|
583
|
+
let preflight;
|
|
584
|
+
try {
|
|
585
|
+
preflight = await mergeBranch(featureWtPath, baseMainSha, {
|
|
586
|
+
autoResolve,
|
|
587
|
+
autoResolveOurs,
|
|
588
|
+
dryRun: true,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
this.logger?.integration(job.taskId, "warn", "pre-flight conflict check failed — falling through to the normal sync/validate flow", { reason: err instanceof Error ? err.message : String(err) });
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
if (preflight.merged || preflight.conflicts.length === 0)
|
|
596
|
+
return null;
|
|
597
|
+
return (`merge conflict in ${preflight.conflicts.join(", ")} — resolve it in ` +
|
|
598
|
+
`the feature branch's own worktree (merge main into the branch), then retry`);
|
|
599
|
+
}
|
|
600
|
+
async syncCandidate(job) {
|
|
601
|
+
this.onProgress?.("sync");
|
|
602
|
+
const root = this.config.root;
|
|
603
|
+
const branch = candidateBranchName(job.taskId);
|
|
604
|
+
// Resolve the repository's actual default branch
|
|
605
|
+
const mainBranch = await resolveDefaultBranch(root);
|
|
606
|
+
// Record main SHA at validation start.
|
|
607
|
+
const mainShaRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
|
|
608
|
+
if (mainShaRes.status !== 0) {
|
|
609
|
+
return { ok: false, reason: "could not get main SHA" };
|
|
610
|
+
}
|
|
611
|
+
const baseMainSha = mainShaRes.stdout.trim();
|
|
612
|
+
this.coordinator.updateJob(job.taskId, { baseMainSha });
|
|
613
|
+
// Pre-flight (#0358): a real, non-auto-resolvable conflict against current
|
|
614
|
+
// main is fully knowable from cheap git plumbing, so detect it BEFORE the
|
|
615
|
+
// candidate worktree is created and route straight to the conflict-repair
|
|
616
|
+
// handoff the validating phase already uses. This job then never pays for
|
|
617
|
+
// a candidate worktree (or a doomed merge) just to rediscover a conflict
|
|
618
|
+
// that was predictable in advance. Any failure of the check itself is
|
|
619
|
+
// fail-open — fall through to the normal sync/validate flow unchanged.
|
|
620
|
+
const preflightReason = await this.preflightConflict(job, baseMainSha);
|
|
621
|
+
if (preflightReason) {
|
|
622
|
+
return { ok: false, conflict: true, reason: preflightReason };
|
|
623
|
+
}
|
|
624
|
+
// Ensure candidate worktree exists and is on main.
|
|
625
|
+
const wtRes = ensureWorktree(root, branch, `candidate-${job.taskId}`);
|
|
626
|
+
if (!wtRes.ok) {
|
|
627
|
+
return { ok: false, reason: `could not create candidate worktree: ${wtRes.reason}` };
|
|
628
|
+
}
|
|
629
|
+
// A fresh candidate worktree has no dependencies, and the gate below runs a
|
|
630
|
+
// full `bun run build` + check. Reuse the main checkout's node_modules via
|
|
631
|
+
// a symlink instead of a slow cold install; fail-soft so a missing install
|
|
632
|
+
// surfaces as a build/check error rather than a misleading sync failure.
|
|
633
|
+
const candidateNodeModules = join(wtRes.path, "node_modules");
|
|
634
|
+
if (!existsSync(candidateNodeModules)) {
|
|
635
|
+
const rootNodeModules = join(root, "node_modules");
|
|
636
|
+
if (existsSync(rootNodeModules)) {
|
|
637
|
+
try {
|
|
638
|
+
symlinkSync(rootNodeModules, candidateNodeModules, "dir");
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
/* fail-soft: the build step will report the real error */
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Reset candidate to main so it's a clean base for the merge.
|
|
646
|
+
const resetRes = await runGit(wtRes.path, ["reset", "--hard", mainBranch], 30_000);
|
|
647
|
+
if (resetRes.status !== 0) {
|
|
648
|
+
return { ok: false, reason: `could not reset candidate to main: ${resetRes.stderr}` };
|
|
649
|
+
}
|
|
650
|
+
// Validate and record the feature branch SHA.
|
|
651
|
+
const taskBranch = job.branch ?? job.taskId;
|
|
652
|
+
// Check if the feature branch exists (critical: avoid merging unrelated history)
|
|
653
|
+
const taskWtPath = worktreePathForBranch(root, taskBranch);
|
|
654
|
+
if (!taskWtPath) {
|
|
655
|
+
return { ok: false, reason: `feature branch ${taskBranch} worktree not found` };
|
|
656
|
+
}
|
|
657
|
+
const branchShaRes = await runGit(taskWtPath, ["rev-parse", "HEAD"], 4000);
|
|
658
|
+
if (branchShaRes.status !== 0) {
|
|
659
|
+
return { ok: false, reason: "could not get feature branch SHA" };
|
|
660
|
+
}
|
|
661
|
+
this.coordinator.updateJob(job.taskId, { branchSha: branchShaRes.stdout.trim() });
|
|
662
|
+
return { ok: true, candidateSha: baseMainSha };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Merge the feature branch into the candidate, then run the gate on it.
|
|
666
|
+
*
|
|
667
|
+
* `retryable: false` marks a failure that cannot possibly resolve on a second
|
|
668
|
+
* identical run — a merge conflict is the same conflict every time — so the
|
|
669
|
+
* caller can skip its retry instead of spending another few minutes proving
|
|
670
|
+
* the point. Everything else defaults to retryable: build and check failures
|
|
671
|
+
* are where genuine flakiness lives.
|
|
672
|
+
*/
|
|
673
|
+
async validateCandidate(job) {
|
|
674
|
+
const root = this.config.root;
|
|
675
|
+
const branch = candidateBranchName(job.taskId);
|
|
676
|
+
const wtPath = worktreePathForBranch(root, branch);
|
|
677
|
+
if (!wtPath) {
|
|
678
|
+
return { ok: false, reason: "candidate worktree not found" };
|
|
679
|
+
}
|
|
680
|
+
// Check for main SHA changes. If main advanced, discard candidate and rebuild.
|
|
681
|
+
const mainBranch = await resolveDefaultBranch(root);
|
|
682
|
+
const currentMainRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
|
|
683
|
+
if (currentMainRes.status !== 0) {
|
|
684
|
+
return { ok: false, reason: "could not get current main SHA" };
|
|
685
|
+
}
|
|
686
|
+
const currentMainSha = currentMainRes.stdout.trim();
|
|
687
|
+
if (job.baseMainSha && currentMainSha !== job.baseMainSha) {
|
|
688
|
+
// Main advanced: discard candidate, rebuild from new SHA, and revalidate.
|
|
689
|
+
removeWorktree(root, branch);
|
|
690
|
+
this.coordinator.updateJob(job.taskId, {
|
|
691
|
+
phase: "syncing",
|
|
692
|
+
baseMainSha: null,
|
|
693
|
+
candidateSha: null,
|
|
694
|
+
});
|
|
695
|
+
// The resync runs the same pre-flight; a conflict it finds must keep the
|
|
696
|
+
// non-retryable classification so the caller still routes it to repair.
|
|
697
|
+
const resync = await this.syncCandidate(job);
|
|
698
|
+
if (!resync.ok && resync.conflict) {
|
|
699
|
+
return { ok: false, retryable: false, reason: resync.reason };
|
|
700
|
+
}
|
|
701
|
+
return resync;
|
|
702
|
+
}
|
|
703
|
+
// Merge feature branch into candidate.
|
|
704
|
+
const featureBranch = job.branch ?? job.taskId;
|
|
705
|
+
// Verify feature branch still exists before attempting merge
|
|
706
|
+
const branchListRes = await runGit(root, ["branch", "--list", featureBranch], 4000);
|
|
707
|
+
if (branchListRes.status !== 0 || !branchListRes.stdout.trim()) {
|
|
708
|
+
return { ok: false, reason: `feature branch ${featureBranch} no longer exists` };
|
|
709
|
+
}
|
|
710
|
+
// In the candidate worktree, merge the feature branch from its location.
|
|
711
|
+
// dist/ and screenshots/ are generated output — the build step right
|
|
712
|
+
// after this merge (below) regenerates them from source regardless of
|
|
713
|
+
// what the merge produced, so a conflict there must never block the
|
|
714
|
+
// merge. The task's own doc file routinely differs between main and the
|
|
715
|
+
// branch (status/review_rounds bookkeeping on either side), so its
|
|
716
|
+
// branch version is taken as authoritative, same as the legacy done.ts
|
|
717
|
+
// close-out path. Reuses the existing, tested autoResolve semantics in
|
|
718
|
+
// core/git.ts rather than reimplementing conflict resolution here.
|
|
719
|
+
//
|
|
720
|
+
// dist/ is gitignored on main as of 2026-08-15 (see docs/dogfooding-vs-
|
|
721
|
+
// general.md), so most new merges won't touch this entry at all — a
|
|
722
|
+
// branch that never modified dist/ resolves as a clean deletion. It stays
|
|
723
|
+
// in the list because a branch cut BEFORE that change can still have
|
|
724
|
+
// dist/ tracked and modified; mergeBranch's `-X theirs` fallback already
|
|
725
|
+
// handles that as a modify/delete conflict. Safe to drop once no such
|
|
726
|
+
// branch remains, but harmless to leave indefinitely.
|
|
727
|
+
const task = this.getTask?.(job.taskId);
|
|
728
|
+
const autoResolve = ["dist/", "screenshots/", ...(task ? [relative(root, task.absPath)] : [])];
|
|
729
|
+
// The task currently closing is authoritative on its branch. Other task
|
|
730
|
+
// files can change independently on main (for example, a CTO nudge), so
|
|
731
|
+
// preserve main's version for those rather than blocking close-out.
|
|
732
|
+
const autoResolveOurs = ["work/"];
|
|
733
|
+
this.onProgress?.("merge");
|
|
734
|
+
const merge = await mergeBranch(wtPath, featureBranch, { autoResolve, autoResolveOurs });
|
|
735
|
+
if (!merge.merged) {
|
|
736
|
+
// A conflict is a property of the two trees, not of the machine. Retrying
|
|
737
|
+
// re-derives the identical conflict; the fix is always to merge main into
|
|
738
|
+
// the feature branch and resolve it there (see docs/close-out-pipeline.md).
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
retryable: false,
|
|
742
|
+
reason: merge.conflicts.length
|
|
743
|
+
? `merge conflict in ${merge.conflicts.join(", ")} — resolve it in the feature branch's own worktree (merge main into the branch), then retry`
|
|
744
|
+
: (merge.reason ?? "merge failed"),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
// The merge "succeeded" but must have actually changed something, unless
|
|
748
|
+
// the branch was already fully in main. A no-op merge that swallowed real
|
|
749
|
+
// branch work would otherwise sail through the gate (it validates bare
|
|
750
|
+
// main) and publish the task as done with nothing integrated (#0306 et al).
|
|
751
|
+
const postMergeHead = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
|
|
752
|
+
if (postMergeHead.status === 0) {
|
|
753
|
+
const dropped = await detectDroppedMerge(root, mainBranch, featureBranch, postMergeHead.stdout.trim(), currentMainSha);
|
|
754
|
+
if (dropped) {
|
|
755
|
+
return { ok: false, retryable: false, reason: dropped };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// A clean merge still carries any edits the feature branch made to OTHER
|
|
759
|
+
// tasks' work/*.md files (branch changed them, main did not touch them since
|
|
760
|
+
// the merge-base → git merged them with no conflict, so `autoResolveOurs`,
|
|
761
|
+
// which only runs on conflicts, never fired). Restore main's copy of each.
|
|
762
|
+
// Observed live: #0319's close-out published #0202/#0275 frontmatter drift.
|
|
763
|
+
try {
|
|
764
|
+
const reset = await resetForeignWorkFiles({
|
|
765
|
+
candidateWtPath: wtPath,
|
|
766
|
+
baseMainSha: currentMainSha,
|
|
767
|
+
workDir: this.config.workDir,
|
|
768
|
+
ownWorkFile: task ? relative(root, task.absPath) : null,
|
|
769
|
+
});
|
|
770
|
+
if (reset.length > 0) {
|
|
771
|
+
this.logger?.integration(job.taskId, "info", "restored unrelated task files to main during close-out", { files: reset });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
this.logger?.integration(job.taskId, "warn", "could not restore unrelated task files during close-out", { reason: err instanceof Error ? err.message : String(err) });
|
|
776
|
+
}
|
|
777
|
+
// Check for merge conflict markers in text files (unresolved conflicts in content).
|
|
778
|
+
const lsRes = await runGit(wtPath, ["ls-files"], 5000);
|
|
779
|
+
if (lsRes.status === 0) {
|
|
780
|
+
const files = lsRes.stdout.split("\n").filter(Boolean);
|
|
781
|
+
const textExtensions = [".json", ".md", ".ts", ".tsx", ".js", ".jsx", ".css", ".html"];
|
|
782
|
+
for (const file of files) {
|
|
783
|
+
if (textExtensions.some((ext) => file.endsWith(ext))) {
|
|
784
|
+
try {
|
|
785
|
+
const content = readFileSync(join(wtPath, file), "utf8");
|
|
786
|
+
// Check for git conflict markers: exactly 7 of each character at line start
|
|
787
|
+
if (/^<{7}$|^={7}$|^>{7}$/m.test(content)) {
|
|
788
|
+
return { ok: false, reason: `unresolved conflict markers in ${file}` };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
/* skip unreadable files */
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// Docs-only fast path (#0355): a candidate whose merged diff touches
|
|
798
|
+
// nothing but documentation cannot be affected by build/typecheck/tests/
|
|
799
|
+
// ui-smoke, so the whole expensive post-merge gate is skipped. The
|
|
800
|
+
// predicate is literal — ANY path outside the allowlist (one line in
|
|
801
|
+
// src/, repoos.toml, package.json, scripts/, .github/, …) runs the full
|
|
802
|
+
// gate unchanged — and a git error (`null`) always fails safe to the full
|
|
803
|
+
// gate too.
|
|
804
|
+
const changedPaths = await getChangedFilePaths(wtPath, mainBranch);
|
|
805
|
+
const docsOnly = changedPaths !== null && isDocsOnlyChange(changedPaths);
|
|
806
|
+
if (docsOnly) {
|
|
807
|
+
this.logger?.integration(job.taskId, "info", "docs-only candidate — skipping the full build + check gate (no code/source paths changed)", { paths: changedPaths });
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// Run the post-merge gate: build + check via bun/npm.
|
|
811
|
+
this.onProgress?.("build");
|
|
812
|
+
let buildRes = await runProcess("bun", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
|
|
813
|
+
if (commandMissing(buildRes)) {
|
|
814
|
+
buildRes = await runProcess("npm", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
|
|
815
|
+
}
|
|
816
|
+
if (buildRes.status !== 0) {
|
|
817
|
+
return { ok: false, reason: `build failed: ${tailLine(buildRes.stdout, buildRes.stderr)}` };
|
|
818
|
+
}
|
|
819
|
+
// Remote Validation Runner (docs/remote-validation.md): hand the expensive
|
|
820
|
+
// half of the gate — `bun install` + `bun run build` + `bun run test` — to a
|
|
821
|
+
// disposable cloud VM, which is where MTD keeps failing under local memory
|
|
822
|
+
// pressure. On a remote pass, the LOCAL `repoos check` below runs only the
|
|
823
|
+
// cheap static guards + UI smoke (REPOOS_SKIP_TESTS=1). On a transient infra
|
|
824
|
+
// failure the job fails RETRYABLY (resumes from this phase) unless
|
|
825
|
+
// `remoteValidation.fallbackToLocal` is set; a real remote test failure is
|
|
826
|
+
// non-retryable — fix it in the feature branch and resubmit.
|
|
827
|
+
let skipTestsLocally = false;
|
|
828
|
+
if (this.remoteValidator && this.config.remoteValidation?.enabled) {
|
|
829
|
+
this.onProgress?.("check");
|
|
830
|
+
const headRes = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
|
|
831
|
+
if (headRes.status !== 0) {
|
|
832
|
+
return { ok: false, reason: "could not resolve candidate HEAD before remote validation" };
|
|
833
|
+
}
|
|
834
|
+
const remote = await this.remoteValidator.validate({
|
|
835
|
+
taskId: job.taskId,
|
|
836
|
+
worktreePath: wtPath,
|
|
837
|
+
candidateSha: headRes.stdout.trim(),
|
|
838
|
+
});
|
|
839
|
+
if (remote.ok) {
|
|
840
|
+
skipTestsLocally = true;
|
|
841
|
+
}
|
|
842
|
+
else if (remote.transient && !this.config.remoteValidation.fallbackToLocal) {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
retryable: true,
|
|
846
|
+
reason: `${remote.detail ?? "remote validation unavailable"} — the branch IS merged into the candidate; retrying resumes from the check step`,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
else if (!remote.transient) {
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
retryable: false,
|
|
853
|
+
reason: `remote validation failed: ${remote.detail ?? "build or test suite failed on the runner"} — fix it in the feature branch and resubmit`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
this.logger?.integration(job.taskId, "warn", "remote validation unavailable — falling back to the full local gate (remoteValidation.fallbackToLocal)", { detail: remote.detail });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
this.onProgress?.("check");
|
|
861
|
+
// The candidate's OWN freshly-built CLI comes first, same as the legacy
|
|
862
|
+
// done.ts close-out gate (#0130): a globally linked `repoos` resolves
|
|
863
|
+
// build freshness and gate code against its own install snapshot, which
|
|
864
|
+
// can disagree with the checkout actually being validated here. Running
|
|
865
|
+
// `check` via the candidate's own `dist/cli/index.js` guarantees the gate
|
|
866
|
+
// evaluates the exact code that was just merged and built above.
|
|
867
|
+
// The build above already ran `bun run build` with nothing changed since,
|
|
868
|
+
// and `bun run build` is staleness-aware now (#0377), so `check`'s own
|
|
869
|
+
// "Full build" step detects the fresh marker and skips itself — no
|
|
870
|
+
// private skip env flag needed.
|
|
871
|
+
const checkEnv = {
|
|
872
|
+
...process.env,
|
|
873
|
+
...(skipTestsLocally ? { REPOOS_SKIP_TESTS: "1" } : {}),
|
|
874
|
+
};
|
|
875
|
+
const localCli = join(wtPath, "dist", "cli", "index.js");
|
|
876
|
+
const localCliPresent = existsSync(localCli);
|
|
877
|
+
const checkHandle = this.taskChecks && this.onTaskCheckEvent
|
|
878
|
+
? this.taskChecks.start(job.taskId, "merge-gate", this.onTaskCheckEvent)
|
|
879
|
+
: undefined;
|
|
880
|
+
const rawCheck = (cli, args) => runProcess(cli, args, {
|
|
881
|
+
cwd: wtPath,
|
|
882
|
+
timeout: 600_000,
|
|
883
|
+
env: checkEnv,
|
|
884
|
+
onChunk: checkHandle?.chunk,
|
|
885
|
+
});
|
|
886
|
+
// A check whose ONLY failure is a stale build marker: the same marker the
|
|
887
|
+
// close-out build above should have refreshed. This is the self-resolving
|
|
888
|
+
// staleness pattern (#0276 Flavour B) — refreshing the marker and re-running
|
|
889
|
+
// the identical check on the same tree passes. Cases that are NOT this (a
|
|
890
|
+
// genuine non-staleness failure, or the local CLI being absent entirely) must
|
|
891
|
+
// not be absorbed; see the branching below.
|
|
892
|
+
const isStalenessFailure = (res) => /stale build|no build found|build-info\.json|build is stale|cannot verify build freshness/i.test(`${res.stdout}\n${res.stderr}`);
|
|
893
|
+
let checkRes;
|
|
894
|
+
// Why the check result deviates from a plain local-CLI pass:
|
|
895
|
+
// 'local-ok' — candidate's own CLI passed (common case)
|
|
896
|
+
// 'absorbed' — local CLI reported staleness; marker refreshed and
|
|
897
|
+
// re-check passed on the same tree (self-resolving)
|
|
898
|
+
// 'fallback' — local CLI failed for a genuine non-staleness reason;
|
|
899
|
+
// fell through to the global CLI fallback
|
|
900
|
+
// 'local-missing'— candidate's own CLI was absent AND this project is
|
|
901
|
+
// meant to build one — only the global CLI fallback
|
|
902
|
+
// could run (Flavour A, not self-resolving)
|
|
903
|
+
// 'no-cli-expected' — candidate's own CLI was absent, but this project
|
|
904
|
+
// never builds a dist/cli/index.js at all (the common
|
|
905
|
+
// case for a managed web/backend project) — the
|
|
906
|
+
// fallback is expected, not a regression signal
|
|
907
|
+
let outcome;
|
|
908
|
+
if (!localCliPresent) {
|
|
909
|
+
// Only a project that is itself meant to build dist/cli/index.js
|
|
910
|
+
// (RepoOS self-hosting, or another project with the same bin target)
|
|
911
|
+
// can suffer the #0213/3fbbd707 CLI-selection regression, where the
|
|
912
|
+
// global CLI fallback compares this candidate's src hash against a
|
|
913
|
+
// DIFFERENT install's marker — a guaranteed "stale" mismatch regardless
|
|
914
|
+
// of how fresh the candidate really is. For every other managed
|
|
915
|
+
// project (the common case) there was never a local CLI to find, so a
|
|
916
|
+
// missing one is expected, not a regression; labelling it as one on
|
|
917
|
+
// every such MTD (#0345) buried the real failure reason behind a false
|
|
918
|
+
// lead.
|
|
919
|
+
const cliExpected = expectsOwnCli(wtPath);
|
|
920
|
+
checkRes = await rawCheck("repoos", ["check"]);
|
|
921
|
+
outcome = cliExpected ? "local-missing" : "no-cli-expected";
|
|
922
|
+
if (cliExpected) {
|
|
923
|
+
this.logger?.integration(job.taskId, "error", "candidate dist/cli/index.js is missing — gate fell back to the globally linked repoos; any 'stale' result here is a CLI-selection regression (#0276 Flavour A), not self-resolving staleness");
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
checkRes = await rawCheck(process.execPath, [localCli, "check"]);
|
|
928
|
+
if (checkRes.status === 0) {
|
|
929
|
+
outcome = "local-ok";
|
|
930
|
+
}
|
|
931
|
+
else if (isStalenessFailure(checkRes)) {
|
|
932
|
+
// Self-resolving build staleness: only the stale-marker report failed,
|
|
933
|
+
// and that same marker is what `bun run build` below refreshes. Refresh
|
|
934
|
+
// it provably for the current source — `bun run build` is
|
|
935
|
+
// staleness-aware (#0377) and rebuilds because the marker is stale —
|
|
936
|
+
// then re-run the SAME check on the same candidate tree. Bounded to
|
|
937
|
+
// this one re-check — it never loops, and it stays inside
|
|
938
|
+
// validateCandidate rather than triggering an extra orchestrator-level
|
|
939
|
+
// retry / re-sync / debugger.
|
|
940
|
+
this.logger?.integration(job.taskId, "info", "check reported self-resolving build staleness — refreshing marker and re-checking the same tree in place (no debugger detour)");
|
|
941
|
+
await runProcess("bun", ["run", "build"], { cwd: wtPath, timeout: 300_000 });
|
|
942
|
+
checkRes = await rawCheck(process.execPath, [localCli, "check"]);
|
|
943
|
+
if (checkRes.status !== 0) {
|
|
944
|
+
checkHandle?.done(checkRes.status);
|
|
945
|
+
return {
|
|
946
|
+
ok: false,
|
|
947
|
+
reason: `check failed after in-place staleness re-check: ${tailLine(checkRes.stdout, checkRes.stderr)}`,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
outcome = "absorbed";
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
// Genuine non-staleness failure from the local CLI: preserve the prior
|
|
954
|
+
// fallback behaviour (retry via the global repoos, then bun run repoos).
|
|
955
|
+
outcome = "fallback";
|
|
956
|
+
checkRes = await rawCheck("repoos", ["check"]);
|
|
957
|
+
if (checkRes.status !== 0) {
|
|
958
|
+
checkRes = await rawCheck("bun", ["run", "repoos", "check"]);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
checkHandle?.done(checkRes.status);
|
|
963
|
+
if (checkRes.status !== 0) {
|
|
964
|
+
return {
|
|
965
|
+
ok: false,
|
|
966
|
+
reason: outcome === "local-missing"
|
|
967
|
+
? `check failed: the candidate's own dist/cli/index.js was not used (CLI-selection regression — the globally linked repoos evaluates a different install's build marker). ${tailLine(checkRes.stdout, checkRes.stderr)}`
|
|
968
|
+
: `check failed: ${tailLine(checkRes.stdout, checkRes.stderr)}`,
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// Candidate is green. Capture its SHA.
|
|
973
|
+
const candidateShaRes = await runGit(wtPath, ["rev-parse", "HEAD"], 4000);
|
|
974
|
+
if (candidateShaRes.status !== 0) {
|
|
975
|
+
return { ok: false, reason: "could not get candidate SHA after validation" };
|
|
976
|
+
}
|
|
977
|
+
// Capture the reviewable source diff before publication removes the task
|
|
978
|
+
// branch/worktree. The candidate is based on the exact main SHA checked
|
|
979
|
+
// above and contains the validated feature merge, so this records the
|
|
980
|
+
// precise change that is about to land (not a later, polluted main diff).
|
|
981
|
+
const snapshotDiff = await getDiff(wtPath, mainBranch);
|
|
982
|
+
const snapshotStats = getDiffStats(wtPath, mainBranch);
|
|
983
|
+
saveDiffSnapshot(root, this.config.cacheDir, job.taskId, snapshotStats, snapshotDiff);
|
|
984
|
+
return { ok: true, candidateSha: candidateShaRes.stdout.trim() };
|
|
985
|
+
}
|
|
986
|
+
async publishCandidate(job) {
|
|
987
|
+
const root = this.config.root;
|
|
988
|
+
const mainBranch = await resolveDefaultBranch(root);
|
|
989
|
+
const branch = candidateBranchName(job.taskId);
|
|
990
|
+
const wtPath = worktreePathForBranch(root, branch);
|
|
991
|
+
if (!wtPath) {
|
|
992
|
+
return { ok: false, reason: "candidate worktree missing at publish time" };
|
|
993
|
+
}
|
|
994
|
+
// Acquire the repository lock before publishing.
|
|
995
|
+
if (this.repoLock) {
|
|
996
|
+
let acquireAttempts = 0;
|
|
997
|
+
while (!this.repoLock.acquire(job.taskId) && acquireAttempts < 60) {
|
|
998
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
999
|
+
acquireAttempts++;
|
|
1000
|
+
}
|
|
1001
|
+
if (acquireAttempts >= 60) {
|
|
1002
|
+
return { ok: false, reason: "could not acquire publication lock (timeout)" };
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// Publishing mutates the live checkout. It must therefore mutually
|
|
1006
|
+
// exclude a hotfix, which also owns that checkout for its entire run.
|
|
1007
|
+
// Acquire after the publication lock so concurrent close-outs retain
|
|
1008
|
+
// their existing serialization, but before inspecting or merging main.
|
|
1009
|
+
if (this.rootLock && !this.rootLock.acquire(job.taskId, "close-out")) {
|
|
1010
|
+
const holder = this.rootLock.getHolder();
|
|
1011
|
+
this.repoLock?.release(job.taskId);
|
|
1012
|
+
return {
|
|
1013
|
+
ok: false,
|
|
1014
|
+
reason: `main checkout is held by ${holder?.kind ?? "another operation"} (task #${holder?.taskId ?? "unknown"}); wait for it to finish before moving this task to done`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
// Final SHA check: ensure candidate is still based on current main (holding the lock).
|
|
1019
|
+
const currentMainRes = await runGit(root, ["rev-parse", `${mainBranch}^{commit}`], 4000);
|
|
1020
|
+
if (currentMainRes.status !== 0) {
|
|
1021
|
+
return { ok: false, reason: "could not verify main before publish" };
|
|
1022
|
+
}
|
|
1023
|
+
const currentMainSha = currentMainRes.stdout.trim();
|
|
1024
|
+
if (job.baseMainSha !== currentMainSha) {
|
|
1025
|
+
// Main advanced between validation and publishing. Every OTHER retry
|
|
1026
|
+
// path in this close-out pipeline is capped (#0216's validate-phase
|
|
1027
|
+
// 2-attempt cap; handoff.ts's MAX_*_RETRY_ATTEMPTS for merge-conflict/
|
|
1028
|
+
// check-failure/handoff-signal repairs) — this one wasn't, so a task
|
|
1029
|
+
// whose sync→validate→publish cycle takes longer than the interval
|
|
1030
|
+
// between OTHER tasks landing on main could retry forever, never
|
|
1031
|
+
// winning the race. Reproduced live twice in one session (#0376,
|
|
1032
|
+
// #0382): 3-4 consecutive resets, ~2 minutes each, on a busy board.
|
|
1033
|
+
const driftCount = (job.publishDriftCount ?? 0) + 1;
|
|
1034
|
+
if (driftCount > MAX_PUBLISH_DRIFT_RETRIES) {
|
|
1035
|
+
// Deliberately do NOT set phase: "syncing" here — leaving it
|
|
1036
|
+
// unchanged (still "publishing") means processJob's own existing
|
|
1037
|
+
// `currentJob.phase === "syncing"` check below is false, so it
|
|
1038
|
+
// falls through to failOrReconcile exactly the way a genuine
|
|
1039
|
+
// publish failure already does. No new give-up path needed; this
|
|
1040
|
+
// reuses the one that's already there.
|
|
1041
|
+
return {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
reason: `main advanced ${driftCount} times in a row while trying to publish — giving up ` +
|
|
1044
|
+
"rather than retrying forever. The branch itself is fine, it's just losing the race " +
|
|
1045
|
+
"to land; retry Move-to-done once main quiets down.",
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
removeWorktree(root, branch);
|
|
1049
|
+
this.coordinator.updateJob(job.taskId, {
|
|
1050
|
+
phase: "syncing",
|
|
1051
|
+
baseMainSha: null,
|
|
1052
|
+
candidateSha: null,
|
|
1053
|
+
publishDriftCount: driftCount,
|
|
1054
|
+
});
|
|
1055
|
+
return { ok: false, reason: "main advanced, revalidating" };
|
|
1056
|
+
}
|
|
1057
|
+
// Ensure the main checkout is on the actual main branch before merging.
|
|
1058
|
+
// A branch-mode hotfix leaves the main checkout on its hotfix branch
|
|
1059
|
+
// (ensureHotfix checks it out there). Merging the candidate into the
|
|
1060
|
+
// hotfix branch instead of main silently succeeds (FF or no-op) but
|
|
1061
|
+
// leaves main unchanged and blocks hotfix-branch cleanup. Switch to
|
|
1062
|
+
// main first so the merge, dirty check, and subsequent branch deletion
|
|
1063
|
+
// all target the correct branch.
|
|
1064
|
+
const currentHead = currentBranch(root);
|
|
1065
|
+
if (currentHead && currentHead !== mainBranch) {
|
|
1066
|
+
const checkoutRes = await runGit(root, ["checkout", mainBranch], 10_000);
|
|
1067
|
+
if (checkoutRes.status !== 0) {
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
reason: `could not switch main checkout from ${currentHead} to ${mainBranch} before publishing (${checkoutRes.stderr.trim()}). The candidate was NOT merged; retry.`,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
// Publish-time dirty-main guard (#0211): the main working tree can be
|
|
1075
|
+
// dirtied between enqueue and publish (validation runs minutes-long
|
|
1076
|
+
// builds in the candidate worktree while `repoos check` regenerates a
|
|
1077
|
+
// dirty `dist/` on main). Re-check right before the merge so a dirty
|
|
1078
|
+
// main is surfaced as an actionable message instead of git's raw
|
|
1079
|
+
// "your local changes would be overwritten" at the tail of a long job.
|
|
1080
|
+
// Fails closed: an error/timeout in the check is "unknown", never
|
|
1081
|
+
// "clean", so we never merge blindly.
|
|
1082
|
+
let dirtyOnMain;
|
|
1083
|
+
try {
|
|
1084
|
+
dirtyOnMain = await dirtyFiles(root);
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
if (err instanceof GitDirtyCheckError) {
|
|
1088
|
+
return {
|
|
1089
|
+
ok: false,
|
|
1090
|
+
reason: `could not verify main is clean at publish time (${err.message}). The candidate was NOT merged; retry, or commit/stash main's working tree first.`,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
throw err;
|
|
1094
|
+
}
|
|
1095
|
+
if (dirtyOnMain.length > 0) {
|
|
1096
|
+
// Auto-checkpoint routine, server-written churn instead of blocking
|
|
1097
|
+
// the merge on it (#0271 follow-up, confirmed live: #0293's close-out
|
|
1098
|
+
// was refused because an UNRELATED task's `work/*.md` file — an
|
|
1099
|
+
// activity-log stamp from a normal status/override change — was
|
|
1100
|
+
// dirty on main at publish time). Every routine task write already
|
|
1101
|
+
// auto-commits this exact way via `commitTaskFile`/`recordChange`
|
|
1102
|
+
// elsewhere in the system; a task file being dirty for a moment
|
|
1103
|
+
// between that write and this check is expected traffic on a busy
|
|
1104
|
+
// board, not a signal something risky is in flight.
|
|
1105
|
+
//
|
|
1106
|
+
// `repoos.toml` gets the same treatment: it's always written whole by
|
|
1107
|
+
// the settings API (agent config, maxActiveTasks, etc.), never
|
|
1108
|
+
// hand-edited mid-change the way a source file might be, so a dirty
|
|
1109
|
+
// moment there is the same class of routine churn, not a signal of
|
|
1110
|
+
// in-progress human work.
|
|
1111
|
+
//
|
|
1112
|
+
// Scoped narrowly: only when EVERY dirty path is under the work dir
|
|
1113
|
+
// or is exactly `repoos.toml` — anything else (source, other config,
|
|
1114
|
+
// a stray build artifact) still fails closed exactly as before,
|
|
1115
|
+
// since that's genuinely ambiguous and worth a human's attention
|
|
1116
|
+
// rather than a blind auto-commit.
|
|
1117
|
+
const workPrefix = `${this.config.workDir}/`;
|
|
1118
|
+
const isSafeChurn = (p) => p.startsWith(workPrefix) || p === "repoos.toml";
|
|
1119
|
+
const onlySafeChurn = dirtyOnMain.every(isSafeChurn);
|
|
1120
|
+
if (onlySafeChurn) {
|
|
1121
|
+
try {
|
|
1122
|
+
await commitDirtyFiles(root, "chore: checkpoint bookkeeping/config before publish");
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
reason: `main has ${dirtyOnMain.length} uncommitted file${dirtyOnMain.length === 1 ? "" : "s"} at publish time and the automatic checkpoint commit failed: ${dirtyOnMain.slice(0, 8).join(", ")}${dirtyOnMain.length > 8 ? ", …" : ""}. The candidate was NOT merged; commit or stash those on main and retry.`,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
return {
|
|
1133
|
+
ok: false,
|
|
1134
|
+
reason: `main has ${dirtyOnMain.length} uncommitted file${dirtyOnMain.length === 1 ? "" : "s"} at publish time, so the merge would abort: ${dirtyOnMain.slice(0, 8).join(", ")}${dirtyOnMain.length > 8 ? ", …" : ""}. The candidate was NOT merged; commit or stash those on main (or use "Commit & continue") and retry.`,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Merge candidate to live main. Reuses mergeBranch's tested
|
|
1139
|
+
// autoResolve semantics (already used above, in the opposite
|
|
1140
|
+
// direction, to merge main into the candidate worktree) instead of a
|
|
1141
|
+
// hand-rolled ff-only/no-edit pair with no conflict recovery — that
|
|
1142
|
+
// older version left a genuine conflict as a dangling, uncommitted
|
|
1143
|
+
// `git merge` on the LIVE main checkout (MERGE_HEAD set, conflict
|
|
1144
|
+
// markers on disk) rather than aborting it, so every later retry's
|
|
1145
|
+
// dirty-main check just re-discovered the same stuck merge and
|
|
1146
|
+
// reported it as generic "uncommitted changes" (task #0338's
|
|
1147
|
+
// close-out hit exactly this on work/0338-*.md — main had picked up
|
|
1148
|
+
// its own bookkeeping commits for that same task file while the
|
|
1149
|
+
// candidate was in flight, a routine, expected divergence that should
|
|
1150
|
+
// never have blocked a merge in the first place).
|
|
1151
|
+
// dist/screenshots/ are generated output (never worth keeping main's
|
|
1152
|
+
// stale copy over the candidate's); every work/*.md file keeps MAIN's
|
|
1153
|
+
// copy — main is authoritative for task bookkeeping by publish time
|
|
1154
|
+
// (routine writes land there throughout the task's lifetime), the
|
|
1155
|
+
// reverse of the validate-phase merge above where the candidate's own
|
|
1156
|
+
// file is what's being tested in isolation.
|
|
1157
|
+
const publishMerge = await mergeBranch(root, branch, {
|
|
1158
|
+
autoResolve: ["dist/", "screenshots/"],
|
|
1159
|
+
autoResolveOurs: ["work/"],
|
|
1160
|
+
});
|
|
1161
|
+
if (!publishMerge.merged) {
|
|
1162
|
+
if (publishMerge.conflicts.length > 0) {
|
|
1163
|
+
return {
|
|
1164
|
+
ok: false,
|
|
1165
|
+
reason: `main has a genuine merge conflict with the candidate (not auto-resolvable): ${publishMerge.conflicts.slice(0, 8).join(", ")}${publishMerge.conflicts.length > 8 ? ", …" : ""}. The candidate was NOT merged and the attempt was cleanly aborted; resolve this on the branch (merge main into it) and retry.`,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
// A dirty main that slipped in between the check and the merge
|
|
1169
|
+
// surfaces as git's raw "would be overwritten" failure with no
|
|
1170
|
+
// conflict list — re-frame as an actionable instruction.
|
|
1171
|
+
if (/would be overwritten by merge/i.test(publishMerge.reason ?? "")) {
|
|
1172
|
+
return {
|
|
1173
|
+
ok: false,
|
|
1174
|
+
reason: `main has uncommitted files blocking the merge. The candidate was NOT merged; commit or stash those on main (or use "Commit & continue") and retry.`,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
ok: false,
|
|
1179
|
+
reason: `could not merge to main: ${publishMerge.reason ?? "unknown error"}`,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
// Guard against the same failure class detectDroppedMerge exists for
|
|
1183
|
+
// (#0306/#0307/#0309/#0312, see its docstring above): the autoResolve
|
|
1184
|
+
// this merge just used is new here (the old ff-only/no-edit pair had
|
|
1185
|
+
// none), so confirm it didn't silently discard real candidate content
|
|
1186
|
+
// the way conflict auto-resolution has before. Cheap and specific:
|
|
1187
|
+
// only fires when the merge produced no change at all AND the branch
|
|
1188
|
+
// still genuinely differs from main.
|
|
1189
|
+
const postMergeHead = await runGit(root, ["rev-parse", "HEAD"], 4000);
|
|
1190
|
+
if (postMergeHead.status === 0) {
|
|
1191
|
+
const dropped = await detectDroppedMerge(root, mainBranch, branch, postMergeHead.stdout.trim(), currentMainSha);
|
|
1192
|
+
if (dropped) {
|
|
1193
|
+
return { ok: false, reason: dropped };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// Rebuild the live checkout's dist/ so the running `repoos serve`
|
|
1197
|
+
// process (which loads compiled JS at boot and never reloads on its
|
|
1198
|
+
// own) has something newer to notice. dist/ is gitignored, so the
|
|
1199
|
+
// merge above brought in new source but left dist/ exactly as it was.
|
|
1200
|
+
// This runs while rootLock/repoLock are still held (released in the
|
|
1201
|
+
// `finally` below), so ReloadManager's closingOut() check correctly
|
|
1202
|
+
// parks the resulting build for a user-triggered reload instead of
|
|
1203
|
+
// restarting the server out from under this still-running publish.
|
|
1204
|
+
// Fail-soft: the merge already succeeded, so a failed rebuild here
|
|
1205
|
+
// must not fail the whole publish — it just leaves dist/ stale, the
|
|
1206
|
+
// same pre-existing failure mode this is fixing, now at least logged.
|
|
1207
|
+
//
|
|
1208
|
+
// Skip entirely when there's no package.json: neither `bun run build`
|
|
1209
|
+
// nor the `npm run build` fallback can ever succeed without one, so
|
|
1210
|
+
// attempting them is two guaranteed-useless subprocess spawns every
|
|
1211
|
+
// time — real cost under load, and observed contributing to orphaned
|
|
1212
|
+
// build processes outliving a fixture test's own vitest timeout
|
|
1213
|
+
// (testTimeout: 15_000 vs. up to 300s per attempt here). Root without
|
|
1214
|
+
// a package.json only happens in this orchestrator's own unit-test
|
|
1215
|
+
// fixtures (bare git repos with no app to build) — a real repo always
|
|
1216
|
+
// has one, so this never skips a build that could have mattered.
|
|
1217
|
+
const canBuild = existsSync(join(root, "package.json"));
|
|
1218
|
+
let rebuildRes;
|
|
1219
|
+
if (canBuild) {
|
|
1220
|
+
rebuildRes = await runProcess("bun", ["run", "build"], { cwd: root, timeout: 300_000 });
|
|
1221
|
+
if (commandMissing(rebuildRes)) {
|
|
1222
|
+
rebuildRes = await runProcess("npm", ["run", "build"], { cwd: root, timeout: 300_000 });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
if (!canBuild) {
|
|
1226
|
+
console.error(`Post-merge rebuild of ${root} skipped for task ${job.taskId}: no package.json`);
|
|
1227
|
+
}
|
|
1228
|
+
else if (rebuildRes && rebuildRes.status !== 0) {
|
|
1229
|
+
console.error(`Post-merge rebuild of ${root} failed for task ${job.taskId}: ${tailLine(rebuildRes.stdout, rebuildRes.stderr)}`);
|
|
1230
|
+
}
|
|
1231
|
+
this.onProgress?.("done");
|
|
1232
|
+
return { ok: true };
|
|
1233
|
+
}
|
|
1234
|
+
finally {
|
|
1235
|
+
this.rootLock?.release(job.taskId);
|
|
1236
|
+
// Always release the lock when done publishing.
|
|
1237
|
+
if (this.repoLock) {
|
|
1238
|
+
this.repoLock.release(job.taskId);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
async cleanup(job) {
|
|
1243
|
+
const root = this.config.root;
|
|
1244
|
+
const featureBranch = job.branch ?? job.taskId;
|
|
1245
|
+
// Candidate worktree + throwaway branch.
|
|
1246
|
+
this.removeCandidate(job.taskId);
|
|
1247
|
+
// Task's feature worktree + branch. The branch was just merged into main, so
|
|
1248
|
+
// `-d` is correct; a false return means the worktree is still registered
|
|
1249
|
+
// (leak) — log the path so a human/gc can see which one.
|
|
1250
|
+
if (!removeWorktree(root, featureBranch)) {
|
|
1251
|
+
const stuck = worktreePathForBranch(root, featureBranch);
|
|
1252
|
+
this.logger?.integration(job.taskId, "warn", "feature worktree not removed at close-out", {
|
|
1253
|
+
branch: featureBranch,
|
|
1254
|
+
path: stuck ?? "unknown",
|
|
1255
|
+
});
|
|
1256
|
+
console.warn(`Close-out for ${job.taskId}: feature worktree for ${featureBranch} still registered (${stuck ?? "unknown"})`);
|
|
1257
|
+
}
|
|
1258
|
+
deleteBranch(root, featureBranch);
|
|
1259
|
+
pruneWorktrees(root);
|
|
1260
|
+
// Mark the task as done in the main checkout.
|
|
1261
|
+
//
|
|
1262
|
+
// The live index can miss a lookup right after a reload (its in-memory
|
|
1263
|
+
// rebuild is not instant, and this cleanup step can land in that window —
|
|
1264
|
+
// observed live, 2026-08-15, #0195: publish succeeded, main fast-forwarded
|
|
1265
|
+
// to the candidate, but this step silently no-op'd — `task` was undefined,
|
|
1266
|
+
// the `if (task)` guard skipped markTaskReleased with no error, and the
|
|
1267
|
+
// task sat published-but-not-released until a manual retry). Falling back
|
|
1268
|
+
// to a direct on-disk lookup by id makes this step independent of index
|
|
1269
|
+
// freshness — the file is exactly what markTaskReleased writes to anyway.
|
|
1270
|
+
try {
|
|
1271
|
+
const absPath = this.getTask?.(job.taskId)?.absPath ??
|
|
1272
|
+
findTaskFileById(root, this.config.workDir, job.taskId);
|
|
1273
|
+
if (absPath) {
|
|
1274
|
+
markTaskReleased(this.config, absPath);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
console.error(`Could not locate task ${job.taskId} on disk to mark it released — publish succeeded but release marking was skipped`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
catch (err) {
|
|
1281
|
+
console.error(`Failed to mark task ${job.taskId} as done:`, err);
|
|
1282
|
+
// Don't fail cleanup - the important part (merge) already succeeded
|
|
1283
|
+
}
|
|
1284
|
+
// Opportunistic GC: every close-out is a natural moment to reclaim
|
|
1285
|
+
// `repoos/integrate/*` candidates that failed jobs left behind (this
|
|
1286
|
+
// task's own were just removed above). Conservative — never touches
|
|
1287
|
+
// `feat/*`. Also logs a "run `repoos gc`" warning if the worktree count is
|
|
1288
|
+
// over the advisory ceiling. Fail-soft and not awaited-critical.
|
|
1289
|
+
sweepAndWarn(this.config, {
|
|
1290
|
+
activeJobIds: new Set(this.coordinator
|
|
1291
|
+
.allJobs()
|
|
1292
|
+
.filter((j) => j.phase !== "done" && j.phase !== "failed")
|
|
1293
|
+
.map((j) => j.taskId)),
|
|
1294
|
+
threshold: this.config.worktreeWarnThreshold,
|
|
1295
|
+
log: (level, msg, meta) => this.logger?.integration(job.taskId, level, msg, meta),
|
|
1296
|
+
});
|
|
1297
|
+
return { ok: true };
|
|
1298
|
+
}
|
|
1299
|
+
}
|