rovecode 0.4.0-beta.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -69
- package/THIRD_PARTY_NOTICES.md +0 -44
- package/bin/rovecode.ts +21 -0
- package/package.json +16 -37
- package/src/account/keys.ts +97 -0
- package/src/account/login.ts +158 -0
- package/src/account/provision.ts +47 -0
- package/src/account/store.ts +63 -0
- package/src/acp/server.ts +373 -0
- package/src/cli/account-cmd.ts +116 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +109 -0
- package/src/cli/doctor.ts +324 -0
- package/src/cli/export.ts +278 -0
- package/src/cli/help.ts +240 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +599 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-market-cmd.ts +299 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +172 -0
- package/src/cli/resume.ts +32 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +792 -0
- package/src/cli/setup.ts +187 -0
- package/src/cli/update-cmd.ts +78 -0
- package/src/cli/workflow-cmd.ts +100 -0
- package/src/coding/checkpoints.ts +270 -0
- package/src/coding/diff.ts +136 -0
- package/src/coding/files.ts +339 -0
- package/src/coding/hashline.ts +319 -0
- package/src/coding/lsp.ts +406 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +110 -0
- package/src/coding/repomap.ts +392 -0
- package/src/core/compaction.ts +399 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +397 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +398 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +236 -0
- package/src/core/loop.ts +621 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +207 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +167 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session.ts +398 -0
- package/src/core/settings.ts +98 -0
- package/src/core/stuck-detector.ts +273 -0
- package/src/core/tasks.ts +374 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tool-output-budget.ts +166 -0
- package/src/core/tools.ts +288 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/update.ts +158 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +237 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +218 -0
- package/src/eval/gauntlet.ts +226 -0
- package/src/eval/grader.ts +186 -0
- package/src/eval/record.ts +202 -0
- package/src/eval/redact.ts +141 -0
- package/src/eval/replay.ts +147 -0
- package/src/eval/trajectory.ts +373 -0
- package/src/index.ts +17 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +431 -0
- package/src/mcp/config.ts +239 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +0 -0
- package/src/mcp/tools.ts +131 -0
- package/src/mcp/trust.ts +49 -0
- package/src/memory/blocks.ts +175 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +123 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/init.ts +140 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +149 -0
- package/src/plugins/manifest.ts +106 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +293 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +408 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +302 -0
- package/src/providers/response-validation.ts +80 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +569 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/index.ts +3 -0
- package/src/server/dashboard.ts +144 -0
- package/src/server/http.ts +343 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +159 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +488 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +156 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +141 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +451 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/selection.ts +123 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +314 -0
- package/src/sextant/sextant-renderer.ts +478 -0
- package/src/sextant/sextant-repo.ts +131 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +473 -0
- package/src/skills/index.ts +306 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel.ts +353 -0
- package/src/telemetry/otlp.ts +68 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +216 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +331 -0
- package/src/tui/app.ts +608 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/commands.ts +277 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/info-cmd.ts +144 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +424 -0
- package/src/tui/providers-cmd.ts +366 -0
- package/src/tui/renderer.ts +101 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +146 -0
- package/src/tui/sextant-attach.ts +68 -0
- package/src/tui/sextant-io.ts +184 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/workflow/engine.ts +266 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
- package/CHANGELOG.md +0 -512
- package/bin/rovecode.js +0 -24
- package/dist/cli/app-dybnr56b.js +0 -2
- package/dist/cli/ask-user-p8hq4xgj.js +0 -2
- package/dist/cli/auth-login-ewpgw5sm.js +0 -2
- package/dist/cli/auth-m8p9grty.js +0 -2
- package/dist/cli/bench-xv3ypwev.js +0 -9
- package/dist/cli/catalog-737wb2s0.js +0 -2
- package/dist/cli/cli-arhg40m0.js +0 -2
- package/dist/cli/client-cf2pxx8q.js +0 -2
- package/dist/cli/commands-3p7e4xxs.js +0 -2
- package/dist/cli/connect-3q93d7cb.js +0 -2
- package/dist/cli/context-cmd-eqxmxhzq.js +0 -2
- package/dist/cli/context-report-hbw9zfes.js +0 -2
- package/dist/cli/count-remote-mby98cd0.js +0 -2
- package/dist/cli/design-122y0axd.js +0 -2
- package/dist/cli/dispatch-b4egzvvh.js +0 -2
- package/dist/cli/doctor-x4jkv72e.js +0 -3
- package/dist/cli/executor-ftvg6tsy.js +0 -2
- package/dist/cli/export-pdgdhkch.js +0 -2
- package/dist/cli/files-cez9a96p.js +0 -2
- package/dist/cli/gauntlet-r3xxaszc.js +0 -2
- package/dist/cli/gauntlet-runner-r515m7kk.js +0 -10
- package/dist/cli/gauntlet-wave3-bnkjk2v2.js +0 -5
- package/dist/cli/gauntlet-wave4-acs9s60q.js +0 -14
- package/dist/cli/hashline-ewg5hbe3.js +0 -2
- package/dist/cli/http-n0kehsk8.js +0 -5
- package/dist/cli/index-z5qt1s76.js +0 -2
- package/dist/cli/install-80mp63kx.js +0 -2
- package/dist/cli/loop-12twjcat.js +0 -2
- package/dist/cli/main-01pv9206.js +0 -4
- package/dist/cli/main-0jys2ccn.js +0 -3
- package/dist/cli/main-1ztz6fkj.js +0 -10
- package/dist/cli/main-23q7cmww.js +0 -9
- package/dist/cli/main-2rzbexn2.js +0 -3
- package/dist/cli/main-2wyax8k9.js +0 -9
- package/dist/cli/main-2yeveeve.js +0 -6
- package/dist/cli/main-2z3dek0b.js +0 -3
- package/dist/cli/main-2zgsknth.js +0 -3
- package/dist/cli/main-45ejth3a.js +0 -4
- package/dist/cli/main-45rn3trk.js +0 -22
- package/dist/cli/main-4p4e2w7x.js +0 -4
- package/dist/cli/main-4y0tnfpa.js +0 -16
- package/dist/cli/main-5py0rkmc.js +0 -4
- package/dist/cli/main-6dtqmbt6.js +0 -7
- package/dist/cli/main-6h9x282m.js +0 -4
- package/dist/cli/main-6vjeds42.js +0 -3
- package/dist/cli/main-78gq4bt9.js +0 -6
- package/dist/cli/main-7jd5vh3x.js +0 -4
- package/dist/cli/main-7kt6r53y.js +0 -4
- package/dist/cli/main-8c1tbazx.js +0 -58
- package/dist/cli/main-9a9rnh47.js +0 -19
- package/dist/cli/main-9ht36z12.js +0 -3
- package/dist/cli/main-a2yfvcy9.js +0 -7
- package/dist/cli/main-a3f51n0x.js +0 -5
- package/dist/cli/main-b8zq261k.js +0 -3
- package/dist/cli/main-bxtvnf6d.js +0 -13
- package/dist/cli/main-edxc3yzt.js +0 -4
- package/dist/cli/main-evgz4mp5.js +0 -21
- package/dist/cli/main-f33fc5je.js +0 -9
- package/dist/cli/main-fvnpq46y.js +0 -12
- package/dist/cli/main-gbbty4d4.js +0 -3
- package/dist/cli/main-gth53dnt.js +0 -25
- package/dist/cli/main-hqbz10aw.js +0 -9
- package/dist/cli/main-hrrvcfan.js +0 -38
- package/dist/cli/main-hzwtsb2m.js +0 -5
- package/dist/cli/main-j7ttv0sd.js +0 -34
- package/dist/cli/main-jak598k9.js +0 -5
- package/dist/cli/main-kba6zeyd.js +0 -6
- package/dist/cli/main-kwwsz6rq.js +0 -3
- package/dist/cli/main-m8vm17zq.js +0 -3
- package/dist/cli/main-mg4f96e1.js +0 -3
- package/dist/cli/main-mg9b20ac.js +0 -18
- package/dist/cli/main-mgb9ccnx.js +0 -3
- package/dist/cli/main-mjt2p7aj.js +0 -3
- package/dist/cli/main-n6qrdbmy.js +0 -3
- package/dist/cli/main-na7wse0x.js +0 -5
- package/dist/cli/main-nqveez48.js +0 -4
- package/dist/cli/main-ntqef02r.js +0 -10
- package/dist/cli/main-nvc3yjay.js +0 -136
- package/dist/cli/main-p0cfn6nr.js +0 -16
- package/dist/cli/main-qj2djy17.js +0 -19
- package/dist/cli/main-qsevpgsv.js +0 -3
- package/dist/cli/main-qvarybsp.js +0 -3
- package/dist/cli/main-rebtt91r.js +0 -5
- package/dist/cli/main-rpg7h8mb.js +0 -3
- package/dist/cli/main-rsy72qmw.js +0 -15
- package/dist/cli/main-rvetps99.js +0 -18
- package/dist/cli/main-s4bb0jav.js +0 -3
- package/dist/cli/main-s9v8k74e.js +0 -3
- package/dist/cli/main-tjvwmscs.js +0 -3
- package/dist/cli/main-tkgarpjj.js +0 -4
- package/dist/cli/main-v8y60bb2.js +0 -3
- package/dist/cli/main-vhrrq337.js +0 -3
- package/dist/cli/main-vp2dfb7s.js +0 -4
- package/dist/cli/main-vqbr22sz.js +0 -8
- package/dist/cli/main-vxnwe5xx.js +0 -18
- package/dist/cli/main-wgph00xf.js +0 -5
- package/dist/cli/main-wk2csfnj.js +0 -5
- package/dist/cli/main-wm997zjx.js +0 -3
- package/dist/cli/main-wpkyraxh.js +0 -3
- package/dist/cli/main-wqt32p5x.js +0 -4
- package/dist/cli/main-x9ct6y1a.js +0 -3
- package/dist/cli/main-xfekqh9m.js +0 -7
- package/dist/cli/main-xt9zc3n6.js +0 -7
- package/dist/cli/main-xx2z3zh5.js +0 -4
- package/dist/cli/main-y5c82rxr.js +0 -3
- package/dist/cli/main-yrjt2sqt.js +0 -14
- package/dist/cli/main-ys6zj3yr.js +0 -3
- package/dist/cli/main-ywbxshqc.js +0 -8
- package/dist/cli/main-z13755t8.js +0 -25
- package/dist/cli/main-zc7pyrbj.js +0 -4
- package/dist/cli/main.js +0 -279
- package/dist/cli/market-cmd-bm5xvn9f.js +0 -5
- package/dist/cli/mcp-login-bthtfpt7.js +0 -2
- package/dist/cli/mcp-market-cmd-mbeshfyd.js +0 -2
- package/dist/cli/notify-54v5z9dz.js +0 -2
- package/dist/cli/oauth-g5gme95c.js +0 -2
- package/dist/cli/output-satndjap.js +0 -16
- package/dist/cli/profiles-sfhpbq3m.js +0 -2
- package/dist/cli/provider-config-hv3xtdt4.js +0 -2
- package/dist/cli/provider-kwzq6g84.js +0 -2
- package/dist/cli/registry-fh0hdnyn.js +0 -2
- package/dist/cli/registry-y1y8e94r.js +0 -2
- package/dist/cli/repl-t4z03mqq.js +0 -11
- package/dist/cli/resume-fqt4chg8.js +0 -2
- package/dist/cli/run-flags-rysbag9t.js +0 -2
- package/dist/cli/runtime-j19fjbsa.js +0 -2
- package/dist/cli/sandbox-config-g4qxd7y5.js +0 -2
- package/dist/cli/server-r0b6bksk.js +0 -5
- package/dist/cli/session-arg-txmn5g4x.js +0 -2
- package/dist/cli/session-ed250d9j.js +0 -2
- package/dist/cli/sessions-cmd-adw7svfn.js +0 -7
- package/dist/cli/settings-y9rzcqx8.js +0 -2
- package/dist/cli/setup-jmbr11j0.js +0 -2
- package/dist/cli/sextant-smoke-tcth0vea.js +0 -5
- package/dist/cli/skills-cmd-zbdy99v6.js +0 -2
- package/dist/cli/smoke-1bg937kx.js +0 -8
- package/dist/cli/start-chat-p01cdks3.js +0 -12
- package/dist/cli/stream-4wmyaypz.js +0 -2
- package/dist/cli/task-eg4s093s.js +0 -2
- package/dist/cli/tasks-12v9rr9k.js +0 -2
- package/dist/cli/thinking-a5ngvqyh.js +0 -2
- package/dist/cli/todo-1wxpcecx.js +0 -2
- package/dist/cli/tools-2ftsya7w.js +0 -2
- package/dist/cli/tools-x1tj4fxm.js +0 -2
- package/dist/cli/trust-cmd-hccxehzb.js +0 -2
- package/dist/cli/update-check-ygt3vd7m.js +0 -2
- package/dist/cli/update-cmd-v23qhr8c.js +0 -2
- package/dist/cli/voice-g1gtck92.js +0 -2
- package/dist/cli/webfetch-0nnrjgb5.js +0 -2
- package/dist/cli/websearch-f0vr2p7d.js +0 -2
- package/dist/cli/workspace-9rq1w4ta.js +0 -2
- package/dist/lib/index.js +0 -62
- package/dist/lib/models-index.json +0 -1
- package/dist/lib/plugins.js +0 -6
- package/dist/lib/providers.js +0 -17
- package/dist/lib/public-api.js +0 -20
- package/dist/rovecode.exe +0 -4
- /package/{dist/cli → src/providers}/models-index.json +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 5-pattern stuck detector core (eval P0-4 / gap G12).
|
|
3
|
+
*
|
|
4
|
+
* Ports OpenHands' agent-stuck-detector semantics (docs.openhands.dev/sdk/guides/
|
|
5
|
+
* agent-stuck-detector.md, thresholds verified in the harness research, G12) as a PURE
|
|
6
|
+
* module: no I/O, no clock, no loop imports, bounded memory (a 64-step window). The
|
|
7
|
+
* loop integration has a SINGLE OWNER by coordinator decision (Orca msg_ef37004c2122,
|
|
8
|
+
* 2026-09-14) — this file deliberately ships the detector + its API and stops there.
|
|
9
|
+
* Wire it as a middleware by calling `observe` per loop step and `detect` where a
|
|
10
|
+
* verdict is needed; nothing in the loop changes until that owner lands the seam.
|
|
11
|
+
*
|
|
12
|
+
* The five patterns (defaults follow the research: 4 / 3 / 3 / 6, context-window 2):
|
|
13
|
+
* 1. repeated-action-observation — the same (action, observation) pair repeated
|
|
14
|
+
* ≥ 4 times consecutively; a CHANGED observation resets it (progress forgives).
|
|
15
|
+
* 2. repeated-action-error — the same failing action ≥ 3 times consecutively.
|
|
16
|
+
* 3. monologue — ≥ 3 assistant turns with no tool call in a row
|
|
17
|
+
* (talking instead of working); any action or user
|
|
18
|
+
* turn resets.
|
|
19
|
+
* 4. ping-pong — two action signatures strictly alternating
|
|
20
|
+
* ≥ 6 times (A,B,A,B,A,B); a third signature or a
|
|
21
|
+
* repeat breaks it.
|
|
22
|
+
* 5. context-window-thrash — ≥ 2 consecutive context-window errors (the G10
|
|
23
|
+
* "compacted and immediately full" signature,
|
|
24
|
+
* represented here via isContextWindowError).
|
|
25
|
+
*
|
|
26
|
+
* False-positive protections (the part that makes it shippable):
|
|
27
|
+
* - poller exemption: `process` and *_get_result / *_poll style tools legitimately
|
|
28
|
+
* repeat — they are exempt from the two repetition patterns (same list hermes'
|
|
29
|
+
* guard uses, GUARDRAIL_DEFAULTS.repeatableTools/Suffixes).
|
|
30
|
+
* - progress resets: a changed observation, a successful action or a user/steering
|
|
31
|
+
* turn resets the relevant streaks; detection degrades to FEWER detections on
|
|
32
|
+
* interleaved/parallel wiring, never to more.
|
|
33
|
+
* - bounded window: only the last `windowSize` steps are remembered, so a single
|
|
34
|
+
* stale pattern cannot shadow a fresh run forever — and a long clean history
|
|
35
|
+
* cannot be re-judged from memory it no longer holds.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type StuckPattern =
|
|
39
|
+
| "repeated-action-observation"
|
|
40
|
+
| "repeated-action-error"
|
|
41
|
+
| "monologue"
|
|
42
|
+
| "ping-pong"
|
|
43
|
+
| "context-window-thrash";
|
|
44
|
+
|
|
45
|
+
/** OpenHands-derived defaults. "Fires at" semantics: the pattern is reported once the
|
|
46
|
+
* run reaches the threshold (4+ pairs, 3+ errors, 3+ turns, 6+ alternations, 2+ errors). */
|
|
47
|
+
export const STUCK_THRESHOLDS = {
|
|
48
|
+
actionObservation: 4,
|
|
49
|
+
actionError: 3,
|
|
50
|
+
monologue: 3,
|
|
51
|
+
pingPong: 6,
|
|
52
|
+
contextWindowErrors: 2,
|
|
53
|
+
windowSize: 64,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export interface StuckStep {
|
|
57
|
+
kind: "action" | "observation" | "assistant" | "user";
|
|
58
|
+
/** tool name (action/observation) */
|
|
59
|
+
tool?: string;
|
|
60
|
+
/** caller-computed canonical signature: action = tool+args hash, observation = output
|
|
61
|
+
* hash. The detector compares signatures; it never sees payloads. */
|
|
62
|
+
signature?: string;
|
|
63
|
+
/** observation success flag (action-error pattern) */
|
|
64
|
+
ok?: boolean;
|
|
65
|
+
/** observation errored because the context window/limit was hit (thrash pattern) */
|
|
66
|
+
isContextWindowError?: boolean;
|
|
67
|
+
/** assistant text length — a no-tool turn (monologue pattern) */
|
|
68
|
+
textLength?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StuckEvent {
|
|
72
|
+
pattern: StuckPattern;
|
|
73
|
+
/** how many consecutive steps/pairs the pattern has run (≥ threshold) */
|
|
74
|
+
count: number;
|
|
75
|
+
detail: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface StuckThresholds {
|
|
79
|
+
actionObservation: number;
|
|
80
|
+
actionError: number;
|
|
81
|
+
monologue: number;
|
|
82
|
+
pingPong: number;
|
|
83
|
+
contextWindowErrors: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StuckDetectorOptions {
|
|
87
|
+
thresholds?: Partial<StuckThresholds>;
|
|
88
|
+
/** tools exempt from the repetition patterns (default: hermes' poller list) */
|
|
89
|
+
repeatableTools?: readonly string[];
|
|
90
|
+
/** name suffixes exempt likewise (default: ["_get_result", "_poll"]) */
|
|
91
|
+
repeatableSuffixes?: readonly string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Resolved {
|
|
95
|
+
thresholds: StuckThresholds;
|
|
96
|
+
repeatableTools: ReadonlySet<string>;
|
|
97
|
+
repeatableSuffixes: readonly string[];
|
|
98
|
+
windowSize: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Severity order for report lists: hard errors first, chatty monologues last. */
|
|
102
|
+
const PATTERN_ORDER: readonly StuckPattern[] = [
|
|
103
|
+
"context-window-thrash",
|
|
104
|
+
"repeated-action-error",
|
|
105
|
+
"repeated-action-observation",
|
|
106
|
+
"ping-pong",
|
|
107
|
+
"monologue",
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
function positiveInt(v: number | undefined, fallback: number): number {
|
|
111
|
+
if (v === undefined || !Number.isFinite(v)) return fallback;
|
|
112
|
+
const n = Math.trunc(v);
|
|
113
|
+
return n >= 1 ? n : fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class StuckDetector {
|
|
117
|
+
private readonly opts: Resolved;
|
|
118
|
+
private window: StuckStep[] = [];
|
|
119
|
+
|
|
120
|
+
constructor(opts?: StuckDetectorOptions) {
|
|
121
|
+
const d = STUCK_THRESHOLDS;
|
|
122
|
+
this.opts = {
|
|
123
|
+
thresholds: {
|
|
124
|
+
actionObservation: positiveInt(opts?.thresholds?.actionObservation, d.actionObservation),
|
|
125
|
+
actionError: positiveInt(opts?.thresholds?.actionError, d.actionError),
|
|
126
|
+
monologue: positiveInt(opts?.thresholds?.monologue, d.monologue),
|
|
127
|
+
pingPong: positiveInt(opts?.thresholds?.pingPong, d.pingPong),
|
|
128
|
+
contextWindowErrors: positiveInt(opts?.thresholds?.contextWindowErrors, d.contextWindowErrors),
|
|
129
|
+
},
|
|
130
|
+
repeatableTools: new Set(opts?.repeatableTools ?? ["process"]),
|
|
131
|
+
repeatableSuffixes: opts?.repeatableSuffixes ?? ["_get_result", "_poll"],
|
|
132
|
+
windowSize: d.windowSize,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Feed one loop step (action = the assistant's tool call, observation = its result).
|
|
137
|
+
* Oldest steps beyond the window bound are forgotten. */
|
|
138
|
+
observe(step: StuckStep): void {
|
|
139
|
+
this.window.push(step);
|
|
140
|
+
if (this.window.length > this.opts.windowSize) this.window.splice(0, this.window.length - this.opts.windowSize);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Active stuck patterns over the current window, most severe first. */
|
|
144
|
+
detect(): StuckEvent[] {
|
|
145
|
+
const events: StuckEvent[] = [];
|
|
146
|
+
const push = (pattern: StuckPattern, count: number, detail: string): void => {
|
|
147
|
+
events.push({ pattern, count, detail });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 1+2. consecutive (action, observation) pairs — by action signature, split ok / error
|
|
151
|
+
type Pair = { actionSig: string; obsSig: string | null; ok: boolean; tool: string };
|
|
152
|
+
const pairs: Pair[] = [];
|
|
153
|
+
let pending: { tool: string; sig: string } | null = null;
|
|
154
|
+
for (const step of this.window) {
|
|
155
|
+
if (step.kind === "action") {
|
|
156
|
+
pending = { tool: step.tool ?? "?", sig: step.signature ?? "" };
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (step.kind === "observation" && pending !== null) {
|
|
160
|
+
pairs.push({ actionSig: pending.sig, obsSig: step.signature ?? null, ok: step.ok !== false, tool: pending.tool });
|
|
161
|
+
pending = null;
|
|
162
|
+
} else if (step.kind === "user" || step.kind === "assistant") {
|
|
163
|
+
pending = null; // an interleaved non-observation breaks the pairing
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const exempt = (tool: string): boolean =>
|
|
167
|
+
this.opts.repeatableTools.has(tool) || this.opts.repeatableSuffixes.some((s) => tool.endsWith(s));
|
|
168
|
+
|
|
169
|
+
let okStreak: { sig: string; obs: string | null; tool: string; count: number } | null = null;
|
|
170
|
+
let errStreak: { sig: string; tool: string; count: number } | null = null;
|
|
171
|
+
let okMax: { count: number; tool: string } | null = null;
|
|
172
|
+
let errMax: { count: number; tool: string } | null = null;
|
|
173
|
+
for (const p of pairs) {
|
|
174
|
+
const repeatable = exempt(p.tool);
|
|
175
|
+
// ok-pairs: same action AND same observation signature — changed obs = progress
|
|
176
|
+
if (!repeatable && p.ok && p.actionSig === okStreak?.sig && p.obsSig === okStreak.obs) {
|
|
177
|
+
okStreak.count++;
|
|
178
|
+
} else {
|
|
179
|
+
okStreak = p.ok && !repeatable ? { sig: p.actionSig, obs: p.obsSig, tool: p.tool, count: 1 } : null;
|
|
180
|
+
}
|
|
181
|
+
if (okStreak !== null && (okMax === null || okStreak.count > okMax.count)) {
|
|
182
|
+
okMax = { count: okStreak.count, tool: okStreak.tool };
|
|
183
|
+
}
|
|
184
|
+
// error-pairs: same action failing repeatedly
|
|
185
|
+
if (!repeatable && !p.ok && p.actionSig === errStreak?.sig) {
|
|
186
|
+
errStreak.count++;
|
|
187
|
+
} else {
|
|
188
|
+
errStreak = !p.ok && !repeatable ? { sig: p.actionSig, tool: p.tool, count: 1 } : null;
|
|
189
|
+
}
|
|
190
|
+
if (errStreak !== null && (errMax === null || errStreak.count > errMax.count)) {
|
|
191
|
+
errMax = { count: errStreak.count, tool: errStreak.tool };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (okMax !== null && okMax.count >= this.opts.thresholds.actionObservation) {
|
|
195
|
+
push("repeated-action-observation", okMax.count, `identical action+observation pair repeated ${okMax.count}× (tool ${okMax.tool})`);
|
|
196
|
+
}
|
|
197
|
+
if (errMax !== null && errMax.count >= this.opts.thresholds.actionError) {
|
|
198
|
+
push("repeated-action-error", errMax.count, `the same failing action repeated ${errMax.count}× (tool ${errMax.tool})`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. monologue: consecutive assistant turns with no action in between
|
|
202
|
+
let mono = 0;
|
|
203
|
+
let monoMax = 0;
|
|
204
|
+
for (const step of this.window) {
|
|
205
|
+
if (step.kind === "assistant") {
|
|
206
|
+
mono++;
|
|
207
|
+
monoMax = Math.max(monoMax, mono);
|
|
208
|
+
} else if (step.kind === "action" || step.kind === "user") {
|
|
209
|
+
mono = 0;
|
|
210
|
+
}
|
|
211
|
+
// observations belong to their action; they do not reset the count
|
|
212
|
+
}
|
|
213
|
+
if (monoMax >= this.opts.thresholds.monologue) {
|
|
214
|
+
push("monologue", monoMax, `${monoMax} consecutive assistant turns with no tool call`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 4. ping-pong: strict two-signature alternation (a repeat or a third signature breaks it)
|
|
218
|
+
let cycle: string[] = [];
|
|
219
|
+
let cycleMax = 0;
|
|
220
|
+
for (const step of this.window) {
|
|
221
|
+
if (step.kind !== "action" || step.signature === undefined || exempt(step.tool ?? "?")) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const sig = step.signature;
|
|
225
|
+
if (cycle.length >= 2 && cycle[cycle.length - 1] === sig) {
|
|
226
|
+
cycle = [sig]; // immediate repeat — that is pattern 1's territory, not alternation
|
|
227
|
+
} else if (cycle.length >= 2 && cycle[cycle.length - 2] === sig) {
|
|
228
|
+
cycle.push(sig); // continues the A,B,A… alternation
|
|
229
|
+
} else if (cycle.length < 2) {
|
|
230
|
+
cycle.push(sig);
|
|
231
|
+
} else {
|
|
232
|
+
cycle = [sig]; // a third signature broke it
|
|
233
|
+
}
|
|
234
|
+
cycleMax = Math.max(cycleMax, cycle.length);
|
|
235
|
+
}
|
|
236
|
+
if (cycleMax >= this.opts.thresholds.pingPong) {
|
|
237
|
+
push("ping-pong", cycleMax, `two actions strictly alternating ${cycleMax}× without progress`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 5. context-window thrash: consecutive context-window errors
|
|
241
|
+
let cw = 0;
|
|
242
|
+
let cwMax = 0;
|
|
243
|
+
for (const step of this.window) {
|
|
244
|
+
if (step.isContextWindowError === true) {
|
|
245
|
+
cw++;
|
|
246
|
+
cwMax = Math.max(cwMax, cw);
|
|
247
|
+
} else {
|
|
248
|
+
cw = 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (cwMax >= this.opts.thresholds.contextWindowErrors) {
|
|
252
|
+
push("context-window-thrash", cwMax, `${cwMax} consecutive context-window errors — the window refills as fast as it compacts`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return events.sort((a, b) => PATTERN_ORDER.indexOf(a.pattern) - PATTERN_ORDER.indexOf(b.pattern));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
isStuck(): boolean {
|
|
259
|
+
return this.detect().length > 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Full reset (new run / new user turn). */
|
|
263
|
+
reset(): void {
|
|
264
|
+
this.window = [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Stateless form over a recorded step list (eval/replay, tests, offline analysis). */
|
|
269
|
+
export function detectStuck(steps: readonly StuckStep[], opts?: StuckDetectorOptions): StuckEvent[] {
|
|
270
|
+
const d = new StuckDetector(opts);
|
|
271
|
+
for (const s of steps) d.observe(s);
|
|
272
|
+
return d.detect();
|
|
273
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/** Background subagents as jobs (port #26): a bounded FIFO job manager over the ONE
|
|
2
|
+
* agent loop. Every task runs through orchestrator.ts runChild (ADR-009), which runs
|
|
3
|
+
* agentLoop (ADR-003) — this file only schedules, collects results and notifies. It is
|
|
4
|
+
* NOT a second loop generation (research/anti_patterns.md:13; ADR-013 rejects lanes
|
|
5
|
+
* that are not child sessions/jobs).
|
|
6
|
+
*
|
|
7
|
+
* Pattern source: opencode packages/core/src/background-job.ts (MIT, snapshot
|
|
8
|
+
* research/source_snapshots/opencode-2026 @ ebece6e) — a process-local, deliberately
|
|
9
|
+
* non-durable job registry (:113-119); Status running|completed|error|cancelled (:7);
|
|
10
|
+
* Info {id,type,title,status,started_at,completed_at,output,error} (:9-19); start forks
|
|
11
|
+
* the run and settle() derives the terminal status from the exit (:126-171, :202-254);
|
|
12
|
+
* wait with an optional timeout returns the snapshot on timeout (:292-301); cancel marks
|
|
13
|
+
* the job and closes its scope (:337-358). Its task tool (packages/opencode/src/tool/
|
|
14
|
+
* task.ts) injects a synthetic message into the PARENT session when the job settles
|
|
15
|
+
* (:227-265 inject/notify) and tells the model not to poll (:31-35).
|
|
16
|
+
* Departures: opencode starts every job at once (no bound); here a FIFO queue bounds
|
|
17
|
+
* concurrency (default 3, env ROVECODE_TASKS_MAX) and a RUNNING task that waits on a QUEUED
|
|
18
|
+
* one lends it its slot so nested pools cannot deadlock. Their notification is a new
|
|
19
|
+
* prompt on the parent session; ours is a push into the parent's SteeringQueue, which
|
|
20
|
+
* the loop drains before its next model call (loop.ts:136) — the loop stays untouched. */
|
|
21
|
+
|
|
22
|
+
import { SteeringQueue } from "./loop.ts";
|
|
23
|
+
import { DEFAULT_MAX_DEPTH, preflightSpawn, runChild, type ChildRunnerDeps } from "./orchestrator.ts";
|
|
24
|
+
import type { SpawnRequest, SpawnResult, TokenUsage } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
export type TaskId = string;
|
|
27
|
+
export type TaskStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
|
28
|
+
|
|
29
|
+
export interface TaskInfo {
|
|
30
|
+
id: TaskId;
|
|
31
|
+
label: string;
|
|
32
|
+
agent: string;
|
|
33
|
+
/** bounded preview of the goal (≤200 chars) */
|
|
34
|
+
goal: string;
|
|
35
|
+
isolated: boolean;
|
|
36
|
+
/** the child's depth (parent depth + 1; root-started tasks run at 1) */
|
|
37
|
+
depth: number;
|
|
38
|
+
/** the RUNNING task that started this one (StartOptions.caller); absent for
|
|
39
|
+
* root-started tasks — the sdk/dashboard agent tree hangs on this edge */
|
|
40
|
+
parent?: TaskId;
|
|
41
|
+
status: TaskStatus;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
startedAt?: number;
|
|
44
|
+
finishedAt?: number;
|
|
45
|
+
/** the child's final text (done) — bounded by runChild (≤4000 chars) */
|
|
46
|
+
summary?: string;
|
|
47
|
+
/** failure reason (failed) or "cancelled" */
|
|
48
|
+
error?: string;
|
|
49
|
+
usage?: TokenUsage;
|
|
50
|
+
/** isolated children: line count of the patch merged back into the parent tree */
|
|
51
|
+
patchLines?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StartOptions {
|
|
55
|
+
label?: string;
|
|
56
|
+
/** depth of the loop starting this task (root = 0); the child runs at depth + 1,
|
|
57
|
+
* mirroring the loop's own ctx.spawn contract (loop.ts:267) */
|
|
58
|
+
parentDepth?: number;
|
|
59
|
+
/** where the completion note lands; default = the queue attached to the manager */
|
|
60
|
+
notify?: SteeringQueue;
|
|
61
|
+
/** id of the RUNNING task that starts this one (nested) — enables slot lending */
|
|
62
|
+
caller?: TaskId;
|
|
63
|
+
/** the run that OWNS this task: its abort cancels the task (nested: the parent task's
|
|
64
|
+
* own signal, so cancellation cascades). Default = the signal bound via bindRun(). */
|
|
65
|
+
owner?: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `childPolicy` (fix-wave MED-2): how the child's rules derive from the starting run's —
|
|
69
|
+
* "gated" when that config carries a prompt rule (children turn prompt→deny, orchestrator
|
|
70
|
+
* deriveChildRules: read-only unless allow rules cover an action), "open" otherwise (yolo).
|
|
71
|
+
* The `task` tool's start output says so, for the approver and the model. */
|
|
72
|
+
export type StartResult = { ok: true; id: TaskId; childPolicy: "gated" | "open" } | { ok: false; reason: string };
|
|
73
|
+
|
|
74
|
+
export interface WaitOptions {
|
|
75
|
+
/** max wait in ms; undefined = until settled (hold an abort signal); 0 = snapshot now */
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
/** the running task doing the waiting — its slot is lent to a queued waited-on task */
|
|
79
|
+
caller?: TaskId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TaskManagerOptions {
|
|
83
|
+
/** child runner deps, resolved at EACH start so defs/config follow the parent's
|
|
84
|
+
* latest run; null = no provider configured (start refuses) */
|
|
85
|
+
deps: () => ChildRunnerDeps | null;
|
|
86
|
+
/** concurrent children bound; default tasksMaxFromEnv() */
|
|
87
|
+
maxConcurrent?: number;
|
|
88
|
+
maxDepth?: number;
|
|
89
|
+
/** injectable child runner (tests); default orchestrator runChild */
|
|
90
|
+
run?: typeof runChild;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const DEFAULT_TASKS_MAX = 3;
|
|
94
|
+
|
|
95
|
+
/** ROVECODE_TASKS_MAX: positive integer, else the default. */
|
|
96
|
+
export function tasksMaxFromEnv(env: Record<string, string | undefined> = process.env): number {
|
|
97
|
+
const n = Number(env["ROVECODE_TASKS_MAX"] ?? "");
|
|
98
|
+
return Number.isInteger(n) && n >= 1 ? n : DEFAULT_TASKS_MAX;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface TaskRecord {
|
|
102
|
+
info: TaskInfo;
|
|
103
|
+
req: SpawnRequest;
|
|
104
|
+
deps: ChildRunnerDeps;
|
|
105
|
+
ac: AbortController;
|
|
106
|
+
notify?: SteeringQueue;
|
|
107
|
+
done: Promise<void>;
|
|
108
|
+
resolveDone: () => void;
|
|
109
|
+
/** true once the child run has RETURNED (status alone is not enough: cancel() flips
|
|
110
|
+
* status to "cancelled" while the aborted run is still winding down) */
|
|
111
|
+
settled: boolean;
|
|
112
|
+
/** detach the owner-abort listener (settle) */
|
|
113
|
+
unbind?: () => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const TERMINAL: ReadonlySet<TaskStatus> = new Set(["done", "failed", "cancelled"]);
|
|
117
|
+
export const isTerminal = (s: TaskStatus): boolean => TERMINAL.has(s);
|
|
118
|
+
|
|
119
|
+
function brief(text: string | undefined, max = 200): string {
|
|
120
|
+
const one = (text ?? "").split("\n").find((l) => l.trim() !== "")?.trim() ?? "";
|
|
121
|
+
return one.length > max ? one.slice(0, max - 1) + "…" : one || "(no output)";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The steer the parent sees on its next turn (opencode task.ts:241-249 renders a
|
|
125
|
+
* <task state> block; ours is one line the model can act on). */
|
|
126
|
+
export function taskNote(t: TaskInfo): string {
|
|
127
|
+
const head = `task ${t.id} (${t.label})`;
|
|
128
|
+
if (t.status === "done") return `${head} finished: ${brief(t.summary)} — call task_status result ${t.id} for details`;
|
|
129
|
+
if (t.status === "failed") return `${head} failed: ${brief(t.error)} — call task_status result ${t.id} for details`;
|
|
130
|
+
return `${head} cancelled`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One line per task for /tasks and the tool's list action; newest last, ≤50 rows. */
|
|
134
|
+
export function formatTaskList(tasks: TaskInfo[], now = Date.now()): string {
|
|
135
|
+
if (tasks.length === 0) return "(no background tasks)";
|
|
136
|
+
const rows = tasks.slice(-50).map((t) => {
|
|
137
|
+
const end = t.finishedAt ?? now;
|
|
138
|
+
const age = t.startedAt !== undefined ? ` ${Math.max(0, Math.round((end - t.startedAt) / 1000))}s` : "";
|
|
139
|
+
const tail = t.status === "failed" ? ` — ${brief(t.error, 80)}` : t.status === "done" ? ` — ${brief(t.summary, 80)}` : "";
|
|
140
|
+
return `${t.id.padEnd(4)} ${t.status.padEnd(9)}${age.padEnd(6)} ${t.label}${tail}`;
|
|
141
|
+
});
|
|
142
|
+
return (tasks.length > 50 ? `(showing 50 of ${tasks.length})\n` : "") + rows.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class TaskManager {
|
|
146
|
+
readonly maxConcurrent: number;
|
|
147
|
+
readonly maxDepth: number;
|
|
148
|
+
private readonly tasks = new Map<TaskId, TaskRecord>();
|
|
149
|
+
private readonly queue: TaskId[] = [];
|
|
150
|
+
private readonly listeners = new Set<(t: TaskInfo) => void>();
|
|
151
|
+
private readonly run: typeof runChild;
|
|
152
|
+
private sink: SteeringQueue | null = null;
|
|
153
|
+
private runSignal: AbortSignal | null = null;
|
|
154
|
+
private running = 0;
|
|
155
|
+
private seq = 0;
|
|
156
|
+
|
|
157
|
+
constructor(private readonly opts: TaskManagerOptions) {
|
|
158
|
+
this.maxConcurrent = Math.max(1, Math.floor(opts.maxConcurrent ?? tasksMaxFromEnv()));
|
|
159
|
+
// fix-wave L4: runChild re-preflights against the orchestrator's DEFAULT_MAX_DEPTH, so a
|
|
160
|
+
// LARGER manager cap would launch a child only to fail it there — clamp, and every depth
|
|
161
|
+
// refusal is start() data. Smaller caps are honored as given.
|
|
162
|
+
this.maxDepth = Math.min(opts.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH);
|
|
163
|
+
this.run = opts.run ?? runChild;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Default notification target: the SAME SteeringQueue the surface hands to agentLoop. */
|
|
167
|
+
attach(steering: SteeringQueue): void { this.sink = steering; }
|
|
168
|
+
|
|
169
|
+
/** The surface's per-run controller signal (the one Esc / DELETE / session-cancel
|
|
170
|
+
* aborts): root tasks started from now on are owned by that run and cancelled when it
|
|
171
|
+
* aborts. NOT ToolContext.signal — the loop's own controller aborts on EVERY settle,
|
|
172
|
+
* a normal run end included (loop.ts:91), which would kill background work the moment
|
|
173
|
+
* the parent finishes its turn. Call before each agentLoop; tasks from earlier runs
|
|
174
|
+
* keep their own owner. */
|
|
175
|
+
bindRun(signal: AbortSignal): void { this.runSignal = signal; }
|
|
176
|
+
|
|
177
|
+
/** Enqueue a child run. Refusals are data, never throws: unknown agent, no provider,
|
|
178
|
+
* an already-aborted owner, and the orchestrator's own preflight (depth cap, spawn
|
|
179
|
+
* policy "none"). */
|
|
180
|
+
start(req: SpawnRequest, opts: StartOptions = {}): StartResult {
|
|
181
|
+
const deps = this.opts.deps();
|
|
182
|
+
if (!deps) return { ok: false, reason: "no provider configured" };
|
|
183
|
+
const def = deps.defs.get(req.agent);
|
|
184
|
+
if (!def) return { ok: false, reason: `unknown agent '${req.agent}'` };
|
|
185
|
+
const owner = opts.owner ?? this.runSignal ?? undefined;
|
|
186
|
+
if (owner?.aborted) return { ok: false, reason: "parent run aborted" };
|
|
187
|
+
const depth = (opts.parentDepth ?? 0) + 1;
|
|
188
|
+
const gate = preflightSpawn(def, { depth, maxDepth: this.maxDepth, parentSessionId: opts.caller ?? "" });
|
|
189
|
+
if (!gate.ok) return { ok: false, reason: gate.reason ?? "spawn refused" };
|
|
190
|
+
const childPolicy = deps.baseConfig.permissionRules.some((r) => r.effect === "prompt") ? "gated" : "open";
|
|
191
|
+
const id: TaskId = `t${++this.seq}`;
|
|
192
|
+
let resolveDone: () => void = () => {};
|
|
193
|
+
const done = new Promise<void>((r) => { resolveDone = r; });
|
|
194
|
+
const goal = req.goal.replace(/\s+/g, " ").trim();
|
|
195
|
+
const rec: TaskRecord = {
|
|
196
|
+
info: {
|
|
197
|
+
id, label: (opts.label ?? "").trim() || (goal.length > 40 ? goal.slice(0, 39) + "…" : goal || "(no goal)"),
|
|
198
|
+
agent: req.agent, goal: goal.length > 200 ? goal.slice(0, 199) + "…" : goal,
|
|
199
|
+
isolated: req.isolated === true, depth, status: "queued", createdAt: Date.now(),
|
|
200
|
+
...(opts.caller !== undefined ? { parent: opts.caller } : {}),
|
|
201
|
+
},
|
|
202
|
+
req, deps, ac: new AbortController(), notify: opts.notify, done, resolveDone, settled: false,
|
|
203
|
+
};
|
|
204
|
+
if (owner) {
|
|
205
|
+
// a parent-run abort reaches its children: cancel() aborts the child's own controller
|
|
206
|
+
const onAbort = (): void => { this.cancel(id); };
|
|
207
|
+
owner.addEventListener("abort", onAbort, { once: true });
|
|
208
|
+
rec.unbind = () => owner.removeEventListener("abort", onAbort);
|
|
209
|
+
}
|
|
210
|
+
this.tasks.set(id, rec);
|
|
211
|
+
this.queue.push(id);
|
|
212
|
+
this.emit(rec);
|
|
213
|
+
this.pump();
|
|
214
|
+
return { ok: true, id, childPolicy };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
status(id: TaskId): TaskInfo | undefined {
|
|
218
|
+
const t = this.tasks.get(id);
|
|
219
|
+
return t ? { ...t.info } : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** All tasks, oldest first. */
|
|
223
|
+
list(): TaskInfo[] { return [...this.tasks.values()].map((t) => ({ ...t.info })); }
|
|
224
|
+
|
|
225
|
+
counts(): Record<TaskStatus, number> {
|
|
226
|
+
const c: Record<TaskStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, cancelled: 0 };
|
|
227
|
+
for (const t of this.tasks.values()) c[t.info.status]++;
|
|
228
|
+
return c;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Wait for a task to settle (bounded/abortable), then return its snapshot — on a
|
|
232
|
+
* timeout or abort the snapshot still reports the live status. A cancelled task is
|
|
233
|
+
* "settled" only once its aborted run has returned. Unknown id → undefined. */
|
|
234
|
+
async result(id: TaskId, opts: WaitOptions = {}): Promise<TaskInfo | undefined> {
|
|
235
|
+
const t = this.tasks.get(id);
|
|
236
|
+
if (!t) return undefined;
|
|
237
|
+
if (t.settled) return { ...t.info };
|
|
238
|
+
// slot lending: the waiter holds a slot but generates no load while it waits — under
|
|
239
|
+
// a full pool a parent waiting on its queued child would otherwise sit until the
|
|
240
|
+
// deadline (nested-pool deadlock). Promote the waited-on task now; `running` may
|
|
241
|
+
// exceed the bound by exactly the number of blocked waiters.
|
|
242
|
+
if (t.info.status === "queued" && opts.caller !== undefined && this.tasks.get(opts.caller)?.info.status === "running") {
|
|
243
|
+
this.dequeue(id);
|
|
244
|
+
this.launch(t);
|
|
245
|
+
}
|
|
246
|
+
await waitFor(t.done, opts.timeoutMs, opts.signal);
|
|
247
|
+
return { ...t.info };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Cancel: a queued task never runs; a running one has its child run aborted (the
|
|
251
|
+
* signal threads runChild → agentLoop → fetch/tools) and settles when it returns.
|
|
252
|
+
* Returns the snapshot (status already "cancelled"); unknown id → undefined. */
|
|
253
|
+
cancel(id: TaskId): TaskInfo | undefined {
|
|
254
|
+
const t = this.tasks.get(id);
|
|
255
|
+
if (!t) return undefined;
|
|
256
|
+
if (isTerminal(t.info.status)) return { ...t.info };
|
|
257
|
+
if (t.info.status === "queued") {
|
|
258
|
+
this.dequeue(id);
|
|
259
|
+
t.info.status = "cancelled"; t.info.error = "cancelled";
|
|
260
|
+
this.settle(t);
|
|
261
|
+
} else {
|
|
262
|
+
t.info.status = "cancelled"; t.info.error = "cancelled";
|
|
263
|
+
t.ac.abort(); // finish() → settle() runs (one terminal emission) when runChild returns
|
|
264
|
+
}
|
|
265
|
+
return { ...t.info };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Cancel every non-terminal task (surface shutdown); returns how many were live. */
|
|
269
|
+
cancelAll(): number {
|
|
270
|
+
let n = 0;
|
|
271
|
+
for (const t of this.tasks.values()) {
|
|
272
|
+
if (!isTerminal(t.info.status)) { this.cancel(t.info.id); n++; }
|
|
273
|
+
}
|
|
274
|
+
return n;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Resolve when nothing is queued or running (or the deadline passes → false). */
|
|
278
|
+
async drain(timeoutMs: number): Promise<boolean> {
|
|
279
|
+
const deadline = Date.now() + timeoutMs;
|
|
280
|
+
while (this.running > 0 || this.queue.length > 0) {
|
|
281
|
+
if (Date.now() >= deadline) return false;
|
|
282
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Status-transition listener (TUI live notes, tests). Returns the unsubscribe. */
|
|
288
|
+
subscribe(fn: (t: TaskInfo) => void): () => void {
|
|
289
|
+
this.listeners.add(fn);
|
|
290
|
+
return () => { this.listeners.delete(fn); };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private dequeue(id: TaskId): void {
|
|
294
|
+
const i = this.queue.indexOf(id);
|
|
295
|
+
if (i >= 0) this.queue.splice(i, 1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private pump(): void {
|
|
299
|
+
while (this.running < this.maxConcurrent && this.queue.length > 0) {
|
|
300
|
+
const t = this.tasks.get(this.queue.shift()!);
|
|
301
|
+
if (t && t.info.status === "queued") this.launch(t);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private launch(t: TaskRecord): void {
|
|
306
|
+
t.info.status = "running"; t.info.startedAt = Date.now();
|
|
307
|
+
this.running++;
|
|
308
|
+
this.emit(t);
|
|
309
|
+
// the child's registry learns its task id (ChildContext) so a nested `task_status result`
|
|
310
|
+
// can identify its caller for slot lending
|
|
311
|
+
const deps: ChildRunnerDeps = {
|
|
312
|
+
...t.deps,
|
|
313
|
+
registryFactory: (def, cwd, child) => t.deps.registryFactory(def, cwd, child ? { ...child, taskId: t.info.id } : undefined),
|
|
314
|
+
};
|
|
315
|
+
void this.run(deps, t.req, t.info.depth, t.ac.signal)
|
|
316
|
+
.then((r) => this.finish(t, r), (e: unknown) => this.finish(t, undefined, e))
|
|
317
|
+
.finally(() => { this.running--; this.pump(); });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private finish(t: TaskRecord, r: SpawnResult | undefined, err?: unknown): void {
|
|
321
|
+
const info = t.info;
|
|
322
|
+
if (info.status === "cancelled") {
|
|
323
|
+
if (r) info.usage = r.usage; // the aborted run's own summary ("run aborted") is not a result
|
|
324
|
+
} else if (r === undefined) {
|
|
325
|
+
info.status = "failed";
|
|
326
|
+
info.error = `child runner threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
327
|
+
} else if (!r.ok) {
|
|
328
|
+
info.status = "failed"; info.error = r.summary; info.usage = r.usage;
|
|
329
|
+
} else {
|
|
330
|
+
info.status = "done"; info.summary = r.summary; info.usage = r.usage;
|
|
331
|
+
if (r.patch !== undefined) info.patchLines = r.patch.trim() === "" ? 0 : r.patch.split("\n").length;
|
|
332
|
+
}
|
|
333
|
+
this.settle(t);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Terminal tail shared by finish() and queued-cancel: stamp, notify the parent
|
|
337
|
+
* (steer — queued BEFORE waiters wake, so a `result` waiter's next turn sees it),
|
|
338
|
+
* release waiters, tell listeners. */
|
|
339
|
+
private settle(t: TaskRecord): void {
|
|
340
|
+
t.settled = true;
|
|
341
|
+
t.unbind?.();
|
|
342
|
+
t.info.finishedAt = Date.now();
|
|
343
|
+
(t.notify ?? this.sink)?.push(taskNote(t.info));
|
|
344
|
+
t.resolveDone();
|
|
345
|
+
this.emit(t);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private emit(t: TaskRecord): void {
|
|
349
|
+
for (const fn of this.listeners) {
|
|
350
|
+
try { fn({ ...t.info }); } catch { /* listeners never break the manager */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Resolve when `p` settles, the deadline passes, or `signal` aborts — whichever is
|
|
356
|
+
* first; the timer and listener are always released (a pending timer would hold the
|
|
357
|
+
* process; a parked promise with no timer hangs the Bun runner). */
|
|
358
|
+
function waitFor(p: Promise<void>, timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise<void> {
|
|
359
|
+
return new Promise<void>((resolve) => {
|
|
360
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
361
|
+
let settled = false;
|
|
362
|
+
const finish = (): void => {
|
|
363
|
+
if (settled) return;
|
|
364
|
+
settled = true;
|
|
365
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
366
|
+
signal?.removeEventListener("abort", finish);
|
|
367
|
+
resolve();
|
|
368
|
+
};
|
|
369
|
+
if (signal?.aborted) { finish(); return; }
|
|
370
|
+
if (timeoutMs !== undefined) timer = setTimeout(finish, Math.max(0, timeoutMs));
|
|
371
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
372
|
+
void p.then(finish, finish);
|
|
373
|
+
});
|
|
374
|
+
}
|