@edspencer/paddock 0.66.0 → 0.66.2
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/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -1
- package/packages/server/dist/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +87 -0
- package/packages/server/dist/queued-message.js +214 -1
- package/packages/server/dist/routes/chats.js +27 -8
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/turn-interlock.js +8 -0
- package/packages/server/dist/ws-protocol.d.ts +128 -5
- package/packages/server/dist/ws-protocol.js +30 -13
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +545 -127
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
- package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
- package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
- package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
- package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
- package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
|
@@ -1,9 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket chat transport.
|
|
3
|
+
*
|
|
4
|
+
* Protocol (the contract the frontend agent matches):
|
|
5
|
+
*
|
|
6
|
+
* client -> server:
|
|
7
|
+
* { type: "chat:send", payload: {
|
|
8
|
+
* projectSlug: string, // workspace key: a project slug, or "" for the root
|
|
9
|
+
* sessionId: string | null, // resume an existing chat, or null = new
|
|
10
|
+
* message: string,
|
|
11
|
+
* preloadContext?: boolean, // new chat: prepend the project OVERVIEW.md
|
|
12
|
+
* model?: string, // per-chat model override (a known model id)
|
|
13
|
+
* } }
|
|
14
|
+
* { type: "chat:cancel", payload: { jobId } } // optional: stop a running turn
|
|
15
|
+
* { type: "ping" }
|
|
16
|
+
*
|
|
17
|
+
* server -> client (all carry projectSlug + sessionId + jobId for routing):
|
|
18
|
+
* { type: "chat:response", payload: { projectSlug, sessionId, jobId, chunk } }
|
|
19
|
+
* { type: "chat:tool_call", payload: { projectSlug, sessionId, jobId,
|
|
20
|
+
* toolName, inputSummary, output,
|
|
21
|
+
* isError, durationMs } }
|
|
22
|
+
* { type: "chat:message_boundary", payload: { projectSlug, sessionId, jobId } }
|
|
23
|
+
* { type: "chat:complete", payload: { projectSlug, sessionId, jobId, success, error?,
|
|
24
|
+
* model?, usage? } }
|
|
25
|
+
* // model: the model the turn ran on (lastModel ?? effectiveModel).
|
|
26
|
+
* // usage: { inputTokens, outputTokens, cacheReadTokens,
|
|
27
|
+
* // cacheCreationTokens, contextTokens, contextLimit } — the
|
|
28
|
+
* // LAST per-turn usage observed; omitted (with model) if none.
|
|
29
|
+
* // contextTokens = input + cacheRead + cacheCreation;
|
|
30
|
+
* // contextLimit = getContextLimit(model). Stale-by-one-turn
|
|
31
|
+
* // by design (it reflects the just-completed turn's input).
|
|
32
|
+
* { type: "chat:injected", payload: { projectSlug, sessionId, jobId,
|
|
33
|
+
* sender, content, timestamp } }
|
|
34
|
+
* // A machine-injected user turn (issue #290 Part 2): another chat
|
|
35
|
+
* // send_message'd / a schedule fired into this session. Emitted so a
|
|
36
|
+
* // client already viewing the recipient chat renders the injected user
|
|
37
|
+
* // bubble LIVE (with its sender attribution) instead of only seeing the
|
|
38
|
+
* // assistant reply and needing a refresh. `sender` is the MessageSender.
|
|
39
|
+
* { type: "chat:error", payload: { projectSlug, error } }
|
|
40
|
+
* { type: "pong" }
|
|
41
|
+
*
|
|
42
|
+
* Streaming is wired for real via HerdctlService.chat()'s onMessage callback
|
|
43
|
+
* (the public trigger API supports it). The SDKMessage -> chat-event translation
|
|
44
|
+
* (assistant text deltas, message boundaries, and paired tool_use -> tool_result
|
|
45
|
+
* calls enriched with input summaries + wall-clock durations) is done by
|
|
46
|
+
* @herdctl/chat's `createSDKMessageHandler` — the shared, transport-agnostic
|
|
47
|
+
* translator every herdctl chat surface uses — so paddock no longer reimplements
|
|
48
|
+
* it (and as of @herdctl/chat@0.4.1 it pairs CLI tool results correctly, so the
|
|
49
|
+
* prior `normalizeForTranslator` shim is gone). We compose it with a tiny wrapper
|
|
50
|
+
* that also captures, from each raw SDK message, the session id and the per-turn
|
|
51
|
+
* usage + model (the translator only exposes text/boundary/tool events).
|
|
52
|
+
*/
|
|
53
|
+
import { randomUUID } from "node:crypto";
|
|
1
54
|
import { createSDKMessageHandler } from "@herdctl/chat";
|
|
2
55
|
import { extractSubagentLaunches, subagentLaunchFields, } from "./subagents.js";
|
|
3
56
|
import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
|
|
4
57
|
import { consumeResumedTurn } from "./resume-drain.js";
|
|
5
58
|
import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
|
|
6
59
|
import { SessionHub } from "./session-hub.js";
|
|
60
|
+
import { hasQueuedContent, partsOf, } from "./queued-message.js";
|
|
7
61
|
import { resolveAttachmentsConfig } from "./attachments-config.js";
|
|
8
62
|
import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
|
|
9
63
|
import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
|
|
@@ -40,6 +94,48 @@ const SERVER_PING_INTERVAL_MS = 30_000;
|
|
|
40
94
|
// ripgrep/grep treat this file as binary and skip it during directory traversal
|
|
41
95
|
// (silently, exit 1, indistinguishable from "no matches"). See issue #570.
|
|
42
96
|
const KEY_SEP = "\u0000";
|
|
97
|
+
/**
|
|
98
|
+
* Cap on a single queued message. The sidecar is rewritten in full on every queue
|
|
99
|
+
* mutation and the text is persisted verbatim, so an unbounded queue is a cheap
|
|
100
|
+
* way to bloat the data dir (a 2 MB `chat:set_queue` was accepted as-is). Well
|
|
101
|
+
* above any hand-typed follow-up, including a pasted stack trace.
|
|
102
|
+
*/
|
|
103
|
+
const MAX_QUEUED_CHARS = 100_000;
|
|
104
|
+
/**
|
|
105
|
+
* The opaque identity of the client queue a `chat:set_queue` belongs to (#736).
|
|
106
|
+
*
|
|
107
|
+
* Whatever the client supplies is used for EQUALITY ONLY — never compared as a
|
|
108
|
+
* clock — so a skewed clock can't poison anything. A legacy client sends only the
|
|
109
|
+
* enqueue `ts`, which is folded into an id (still a perfectly good per-client
|
|
110
|
+
* identity, just no longer an ordering). A client that sends neither gets a fresh
|
|
111
|
+
* id, so its queue is always treated as new: with nothing stable to match on, the
|
|
112
|
+
* safe failure is to send the message rather than to discard it.
|
|
113
|
+
*/
|
|
114
|
+
function queueIdOf(payload) {
|
|
115
|
+
if (typeof payload.qid === "string" && payload.qid.length > 0)
|
|
116
|
+
return payload.qid;
|
|
117
|
+
if (typeof payload.ts === "number")
|
|
118
|
+
return `ts:${payload.ts}`;
|
|
119
|
+
return `anon:${randomUUID()}`;
|
|
120
|
+
}
|
|
121
|
+
/** Write one frame straight to a single socket, skipping a closed one. */
|
|
122
|
+
function writeFrame(socket, msg) {
|
|
123
|
+
if (socket.readyState !== socket.OPEN)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
socket.send(JSON.stringify(msg));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* a socket that throws on send is effectively gone */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The identity of a slot as a whole (not of one contribution). Written by every
|
|
134
|
+
* `upsert`; synthesised for an entry an older server wrote without one.
|
|
135
|
+
*/
|
|
136
|
+
function slotVersion(m) {
|
|
137
|
+
return m.id ?? `legacy:${m.createdAtMs}`;
|
|
138
|
+
}
|
|
43
139
|
export function makeChatHandler(deps) {
|
|
44
140
|
// ONE hub shared across every socket this handler serves: it tracks each
|
|
45
141
|
// session's in-flight turn and fans its frames out to whichever socket(s) are
|
|
@@ -53,14 +149,61 @@ export function makeChatHandler(deps) {
|
|
|
53
149
|
// double-send only survives a server restart, when the persisted store is
|
|
54
150
|
// already empty anyway.
|
|
55
151
|
//
|
|
56
|
-
// A message's identity is the (
|
|
57
|
-
// The client
|
|
58
|
-
// queue (#245 stable identity), so "same
|
|
59
|
-
// message — deduping on the
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
152
|
+
// A queued message's identity is the (queue id, text) TUPLE, matched by
|
|
153
|
+
// EQUALITY (#628/#736). The client KEEPS one queue id when APPENDING to an
|
|
154
|
+
// existing queue (#245 stable identity), so "same id, different text" is a
|
|
155
|
+
// genuinely new message — deduping on the id alone would silently destroy the
|
|
156
|
+
// appended text.
|
|
157
|
+
//
|
|
158
|
+
// This used to be a (ts, text) tuple with an ORDERING rule on top: anything
|
|
159
|
+
// older than the last flushed ts was "stale by construction, timestamps being
|
|
160
|
+
// monotonic". They are monotonic within ONE client — but the ts came from
|
|
161
|
+
// whichever browser queued the message, so a single client with a fast clock
|
|
162
|
+
// parked the marker in the future and every subsequent queued message on that
|
|
163
|
+
// chat, from ANY client, was silently destroyed until wall-clock time caught up
|
|
164
|
+
// (#736). Equality assumes nothing about clocks, so the ordering rule is gone;
|
|
165
|
+
// in exchange every flushed id has to be remembered rather than just the newest.
|
|
166
|
+
//
|
|
167
|
+
// Bounded on both axes: MAX_FLUSHED_SESSIONS chats, MAX_FLUSHED_IDS ids each,
|
|
168
|
+
// oldest evicted first (insertion-ordered Maps). Overflow can only cost a rare
|
|
169
|
+
// double-send of a re-asserted message, the same exposure a server restart has.
|
|
63
170
|
const lastFlushed = new Map();
|
|
171
|
+
const MAX_FLUSHED_SESSIONS = 512;
|
|
172
|
+
const MAX_FLUSHED_IDS = 32;
|
|
173
|
+
const MAX_FLUSHED_TEXTS = 8;
|
|
174
|
+
/** This session's flushed-parts ledger (id -> the texts flushed under it). */
|
|
175
|
+
const flushedLedger = (markerKey) => {
|
|
176
|
+
let m = lastFlushed.get(markerKey);
|
|
177
|
+
if (!m) {
|
|
178
|
+
m = new Map();
|
|
179
|
+
lastFlushed.set(markerKey, m);
|
|
180
|
+
if (lastFlushed.size > MAX_FLUSHED_SESSIONS) {
|
|
181
|
+
lastFlushed.delete(lastFlushed.keys().next().value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return m;
|
|
185
|
+
};
|
|
186
|
+
// Slots we could NOT hand back to a composer on Stop, keyed `agent \0 sessionId`
|
|
187
|
+
// → the slot version that was parked. A parked message stays queued and visible,
|
|
188
|
+
// but no turn end auto-sends it: the user pressed Stop, and firing their
|
|
189
|
+
// follow-up later — behind whatever they type next — is the stranding this whole
|
|
190
|
+
// change exists to remove. Any real edit mints a new slot version, so the parked
|
|
191
|
+
// id simply stops matching and the message becomes ordinary again; a bare
|
|
192
|
+
// re-assert keeps the version and stays parked. Same bound as the ledger above.
|
|
193
|
+
const parked = new Map();
|
|
194
|
+
/** Record one flushed part, trimming the oldest entries past the bounds. */
|
|
195
|
+
const rememberFlushed = (ledger, id, text) => {
|
|
196
|
+
let texts = ledger.get(id);
|
|
197
|
+
if (!texts) {
|
|
198
|
+
texts = new Set();
|
|
199
|
+
ledger.set(id, texts);
|
|
200
|
+
if (ledger.size > MAX_FLUSHED_IDS)
|
|
201
|
+
ledger.delete(ledger.keys().next().value);
|
|
202
|
+
}
|
|
203
|
+
texts.add(text);
|
|
204
|
+
if (texts.size > MAX_FLUSHED_TEXTS)
|
|
205
|
+
texts.delete(texts.values().next().value);
|
|
206
|
+
};
|
|
64
207
|
// Every currently-connected socket, so a turn's start/stop transition can be
|
|
65
208
|
// broadcast to all clients — powering the per-chat sidebar streaming dots that
|
|
66
209
|
// must update even for chats whose pane isn't mounted (issue #53).
|
|
@@ -253,84 +396,19 @@ export function makeChatHandler(deps) {
|
|
|
253
396
|
// consumes the returned surface.
|
|
254
397
|
const engine = makeTurnEngine({ deps, hub });
|
|
255
398
|
const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// terminated on the next tick — freeing server resources and letting the
|
|
270
|
-
// client's own reconnect take over. Cleared when the socket closes.
|
|
271
|
-
let isAlive = true;
|
|
272
|
-
socket.on("pong", () => {
|
|
273
|
-
isAlive = true;
|
|
274
|
-
});
|
|
275
|
-
const heartbeat = setInterval(() => {
|
|
276
|
-
if (!isAlive) {
|
|
277
|
-
socket.terminate();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
isAlive = false;
|
|
281
|
-
try {
|
|
282
|
-
socket.ping();
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
socket.terminate();
|
|
286
|
-
}
|
|
287
|
-
}, SERVER_PING_INTERVAL_MS);
|
|
288
|
-
socket.on("close", () => {
|
|
289
|
-
clearInterval(heartbeat);
|
|
290
|
-
// Drop this socket from every session fan-out set so the hub stops trying
|
|
291
|
-
// to write to it (a running turn keeps going for other attached sockets).
|
|
292
|
-
hub.unsubscribeSocket(socket);
|
|
293
|
-
clients.delete(socket);
|
|
294
|
-
});
|
|
295
|
-
socket.on("message", (raw) => {
|
|
296
|
-
let parsed;
|
|
297
|
-
try {
|
|
298
|
-
parsed = JSON.parse(raw.toString());
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (!isClientMessage(parsed)) {
|
|
305
|
-
send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (parsed.type === "ping") {
|
|
309
|
-
send({ type: "pong" });
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (parsed.type === "chat:cancel") {
|
|
313
|
-
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
if (parsed.type === "chat:subscribe") {
|
|
317
|
-
onSubscribe(parsed);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (parsed.type === "chat:set_queue") {
|
|
321
|
-
void onSetQueue(parsed);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (parsed.type === "chat:command") {
|
|
325
|
-
void onChatCommand(parsed);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (parsed.type === "chat:continue") {
|
|
329
|
-
void onChatContinue(parsed);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
void onChatSend(parsed);
|
|
333
|
-
});
|
|
399
|
+
/**
|
|
400
|
+
* Build the chat operations for ONE connection: the handlers behind every
|
|
401
|
+
* client frame, plus the queue drain they share.
|
|
402
|
+
*
|
|
403
|
+
* A factory rather than plain closures inside {@link handle} because the drain
|
|
404
|
+
* has to run with NO socket at all (#627). A turn started by a trigger, a
|
|
405
|
+
* scheduler wake or a spawn has no originating connection, and a queued
|
|
406
|
+
* follow-up must still go out when it ends — so `socket` is nullable and the
|
|
407
|
+
* ops are built once, socket-less, for those paths (see `headlessOps` below).
|
|
408
|
+
* A socket-less send is a no-op: everything a client needs to see travels over
|
|
409
|
+
* the hub, which fans out to every socket attached to the session.
|
|
410
|
+
*/
|
|
411
|
+
const makeChatOps = (socket, send) => {
|
|
334
412
|
/**
|
|
335
413
|
* Manual keeper recovery (issue #301, Layer 2). Re-drive a hung keeper whose
|
|
336
414
|
* background task was killed at the turn boundary by injecting the recovery
|
|
@@ -369,6 +447,8 @@ export function makeChatHandler(deps) {
|
|
|
369
447
|
});
|
|
370
448
|
};
|
|
371
449
|
const onSubscribe = (msg) => {
|
|
450
|
+
if (!socket)
|
|
451
|
+
return; // the socket-less ops never receive client frames
|
|
372
452
|
const { sessionId, wantReplay, lastSeq } = msg.payload;
|
|
373
453
|
const result = hub.attach(sessionId, socket, {
|
|
374
454
|
wantReplay: wantReplay === true,
|
|
@@ -387,63 +467,158 @@ export function makeChatHandler(deps) {
|
|
|
387
467
|
const active = hub.activeInfo(sessionId);
|
|
388
468
|
if (active)
|
|
389
469
|
send(activeFrame(active));
|
|
470
|
+
// …and what is currently QUEUED on it (#629). The queue is shared chat
|
|
471
|
+
// state, but a pane could only ever learn it from its own localStorage, so
|
|
472
|
+
// a message queued on another device — or one parked by a Stop nobody was
|
|
473
|
+
// left to hand it back to — was invisible to whoever opened the chat next.
|
|
474
|
+
const slug = msg.payload.projectSlug;
|
|
475
|
+
if (deps.queuedMessage && typeof slug === "string") {
|
|
476
|
+
void deps.queuedMessage
|
|
477
|
+
.get(keeperAgentName(slug), sessionId)
|
|
478
|
+
.then((queued) => {
|
|
479
|
+
if (!hasQueuedContent(queued))
|
|
480
|
+
return;
|
|
481
|
+
send({
|
|
482
|
+
type: "chat:queued_state",
|
|
483
|
+
payload: {
|
|
484
|
+
projectSlug: slug,
|
|
485
|
+
sessionId,
|
|
486
|
+
text: queued.text || null,
|
|
487
|
+
...(queued.id ? { qid: queued.id } : {}),
|
|
488
|
+
...(queued.attachments?.length ? { attachments: queued.attachments } : {}),
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
})
|
|
492
|
+
.catch(() => undefined);
|
|
493
|
+
}
|
|
390
494
|
};
|
|
391
495
|
// Server-authoritative queue drain (#245): auto-send a persisted queued
|
|
392
|
-
// message as the next turn, exactly once. Called (a)
|
|
393
|
-
//
|
|
394
|
-
// that arrived (e.g. via the
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
496
|
+
// message as the next turn, exactly once. Called (a) whenever a turn ends —
|
|
497
|
+
// through the hub, so EVERY turn path drains (#627) — and (b) when a queue is
|
|
498
|
+
// set while the session is idle, i.e. a queue that arrived (e.g. via the
|
|
499
|
+
// reconnect outbox) after the turn it was meant to follow already ended.
|
|
500
|
+
// `take` makes the read+clear atomic so two concurrent drains can never both
|
|
501
|
+
// send it; the flushed-parts ledger skips what was already drained (a stale
|
|
502
|
+
// client re-assert on reload) so it isn't sent twice.
|
|
503
|
+
/**
|
|
504
|
+
* Put attachments the drain could not carry back onto the (now empty) slot as
|
|
505
|
+
* an attachments-only queued message, and tell every client (#728). Only the
|
|
506
|
+
* slash-command path uses this: a command cannot carry files, and dropping
|
|
507
|
+
* them here would be the silent loss this whole change is about.
|
|
508
|
+
*/
|
|
509
|
+
const restageAttachments = async (slug, sessionId, attachments) => {
|
|
510
|
+
if (!deps.queuedMessage)
|
|
511
|
+
return;
|
|
512
|
+
const agent = keeperAgentName(slug);
|
|
513
|
+
await deps.queuedMessage
|
|
514
|
+
.set(agent, sessionId, {
|
|
515
|
+
text: "",
|
|
516
|
+
createdAtMs: Date.now(),
|
|
517
|
+
id: randomUUID(),
|
|
518
|
+
attachments,
|
|
519
|
+
})
|
|
520
|
+
.catch(() => undefined);
|
|
521
|
+
hub.broadcast(sessionId, {
|
|
522
|
+
type: "chat:queued_state",
|
|
523
|
+
payload: { projectSlug: slug, sessionId, text: null, attachments },
|
|
524
|
+
});
|
|
525
|
+
};
|
|
398
526
|
const drainQueue = async (slug, sessionId) => {
|
|
399
527
|
if (!deps.queuedMessage)
|
|
400
528
|
return;
|
|
529
|
+
// Another turn is already live on this session (a drain racing a fresh send,
|
|
530
|
+
// or a turn ending while its successor has started). Leave the queue for
|
|
531
|
+
// that turn's own end — sending now would interleave two turns.
|
|
532
|
+
if (hub.isRunning(sessionId))
|
|
533
|
+
return;
|
|
401
534
|
const agent = keeperAgentName(slug);
|
|
402
535
|
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
403
|
-
|
|
536
|
+
// A slot with no prose but staged files is a real message (#328/#728), so
|
|
537
|
+
// "is there anything here" is content, not text.
|
|
538
|
+
if (!hasQueuedContent(queued) || !queued)
|
|
404
539
|
return;
|
|
405
540
|
const markerKey = `${agent}${KEY_SEP}${sessionId}`;
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
541
|
+
// Parked by a Stop we couldn't hand back (see returnQueueToComposer): the
|
|
542
|
+
// user asked for control, so it waits for them, not for the next turn end.
|
|
543
|
+
// Put it back — `take` already removed it.
|
|
544
|
+
if (parked.get(markerKey) === slotVersion(queued)) {
|
|
545
|
+
await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const ledger = flushedLedger(markerKey);
|
|
549
|
+
// Have we already sent this? (#628/#736.)
|
|
550
|
+
//
|
|
551
|
+
// Identity is the (id, text) TUPLE, matched by EQUALITY, against every
|
|
552
|
+
// identity the slot has been known by — so a client re-asserting its stored
|
|
553
|
+
// copy on reload is recognised whether it holds the id it sent or the slot
|
|
554
|
+
// version the server broadcast back.
|
|
555
|
+
//
|
|
556
|
+
// It used to be a (ts, text) tuple with an ORDERING rule on top: "older than
|
|
557
|
+
// the last flush ⇒ stale by construction, timestamps being monotonic". They
|
|
558
|
+
// are monotonic within one client, but the ts came from whichever BROWSER
|
|
559
|
+
// queued the message — so a single client with a fast clock left the marker
|
|
560
|
+
// five minutes in the future, and every later queued message on that chat,
|
|
561
|
+
// from any client, was taken, classified already-flushed, deleted and never
|
|
562
|
+
// sent, until wall-clock time caught up (#736). Equality assumes nothing
|
|
563
|
+
// about clocks. An APPEND keeps its identity with LONGER text (#245), so it
|
|
564
|
+
// still reads as a genuinely new message and is sent — #628, unchanged.
|
|
565
|
+
const parts = partsOf(queued);
|
|
566
|
+
const stale = parts.some((p) => ledger.get(p.id)?.has(p.text));
|
|
567
|
+
const text = stale ? "" : queued.text;
|
|
568
|
+
// A leading-slash queued message is a slash command (e.g. "/compact"): the
|
|
569
|
+
// CLI dispatches it, and a command carries no attachments — exactly as the
|
|
570
|
+
// live composer refuses to attach files to one. Keep them on the slot rather
|
|
571
|
+
// than destroying them: they stay visible as a queued message of their own
|
|
572
|
+
// and go out after the command's turn, which is the only outcome here that
|
|
573
|
+
// isn't silent loss (#728). Unreachable from the current client, which never
|
|
574
|
+
// stages files onto a slash-command queue; reachable if one ever appends
|
|
575
|
+
// prose + a file behind an already-queued command.
|
|
576
|
+
const isCommand = !stale && text.startsWith("/");
|
|
577
|
+
const attachments = stale || isCommand ? [] : (queued.attachments ?? []);
|
|
578
|
+
// An attachments-only slot has no text but is still a real message
|
|
579
|
+
// (#328/#728), so what makes this a send is CONTENT, not prose.
|
|
580
|
+
const sending = !stale && (Boolean(text) || attachments.length > 0);
|
|
414
581
|
// Tell every attached client (origin + reconnected sockets) to clear its copy
|
|
415
|
-
// of this message. When we're really sending it, carry the text
|
|
416
|
-
// renders the sent bubble in-transcript
|
|
582
|
+
// of this message. When we're really sending it, carry the text + attachments
|
|
583
|
+
// so the client renders the sent bubble in-transcript exactly as a live send
|
|
584
|
+
// does; on a stale re-assert we only clear.
|
|
417
585
|
hub.broadcast(sessionId, {
|
|
418
586
|
type: "chat:queued_flushed",
|
|
419
587
|
payload: {
|
|
420
588
|
projectSlug: slug,
|
|
421
589
|
sessionId,
|
|
422
|
-
...(
|
|
590
|
+
...(sending && text ? { text } : {}),
|
|
591
|
+
...(sending && attachments.length > 0 ? { attachments } : {}),
|
|
423
592
|
},
|
|
424
593
|
});
|
|
425
|
-
if (
|
|
594
|
+
if (!sending)
|
|
426
595
|
return;
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
else
|
|
432
|
-
lastFlushed.set(markerKey, { ts: queued.createdAtMs, texts: new Set([queued.text]) });
|
|
596
|
+
// Every identity this slot carried is now flushed, so a re-assert from any
|
|
597
|
+
// client that held one of them is recognised rather than sent again.
|
|
598
|
+
for (const p of parts)
|
|
599
|
+
rememberFlushed(ledger, p.id, p.text);
|
|
433
600
|
// Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
|
|
434
|
-
// above the reply. Run it detached, like a human send.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
601
|
+
// above the reply. Run it detached, like a human send.
|
|
602
|
+
if (isCommand) {
|
|
603
|
+
if ((queued.attachments?.length ?? 0) > 0) {
|
|
604
|
+
void restageAttachments(slug, sessionId, queued.attachments);
|
|
605
|
+
}
|
|
438
606
|
void onChatCommand({
|
|
439
607
|
type: "chat:command",
|
|
440
|
-
payload: { projectSlug: slug, command:
|
|
608
|
+
payload: { projectSlug: slug, command: text, sessionId },
|
|
441
609
|
});
|
|
442
610
|
}
|
|
443
611
|
else {
|
|
444
612
|
void onChatSend({
|
|
445
613
|
type: "chat:send",
|
|
446
|
-
payload: {
|
|
614
|
+
payload: {
|
|
615
|
+
projectSlug: slug,
|
|
616
|
+
sessionId,
|
|
617
|
+
message: text,
|
|
618
|
+
// The files the user staged behind this message (#728). They used to
|
|
619
|
+
// stay in the composer tray and ride whatever was sent NEXT.
|
|
620
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
621
|
+
},
|
|
447
622
|
});
|
|
448
623
|
}
|
|
449
624
|
};
|
|
@@ -456,18 +631,59 @@ export function makeChatHandler(deps) {
|
|
|
456
631
|
return;
|
|
457
632
|
const sessionId = msg.payload.sessionId ?? null;
|
|
458
633
|
const text = msg.payload.text ?? null;
|
|
634
|
+
// The composer's staged files ride the queued message (#728). They merge
|
|
635
|
+
// additively into the slot, so a write that carries none never removes any.
|
|
636
|
+
const attachments = msg.payload.attachments ?? [];
|
|
459
637
|
// Every chat belongs to a workspace keeper.
|
|
460
638
|
const agent = keeperAgentName(slug);
|
|
461
639
|
if (!sessionId) {
|
|
462
640
|
// New chat: queue isn't stored until the session id exists. The client
|
|
463
|
-
// re-asserts it (
|
|
641
|
+
// re-asserts it (same qid) once the id resolves, so it persists then.
|
|
464
642
|
return;
|
|
465
643
|
}
|
|
466
|
-
// Store or clear the queued message.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
644
|
+
// Store or clear the queued message. A slot with staged files but no prose is
|
|
645
|
+
// a real message (#328: an attachment-only send is valid), so "is there
|
|
646
|
+
// anything to store" is text OR attachments — it used to be text alone, which
|
|
647
|
+
// is why an attachment-only submit during a live turn was a silent no-op.
|
|
648
|
+
if ((text && text.trim().length > 0) || attachments.length > 0) {
|
|
649
|
+
// A queued message is persisted verbatim and the whole sidecar is rewritten
|
|
650
|
+
// on every mutation, so an unbounded one is a cheap way to bloat the data
|
|
651
|
+
// dir. Refuse it loudly rather than silently truncating the user's text.
|
|
652
|
+
if (text != null && text.length > MAX_QUEUED_CHARS) {
|
|
653
|
+
send({
|
|
654
|
+
type: "chat:error",
|
|
655
|
+
payload: {
|
|
656
|
+
projectSlug: slug,
|
|
657
|
+
error: `Queued message is too long (${text.length} characters; the limit is ${MAX_QUEUED_CHARS}).`,
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
// Merge into the chat's single slot rather than overwriting it (#629). The
|
|
663
|
+
// id the client wrote under says which this is: a client editing the slot
|
|
664
|
+
// it can currently see (replace) or one that queued without knowing what
|
|
665
|
+
// was already there (append). The old unconditional overwrite destroyed the
|
|
666
|
+
// first client's message with no signal to anyone — and then rendered it,
|
|
667
|
+
// in that client's own transcript, as a user bubble they never typed.
|
|
668
|
+
const next = await deps.queuedMessage
|
|
669
|
+
.upsert(agent, sessionId, { id: queueIdOf(msg.payload), text: text ?? "", attachments }, Date.now(), randomUUID)
|
|
470
670
|
.catch(() => undefined);
|
|
671
|
+
// The queue is shared chat state, so announce it: every attached client
|
|
672
|
+
// renders the same slot, and echoing `qid` back on its next write is how a
|
|
673
|
+
// client proves it is editing the slot as it stands rather than replacing
|
|
674
|
+
// someone else's text with its own.
|
|
675
|
+
if (next !== undefined) {
|
|
676
|
+
hub.broadcast(sessionId, {
|
|
677
|
+
type: "chat:queued_state",
|
|
678
|
+
payload: {
|
|
679
|
+
projectSlug: slug,
|
|
680
|
+
sessionId,
|
|
681
|
+
text: next?.text || null,
|
|
682
|
+
...(next?.id ? { qid: next.id } : {}),
|
|
683
|
+
...(next?.attachments?.length ? { attachments: next.attachments } : {}),
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
}
|
|
471
687
|
// If no turn is running, this queue arrived after the turn it was meant to
|
|
472
688
|
// follow already ended — drain it now rather than wait for a completion
|
|
473
689
|
// that won't come (the reported stranding bug, #245).
|
|
@@ -475,7 +691,14 @@ export function makeChatHandler(deps) {
|
|
|
475
691
|
await drainQueue(slug, sessionId);
|
|
476
692
|
}
|
|
477
693
|
else {
|
|
694
|
+
// A clear wipes the WHOLE slot, including another client's contribution:
|
|
695
|
+
// with the state broadcast the chip is shared, so clearing it is a
|
|
696
|
+
// deliberate act on something the user can actually see.
|
|
478
697
|
await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
|
|
698
|
+
hub.broadcast(sessionId, {
|
|
699
|
+
type: "chat:queued_state",
|
|
700
|
+
payload: { projectSlug: slug, sessionId, text: null },
|
|
701
|
+
});
|
|
479
702
|
}
|
|
480
703
|
};
|
|
481
704
|
const onChatSend = async (msg) => {
|
|
@@ -838,13 +1061,13 @@ export function makeChatHandler(deps) {
|
|
|
838
1061
|
...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
|
|
839
1062
|
},
|
|
840
1063
|
});
|
|
1064
|
+
// Ending the turn drains any queued follow-up (#197/#245) via the hub's
|
|
1065
|
+
// turn-end hook — the ONE place every turn path converges (#627), rather
|
|
1066
|
+
// than this call site alone. It no longer waits for a SUCCESSFUL turn:
|
|
1067
|
+
// holding the queue on a failed/Stopped turn didn't keep it for the user,
|
|
1068
|
+
// it stranded it, to be flushed by the NEXT `chat:send` — landing behind a
|
|
1069
|
+
// message typed later, with a stale chip in between. See drainQueue.
|
|
841
1070
|
turn.end();
|
|
842
|
-
// After a SUCCESSFUL turn, auto-send any queued follow-up (#197/#245). A
|
|
843
|
-
// Stop/failed turn holds the queue for the user (no drain). drainQueue owns
|
|
844
|
-
// the take + client notify + next-turn kickoff, shared with the idle path.
|
|
845
|
-
if (effectiveSuccess && finalSession) {
|
|
846
|
-
await drainQueue(slug, finalSession);
|
|
847
|
-
}
|
|
848
1071
|
// Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
|
|
849
1072
|
// keeper turn. If this turn launched a background task that the runtime kills
|
|
850
1073
|
// at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
|
|
@@ -1040,6 +1263,201 @@ export function makeChatHandler(deps) {
|
|
|
1040
1263
|
turn.end();
|
|
1041
1264
|
}
|
|
1042
1265
|
};
|
|
1266
|
+
return { onChatContinue, onSubscribe, drainQueue, onSetQueue, onChatSend, onChatCommand };
|
|
1267
|
+
};
|
|
1268
|
+
// The ops used when a turn ends with no socket behind it — a trigger, a wake, a
|
|
1269
|
+
// spawned turn, a background sub-agent stretch — so the drain still has a way to
|
|
1270
|
+
// dispatch the follow-up. Built once: it holds no per-connection state.
|
|
1271
|
+
const headlessOps = makeChatOps(null, () => { });
|
|
1272
|
+
// Prefer the ending turn's ORIGIN socket when there is one, so a drained turn
|
|
1273
|
+
// keeps the same fan-out (and error routing) the turn it followed had; a turn
|
|
1274
|
+
// with no origin falls back to the headless ops.
|
|
1275
|
+
const opsFor = (origin) => origin
|
|
1276
|
+
? makeChatOps(origin, (m) => {
|
|
1277
|
+
if (origin.readyState === origin.OPEN)
|
|
1278
|
+
origin.send(JSON.stringify(m));
|
|
1279
|
+
})
|
|
1280
|
+
: headlessOps;
|
|
1281
|
+
/**
|
|
1282
|
+
* Stop hands the queued message BACK to the user instead of sending it.
|
|
1283
|
+
*
|
|
1284
|
+
* Stop means "give me control back", so the one thing it must not do is have
|
|
1285
|
+
* the agent start working again on the follow-up the instant it is stopped.
|
|
1286
|
+
* Holding the message server-side isn't the answer either — that is exactly
|
|
1287
|
+
* what stranded it (#627): it sat there and went out behind whatever the user
|
|
1288
|
+
* typed next. So it goes back to the composer of the client that pressed Stop,
|
|
1289
|
+
* where it is visible, editable, and sent only when they say so. The client
|
|
1290
|
+
* runs its existing "Edit queued message" path on the frame; the composer draft
|
|
1291
|
+
* is persisted to localStorage by the same effect that persists any draft.
|
|
1292
|
+
*
|
|
1293
|
+
* Every OTHER turn-ending path — normal completion, a slash command, a trigger,
|
|
1294
|
+
* a wake, a spawn, a background stretch — still drains. This is the single
|
|
1295
|
+
* explicit exception, not a gap.
|
|
1296
|
+
*/
|
|
1297
|
+
const returnQueueToComposer = async (slug, sessionId, to) => {
|
|
1298
|
+
if (!deps.queuedMessage)
|
|
1299
|
+
return;
|
|
1300
|
+
// A successor turn is already live (a Stop racing the next send): leave the
|
|
1301
|
+
// queue to that turn's own end rather than yanking it mid-flight.
|
|
1302
|
+
if (hub.isRunning(sessionId))
|
|
1303
|
+
return;
|
|
1304
|
+
const agent = keeperAgentName(slug);
|
|
1305
|
+
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
1306
|
+
if (!hasQueuedContent(queued) || !queued)
|
|
1307
|
+
return;
|
|
1308
|
+
const attachments = queued.attachments ?? [];
|
|
1309
|
+
const live = to && to.readyState === to.OPEN ? to : null;
|
|
1310
|
+
if (!live) {
|
|
1311
|
+
// Nobody to hand it to — the tab closed between the Stop and this turn
|
|
1312
|
+
// ending, or the cancel came from no socket at all. Put it back and PARK
|
|
1313
|
+
// it: still queued, still visible (every attached client is told, and a
|
|
1314
|
+
// pane opening this chat is told on subscribe), but no turn end will
|
|
1315
|
+
// auto-send it. Dropping it here would be exactly the silent loss this
|
|
1316
|
+
// change is about, and sending it would be the surprise Stop must not have.
|
|
1317
|
+
await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
|
|
1318
|
+
const key = `${agent}${KEY_SEP}${sessionId}`;
|
|
1319
|
+
parked.set(key, slotVersion(queued));
|
|
1320
|
+
if (parked.size > MAX_FLUSHED_SESSIONS) {
|
|
1321
|
+
parked.delete(parked.keys().next().value);
|
|
1322
|
+
}
|
|
1323
|
+
hub.broadcast(sessionId, {
|
|
1324
|
+
type: "chat:queued_state",
|
|
1325
|
+
payload: {
|
|
1326
|
+
projectSlug: slug,
|
|
1327
|
+
sessionId,
|
|
1328
|
+
text: queued.text || null,
|
|
1329
|
+
...(queued.id ? { qid: queued.id } : {}),
|
|
1330
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1331
|
+
},
|
|
1332
|
+
});
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
// The stopping client gets the text AND the files staged behind it (#728) —
|
|
1336
|
+
// Stop returns the whole message, and a file left on the cleared slot would be
|
|
1337
|
+
// stranded server-side. Everyone else just sees the shared slot clear, WITH a
|
|
1338
|
+
// reason — a chip vanishing from under you with no explanation is the same
|
|
1339
|
+
// silence that made #629 bite.
|
|
1340
|
+
writeFrame(live, {
|
|
1341
|
+
type: "chat:queued_returned",
|
|
1342
|
+
payload: {
|
|
1343
|
+
projectSlug: slug,
|
|
1344
|
+
sessionId,
|
|
1345
|
+
text: queued.text,
|
|
1346
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
hub.broadcast(sessionId, {
|
|
1350
|
+
type: "chat:queued_state",
|
|
1351
|
+
payload: { projectSlug: slug, sessionId, text: null, reason: "returned" },
|
|
1352
|
+
}, { except: live });
|
|
1353
|
+
};
|
|
1354
|
+
// #627: EVERY turn-ending path deals with the queue, because every one of them
|
|
1355
|
+
// ends its turn through the hub. Previously this was a single call after a
|
|
1356
|
+
// successful `chat:send`, so a message queued during a slash command, a trigger,
|
|
1357
|
+
// a wake or a background stretch was stranded until some later `chat:send`
|
|
1358
|
+
// flushed it — out of order, behind a message the user typed afterwards.
|
|
1359
|
+
hub.onTurnEnd = ({ sessionId, projectSlug, origin, cancel }) => {
|
|
1360
|
+
// A destructive op stopped this turn so it can delete/revert/promote the
|
|
1361
|
+
// chat (#731). Its transcript is about to be rewritten or removed, so a
|
|
1362
|
+
// drained follow-up would either race that or resurrect the chat (#730) —
|
|
1363
|
+
// and, by making the session busy again, would make the interlock time out
|
|
1364
|
+
// and refuse the operation. Leave the queue exactly where it is.
|
|
1365
|
+
if (cancel?.reason === "quiesce")
|
|
1366
|
+
return;
|
|
1367
|
+
if (cancel?.reason === "stop") {
|
|
1368
|
+
void returnQueueToComposer(projectSlug, sessionId, cancel.origin);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
void opsFor(origin).drainQueue(projectSlug, sessionId);
|
|
1372
|
+
};
|
|
1373
|
+
const handle = async function handle(socket) {
|
|
1374
|
+
const send = (m) => {
|
|
1375
|
+
if (socket.readyState === socket.OPEN)
|
|
1376
|
+
socket.send(JSON.stringify(m));
|
|
1377
|
+
};
|
|
1378
|
+
// This connection's operations. The frame dispatcher below closes over them.
|
|
1379
|
+
const { onChatContinue, onSubscribe, onSetQueue, onChatSend, onChatCommand } = makeChatOps(socket, send);
|
|
1380
|
+
// Register this socket for active-turn broadcasts, and immediately catch it
|
|
1381
|
+
// up on which sessions are currently running (so the sidebar dots and a
|
|
1382
|
+
// returning pane's Stop button reflect reality from the first paint).
|
|
1383
|
+
clients.add(socket);
|
|
1384
|
+
for (const info of hub.runningSessions())
|
|
1385
|
+
send(activeFrame(info));
|
|
1386
|
+
// Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
|
|
1387
|
+
// client whose TCP has silently died (idle drop, sleep) fails to pong and is
|
|
1388
|
+
// terminated on the next tick — freeing server resources and letting the
|
|
1389
|
+
// client's own reconnect take over. Cleared when the socket closes.
|
|
1390
|
+
let isAlive = true;
|
|
1391
|
+
socket.on("pong", () => {
|
|
1392
|
+
isAlive = true;
|
|
1393
|
+
});
|
|
1394
|
+
const heartbeat = setInterval(() => {
|
|
1395
|
+
if (!isAlive) {
|
|
1396
|
+
socket.terminate();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
isAlive = false;
|
|
1400
|
+
try {
|
|
1401
|
+
socket.ping();
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
socket.terminate();
|
|
1405
|
+
}
|
|
1406
|
+
}, SERVER_PING_INTERVAL_MS);
|
|
1407
|
+
socket.on("close", () => {
|
|
1408
|
+
clearInterval(heartbeat);
|
|
1409
|
+
// Drop this socket from every session fan-out set so the hub stops trying
|
|
1410
|
+
// to write to it (a running turn keeps going for other attached sockets).
|
|
1411
|
+
hub.unsubscribeSocket(socket);
|
|
1412
|
+
clients.delete(socket);
|
|
1413
|
+
});
|
|
1414
|
+
socket.on("message", (raw) => {
|
|
1415
|
+
let parsed;
|
|
1416
|
+
try {
|
|
1417
|
+
parsed = JSON.parse(raw.toString());
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (!isClientMessage(parsed)) {
|
|
1424
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (parsed.type === "ping") {
|
|
1428
|
+
send({ type: "pong" });
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (parsed.type === "chat:cancel") {
|
|
1432
|
+
// Tell the hub this end is a user Stop, and which socket asked, so the
|
|
1433
|
+
// turn end it causes hands the queued follow-up back to THAT composer
|
|
1434
|
+
// instead of sending it. `chat:cancel` names only a job id, so the hub
|
|
1435
|
+
// resolves the chat it belongs to; a stale Stop for a turn that already
|
|
1436
|
+
// finished resolves to nothing and marks nothing.
|
|
1437
|
+
const target = hub.sessionForJob(parsed.payload.jobId);
|
|
1438
|
+
if (target)
|
|
1439
|
+
hub.noteCancel(target.sessionId, { reason: "stop", origin: socket });
|
|
1440
|
+
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (parsed.type === "chat:subscribe") {
|
|
1444
|
+
onSubscribe(parsed);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (parsed.type === "chat:set_queue") {
|
|
1448
|
+
void onSetQueue(parsed);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (parsed.type === "chat:command") {
|
|
1452
|
+
void onChatCommand(parsed);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (parsed.type === "chat:continue") {
|
|
1456
|
+
void onChatContinue(parsed);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
void onChatSend(parsed);
|
|
1460
|
+
});
|
|
1043
1461
|
};
|
|
1044
1462
|
// The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
|
|
1045
1463
|
// action fires a schedule-type trigger on demand through the exact same hub path a
|