@edspencer/paddock 0.0.1 → 0.57.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 +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeper-chat recovery ENGINE — Layer 3 (automatic re-drive) for issue #301.
|
|
3
|
+
*
|
|
4
|
+
* ── What it does ──────────────────────────────────────────────────────────────
|
|
5
|
+
* Phase 0 (recovery-config.ts) defined the config surface and Phase 1 (Layer 2)
|
|
6
|
+
* shipped the manual "Continue" button. This module is Layer 3: it AUTOMATICALLY
|
|
7
|
+
* unsticks a keeper whose background task was killed at the turn boundary, with no
|
|
8
|
+
* human in the loop — the same nudge a human sends by hand (or clicks Continue for),
|
|
9
|
+
* fired on its own.
|
|
10
|
+
*
|
|
11
|
+
* ── How it detects a hang (approach A — post-turn transcript tail) ────────────
|
|
12
|
+
* The one thing we can rely on (see edspencer/herdctl#374) is that herdctl keeps
|
|
13
|
+
* the session ALIVE at the turn boundary and the transcript is the source of truth.
|
|
14
|
+
* So after a SESSION-mode keeper turn completes, {@link RecoveryEngine.armWatch}
|
|
15
|
+
* tails that session's transcript JSONL for a bounded window:
|
|
16
|
+
*
|
|
17
|
+
* 1. A COMPLETED background task emits a follow-up that WAKES the keeper — more
|
|
18
|
+
* `assistant` entries append. A KILLED/STOPPED task writes a terminated
|
|
19
|
+
* `<task-notification>` (a `user` entry) that wakes NOTHING — no assistant
|
|
20
|
+
* entry follows it.
|
|
21
|
+
* 2. So the hung signature is: the tail ends (logically) with a terminated
|
|
22
|
+
* task-notification and NO `assistant` activity after it. That's exactly what
|
|
23
|
+
* {@link detectHang} computes by reducing the appended entries in order.
|
|
24
|
+
* 3. The kill lands ~2–8s AFTER the turn ends, so the watch polls for
|
|
25
|
+
* `debounceMs + KILL_GRACE_MS`, and only fires once the notification has sat
|
|
26
|
+
* un-answered for the full `debounceMs` quiet window — a keeper that wakes
|
|
27
|
+
* itself inside that window resets the pending state and is never poked.
|
|
28
|
+
*
|
|
29
|
+
* ── Guards (issue #301) ───────────────────────────────────────────────────────
|
|
30
|
+
* - Only fires when the resolved `recovery.autoReDrive` (per-project override
|
|
31
|
+
* else instance default) is ON — Layer 3 is opt-in.
|
|
32
|
+
* - Per-session `{ lastNudgeAt, retryCount }`: a wedged keeper is re-driven at
|
|
33
|
+
* most `maxRetries` times, then left alone (no poke-loop).
|
|
34
|
+
* - `debounceMs`: never fires until the notification has been quiet that long.
|
|
35
|
+
* - A HUMAN message ({@link RecoveryEngine.onHumanMessage}) clears the session's
|
|
36
|
+
* entry, so a genuinely-new later hang re-arms fresh.
|
|
37
|
+
*
|
|
38
|
+
* The engine is deliberately narrow + injectable (clock, scheduler, transcript
|
|
39
|
+
* reader) so the timing-sensitive orchestration is unit-testable without real
|
|
40
|
+
* timers or a real filesystem — same discipline as the sidecar-store modules.
|
|
41
|
+
*/
|
|
42
|
+
import { promises as fsp } from "node:fs";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { projectChatsDir } from "./transcripts.js";
|
|
45
|
+
import { isTerminatedTaskStatus, resolveRecoveryConfig } from "./recovery-config.js";
|
|
46
|
+
/** Flatten a transcript message `content` (string, or an array of blocks) to text. */
|
|
47
|
+
function contentText(content) {
|
|
48
|
+
if (typeof content === "string")
|
|
49
|
+
return content;
|
|
50
|
+
if (!Array.isArray(content))
|
|
51
|
+
return "";
|
|
52
|
+
return content
|
|
53
|
+
.map((b) => {
|
|
54
|
+
const blk = b;
|
|
55
|
+
return blk?.type === "text" && typeof blk.text === "string" ? blk.text : "";
|
|
56
|
+
})
|
|
57
|
+
.join("");
|
|
58
|
+
}
|
|
59
|
+
/** Extract a `<task-notification>` `<status>` value, or undefined. */
|
|
60
|
+
function notificationStatus(text) {
|
|
61
|
+
return /<status>([\s\S]*?)<\/status>/.exec(text)?.[1];
|
|
62
|
+
}
|
|
63
|
+
/** True when `text` is a `<task-notification>` carrying a terminated (killed/
|
|
64
|
+
* stopped) status — the turn-boundary-kill signature this recovers from. */
|
|
65
|
+
function isTerminatedNotificationText(text) {
|
|
66
|
+
return text.includes("<task-notification>") && isTerminatedTaskStatus(notificationStatus(text));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Classify one already-parsed transcript entry as the hang detector sees it.
|
|
70
|
+
*
|
|
71
|
+
* A terminated `<task-notification>` reaches the transcript in TWO shapes, and we
|
|
72
|
+
* must catch both (issue #347):
|
|
73
|
+
* - As a materialised `type:"user"` entry — but that only appears once a LATER
|
|
74
|
+
* turn flushes the SDK's input queue, often tens of seconds after the kill.
|
|
75
|
+
* - At the moment of the kill, as a `type:"queue-operation"` `enqueue` whose
|
|
76
|
+
* `content` IS the notification. This is the one that lands inside the watch
|
|
77
|
+
* window; missing it (the original bug) let every real hang go undetected.
|
|
78
|
+
*
|
|
79
|
+
* A keeper reply is `assistant`; a killed/stopped notification in either shape is
|
|
80
|
+
* `terminated-notification`; everything else (system lines, attachments, a
|
|
81
|
+
* `dequeue`, a completed/running notification, plain user text) is `other`.
|
|
82
|
+
*/
|
|
83
|
+
export function classifyEntry(entry) {
|
|
84
|
+
if (entry.type === "assistant")
|
|
85
|
+
return "assistant";
|
|
86
|
+
if (entry.type === "user" && isTerminatedNotificationText(contentText(entry.message?.content))) {
|
|
87
|
+
return "terminated-notification";
|
|
88
|
+
}
|
|
89
|
+
// The kill's notification is delivered to the input queue as an `enqueue`
|
|
90
|
+
// whose `content` is the `<task-notification>`; a `dequeue` carries no payload.
|
|
91
|
+
if (entry.type === "queue-operation" && entry.operation === "enqueue") {
|
|
92
|
+
if (isTerminatedNotificationText(contentText(entry.content)))
|
|
93
|
+
return "terminated-notification";
|
|
94
|
+
}
|
|
95
|
+
return "other";
|
|
96
|
+
}
|
|
97
|
+
/** Extract a `<task-notification>` `<summary>` value (trimmed), or undefined. */
|
|
98
|
+
function notificationSummary(text) {
|
|
99
|
+
return /<summary>([\s\S]*?)<\/summary>/.exec(text)?.[1]?.trim() || undefined;
|
|
100
|
+
}
|
|
101
|
+
/** The notification payload carried by an entry, whichever shape it took: a
|
|
102
|
+
* `user` message's content, or a `queue-operation` enqueue's `content`. */
|
|
103
|
+
function notificationText(entry) {
|
|
104
|
+
if (entry.type === "user")
|
|
105
|
+
return contentText(entry.message?.content);
|
|
106
|
+
if (entry.type === "queue-operation")
|
|
107
|
+
return contentText(entry.content);
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Parse one raw JSONL line into its {@link TailEvent} plus, for a terminated
|
|
112
|
+
* notification, its human-readable `<summary>` (used by the live surface frame).
|
|
113
|
+
* Unparseable/blank → `other`.
|
|
114
|
+
*/
|
|
115
|
+
export function parseTailLine(line) {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (!trimmed)
|
|
118
|
+
return { event: "other" };
|
|
119
|
+
let entry;
|
|
120
|
+
try {
|
|
121
|
+
entry = JSON.parse(trimmed);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { event: "other" };
|
|
125
|
+
}
|
|
126
|
+
const event = classifyEntry(entry);
|
|
127
|
+
return event === "terminated-notification"
|
|
128
|
+
? { event, summary: notificationSummary(notificationText(entry)) }
|
|
129
|
+
: { event };
|
|
130
|
+
}
|
|
131
|
+
/** Classify one raw JSONL line (unparseable/blank → `other`). */
|
|
132
|
+
export function classifyLine(line) {
|
|
133
|
+
return parseTailLine(line).event;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reduce an ordered stream of tail events to whether the transcript currently ends
|
|
137
|
+
* (logically) with an un-answered terminated task-notification — the hung
|
|
138
|
+
* signature. An `assistant` entry clears any pending notification (the keeper
|
|
139
|
+
* woke); a terminated notification sets it; `other` entries are inert. True iff a
|
|
140
|
+
* terminated notification is still pending after the last event.
|
|
141
|
+
*/
|
|
142
|
+
export function detectHang(events) {
|
|
143
|
+
let pending = false;
|
|
144
|
+
for (const ev of events) {
|
|
145
|
+
if (ev === "assistant")
|
|
146
|
+
pending = false;
|
|
147
|
+
else if (ev === "terminated-notification")
|
|
148
|
+
pending = true;
|
|
149
|
+
}
|
|
150
|
+
return pending;
|
|
151
|
+
}
|
|
152
|
+
/** Default poll cadence — brisk enough to catch the kill+debounce, cheap enough to ignore. */
|
|
153
|
+
export const DEFAULT_POLL_MS = 750;
|
|
154
|
+
/**
|
|
155
|
+
* How long past `debounceMs` we keep watching for the kill to land. herdctl#374's
|
|
156
|
+
* teardown fires ~2–8s after the turn boundary, so the notification can appear late;
|
|
157
|
+
* this grace ensures its full `debounceMs` quiet window still fits inside the watch.
|
|
158
|
+
*/
|
|
159
|
+
export const DEFAULT_KILL_GRACE_MS = 12_000;
|
|
160
|
+
/**
|
|
161
|
+
* Cadence (ms) at which a deferred fire re-checks whether the session has gone
|
|
162
|
+
* idle (issue #397). Brisk enough to fire promptly once the reaper releases,
|
|
163
|
+
* cheap enough to ignore.
|
|
164
|
+
*/
|
|
165
|
+
export const DEFAULT_SETTLE_POLL_MS = 1_000;
|
|
166
|
+
/**
|
|
167
|
+
* How long (ms) the engine keeps DEFERRING an otherwise-ready fire while the
|
|
168
|
+
* session is still live (hub or reaper) before standing down (issue #397). Must
|
|
169
|
+
* comfortably exceed herdctl's `DEFAULT_REINVOCATION_GRACE_MS` (15s) — the reaper
|
|
170
|
+
* holds a just-killed-task session live for that grace — plus margin, so the
|
|
171
|
+
* deferred nudge lands once the reaper reaps rather than giving up early. Bounds
|
|
172
|
+
* the defer so a session the reaper never releases can't retry forever.
|
|
173
|
+
*/
|
|
174
|
+
export const DEFAULT_SETTLE_WINDOW_MS = 30_000;
|
|
175
|
+
/**
|
|
176
|
+
* Cheap current size (bytes) of the transcript via `stat` — the arm-time baseline,
|
|
177
|
+
* so we never slurp a multi-MB transcript just to learn where its EOF is. A missing
|
|
178
|
+
* file (the turn wrote no transcript yet) → 0, so the watch starts from the top.
|
|
179
|
+
*/
|
|
180
|
+
async function defaultFileSize(file) {
|
|
181
|
+
try {
|
|
182
|
+
return (await fsp.stat(file)).size;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read the transcript bytes appended since `offset` using a single positioned read
|
|
190
|
+
* (not a whole-file slurp), so tailing a large transcript stays cheap. A missing
|
|
191
|
+
* file yields no new text at the same offset (the turn simply never wrote one).
|
|
192
|
+
*/
|
|
193
|
+
async function defaultReadTail(file, offset) {
|
|
194
|
+
let fh = null;
|
|
195
|
+
try {
|
|
196
|
+
fh = await fsp.open(file, "r");
|
|
197
|
+
const { size } = await fh.stat();
|
|
198
|
+
if (size <= offset)
|
|
199
|
+
return { text: "", size };
|
|
200
|
+
const len = size - offset;
|
|
201
|
+
const buf = Buffer.allocUnsafe(len);
|
|
202
|
+
await fh.read(buf, 0, len, offset);
|
|
203
|
+
return { text: buf.toString("utf8"), size };
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { text: "", size: offset };
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await fh?.close().catch(() => undefined);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Layer 3 automatic-recovery engine (issue #301). Arm a post-turn transcript watch
|
|
214
|
+
* after each session-mode keeper turn; if a killed-at-turn-boundary notification
|
|
215
|
+
* appears with no keeper response, auto-inject a recovery nudge — guarded by
|
|
216
|
+
* debounce + a per-session retry cap, and cleared when a human next messages.
|
|
217
|
+
*/
|
|
218
|
+
export class RecoveryEngine {
|
|
219
|
+
deps;
|
|
220
|
+
now;
|
|
221
|
+
setTimer;
|
|
222
|
+
clearTimer;
|
|
223
|
+
readTail;
|
|
224
|
+
fileSize;
|
|
225
|
+
pollMs;
|
|
226
|
+
killGraceMs;
|
|
227
|
+
settlePollMs;
|
|
228
|
+
settleWindowMs;
|
|
229
|
+
log;
|
|
230
|
+
surface;
|
|
231
|
+
isBusy;
|
|
232
|
+
sdkSessionLive;
|
|
233
|
+
/** Per-session retry/debounce bookkeeping — cleared on a human message. */
|
|
234
|
+
guards = new Map();
|
|
235
|
+
/** In-flight watches keyed by session id — re-arming or a human message cancels one. */
|
|
236
|
+
watches = new Map();
|
|
237
|
+
constructor(deps) {
|
|
238
|
+
this.deps = deps;
|
|
239
|
+
this.now = deps.now ?? Date.now;
|
|
240
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
241
|
+
this.clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
|
242
|
+
this.readTail = deps.readTail ?? defaultReadTail;
|
|
243
|
+
this.fileSize = deps.fileSize ?? defaultFileSize;
|
|
244
|
+
this.pollMs = deps.pollMs ?? DEFAULT_POLL_MS;
|
|
245
|
+
this.killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
246
|
+
this.settlePollMs = deps.settlePollMs ?? DEFAULT_SETTLE_POLL_MS;
|
|
247
|
+
this.settleWindowMs = deps.settleWindowMs ?? DEFAULT_SETTLE_WINDOW_MS;
|
|
248
|
+
this.log = deps.log ?? (() => undefined);
|
|
249
|
+
this.surface = deps.surface ?? (() => undefined);
|
|
250
|
+
this.isBusy = deps.isBusy ?? (() => false);
|
|
251
|
+
this.sdkSessionLive = deps.sdkSessionLive ?? (() => false);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Arm (or re-arm) a post-turn watch for a just-completed SESSION-mode keeper turn.
|
|
255
|
+
* No-op unless SOMETHING wants it for this project: Layer 2 `surfaceKilledTask`
|
|
256
|
+
* (surface the kill live) or Layer 3 `autoReDrive` (auto re-drive, while the
|
|
257
|
+
* session has retries left). Re-arming cancels any prior watch for the same
|
|
258
|
+
* session. Safe to call fire-and-forget; never throws (a lookup/IO failure just
|
|
259
|
+
* declines to watch).
|
|
260
|
+
*/
|
|
261
|
+
armWatch(input) {
|
|
262
|
+
void this.armWatchAsync(input).catch((err) => {
|
|
263
|
+
this.log("recovery: armWatch failed", { err: String(err), ...input });
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async armWatchAsync({ slug, sessionId }) {
|
|
267
|
+
let project;
|
|
268
|
+
try {
|
|
269
|
+
project = await this.deps.getProject(slug);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return; // unknown/deleted project — nothing to recover
|
|
273
|
+
}
|
|
274
|
+
const recovery = resolveRecoveryConfig(project.recovery, this.deps.cfg.recovery);
|
|
275
|
+
// Two independent actions can want this watch: Layer 2 `surfaceKilledTask`
|
|
276
|
+
// (push the "keeper is idle" affordance live) and Layer 3 `autoReDrive`
|
|
277
|
+
// (re-drive automatically). Re-drive is additionally bounded by the retry cap
|
|
278
|
+
// — each armed watch fires at most once, so gating on `retryCount >= maxRetries`
|
|
279
|
+
// bounds total auto re-drives to exactly maxRetries (and `maxRetries: 0`
|
|
280
|
+
// disables it). Surfacing has no such cap: a re-drive that hangs again is a new
|
|
281
|
+
// event worth showing. If neither wants in, don't arm at all.
|
|
282
|
+
const retryCount = this.guards.get(sessionId)?.retryCount ?? 0;
|
|
283
|
+
const wantSurface = recovery.surfaceKilledTask;
|
|
284
|
+
const wantReDrive = recovery.autoReDrive && retryCount < recovery.maxRetries;
|
|
285
|
+
if (!wantSurface && !wantReDrive) {
|
|
286
|
+
this.log("recovery: nothing to arm (surface off, re-drive off or capped)", {
|
|
287
|
+
slug,
|
|
288
|
+
sessionId,
|
|
289
|
+
retryCount,
|
|
290
|
+
maxRetries: recovery.maxRetries,
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Re-arming supersedes any prior watch for this session.
|
|
295
|
+
this.cancelWatch(sessionId);
|
|
296
|
+
const file = path.join(projectChatsDir(project.dir), `${sessionId}.jsonl`);
|
|
297
|
+
// Baseline = the transcript's current EOF via a cheap `stat` (NOT a full read):
|
|
298
|
+
// this runs on EVERY session-mode turn once autoReDrive is on, and transcripts
|
|
299
|
+
// here are multi-MB. Start the tail from EOF so we only ever see entries appended
|
|
300
|
+
// AFTER this turn (the kill's notification), never a stale one from earlier.
|
|
301
|
+
let offset = await this.fileSize(file);
|
|
302
|
+
let carry = "";
|
|
303
|
+
let pending = false;
|
|
304
|
+
let pendingSummary;
|
|
305
|
+
let notifiedAt = null;
|
|
306
|
+
const deadline = this.now() + recovery.debounceMs + this.killGraceMs;
|
|
307
|
+
const watch = { timer: null, cancelled: false };
|
|
308
|
+
this.watches.set(sessionId, watch);
|
|
309
|
+
const tick = async () => {
|
|
310
|
+
if (watch.cancelled)
|
|
311
|
+
return;
|
|
312
|
+
let tail;
|
|
313
|
+
try {
|
|
314
|
+
tail = await this.readTail(file, offset);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
tail = { text: "", size: offset };
|
|
318
|
+
}
|
|
319
|
+
if (watch.cancelled)
|
|
320
|
+
return;
|
|
321
|
+
offset = tail.size;
|
|
322
|
+
const combined = carry + tail.text;
|
|
323
|
+
const lines = combined.split("\n");
|
|
324
|
+
// The last element is an incomplete (unterminated) line — hold it for next read.
|
|
325
|
+
carry = lines.pop() ?? "";
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
const { event, summary } = parseTailLine(line);
|
|
328
|
+
if (event === "assistant") {
|
|
329
|
+
pending = false;
|
|
330
|
+
pendingSummary = undefined;
|
|
331
|
+
notifiedAt = null;
|
|
332
|
+
}
|
|
333
|
+
else if (event === "terminated-notification" && !pending) {
|
|
334
|
+
pending = true;
|
|
335
|
+
pendingSummary = summary;
|
|
336
|
+
notifiedAt = this.now();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const t = this.now();
|
|
340
|
+
if (pending && notifiedAt !== null && t - notifiedAt >= recovery.debounceMs) {
|
|
341
|
+
// Hung: a terminated notification has sat un-answered for the full debounce.
|
|
342
|
+
this.cancelWatch(sessionId);
|
|
343
|
+
await this.fire(project, sessionId, recovery, pendingSummary);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (t >= deadline) {
|
|
347
|
+
// The turn ended cleanly (no kill) or the keeper woke — stop watching.
|
|
348
|
+
this.cancelWatch(sessionId);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (!watch.cancelled)
|
|
352
|
+
watch.timer = this.setTimer(() => void tick(), this.pollMs);
|
|
353
|
+
};
|
|
354
|
+
watch.timer = this.setTimer(() => void tick(), this.pollMs);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A detected turn-boundary kill is ready to recover — kick off the fire, giving
|
|
358
|
+
* it a bounded {@link settleWindowMs} budget to wait out any still-live session.
|
|
359
|
+
* See {@link fireWhenIdle} for the surface/re-drive actions and the defer guard.
|
|
360
|
+
*/
|
|
361
|
+
async fire(project, sessionId, recovery, summary) {
|
|
362
|
+
await this.fireWhenIdle(project, sessionId, recovery, summary, this.now() + this.settleWindowMs);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Handle a detected turn-boundary kill. Two independent, resolved-config-gated
|
|
366
|
+
* actions, in order:
|
|
367
|
+
* 1. `surfaceKilledTask` — push the kill to attached clients LIVE so the
|
|
368
|
+
* "keeper is idle / Continue" affordance appears without a refresh. Never
|
|
369
|
+
* throws; a bad sink can't wedge the engine.
|
|
370
|
+
* 2. `autoReDrive` (under the retry cap) — bump the session's retry bookkeeping
|
|
371
|
+
* FIRST (so a re-drive that itself hangs can't exceed the cap), then inject
|
|
372
|
+
* the recovery nudge via the shared Layer 2 path. A failed inject is logged.
|
|
373
|
+
*
|
|
374
|
+
* Liveness guard + defer (issues #352 / #397). Two busy signals mean the session
|
|
375
|
+
* is NOT idle and firing would collide with a live `claude` subprocess:
|
|
376
|
+
* - {@link isBusy} (hub) — a live turn PADDOCK itself started (a human message,
|
|
377
|
+
* a queued-message drain, or a prior recovery nudge).
|
|
378
|
+
* - {@link sdkSessionLive} (reaper) — a subprocess herdctl is keeping alive for
|
|
379
|
+
* the just-killed background task, or holding through its ~15s re-invocation
|
|
380
|
+
* grace. `isBusy` is BLIND to this, which is what let the auto re-drive fire a
|
|
381
|
+
* COMPETING resume and get interrupted (`[Request interrupted by user]`, #397).
|
|
382
|
+
*
|
|
383
|
+
* When EITHER is set we must NOT fire — but standing down *permanently* leaves
|
|
384
|
+
* recovery incomplete: the reaper reaps SILENTLY (no turn completes, so nothing
|
|
385
|
+
* re-arms a watch), and the user stays stuck. So instead we DEFER: re-check after
|
|
386
|
+
* {@link settlePollMs} and fire exactly once the session is genuinely idle
|
|
387
|
+
* (reaper reaped, hub not running). The deferral is bounded by
|
|
388
|
+
* {@link settleWindowMs} so a session that never releases can't retry forever, and
|
|
389
|
+
* is tracked in {@link watches} so a completing turn ({@link armWatch}) or a human
|
|
390
|
+
* message ({@link onHumanMessage}) supersedes/cancels it — no double fire.
|
|
391
|
+
*/
|
|
392
|
+
async fireWhenIdle(project, sessionId, recovery, summary, settleDeadline) {
|
|
393
|
+
const hubBusy = this.isBusy(sessionId);
|
|
394
|
+
const reaperLive = this.sdkSessionLive(sessionId);
|
|
395
|
+
if (hubBusy || reaperLive) {
|
|
396
|
+
if (this.now() >= settleDeadline) {
|
|
397
|
+
// The session never went idle within the settle budget — give up rather
|
|
398
|
+
// than defer forever. A later genuine hang re-arms a fresh watch.
|
|
399
|
+
this.log("recovery: session still live at settle deadline — standing down", {
|
|
400
|
+
slug: project.slug,
|
|
401
|
+
sessionId,
|
|
402
|
+
hubBusy,
|
|
403
|
+
reaperLive,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Defer: re-check liveness after the settle poll. Register as a watch so a
|
|
408
|
+
// fresh armWatch (a completing turn) or a human message cancels this retry.
|
|
409
|
+
const watch = { timer: null, cancelled: false };
|
|
410
|
+
this.watches.set(sessionId, watch);
|
|
411
|
+
watch.timer = this.setTimer(() => {
|
|
412
|
+
if (watch.cancelled)
|
|
413
|
+
return;
|
|
414
|
+
// Drop this settle-watch from the map before re-attempting; fireWhenIdle
|
|
415
|
+
// re-registers a fresh one if it still needs to defer.
|
|
416
|
+
this.cancelWatch(sessionId);
|
|
417
|
+
void this.fireWhenIdle(project, sessionId, recovery, summary, settleDeadline).catch((err) => {
|
|
418
|
+
this.log("recovery: deferred fire failed", { sessionId, err: String(err) });
|
|
419
|
+
});
|
|
420
|
+
}, this.settlePollMs);
|
|
421
|
+
this.log("recovery: session still live (hub or reaper) — deferring nudge until idle", {
|
|
422
|
+
slug: project.slug,
|
|
423
|
+
sessionId,
|
|
424
|
+
hubBusy,
|
|
425
|
+
reaperLive,
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (recovery.surfaceKilledTask) {
|
|
430
|
+
try {
|
|
431
|
+
this.surface(project, sessionId, summary);
|
|
432
|
+
this.log("recovery: surfaced killed task", { slug: project.slug, sessionId });
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
this.log("recovery: surface failed", { sessionId, err: String(err) });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const retryCount = this.guards.get(sessionId)?.retryCount ?? 0;
|
|
439
|
+
if (!recovery.autoReDrive || retryCount >= recovery.maxRetries)
|
|
440
|
+
return;
|
|
441
|
+
const guard = this.guards.get(sessionId) ?? { lastNudgeAt: 0, retryCount: 0 };
|
|
442
|
+
guard.retryCount += 1;
|
|
443
|
+
guard.lastNudgeAt = this.now();
|
|
444
|
+
this.guards.set(sessionId, guard);
|
|
445
|
+
this.log("recovery: auto re-driving hung keeper", {
|
|
446
|
+
slug: project.slug,
|
|
447
|
+
sessionId,
|
|
448
|
+
retryCount: guard.retryCount,
|
|
449
|
+
maxRetries: recovery.maxRetries,
|
|
450
|
+
});
|
|
451
|
+
try {
|
|
452
|
+
await this.deps.reDrive(project, sessionId);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
this.log("recovery: auto re-drive inject failed", { sessionId, err: String(err) });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* A human just messaged this session — clear its retry/debounce bookkeeping and
|
|
460
|
+
* cancel any in-flight watch, so a genuinely-new later hang is recovered fresh
|
|
461
|
+
* (the retry cap counts auto re-drives BETWEEN human messages).
|
|
462
|
+
*/
|
|
463
|
+
onHumanMessage(sessionId) {
|
|
464
|
+
this.guards.delete(sessionId);
|
|
465
|
+
this.cancelWatch(sessionId);
|
|
466
|
+
}
|
|
467
|
+
/** Cancel the in-flight watch for a session, if any (idempotent). */
|
|
468
|
+
cancelWatch(sessionId) {
|
|
469
|
+
const w = this.watches.get(sessionId);
|
|
470
|
+
if (!w)
|
|
471
|
+
return;
|
|
472
|
+
w.cancelled = true;
|
|
473
|
+
if (w.timer !== null)
|
|
474
|
+
this.clearTimer(w.timer);
|
|
475
|
+
this.watches.delete(sessionId);
|
|
476
|
+
}
|
|
477
|
+
/** Cancel every in-flight watch (e.g. on shutdown). */
|
|
478
|
+
stopAll() {
|
|
479
|
+
for (const id of [...this.watches.keys()])
|
|
480
|
+
this.cancelWatch(id);
|
|
481
|
+
}
|
|
482
|
+
/** Test/inspection helper: the current retry count for a session (0 if untracked). */
|
|
483
|
+
retryCountFor(sessionId) {
|
|
484
|
+
return this.guards.get(sessionId)?.retryCount ?? 0;
|
|
485
|
+
}
|
|
486
|
+
/** Test/inspection helper: whether a watch is currently armed for a session. */
|
|
487
|
+
isWatching(sessionId) {
|
|
488
|
+
return this.watches.has(sessionId);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume self-interrupt fix — drain a stale async-input backlog before the real turn.
|
|
3
|
+
*
|
|
4
|
+
* ## The bug this fixes
|
|
5
|
+
*
|
|
6
|
+
* When a keeper session is resumed and the `claude` CLI still holds a stale
|
|
7
|
+
* async-input backlog (leftover `killed`/orphaned `<task-notification>` messages
|
|
8
|
+
* from background shells/subagents a previous process left running), the CLI's
|
|
9
|
+
* drain loop replays that backlog as its **own turn**, emitting that turn's
|
|
10
|
+
* `result` FIRST — ahead of the human/wake/command turn.
|
|
11
|
+
*
|
|
12
|
+
* Every resume consumer used to `break` its `for await (const m of session.messages)`
|
|
13
|
+
* loop on the FIRST `result`. Breaking a `for await` invokes the async generator's
|
|
14
|
+
* `.return()`, which tears the SDK query / `claude` subprocess down (with only a
|
|
15
|
+
* ~2s SIGTERM grace). So the backlog turn's result closed the subprocess out from
|
|
16
|
+
* under the still-running real turn — killing it (`[Request interrupted by user]` /
|
|
17
|
+
* `interruptedByShutdown:true`, the human message lost). It is intermittent because
|
|
18
|
+
* it races the real turn against the ~2s teardown grace: a fast reply survives; a
|
|
19
|
+
* slow (heavy-keeper) reply is killed.
|
|
20
|
+
*
|
|
21
|
+
* ## The fix
|
|
22
|
+
*
|
|
23
|
+
* {@link consumeResumedTurn} consumes the resumed stream and breaks on a `result`
|
|
24
|
+
* only once the on-disk async-input queue has DRAINED (depth 0) — i.e. after the
|
|
25
|
+
* LAST (real) turn, never after a backlog turn. So a backlog turn's result can no
|
|
26
|
+
* longer tear the real turn down.
|
|
27
|
+
*
|
|
28
|
+
* The gate is **dynamic** (the queue depth is probed after each turn's result), not
|
|
29
|
+
* a one-shot pre-probe. This is deliberate: a backlog can materialize DURING the
|
|
30
|
+
* resume — a crashed process that left background work running writes no `killed`
|
|
31
|
+
* notification, so the CLI reconciles those orphans into the queue at resume time,
|
|
32
|
+
* *after* any pre-open probe would have read 0. Dynamic probing catches both the
|
|
33
|
+
* already-on-disk residue and the reconciled-at-resume residue.
|
|
34
|
+
*
|
|
35
|
+
* Zero added latency when there is no backlog: the real prompt is not enqueued into
|
|
36
|
+
* the async queue when the CLI is idle, so the queue depth is 0 the moment the first
|
|
37
|
+
* (and only) turn completes → we break after the first result exactly like before.
|
|
38
|
+
*
|
|
39
|
+
* Robustness:
|
|
40
|
+
* - The per-turn wait is bounded only for the wait for a turn's FIRST message (so a
|
|
41
|
+
* not-yet-flushed dequeue can't hang us forever waiting on a backlog turn that
|
|
42
|
+
* never arrives). Once a turn starts producing, it is consumed to its `result`
|
|
43
|
+
* UNBOUNDED — a slow real turn is never cut off mid-flight.
|
|
44
|
+
* - An overall deadline is the ultimate safety net.
|
|
45
|
+
* - A single in-flight `next()` is threaded throughout, so the iterator is never
|
|
46
|
+
* advanced twice concurrently.
|
|
47
|
+
*/
|
|
48
|
+
import type { SDKMessage } from "@herdctl/core";
|
|
49
|
+
import type { RuntimeSession } from "@herdctl/core";
|
|
50
|
+
export interface ConsumeResumedTurnOptions {
|
|
51
|
+
/** Current on-disk async-input-queue depth for this session (0 = drained). */
|
|
52
|
+
residueProbe: () => Promise<number>;
|
|
53
|
+
/** Surface a message to the client (translate/emit). */
|
|
54
|
+
onMessage?: (m: SDKMessage) => void | Promise<void>;
|
|
55
|
+
/** Called with the session id the moment it first appears in the stream. */
|
|
56
|
+
onSessionId?: (id: string) => void;
|
|
57
|
+
/** Overall deadline (ms). Default {@link DEFAULT_DEADLINE_MS}. */
|
|
58
|
+
deadlineMs?: number;
|
|
59
|
+
/** Optional trace hook — invoked only when a backlog was actually drained. */
|
|
60
|
+
log?: (msg: string) => void;
|
|
61
|
+
}
|
|
62
|
+
export interface ConsumeResumedTurnResult {
|
|
63
|
+
/** True unless the real turn's terminal `result` was an error/`success:false`. */
|
|
64
|
+
success: boolean;
|
|
65
|
+
/** The session id seen on the stream, if any. */
|
|
66
|
+
sessionId: string | null;
|
|
67
|
+
/**
|
|
68
|
+
* The live iterator this consume drove, handed back so the caller can KEEP
|
|
69
|
+
* consuming the SAME stream after the primary turn — without opening a second
|
|
70
|
+
* iterator over the SDK query (which would split messages unpredictably). Used
|
|
71
|
+
* by the Gap B background-turn delivery ({@link consumeBackgroundTurns}): the
|
|
72
|
+
* session is left open by the reaper for in-flight background work, and the
|
|
73
|
+
* autonomous re-invocation turns it later produces arrive on this iterator.
|
|
74
|
+
*/
|
|
75
|
+
iterator: AsyncIterator<SDKMessage>;
|
|
76
|
+
/**
|
|
77
|
+
* The single in-flight `iterator.next()` left outstanding when we stopped
|
|
78
|
+
* consuming (awaiting the message AFTER the primary turn's terminal `result`).
|
|
79
|
+
* Handing it back — rather than dropping it and re-pulling — means no message
|
|
80
|
+
* between the primary result and the first background re-invocation is lost.
|
|
81
|
+
*/
|
|
82
|
+
pending: Promise<Step>;
|
|
83
|
+
}
|
|
84
|
+
type Step = IteratorResult<SDKMessage>;
|
|
85
|
+
/**
|
|
86
|
+
* Consume a resumed session's stream to the caller's real turn, first draining any
|
|
87
|
+
* stale async-input backlog that replays ahead of it. See the module doc.
|
|
88
|
+
*
|
|
89
|
+
* The session must already have the caller's prompt seeded (chatSession opens with
|
|
90
|
+
* the prompt; runCommand `send`s the command; the wake path is opened with its
|
|
91
|
+
* prompt by herdctl) — this only governs WHEN we stop consuming, never what runs.
|
|
92
|
+
*/
|
|
93
|
+
export declare function consumeResumedTurn(session: RuntimeSession, opts: ConsumeResumedTurnOptions): Promise<ConsumeResumedTurnResult>;
|
|
94
|
+
/**
|
|
95
|
+
* Gap B — deliver autonomous background-completion turns.
|
|
96
|
+
*
|
|
97
|
+
* After {@link consumeResumedTurn} returns, the primary turn is done but the
|
|
98
|
+
* session may be held OPEN by the reaper because the turn launched continuous
|
|
99
|
+
* background work (`decideReap` keepAlives while `backgroundTasks.length > 0`).
|
|
100
|
+
* The `claude` subprocess keeps running and, when a background task completes,
|
|
101
|
+
* the SDK hands the parent the result as a fresh autonomous re-invocation turn —
|
|
102
|
+
* emitted on the SAME message stream. Paddock used to stop consuming at the
|
|
103
|
+
* primary `result`, so those messages buffered undelivered: the CLI still
|
|
104
|
+
* PERSISTED the re-invocation to the transcript (the subprocess writes JSONL
|
|
105
|
+
* directly), but the live UI never saw it until a refresh replayed history.
|
|
106
|
+
*
|
|
107
|
+
* This keeps consuming the handed-back {@link ConsumeResumedTurnResult.iterator}
|
|
108
|
+
* (never a second iterator — that would split the stream), forwarding every
|
|
109
|
+
* subsequent message to `onMessage`. The caller's `onMessage` renders each
|
|
110
|
+
* autonomous turn to the hub exactly like the scheduler-wake path does, so it
|
|
111
|
+
* appears live. Consumption ALSO keeps `tapLifecycleStream` advancing, so the
|
|
112
|
+
* reaper keeps receiving its mid-turn `activity` / `background_tasks_changed`
|
|
113
|
+
* signals (they fire as a side effect of the consumer pulling messages) — the
|
|
114
|
+
* behaviour the reaper was designed around.
|
|
115
|
+
*
|
|
116
|
+
* The loop ends cleanly when the reaper reaps the idle session: `close()` ends
|
|
117
|
+
* the SDK query, the iterator yields `done`, and we return. A stream error is
|
|
118
|
+
* swallowed (the turn is already over). Detached by the caller (`void`), it
|
|
119
|
+
* never throws to the event loop.
|
|
120
|
+
*/
|
|
121
|
+
export declare function consumeBackgroundTurns(iterator: AsyncIterator<SDKMessage>, pending: Promise<Step>, onMessage: (m: SDKMessage) => void | Promise<void>, onDone?: () => void | Promise<void>): Promise<void>;
|
|
122
|
+
export {};
|