@edspencer/paddock 0.0.1 → 0.56.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 +512 -0
- package/packages/server/dist/config.js +593 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -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 +572 -0
- package/packages/server/dist/herdctl.js +1218 -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 +135 -0
- package/packages/server/dist/instance-config.js +345 -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-4cQGTj9Y.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
- package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
- package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
- package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.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-DdsLM6ql.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
- package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.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-CGbZdrm8.js +71 -0
- package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.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-2Nlqs0oH.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-mjU1fMc4.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.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-BxKd91ho.js +2 -0
- package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.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-DNdqX7O5.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.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-OAAhWcfK.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.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,35 @@
|
|
|
1
|
+
/** A queued message awaiting auto-send. */
|
|
2
|
+
export interface QueuedMessage {
|
|
3
|
+
text: string;
|
|
4
|
+
createdAtMs: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class QueuedMessageStore {
|
|
7
|
+
private readonly stateFile;
|
|
8
|
+
/** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
|
|
9
|
+
private state;
|
|
10
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
11
|
+
private writing;
|
|
12
|
+
constructor(dataDir: string);
|
|
13
|
+
/** Load the persisted map (lazily; tolerant of a missing/corrupt file). */
|
|
14
|
+
private ensureLoaded;
|
|
15
|
+
/**
|
|
16
|
+
* Retrieve a queued message for a chat, or `null` if none / unavailable.
|
|
17
|
+
* Non-throwing: any load error reads as null.
|
|
18
|
+
*/
|
|
19
|
+
get(agent: string, sessionId: string): Promise<QueuedMessage | null>;
|
|
20
|
+
/**
|
|
21
|
+
* Atomically read AND remove a chat's queued message (#245). Returns the entry
|
|
22
|
+
* (or null if none). The read+delete happen without an intervening `await`, so
|
|
23
|
+
* two concurrent drains — e.g. a turn-completion drain and an idle `set_queue`
|
|
24
|
+
* drain — can never both observe the same message and double-send it: whichever
|
|
25
|
+
* `take()` runs first gets it, the other gets null. Non-throwing.
|
|
26
|
+
*/
|
|
27
|
+
take(agent: string, sessionId: string): Promise<QueuedMessage | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Store (or clear, when `message` is null) a queued message for a chat.
|
|
30
|
+
* Setting `null` deletes the key, keeping the file compact.
|
|
31
|
+
*/
|
|
32
|
+
set(agent: string, sessionId: string, message: QueuedMessage | null): Promise<void>;
|
|
33
|
+
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
34
|
+
private persist;
|
|
35
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueuedMessageStore — a Paddock-side sidecar for per-chat queued messages (#197).
|
|
3
|
+
*
|
|
4
|
+
* The message queue (#91) lets users stack a follow-up to auto-send when the
|
|
5
|
+
* current turn finishes. In PR #204, the queued text lived only in component-local
|
|
6
|
+
* React state, so closing the browser before the turn completed silently dropped it.
|
|
7
|
+
*
|
|
8
|
+
* This store persists queued messages server-side so they survive browser close,
|
|
9
|
+
* page reload, and server restart — enabling true server-driven auto-send.
|
|
10
|
+
*
|
|
11
|
+
* Clones the ReadStateStore pattern: a lightweight, write-through, serialised,
|
|
12
|
+
* corruption-tolerant JSON sidecar in the data dir, storing a `key -> queued text`
|
|
13
|
+
* map (persisted as a plain JSON object). Keyed by `agent \0 sessionId` (NUL-separated).
|
|
14
|
+
*
|
|
15
|
+
* When a turn completes, the server checks for a queued message, auto-sends it as
|
|
16
|
+
* the next message, and deletes the stored entry. The web client receives a
|
|
17
|
+
* `chat:queued_flushed` frame to clear its localStorage.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
const STATE_FILE = "queued-message.json";
|
|
22
|
+
/** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
|
|
23
|
+
const KEY_SEP = "\u0000";
|
|
24
|
+
/** Compose the storage key for an (agent, session) pair. */
|
|
25
|
+
function keyOf(agent, sessionId) {
|
|
26
|
+
return `${agent}${KEY_SEP}${sessionId}`;
|
|
27
|
+
}
|
|
28
|
+
export class QueuedMessageStore {
|
|
29
|
+
stateFile;
|
|
30
|
+
/** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
|
|
31
|
+
state = null;
|
|
32
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
33
|
+
writing = Promise.resolve();
|
|
34
|
+
constructor(dataDir) {
|
|
35
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
36
|
+
}
|
|
37
|
+
/** Load the persisted map (lazily; tolerant of a missing/corrupt file). */
|
|
38
|
+
async ensureLoaded() {
|
|
39
|
+
if (this.state)
|
|
40
|
+
return this.state;
|
|
41
|
+
const map = new Map();
|
|
42
|
+
try {
|
|
43
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
// A plain JSON object `{ [key]: QueuedMessage }` — NOT an array.
|
|
46
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
47
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
48
|
+
if (v &&
|
|
49
|
+
typeof v === "object" &&
|
|
50
|
+
typeof v.text === "string" &&
|
|
51
|
+
typeof v.createdAtMs === "number") {
|
|
52
|
+
map.set(k, v);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* missing or unreadable — start empty */
|
|
59
|
+
}
|
|
60
|
+
this.state = map;
|
|
61
|
+
return this.state;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Retrieve a queued message for a chat, or `null` if none / unavailable.
|
|
65
|
+
* Non-throwing: any load error reads as null.
|
|
66
|
+
*/
|
|
67
|
+
async get(agent, sessionId) {
|
|
68
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
69
|
+
return map.get(keyOf(agent, sessionId)) ?? null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Atomically read AND remove a chat's queued message (#245). Returns the entry
|
|
73
|
+
* (or null if none). The read+delete happen without an intervening `await`, so
|
|
74
|
+
* two concurrent drains — e.g. a turn-completion drain and an idle `set_queue`
|
|
75
|
+
* drain — can never both observe the same message and double-send it: whichever
|
|
76
|
+
* `take()` runs first gets it, the other gets null. Non-throwing.
|
|
77
|
+
*/
|
|
78
|
+
async take(agent, sessionId) {
|
|
79
|
+
const map = await this.ensureLoaded().catch(() => null);
|
|
80
|
+
if (!map)
|
|
81
|
+
return null;
|
|
82
|
+
const key = keyOf(agent, sessionId);
|
|
83
|
+
const v = map.get(key);
|
|
84
|
+
if (!v)
|
|
85
|
+
return null;
|
|
86
|
+
map.delete(key);
|
|
87
|
+
await this.persist(map);
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Store (or clear, when `message` is null) a queued message for a chat.
|
|
92
|
+
* Setting `null` deletes the key, keeping the file compact.
|
|
93
|
+
*/
|
|
94
|
+
async set(agent, sessionId, message) {
|
|
95
|
+
const map = await this.ensureLoaded();
|
|
96
|
+
const key = keyOf(agent, sessionId);
|
|
97
|
+
if (message === null) {
|
|
98
|
+
map.delete(key);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
map.set(key, message);
|
|
102
|
+
}
|
|
103
|
+
await this.persist(map);
|
|
104
|
+
}
|
|
105
|
+
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
106
|
+
persist(map) {
|
|
107
|
+
this.writing = this.writing.then(async () => {
|
|
108
|
+
const obj = {};
|
|
109
|
+
for (const [k, v] of map)
|
|
110
|
+
obj[k] = v;
|
|
111
|
+
const json = JSON.stringify(obj, null, 2);
|
|
112
|
+
await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
|
|
113
|
+
});
|
|
114
|
+
return this.writing;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose the storage key for a (user, agent, session) triple. A null/empty
|
|
3
|
+
* username selects the SHARED bucket (no user segment) — so `none`-mode
|
|
4
|
+
* deployments get cross-device continuity for free without a per-user segment,
|
|
5
|
+
* and can never collide with a real user's entry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function keyOf(username: string | null, agent: string, sessionId: string): string;
|
|
8
|
+
export declare class ReadStateStore {
|
|
9
|
+
private readonly stateFile;
|
|
10
|
+
/** In-memory map of key -> lastSeenMs (loaded once, written through on change). */
|
|
11
|
+
private state;
|
|
12
|
+
/**
|
|
13
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
14
|
+
* Caching only the resolved `state` would let several marks that begin before
|
|
15
|
+
* the first `fs.readFile` resolves each build their own map and race on the
|
|
16
|
+
* assignment — a lost update (last writer's map wins, dropping the others'
|
|
17
|
+
* keys).
|
|
18
|
+
*/
|
|
19
|
+
private loadPromise;
|
|
20
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
21
|
+
private writing;
|
|
22
|
+
constructor(dataDir: string);
|
|
23
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
24
|
+
private ensureLoaded;
|
|
25
|
+
/**
|
|
26
|
+
* The epoch-ms moment this user last viewed this chat, or 0 if never seen /
|
|
27
|
+
* unavailable. Non-throwing: any load error reads as 0. Pass `null` for
|
|
28
|
+
* `username` to read the shared bucket.
|
|
29
|
+
*/
|
|
30
|
+
getLastSeen(username: string | null, agent: string, sessionId: string): Promise<number>;
|
|
31
|
+
/**
|
|
32
|
+
* Advance this chat's last-seen time to `whenMs` (monotonic: an older or equal
|
|
33
|
+
* timestamp is a no-op, avoiding a needless write and never moving read-state
|
|
34
|
+
* backwards). Pass `null` for `username` to write the shared bucket.
|
|
35
|
+
*/
|
|
36
|
+
setLastSeen(username: string | null, agent: string, sessionId: string, whenMs: number): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Advance MANY chats' last-seen times to `whenMs` in ONE write (#508) — the
|
|
39
|
+
* subtree "mark read" action. Per-key monotonic exactly like
|
|
40
|
+
* {@link setLastSeen}, so a chat already seen later than `whenMs` is skipped
|
|
41
|
+
* rather than moved backwards. Returns the ids that actually advanced.
|
|
42
|
+
*/
|
|
43
|
+
setManyLastSeen(username: string | null, agent: string, sessionIds: readonly string[], whenMs: number): Promise<string[]>;
|
|
44
|
+
/** Write-through, serialised so overlapping marks can't corrupt the file. */
|
|
45
|
+
private persist;
|
|
46
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReadStateStore — a Paddock-side sidecar for per-chat "last seen" (read-state),
|
|
3
|
+
* moving the unread affordance (#160/#161) off the browser's localStorage and
|
|
4
|
+
* onto the server so it follows a user ACROSS DEVICES (#189).
|
|
5
|
+
*
|
|
6
|
+
* This clones the ArchiveStore pattern exactly — a lightweight, write-through,
|
|
7
|
+
* serialised, corruption-tolerant JSON sidecar in the data dir — but stores a
|
|
8
|
+
* `key -> lastSeenMs` MAP (persisted as a plain JSON object) rather than a set,
|
|
9
|
+
* since read-state is a timestamp per chat, not a boolean flag.
|
|
10
|
+
*
|
|
11
|
+
* Keying — by user WHEN PRESENT, else a single shared bucket:
|
|
12
|
+
* - Real identity (trusted-header / jwt): `username \0 agent \0 sessionId`
|
|
13
|
+
* - No user (`none` mode / anonymous): `agent \0 sessionId` (shared)
|
|
14
|
+
* NUL-separated, same rationale as ArchiveStore: a NUL can occur in neither a
|
|
15
|
+
* username, an agent name (`keeper-<slug>`), nor a UUID, so keys
|
|
16
|
+
* can't collide — and a user-keyed entry can never alias the shared bucket.
|
|
17
|
+
*
|
|
18
|
+
* `setLastSeen` is MONOTONIC (only ever advances a chat's last-seen time): the
|
|
19
|
+
* marker means "the most recent moment the user viewed this chat", so an older
|
|
20
|
+
* timestamp is a no-op and never moves read-state backwards.
|
|
21
|
+
*
|
|
22
|
+
* The file is written `0o600` (like github-auth.json) — read-state is
|
|
23
|
+
* low-sensitivity (which chats you viewed + when), but there's no reason for it
|
|
24
|
+
* to be world-readable.
|
|
25
|
+
*/
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
const STATE_FILE = "read-state.json";
|
|
29
|
+
/** Separator in a storage key; a NUL can't occur in a username, agent, or UUID. */
|
|
30
|
+
const KEY_SEP = "\u0000";
|
|
31
|
+
/**
|
|
32
|
+
* Compose the storage key for a (user, agent, session) triple. A null/empty
|
|
33
|
+
* username selects the SHARED bucket (no user segment) — so `none`-mode
|
|
34
|
+
* deployments get cross-device continuity for free without a per-user segment,
|
|
35
|
+
* and can never collide with a real user's entry.
|
|
36
|
+
*/
|
|
37
|
+
export function keyOf(username, agent, sessionId) {
|
|
38
|
+
return username
|
|
39
|
+
? `${username}${KEY_SEP}${agent}${KEY_SEP}${sessionId}`
|
|
40
|
+
: `${agent}${KEY_SEP}${sessionId}`;
|
|
41
|
+
}
|
|
42
|
+
export class ReadStateStore {
|
|
43
|
+
stateFile;
|
|
44
|
+
/** In-memory map of key -> lastSeenMs (loaded once, written through on change). */
|
|
45
|
+
state = null;
|
|
46
|
+
/**
|
|
47
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
48
|
+
* Caching only the resolved `state` would let several marks that begin before
|
|
49
|
+
* the first `fs.readFile` resolves each build their own map and race on the
|
|
50
|
+
* assignment — a lost update (last writer's map wins, dropping the others'
|
|
51
|
+
* keys).
|
|
52
|
+
*/
|
|
53
|
+
loadPromise = null;
|
|
54
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
55
|
+
writing = Promise.resolve();
|
|
56
|
+
constructor(dataDir) {
|
|
57
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
58
|
+
}
|
|
59
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
60
|
+
ensureLoaded() {
|
|
61
|
+
if (this.state)
|
|
62
|
+
return Promise.resolve(this.state);
|
|
63
|
+
if (!this.loadPromise) {
|
|
64
|
+
this.loadPromise = (async () => {
|
|
65
|
+
const map = new Map();
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
// A plain JSON object `{ [key]: number }` — NOT an array (unlike Archive).
|
|
70
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
71
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
72
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
73
|
+
map.set(k, v);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* missing or unreadable — start empty */
|
|
79
|
+
}
|
|
80
|
+
this.state = map;
|
|
81
|
+
return map;
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
return this.loadPromise;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The epoch-ms moment this user last viewed this chat, or 0 if never seen /
|
|
88
|
+
* unavailable. Non-throwing: any load error reads as 0. Pass `null` for
|
|
89
|
+
* `username` to read the shared bucket.
|
|
90
|
+
*/
|
|
91
|
+
async getLastSeen(username, agent, sessionId) {
|
|
92
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
93
|
+
return map.get(keyOf(username, agent, sessionId)) ?? 0;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Advance this chat's last-seen time to `whenMs` (monotonic: an older or equal
|
|
97
|
+
* timestamp is a no-op, avoiding a needless write and never moving read-state
|
|
98
|
+
* backwards). Pass `null` for `username` to write the shared bucket.
|
|
99
|
+
*/
|
|
100
|
+
async setLastSeen(username, agent, sessionId, whenMs) {
|
|
101
|
+
const map = await this.ensureLoaded();
|
|
102
|
+
const key = keyOf(username, agent, sessionId);
|
|
103
|
+
if (whenMs <= (map.get(key) ?? 0))
|
|
104
|
+
return; // monotonic — no-op
|
|
105
|
+
map.set(key, whenMs);
|
|
106
|
+
await this.persist(map);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Advance MANY chats' last-seen times to `whenMs` in ONE write (#508) — the
|
|
110
|
+
* subtree "mark read" action. Per-key monotonic exactly like
|
|
111
|
+
* {@link setLastSeen}, so a chat already seen later than `whenMs` is skipped
|
|
112
|
+
* rather than moved backwards. Returns the ids that actually advanced.
|
|
113
|
+
*/
|
|
114
|
+
async setManyLastSeen(username, agent, sessionIds, whenMs) {
|
|
115
|
+
const map = await this.ensureLoaded();
|
|
116
|
+
const advanced = [];
|
|
117
|
+
for (const sessionId of sessionIds) {
|
|
118
|
+
const key = keyOf(username, agent, sessionId);
|
|
119
|
+
if (whenMs <= (map.get(key) ?? 0))
|
|
120
|
+
continue; // monotonic — no-op
|
|
121
|
+
map.set(key, whenMs);
|
|
122
|
+
advanced.push(sessionId);
|
|
123
|
+
}
|
|
124
|
+
if (advanced.length)
|
|
125
|
+
await this.persist(map);
|
|
126
|
+
return advanced;
|
|
127
|
+
}
|
|
128
|
+
/** Write-through, serialised so overlapping marks can't corrupt the file. */
|
|
129
|
+
persist(map) {
|
|
130
|
+
this.writing = this.writing.then(async () => {
|
|
131
|
+
const obj = {};
|
|
132
|
+
for (const [k, v] of map)
|
|
133
|
+
obj[k] = v;
|
|
134
|
+
const json = JSON.stringify(obj, null, 2);
|
|
135
|
+
await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
|
|
136
|
+
});
|
|
137
|
+
return this.writing;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeper-chat recovery config — the shared foundation for issue #301 (Phase 0).
|
|
3
|
+
*
|
|
4
|
+
* ── The problem (see edspencer/herdctl#374) ──────────────────────────────────
|
|
5
|
+
* When a keeper launches a BACKGROUND task (background `Bash`, or a background
|
|
6
|
+
* `Task`/`Agent`) and ends its turn while that task is still running, herdctl
|
|
7
|
+
* correctly keeps the session ALIVE at the turn boundary (it sees the running
|
|
8
|
+
* task in the Stop snapshot and does not reap). But the SDK/native binary kills
|
|
9
|
+
* the still-running background child ~2–8s later anyway. A COMPLETED task emits a
|
|
10
|
+
* re-invocation turn and the keeper continues; a KILLED task emits no wake at all
|
|
11
|
+
* — the `killed`/`stopped` `<task-notification>` is written to the transcript but
|
|
12
|
+
* nothing consumes it. The keeper is left alive-but-idle-forever, recovering only
|
|
13
|
+
* when a human sends the next message. Because the session stays injectable, the
|
|
14
|
+
* whole class of workarounds is "automate (or one-click) the nudge a human sends."
|
|
15
|
+
*
|
|
16
|
+
* ── The two configurable layers ──────────────────────────────────────────────
|
|
17
|
+
* This ticket adds an app-side recovery mechanism in two independently-toggleable
|
|
18
|
+
* layers, each resolved here:
|
|
19
|
+
*
|
|
20
|
+
* Layer 2 ({@link RecoveryConfig.surfaceKilledTask}, default ON) — pure
|
|
21
|
+
* visibility + a manual one-click "Continue". Low-risk, so on by default.
|
|
22
|
+
* Layer 3 ({@link RecoveryConfig.autoReDrive}, default OFF) — automatic
|
|
23
|
+
* detection + re-drive of a hung keeper. Costs a turn/tokens and acts on its
|
|
24
|
+
* own, so opt-in. The DETECTION/INJECT ENGINE is a FOLLOW-UP chat; this
|
|
25
|
+
* module only defines + resolves the flag so the config surface exists.
|
|
26
|
+
*
|
|
27
|
+
* ── Config discipline (the `driveMode`/`maxSpawnDepth` pattern) ───────────────
|
|
28
|
+
* Every field is an instance default (`PADDOCK_RECOVERY_*` env, YAML instance
|
|
29
|
+
* file beneath it) with an optional PER-PROJECT override (`project.yaml` →
|
|
30
|
+
* {@link import("./projects.js").ProjectYaml.recovery}). An absent/invalid
|
|
31
|
+
* override inherits the instance default, resolved at dispatch by
|
|
32
|
+
* {@link resolveRecoveryConfig} — never baked into the DTO (mirrors how
|
|
33
|
+
* `driveMode` resolves against `cfg.driveMode` and `maxSpawnDepth` via
|
|
34
|
+
* `resolveMaxSpawnDepth`). A malformed override is ignored rather than fatal so a
|
|
35
|
+
* hand-edited `project.yaml` can't wedge dispatch.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Resolved recovery config — all fields concrete. Held on {@link
|
|
39
|
+
* import("./config.js").PaddockConfig.recovery} (instance defaults) and produced
|
|
40
|
+
* per-dispatch by {@link resolveRecoveryConfig} (project override else instance).
|
|
41
|
+
*/
|
|
42
|
+
export interface RecoveryConfig {
|
|
43
|
+
/**
|
|
44
|
+
* Layer 2 — render the killed/stopped `<task-notification>` as a distinct
|
|
45
|
+
* "keeper is idle" affordance with a one-click Continue button, instead of
|
|
46
|
+
* silently hiding it. Default ON (pure visibility + a manual button is
|
|
47
|
+
* low-risk). Env `PADDOCK_RECOVERY_SURFACE`.
|
|
48
|
+
*/
|
|
49
|
+
surfaceKilledTask: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Layer 3 — automatically re-drive a keeper whose background task was killed at
|
|
52
|
+
* the turn boundary (detect + inject a nudge so it wakes on its own). Default
|
|
53
|
+
* OFF (auto-acts and costs a turn/tokens → opt-in). Env
|
|
54
|
+
* `PADDOCK_RECOVERY_AUTODRIVE`. NOTE: this chat only plumbs the flag; the
|
|
55
|
+
* detection/inject engine lands in a follow-up.
|
|
56
|
+
*/
|
|
57
|
+
autoReDrive: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Layer 3 guard — minimum ms of quiet after a killed-task notification before
|
|
60
|
+
* the auto re-drive fires, so a genuinely-finishing keeper isn't poked. Default
|
|
61
|
+
* 5000. Env `PADDOCK_RECOVERY_DEBOUNCE_MS`.
|
|
62
|
+
*/
|
|
63
|
+
debounceMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* Layer 3 guard — how many times a single hung session may be auto re-driven
|
|
66
|
+
* before giving up, so a wedged keeper isn't poked in a loop. Default 1. Env
|
|
67
|
+
* `PADDOCK_RECOVERY_MAX_RETRIES`.
|
|
68
|
+
*/
|
|
69
|
+
maxRetries: number;
|
|
70
|
+
/**
|
|
71
|
+
* Layer 2 backstop — if a kept-alive keeper session shows no activity for this
|
|
72
|
+
* many ms after a killed-task notification, surface it as stuck. `0` disables
|
|
73
|
+
* the backstop (the default). Env `PADDOCK_RECOVERY_LIMBO_MS`.
|
|
74
|
+
*/
|
|
75
|
+
limboTimeoutMs: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A per-project recovery override as stored in `project.yaml` — every field
|
|
79
|
+
* optional (an absent field inherits the instance default at dispatch).
|
|
80
|
+
*/
|
|
81
|
+
export type RecoveryOverride = Partial<RecoveryConfig>;
|
|
82
|
+
/**
|
|
83
|
+
* The built-in recovery defaults (beneath env + YAML + per-project override).
|
|
84
|
+
* Layer 2 ON, Layer 3 OFF, guards at their documented values.
|
|
85
|
+
*/
|
|
86
|
+
export declare const DEFAULT_RECOVERY: RecoveryConfig;
|
|
87
|
+
/**
|
|
88
|
+
* True when a `<task-notification>` `<status>` value denotes a task that was
|
|
89
|
+
* TERMINATED (killed/stopped) — the turn-boundary-kill case #301 recovers from —
|
|
90
|
+
* rather than a clean `completed` (or a still-`running`/`timed out` Monitor).
|
|
91
|
+
* Case-insensitive; a missing/blank/other status is not terminated.
|
|
92
|
+
*/
|
|
93
|
+
export declare function isTerminatedTaskStatus(status: string | null | undefined): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
|
|
96
|
+
* into a {@link RecoveryOverride}, dropping any field that is missing or invalid,
|
|
97
|
+
* and returning `undefined` when nothing valid remains (so an empty override is
|
|
98
|
+
* never persisted). Booleans must be real booleans; the numeric knobs must be
|
|
99
|
+
* non-negative integers. Defensive by design — a malformed hand-edit degrades to
|
|
100
|
+
* "inherit the instance default", never a startup/dispatch crash.
|
|
101
|
+
*/
|
|
102
|
+
export declare function sanitizeRecoveryOverride(value: unknown): RecoveryOverride | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Resolve the effective recovery config for a dispatch: a valid per-project
|
|
105
|
+
* override wins field-by-field; every absent field inherits the instance default.
|
|
106
|
+
* Mirrors how `driveMode`/`maxSpawnDepth` resolve — the override is carried on
|
|
107
|
+
* disk only for the fields explicitly set, so an absent value transparently
|
|
108
|
+
* inherits the instance (env/YAML) default. The override is re-sanitised so a
|
|
109
|
+
* corrupt on-disk value can't leak through.
|
|
110
|
+
*/
|
|
111
|
+
export declare function resolveRecoveryConfig(override: RecoveryOverride | undefined, instanceDefault: RecoveryConfig): RecoveryConfig;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeper-chat recovery config — the shared foundation for issue #301 (Phase 0).
|
|
3
|
+
*
|
|
4
|
+
* ── The problem (see edspencer/herdctl#374) ──────────────────────────────────
|
|
5
|
+
* When a keeper launches a BACKGROUND task (background `Bash`, or a background
|
|
6
|
+
* `Task`/`Agent`) and ends its turn while that task is still running, herdctl
|
|
7
|
+
* correctly keeps the session ALIVE at the turn boundary (it sees the running
|
|
8
|
+
* task in the Stop snapshot and does not reap). But the SDK/native binary kills
|
|
9
|
+
* the still-running background child ~2–8s later anyway. A COMPLETED task emits a
|
|
10
|
+
* re-invocation turn and the keeper continues; a KILLED task emits no wake at all
|
|
11
|
+
* — the `killed`/`stopped` `<task-notification>` is written to the transcript but
|
|
12
|
+
* nothing consumes it. The keeper is left alive-but-idle-forever, recovering only
|
|
13
|
+
* when a human sends the next message. Because the session stays injectable, the
|
|
14
|
+
* whole class of workarounds is "automate (or one-click) the nudge a human sends."
|
|
15
|
+
*
|
|
16
|
+
* ── The two configurable layers ──────────────────────────────────────────────
|
|
17
|
+
* This ticket adds an app-side recovery mechanism in two independently-toggleable
|
|
18
|
+
* layers, each resolved here:
|
|
19
|
+
*
|
|
20
|
+
* Layer 2 ({@link RecoveryConfig.surfaceKilledTask}, default ON) — pure
|
|
21
|
+
* visibility + a manual one-click "Continue". Low-risk, so on by default.
|
|
22
|
+
* Layer 3 ({@link RecoveryConfig.autoReDrive}, default OFF) — automatic
|
|
23
|
+
* detection + re-drive of a hung keeper. Costs a turn/tokens and acts on its
|
|
24
|
+
* own, so opt-in. The DETECTION/INJECT ENGINE is a FOLLOW-UP chat; this
|
|
25
|
+
* module only defines + resolves the flag so the config surface exists.
|
|
26
|
+
*
|
|
27
|
+
* ── Config discipline (the `driveMode`/`maxSpawnDepth` pattern) ───────────────
|
|
28
|
+
* Every field is an instance default (`PADDOCK_RECOVERY_*` env, YAML instance
|
|
29
|
+
* file beneath it) with an optional PER-PROJECT override (`project.yaml` →
|
|
30
|
+
* {@link import("./projects.js").ProjectYaml.recovery}). An absent/invalid
|
|
31
|
+
* override inherits the instance default, resolved at dispatch by
|
|
32
|
+
* {@link resolveRecoveryConfig} — never baked into the DTO (mirrors how
|
|
33
|
+
* `driveMode` resolves against `cfg.driveMode` and `maxSpawnDepth` via
|
|
34
|
+
* `resolveMaxSpawnDepth`). A malformed override is ignored rather than fatal so a
|
|
35
|
+
* hand-edited `project.yaml` can't wedge dispatch.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The built-in recovery defaults (beneath env + YAML + per-project override).
|
|
39
|
+
* Layer 2 ON, Layer 3 OFF, guards at their documented values.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_RECOVERY = Object.freeze({
|
|
42
|
+
surfaceKilledTask: true,
|
|
43
|
+
autoReDrive: false,
|
|
44
|
+
debounceMs: 5000,
|
|
45
|
+
maxRetries: 1,
|
|
46
|
+
limboTimeoutMs: 0,
|
|
47
|
+
});
|
|
48
|
+
/** The `<status>` values a killed-at-turn-boundary task notification carries. */
|
|
49
|
+
const TERMINATED_STATUSES = new Set(["killed", "stopped"]);
|
|
50
|
+
/**
|
|
51
|
+
* True when a `<task-notification>` `<status>` value denotes a task that was
|
|
52
|
+
* TERMINATED (killed/stopped) — the turn-boundary-kill case #301 recovers from —
|
|
53
|
+
* rather than a clean `completed` (or a still-`running`/`timed out` Monitor).
|
|
54
|
+
* Case-insensitive; a missing/blank/other status is not terminated.
|
|
55
|
+
*/
|
|
56
|
+
export function isTerminatedTaskStatus(status) {
|
|
57
|
+
return typeof status === "string" && TERMINATED_STATUSES.has(status.trim().toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
/** True when `n` is a valid non-negative-integer ms/count knob. */
|
|
60
|
+
function isNonNegativeInt(n) {
|
|
61
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 0 && Number.isFinite(n);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
|
|
65
|
+
* into a {@link RecoveryOverride}, dropping any field that is missing or invalid,
|
|
66
|
+
* and returning `undefined` when nothing valid remains (so an empty override is
|
|
67
|
+
* never persisted). Booleans must be real booleans; the numeric knobs must be
|
|
68
|
+
* non-negative integers. Defensive by design — a malformed hand-edit degrades to
|
|
69
|
+
* "inherit the instance default", never a startup/dispatch crash.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitizeRecoveryOverride(value) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
const o = value;
|
|
75
|
+
const out = {};
|
|
76
|
+
if (typeof o.surfaceKilledTask === "boolean")
|
|
77
|
+
out.surfaceKilledTask = o.surfaceKilledTask;
|
|
78
|
+
if (typeof o.autoReDrive === "boolean")
|
|
79
|
+
out.autoReDrive = o.autoReDrive;
|
|
80
|
+
if (isNonNegativeInt(o.debounceMs))
|
|
81
|
+
out.debounceMs = o.debounceMs;
|
|
82
|
+
if (isNonNegativeInt(o.maxRetries))
|
|
83
|
+
out.maxRetries = o.maxRetries;
|
|
84
|
+
if (isNonNegativeInt(o.limboTimeoutMs))
|
|
85
|
+
out.limboTimeoutMs = o.limboTimeoutMs;
|
|
86
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective recovery config for a dispatch: a valid per-project
|
|
90
|
+
* override wins field-by-field; every absent field inherits the instance default.
|
|
91
|
+
* Mirrors how `driveMode`/`maxSpawnDepth` resolve — the override is carried on
|
|
92
|
+
* disk only for the fields explicitly set, so an absent value transparently
|
|
93
|
+
* inherits the instance (env/YAML) default. The override is re-sanitised so a
|
|
94
|
+
* corrupt on-disk value can't leak through.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveRecoveryConfig(override, instanceDefault) {
|
|
97
|
+
const clean = sanitizeRecoveryOverride(override) ?? {};
|
|
98
|
+
return {
|
|
99
|
+
surfaceKilledTask: clean.surfaceKilledTask ?? instanceDefault.surfaceKilledTask,
|
|
100
|
+
autoReDrive: clean.autoReDrive ?? instanceDefault.autoReDrive,
|
|
101
|
+
debounceMs: clean.debounceMs ?? instanceDefault.debounceMs,
|
|
102
|
+
maxRetries: clean.maxRetries ?? instanceDefault.maxRetries,
|
|
103
|
+
limboTimeoutMs: clean.limboTimeoutMs ?? instanceDefault.limboTimeoutMs,
|
|
104
|
+
};
|
|
105
|
+
}
|