dsh-pause-session 0.0.0-stage → 0.2.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/CHANGELOG.md +79 -0
- package/DEVELOPMENT.md +78 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/README.zh.md +101 -0
- package/REHEARSAL.md +90 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/pause-controls.webp +0 -0
- package/icon.svg +5 -0
- package/lib/client.js +2024 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2996 -0
- package/lib/index.js.map +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +339 -0
- package/lib/typert.remote-client.d.ts +38 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +244 -0
- package/lib/types/client/control.d.ts +99 -0
- package/lib/types/client/global.d.ts +125 -0
- package/lib/types/client/icons.d.ts +24 -0
- package/lib/types/client/index.d.ts +78 -0
- package/lib/types/client/locales.d.ts +67 -0
- package/lib/types/client/pill.d.ts +49 -0
- package/lib/types/client/remote.d.ts +71 -0
- package/lib/types/client/view.d.ts +151 -0
- package/lib/types/coldlog.d.ts +111 -0
- package/lib/types/composition.d.ts +94 -0
- package/lib/types/control.d.ts +95 -0
- package/lib/types/gate.d.ts +110 -0
- package/lib/types/global.d.ts +103 -0
- package/lib/types/history.d.ts +47 -0
- package/lib/types/index.d.ts +64 -0
- package/lib/types/park-state.d.ts +51 -0
- package/lib/types/park.d.ts +50 -0
- package/lib/types/registry.d.ts +117 -0
- package/lib/types/release-order.d.ts +21 -0
- package/lib/types/remote-adapter.d.ts +22 -0
- package/lib/types/remote.d.ts +158 -0
- package/lib/types/resume-activation.d.ts +27 -0
- package/lib/types/resume-text.d.ts +67 -0
- package/lib/types/resume.d.ts +126 -0
- package/lib/types/shared.d.ts +221 -0
- package/lib/types/snapshot.d.ts +29 -0
- package/lib/types/tree.d.ts +71 -0
- package/lib/types/triage.d.ts +120 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +166 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,2996 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SessionId, deriveEventMessage, foldRequestHeader, foldSurface, interruptedTurnClosers } from "@deepseek-ai/dsh-session";
|
|
3
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
import { applyChildComposition, foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
5
|
+
import { Remote, RemoteError, TypertRemoteService, principalOf } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
|
+
import { DomainError, defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
7
|
+
//#region lib/types/history.js
|
|
8
|
+
/**
|
|
9
|
+
* The message history a session's log derives to, and the one rule that reads
|
|
10
|
+
* whether it still owes the model an answer.
|
|
11
|
+
*
|
|
12
|
+
* That question is asked twice and must have one answer: the clean wake asks it
|
|
13
|
+
* before it hands a session an input (an input nobody owes an answer to would
|
|
14
|
+
* open a turn of its own), and the decision list publishes it as the row's
|
|
15
|
+
* `wake` field. A published promise that the wake then declines to keep is the
|
|
16
|
+
* report lying, so both read the same derivation and the same rule.
|
|
17
|
+
*
|
|
18
|
+
* The derivation is the harness's own: the surface fold the loop reads its
|
|
19
|
+
* history through, with the same message projections this process has
|
|
20
|
+
* registered, and an interrupted tail closed exactly as the harness closes it
|
|
21
|
+
* on the next read. `resume.ts` reads the live form of it
|
|
22
|
+
* (`Session.deriveMessages()`); {@link derivedHistory} is the cold form over a
|
|
23
|
+
* stored log, which is all the decision list may claim — it opens a log to read
|
|
24
|
+
* it and never activates a session.
|
|
25
|
+
* @module dsh-pause-session/history
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Whether one derived history ends owing the model an answer: the last thing
|
|
29
|
+
* said on the model-visible surface is not an assistant message.
|
|
30
|
+
* @param messages - a derived history, in model-visible order.
|
|
31
|
+
* @returns whether a model still owes that history a reply.
|
|
32
|
+
*/
|
|
33
|
+
function owesReply(messages) {
|
|
34
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
35
|
+
const message = messages[index];
|
|
36
|
+
if (message === void 0 || message.role === "system" || message.role === "developer") continue;
|
|
37
|
+
return message.role !== "assistant";
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The messages one stored log derives to, as the harness would read them on the
|
|
43
|
+
* session's next activation: the open tail turn is closed with the harness's
|
|
44
|
+
* own synthetic results first, then the log is folded into its surface.
|
|
45
|
+
* @param events - the stored events, exactly as they are stored and in sequence order.
|
|
46
|
+
* @param projections - this process's registered message projections.
|
|
47
|
+
* @returns the derived history, in model-visible order.
|
|
48
|
+
* @throws when the log cannot be folded — an event this build has no interpreter
|
|
49
|
+
* for, or surface metadata that does not apply to this history.
|
|
50
|
+
*/
|
|
51
|
+
function derivedHistory(events, projections) {
|
|
52
|
+
const stored = [...events, ...interruptedTurnClosers(events)];
|
|
53
|
+
const fold = foldSurface(stored, projections);
|
|
54
|
+
return fold.nodes.flatMap((seq) => {
|
|
55
|
+
const event = stored[seq];
|
|
56
|
+
if (event === void 0) return [];
|
|
57
|
+
const message = deriveEventMessage(event, fold.projectedMessages);
|
|
58
|
+
return message === null ? [] : [message];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether one stored log names the route a wake would be addressed to. The wake
|
|
63
|
+
* needs a provider and a model to build a request at all, and the request
|
|
64
|
+
* header the session itself persisted is the only place that names them.
|
|
65
|
+
* @param events - the stored events, in sequence order.
|
|
66
|
+
* @returns whether the log's last request header carries both route fields.
|
|
67
|
+
*/
|
|
68
|
+
function routeNamed(events) {
|
|
69
|
+
const config = foldRequestHeader(events)?.config;
|
|
70
|
+
return config !== void 0 && config.provider.length > 0 && config.model.length > 0;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region lib/types/shared.js
|
|
74
|
+
/**
|
|
75
|
+
* The identities both halves of dsh-pause-session name, the phase vocabulary
|
|
76
|
+
* they share, and the one spelling a host log line gives a caught value.
|
|
77
|
+
*
|
|
78
|
+
* The browser bundle may value-import only the shell's baseline module table,
|
|
79
|
+
* so this module imports nothing. The native Remote payloads are the live
|
|
80
|
+
* vocabulary; there is no command encoding or slash-command surface here.
|
|
81
|
+
* @module dsh-pause-session/shared
|
|
82
|
+
*/
|
|
83
|
+
/** Result copy for a press or a release the durable registry refused to record. */
|
|
84
|
+
const UNRECORDED_TEXT = "pause could not write the pause registry, so it did not change the pause state.";
|
|
85
|
+
/**
|
|
86
|
+
* One log line's text for a caught value.
|
|
87
|
+
* @param error - the caught value.
|
|
88
|
+
* @returns the error's message, or the value's own spelling.
|
|
89
|
+
*/
|
|
90
|
+
function describeError(error) {
|
|
91
|
+
return error instanceof Error ? error.message : String(error);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The four park outcomes, listed in the order a park walks them — and the one
|
|
95
|
+
* vocabulary the live status, the durable registry, and the published words all
|
|
96
|
+
* derive from, so they cannot drift apart:
|
|
97
|
+
*
|
|
98
|
+
* - `draining`: paused, and the session is still working — a tool is running, or
|
|
99
|
+
* a turn is between steps.
|
|
100
|
+
* - `parking`: standing still at the park point; the session-level flush has
|
|
101
|
+
* not resolved yet.
|
|
102
|
+
* - `safe`: parked and flushed, so it is safe to walk away. The only clean one.
|
|
103
|
+
* - `flush-failed`: parked, but the flush failed — the durable promise is not
|
|
104
|
+
* kept, and it must never be read as clean.
|
|
105
|
+
*/
|
|
106
|
+
const PARK_PHASES = [
|
|
107
|
+
"draining",
|
|
108
|
+
"parking",
|
|
109
|
+
"safe",
|
|
110
|
+
"flush-failed"
|
|
111
|
+
];
|
|
112
|
+
/**
|
|
113
|
+
* How far along a phase is, worst first. The direction is not the order a park
|
|
114
|
+
* walks: it orders how much of the promise each outcome keeps, which is what a
|
|
115
|
+
* fold over several sessions has to compare. `safe` keeps all of it, `draining`
|
|
116
|
+
* and `parking` are on their way, `flush-failed` has stopped trying — a park
|
|
117
|
+
* that failed its flush is further from clean than one that is still working at
|
|
118
|
+
* it, so it must never be the outcome a report rounds down to.
|
|
119
|
+
*/
|
|
120
|
+
const PHASE_SEVERITY = {
|
|
121
|
+
safe: 0,
|
|
122
|
+
draining: 1,
|
|
123
|
+
parking: 2,
|
|
124
|
+
"flush-failed": 3
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Fold several records into the one verdict that speaks for all of them: the
|
|
128
|
+
* worst phase any of them reached, and whether every one of them is clean.
|
|
129
|
+
*
|
|
130
|
+
* This is how a session's control answers for its tree. "Safe to close" is a
|
|
131
|
+
* promise about everything the press registered, so it may be said only when
|
|
132
|
+
* every member reached the flushed park point; anything else is reported as the
|
|
133
|
+
* least-clean state in the set, never rounded up to the session's own.
|
|
134
|
+
* @param phases - the durable phases of the records being spoken for.
|
|
135
|
+
* @returns the worst phase, and whether that verdict is the clean one.
|
|
136
|
+
*/
|
|
137
|
+
function foldTreePhases(phases) {
|
|
138
|
+
let worst = "safe";
|
|
139
|
+
for (const phase of phases) if (PHASE_SEVERITY[phase] > PHASE_SEVERITY[worst]) worst = phase;
|
|
140
|
+
return {
|
|
141
|
+
phase: worst,
|
|
142
|
+
safe: worst === "safe"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region lib/types/coldlog.js
|
|
147
|
+
/**
|
|
148
|
+
* Reading one parked session's stored log through a bound, and the facts the
|
|
149
|
+
* morning list may claim from what it read.
|
|
150
|
+
*
|
|
151
|
+
* The decision list reads the same log the wake reads, but for the opposite
|
|
152
|
+
* reason: the wake has to reproduce the session, so it reads everything. The
|
|
153
|
+
* list only has to name where the session stopped, which tool call the log
|
|
154
|
+
* never heard back from, and whether an answer is still owed — all of them
|
|
155
|
+
* facts about the log's END. Reading from seq 0 to the end for every row, on
|
|
156
|
+
* every time the panel opens, would make a long
|
|
157
|
+
* session's cost grow with a log nobody is asking about.
|
|
158
|
+
*
|
|
159
|
+
* WHAT THE BOUND BUYS. A stored log is append-only, so the tail is stable: the
|
|
160
|
+
* read takes the last {@link TRIAGE_WINDOW_EVENTS} events, or the whole log when
|
|
161
|
+
* it is shorter, and the answer is derived from that. The window is complete
|
|
162
|
+
* whenever the log fits inside it, which is the common case; the fold the rest
|
|
163
|
+
* of this plugin reads history through is then the harness's own, over exactly
|
|
164
|
+
* the events the harness would fold.
|
|
165
|
+
*
|
|
166
|
+
* WHAT THE TAIL ALONE PROVES. A truncated window still answers the list's
|
|
167
|
+
* questions, for one reason the harness guarantees: a dangling tool call exists
|
|
168
|
+
* only inside the step that is still open, because a step's end is written
|
|
169
|
+
* after its results. So an unanswered call is always inside the final open
|
|
170
|
+
* span, and the tail that contains that span contains every one of them. What
|
|
171
|
+
* the window cannot prove — a log a replacement event rewrote from further back
|
|
172
|
+
* — is not guessed at: {@link logFacts} reports `undefined` and the row says
|
|
173
|
+
* `unreadable`, because a list that invents a stop position is worse than a
|
|
174
|
+
* list that says it could not read one.
|
|
175
|
+
* @module dsh-pause-session/coldlog
|
|
176
|
+
*/
|
|
177
|
+
/**
|
|
178
|
+
* Events the read looks at before it gives up. Sized to hold a whole final
|
|
179
|
+
* step — the model exchange, its tool calls, and their results — with room to
|
|
180
|
+
* spare, at a cost that does not grow with the session.
|
|
181
|
+
*/
|
|
182
|
+
const TRIAGE_WINDOW_EVENTS = 2e3;
|
|
183
|
+
/**
|
|
184
|
+
* The longest log the read will walk while locating where its tail starts.
|
|
185
|
+
*
|
|
186
|
+
* The window's start is found by probing for the log's length, so a log longer
|
|
187
|
+
* than this cannot be located from its end at a cost worth paying for a panel
|
|
188
|
+
* left open. Such a log — a million stored events — is reported unreadable
|
|
189
|
+
* rather than read from the front, which is the read this module exists to
|
|
190
|
+
* avoid.
|
|
191
|
+
*/
|
|
192
|
+
const TRIAGE_LOG_MAX_EVENTS = 1e6;
|
|
193
|
+
/**
|
|
194
|
+
* Events that are not the session's own story: the bookkeeping of the pause
|
|
195
|
+
* legacy command records a previous surface wrote, and the pending-input
|
|
196
|
+
* splices the inbox writes. They are real and durable, but "it stopped right
|
|
197
|
+
* after the action finished"
|
|
198
|
+
* is not what "where did it stop" means.
|
|
199
|
+
*/
|
|
200
|
+
const BOOKKEEPING_EVENTS = /* @__PURE__ */ new Set([
|
|
201
|
+
"command/run",
|
|
202
|
+
"command/done",
|
|
203
|
+
"agent/inbox/spliced"
|
|
204
|
+
]);
|
|
205
|
+
/**
|
|
206
|
+
* The turn and step an event belongs to, when it carries them.
|
|
207
|
+
* @param event - one stored event.
|
|
208
|
+
* @returns the position fields the event has, and nothing for the fields it does not.
|
|
209
|
+
*/
|
|
210
|
+
function positionOf(event) {
|
|
211
|
+
const data = event.data;
|
|
212
|
+
if (typeof data !== "object" || data === null) return {};
|
|
213
|
+
const turn = "turn" in data && typeof data.turn === "number" ? data.turn : void 0;
|
|
214
|
+
const step = "step" in data && typeof data.step === "number" ? data.step : void 0;
|
|
215
|
+
return {
|
|
216
|
+
...turn === void 0 ? {} : { turn },
|
|
217
|
+
...step === void 0 ? {} : { step }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Every tool call the STORED events record and no stored result answers.
|
|
222
|
+
*
|
|
223
|
+
* Deliberately the stored prefix rather than the repaired view: what a row
|
|
224
|
+
* reports is the state of the session's own log, and the harness's synthetic
|
|
225
|
+
* unknown-outcome results are exactly what that log is missing. Taking the
|
|
226
|
+
* repaired view here would subtract the very traces the row exists to show.
|
|
227
|
+
*
|
|
228
|
+
* A call is answered only by a result the SAME window holds, which is what the
|
|
229
|
+
* harness's own repair rule reads too: an unanswered call lives in the step
|
|
230
|
+
* that is still open, so it is inside this window or it is answered.
|
|
231
|
+
* @param events - the stored events the read returned, in sequence order.
|
|
232
|
+
* @returns one trace per call with no result, in call order.
|
|
233
|
+
*/
|
|
234
|
+
function unansweredCalls(events) {
|
|
235
|
+
const names = /* @__PURE__ */ new Map();
|
|
236
|
+
const answered = /* @__PURE__ */ new Set();
|
|
237
|
+
for (const event of events) {
|
|
238
|
+
if (event.type === "tool/call") {
|
|
239
|
+
names.set(String(event.data.callId), event.data.name);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (event.type !== "tool/result") continue;
|
|
243
|
+
answered.add(String(event.data.message.toolCallId));
|
|
244
|
+
}
|
|
245
|
+
return [...names].flatMap(([callId, tool]) => answered.has(callId) ? [] : [{
|
|
246
|
+
tool,
|
|
247
|
+
callId
|
|
248
|
+
}]);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether a log ends owing the model an answer, read from the tail alone.
|
|
252
|
+
*
|
|
253
|
+
* This is the harness's own rule — the last thing on the model-visible surface
|
|
254
|
+
* is not an assistant message — read the way the harness reads it: the surface
|
|
255
|
+
* fold over the events at hand, then the message the last node derives to. The
|
|
256
|
+
* fold is handed a window rather than the whole log, so it refuses rather than
|
|
257
|
+
* guesses when it meets a replacement it cannot resolve; a log it cannot fold
|
|
258
|
+
* falls back to the last event that produces a message at all, which is the
|
|
259
|
+
* same answer by a narrower route.
|
|
260
|
+
* @param events - the stored events the read returned, in sequence order.
|
|
261
|
+
* @param traces - the unanswered calls, as the row reports them.
|
|
262
|
+
* @returns whether the session still owes the model a reply.
|
|
263
|
+
*/
|
|
264
|
+
function tailOwesReply(events, traces) {
|
|
265
|
+
if (traces.length > 0) return true;
|
|
266
|
+
const bySequence = new Map(events.map((event) => [event.seq, event]));
|
|
267
|
+
const lastNode = () => {
|
|
268
|
+
for (const seq of [...foldSurface(events).nodes].reverse()) {
|
|
269
|
+
const event = bySequence.get(seq);
|
|
270
|
+
if (event !== void 0) return event;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
let anchor;
|
|
274
|
+
try {
|
|
275
|
+
anchor = lastNode();
|
|
276
|
+
} catch {
|
|
277
|
+
for (let at = events.length - 1; at >= 0; at -= 1) {
|
|
278
|
+
const event = events[at];
|
|
279
|
+
if (event === void 0 || deriveEventMessage(event) === null) continue;
|
|
280
|
+
anchor = event;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (anchor === void 0) return false;
|
|
285
|
+
const message = deriveEventMessage(anchor);
|
|
286
|
+
if (message === null) return false;
|
|
287
|
+
if (message.role === "system" || message.role === "developer") return false;
|
|
288
|
+
return message.role !== "assistant";
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The facts one bounded read of a stored log supports.
|
|
292
|
+
*
|
|
293
|
+
* A complete read is folded by the harness's own derivation, so the list and
|
|
294
|
+
* the wake answer the "does it still owe a reply" question through one rule.
|
|
295
|
+
* A truncated read answers from the tail, and only where the tail is proof: a
|
|
296
|
+
* replacement event that rewrote a surface range from outside the window is
|
|
297
|
+
* not visible here, so a log whose tail a later event could have rewritten is
|
|
298
|
+
* reported unreadable rather than guessed at.
|
|
299
|
+
* @param events - the stored events the read returned, in sequence order.
|
|
300
|
+
* @param complete - whether those events are the whole stored log.
|
|
301
|
+
* @param projections - this process's registered message projections.
|
|
302
|
+
* @returns the row's facts, or `undefined` when this read cannot support them.
|
|
303
|
+
*/
|
|
304
|
+
function logFacts(events, complete, projections) {
|
|
305
|
+
let last;
|
|
306
|
+
for (let at = events.length - 1; at >= 0; at -= 1) {
|
|
307
|
+
const event = events[at];
|
|
308
|
+
if (event === void 0 || BOOKKEEPING_EVENTS.has(event.type)) continue;
|
|
309
|
+
last = event;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
const traces = unansweredCalls(events);
|
|
313
|
+
const wakeableRoute = routeNamed(events);
|
|
314
|
+
if (!complete) {
|
|
315
|
+
if (last === void 0) return void 0;
|
|
316
|
+
const owed = tailOwesReply(events, traces);
|
|
317
|
+
return {
|
|
318
|
+
last,
|
|
319
|
+
traces,
|
|
320
|
+
owed,
|
|
321
|
+
wakeable: owed && wakeableRoute
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
let owed;
|
|
325
|
+
try {
|
|
326
|
+
owed = owesReply(derivedHistory(events, projections));
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
last,
|
|
332
|
+
traces,
|
|
333
|
+
owed,
|
|
334
|
+
wakeable: owed && wakeableRoute
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Whether one handle holds at least `count` events, asked without materializing
|
|
339
|
+
* more than one of them.
|
|
340
|
+
* @param handle - an open read handle onto the log.
|
|
341
|
+
* @param count - the event count to test for.
|
|
342
|
+
* @param signal - cancellation for the probe.
|
|
343
|
+
* @returns whether the log reaches that far.
|
|
344
|
+
*/
|
|
345
|
+
async function reaches(handle, count, signal) {
|
|
346
|
+
return (await handle.read(count, 1, { ...signal === void 0 ? {} : { signal } })).events.length > 0;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The stored log's own length, found by probing rather than by reading it.
|
|
350
|
+
*
|
|
351
|
+
* The handle's read seam is the only way to ask a backend anything, and it
|
|
352
|
+
* takes an offset — so the count has to be probed for. The probe doubles until
|
|
353
|
+
* it passes the end and then bisects, so a log of any length costs a handful
|
|
354
|
+
* of one-event reads, and none of them materializes the prefix it skips.
|
|
355
|
+
* @param handle - an open read handle onto the log.
|
|
356
|
+
* @param signal - cancellation for the probe.
|
|
357
|
+
* @returns the event count, or `undefined` when the log is longer than this read will walk.
|
|
358
|
+
*/
|
|
359
|
+
async function eventCount(handle, signal) {
|
|
360
|
+
let high = 1;
|
|
361
|
+
while (await reaches(handle, high, signal)) {
|
|
362
|
+
if (high >= 1e6) return void 0;
|
|
363
|
+
high = Math.min(high * 2, TRIAGE_LOG_MAX_EVENTS);
|
|
364
|
+
}
|
|
365
|
+
let low = 0;
|
|
366
|
+
while (high - low > 1) {
|
|
367
|
+
const mid = low + Math.floor((high - low) / 2);
|
|
368
|
+
if (await reaches(handle, mid, signal)) low = mid;
|
|
369
|
+
else high = mid;
|
|
370
|
+
}
|
|
371
|
+
return await reaches(handle, 0, signal) ? low + 1 : 0;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Read one session's stored log, bounded to the events the list's facts need.
|
|
375
|
+
*
|
|
376
|
+
* The window is the last `windowEvents` events, found by probing for the log's
|
|
377
|
+
* length and then reading exactly that slice. A short log is returned whole and
|
|
378
|
+
* marked complete; a log longer than {@link TRIAGE_LOG_MAX_EVENTS} is reported
|
|
379
|
+
* unreadable without being read at all.
|
|
380
|
+
* @param ctx - host context carrying the optional persistence service.
|
|
381
|
+
* @param sessionId - the stored session to read.
|
|
382
|
+
* @param windowEvents - how many events of the log's end one row may fold.
|
|
383
|
+
* @param signal - cancellation for the open, probe, and read.
|
|
384
|
+
* @returns the bounded read, or `unreadable` when no read seam exists, it refused, or the log is too long to walk.
|
|
385
|
+
* @throws the cancellation reason when `signal` aborts the read.
|
|
386
|
+
*/
|
|
387
|
+
async function coldRead(ctx, sessionId, windowEvents = TRIAGE_WINDOW_EVENTS, signal) {
|
|
388
|
+
const persistence = ctx.get("sessionPersistence");
|
|
389
|
+
if (persistence === void 0) return { kind: "unreadable" };
|
|
390
|
+
let handle;
|
|
391
|
+
try {
|
|
392
|
+
handle = await persistence.open(sessionId, "read", { ...signal === void 0 ? {} : { signal } });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (signal?.aborted === true) throw error;
|
|
395
|
+
ctx.logger.warn(`pause: could not open the stored log of "${sessionId}": ${describeError(error)}`);
|
|
396
|
+
return { kind: "unreadable" };
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const window = Math.max(1, Math.floor(windowEvents));
|
|
400
|
+
const count = await eventCount(handle, signal);
|
|
401
|
+
if (count === void 0) {
|
|
402
|
+
ctx.logger.warn(`pause: the stored log of "${sessionId}" is longer than this read will walk`);
|
|
403
|
+
return { kind: "unreadable" };
|
|
404
|
+
}
|
|
405
|
+
const start = Math.max(0, count - window);
|
|
406
|
+
return {
|
|
407
|
+
kind: "read",
|
|
408
|
+
events: (await handle.read(start, window, { ...signal === void 0 ? {} : { signal } })).events,
|
|
409
|
+
complete: start === 0
|
|
410
|
+
};
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (signal?.aborted === true) throw error;
|
|
413
|
+
ctx.logger.warn(`pause: could not read the stored log of "${sessionId}": ${describeError(error)}`);
|
|
414
|
+
return { kind: "unreadable" };
|
|
415
|
+
} finally {
|
|
416
|
+
await handle.close();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region lib/types/tree.js
|
|
421
|
+
/**
|
|
422
|
+
* One session's subagent tree, read from the parent catalogs.
|
|
423
|
+
*
|
|
424
|
+
* "This session's work" includes the subagents it delegated to, nested ones
|
|
425
|
+
* included, so the session-header press and the status the header shows both
|
|
426
|
+
* need the same answer to "which sessions hang under this one".
|
|
427
|
+
* `listDescendants` is the harness's own recursive walk of the parent-owned
|
|
428
|
+
* catalogs, and it is the only source: the tree is read from the parents that
|
|
429
|
+
* record it, never guessed from session ids.
|
|
430
|
+
*
|
|
431
|
+
* A walk is read, not remembered. The tree is a fact that grows — a delegation
|
|
432
|
+
* in flight publishes its child after the parent's catalog was read — so a
|
|
433
|
+
* press walks the tree at press time and a status read walks it at read time.
|
|
434
|
+
* `@module dsh-pause-session/tree`
|
|
435
|
+
*/
|
|
436
|
+
/**
|
|
437
|
+
* Read the subagent tree under one session.
|
|
438
|
+
*
|
|
439
|
+
* A failure to read a branch is reported as unread rather than as an empty
|
|
440
|
+
* tree: the difference is what keeps a press from claiming it registered a tree
|
|
441
|
+
* it never saw, and keeps a status answer from calling itself safe behind an
|
|
442
|
+
* unknown branch.
|
|
443
|
+
* @param ctx - host context carrying the optional subagent service.
|
|
444
|
+
* @param rootSessionId - the session whose descendants are requested.
|
|
445
|
+
* @param signal - cancellation for the catalog walk.
|
|
446
|
+
* @returns the descendants found and what of the walk could not be read.
|
|
447
|
+
* @throws the cancellation reason when `signal` aborts the walk.
|
|
448
|
+
*/
|
|
449
|
+
async function walkDescendants(ctx, rootSessionId, signal) {
|
|
450
|
+
const subagents = ctx.get("subagents");
|
|
451
|
+
if (subagents === void 0) return {
|
|
452
|
+
members: [],
|
|
453
|
+
unread: [],
|
|
454
|
+
unreadWhole: false
|
|
455
|
+
};
|
|
456
|
+
try {
|
|
457
|
+
const rows = await subagents.listDescendants(rootSessionId, signal);
|
|
458
|
+
const members = [];
|
|
459
|
+
const unread = [];
|
|
460
|
+
for (const row of rows) {
|
|
461
|
+
if (row.kind === "child") {
|
|
462
|
+
members.push({
|
|
463
|
+
id: row.id,
|
|
464
|
+
mode: row.mode
|
|
465
|
+
});
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
unread.push({
|
|
469
|
+
id: row.id,
|
|
470
|
+
reason: row.reason
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
members,
|
|
475
|
+
unread,
|
|
476
|
+
unreadWhole: false
|
|
477
|
+
};
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (signal?.aborted === true) throw error;
|
|
480
|
+
ctx.logger.warn(`pause: could not read the subagent tree of "${rootSessionId}": ${describeError(error)}`);
|
|
481
|
+
return {
|
|
482
|
+
members: [],
|
|
483
|
+
unread: [],
|
|
484
|
+
unreadWhole: true
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region lib/types/global.js
|
|
490
|
+
/**
|
|
491
|
+
* The whole-app park: one action that registers every running session tree,
|
|
492
|
+
* and the aggregate answer to "can I close the lid?".
|
|
493
|
+
*
|
|
494
|
+
* The per-session control owns one session's park; this module owns the set.
|
|
495
|
+
* It reads three real sources and never infers what they can say themselves:
|
|
496
|
+
*
|
|
497
|
+
* - `ctx.agents.list()` plus the park activity fold decide which sessions are
|
|
498
|
+
* running. A session with no live agent is not running, and a session with
|
|
499
|
+
* nothing unfinished — no tool in flight, no turn open, no delivery the loop
|
|
500
|
+
* has not claimed — is left alone.
|
|
501
|
+
* - The subagent runtime's descendant rows decide the tree and the one-shot
|
|
502
|
+
* fact. A row's `mode` is the harness's own durable record of whether that
|
|
503
|
+
* child can be resumed after a restart; a branch the walk could not read is
|
|
504
|
+
* reported as unread rather than assumed clean.
|
|
505
|
+
* - The job registry decides which background jobs belong to the registered
|
|
506
|
+
* trees. Those run outside the agent loop: this plugin cannot stop them, so
|
|
507
|
+
* it names them.
|
|
508
|
+
*
|
|
509
|
+
* The aggregate is derived in one place so the host's answer and the browser's
|
|
510
|
+
* reading of it cannot disagree, and it is a promise about the durable
|
|
511
|
+
* registry: a session is `safe` only when its recorded phase says so.
|
|
512
|
+
* @module dsh-pause-session/global
|
|
513
|
+
*/
|
|
514
|
+
/**
|
|
515
|
+
* How many times one press walks the trees again. A tree can grow while the
|
|
516
|
+
* walk runs — a delegation already in flight publishes its child after the
|
|
517
|
+
* parent's catalog was read — so the walk repeats until it stops finding a
|
|
518
|
+
* running session this action has not pressed yet.
|
|
519
|
+
*/
|
|
520
|
+
const PRESS_ROUNDS = 4;
|
|
521
|
+
/** Fold one shared tree walk into the two doubts this report publishes. */
|
|
522
|
+
function readingOf(walk, watch) {
|
|
523
|
+
return {
|
|
524
|
+
members: walk.members.map((member) => member.id),
|
|
525
|
+
unresumable: walk.members.some((member) => member.mode !== "continuable") || walk.unread.some((branch) => branch.reason === "unsupported"),
|
|
526
|
+
unread: walk.unreadWhole || walk.unread.some((branch) => branch.reason !== "unsupported"),
|
|
527
|
+
running: walk.members.some((member) => {
|
|
528
|
+
if (member.mode === "continuable") return false;
|
|
529
|
+
const activity = watch.activity(member.id);
|
|
530
|
+
return activity.turnOpen || activity.runningTools.length > 0;
|
|
531
|
+
})
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Mount the whole-app park.
|
|
536
|
+
* @param ctx - host context carrying the agent store and the optional subagent
|
|
537
|
+
* and job services.
|
|
538
|
+
* @param watch - the activity fold that says whether a session has unfinished work.
|
|
539
|
+
* @param registry - the durable list this aggregate is a promise about.
|
|
540
|
+
* @param deps - the per-session press and the live phase of every mark.
|
|
541
|
+
* @returns the one-press action and the aggregate read.
|
|
542
|
+
*/
|
|
543
|
+
function mountGlobalPause(ctx, watch, registry, deps) {
|
|
544
|
+
/**
|
|
545
|
+
* Sessions this action tried to press and could not record. They are NOT
|
|
546
|
+
* parked and they keep running, so the aggregate counts them and names them
|
|
547
|
+
* instead of quietly leaving them out of a park it then calls safe.
|
|
548
|
+
*/
|
|
549
|
+
const unrecorded = /* @__PURE__ */ new Set();
|
|
550
|
+
const notResumable = /* @__PURE__ */ new Set();
|
|
551
|
+
/** Record or clear one session's "pressed and the registry refused" state. */
|
|
552
|
+
const markUnrecorded = (sessionId, flag) => {
|
|
553
|
+
if (flag) unrecorded.add(sessionId);
|
|
554
|
+
else unrecorded.delete(sessionId);
|
|
555
|
+
};
|
|
556
|
+
/** Per-episode tree walks; an entry lives exactly as long as the park owns it. */
|
|
557
|
+
const walks = /* @__PURE__ */ new Map();
|
|
558
|
+
/**
|
|
559
|
+
* Whether a session has unfinished work right now. Three real answers, any
|
|
560
|
+
* of which is enough: the loop says it is driving the session, the activity
|
|
561
|
+
* fold sees a tool in flight or a turn open, or the inbox holds a delivery
|
|
562
|
+
* the loop has not claimed yet.
|
|
563
|
+
*/
|
|
564
|
+
const running = (agent) => {
|
|
565
|
+
if (agent.status === "running") return true;
|
|
566
|
+
const activity = watch.activity(agent.id);
|
|
567
|
+
if (activity.turnOpen || activity.runningTools.length > 0) return true;
|
|
568
|
+
return agent.inbox.nextTurn.length + agent.inbox.nextStep.length > 0;
|
|
569
|
+
};
|
|
570
|
+
/**
|
|
571
|
+
* Read one session's subagent tree through the shared walker and remember the
|
|
572
|
+
* reading for this park episode. The tree is a fact about the episode, not
|
|
573
|
+
* about the moment it was asked, and the aggregate reads several sessions per
|
|
574
|
+
* answer; the cache keeps that read from repeating a catalog round-trip it
|
|
575
|
+
* has already paid for.
|
|
576
|
+
*/
|
|
577
|
+
const walkTree = async (sessionId, signal, shared) => {
|
|
578
|
+
signal?.throwIfAborted();
|
|
579
|
+
const provided = shared?.get(sessionId);
|
|
580
|
+
if (provided !== void 0) return readingOf(provided, watch);
|
|
581
|
+
const cached = walks.get(sessionId);
|
|
582
|
+
if (cached !== void 0) return cached;
|
|
583
|
+
const reading = ctx.get("subagents") === void 0 ? {
|
|
584
|
+
members: [],
|
|
585
|
+
unresumable: false,
|
|
586
|
+
unread: true,
|
|
587
|
+
running: false
|
|
588
|
+
} : readingOf(await walkDescendants(ctx, sessionId, signal), watch);
|
|
589
|
+
walks.set(sessionId, reading);
|
|
590
|
+
return reading;
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Every running session and every descendant of one, minus the sessions this
|
|
594
|
+
* pause already owns. Descendants are walked whether or not they are live
|
|
595
|
+
* right now: the tree is the unit the user asked to stop. A member this host
|
|
596
|
+
* has no live session for has nothing to hold and nothing to flush, and the
|
|
597
|
+
* press leaves it alone rather than record a park that could never finish
|
|
598
|
+
* (see `./control.ts`).
|
|
599
|
+
*/
|
|
600
|
+
const runningTargets = async (signal) => {
|
|
601
|
+
const found = /* @__PURE__ */ new Set();
|
|
602
|
+
for (const agent of ctx.agents.list()) if (running(agent)) found.add(agent.id);
|
|
603
|
+
for (const root of [...found]) for (const member of (await walkTree(root, signal)).members) found.add(member);
|
|
604
|
+
return [...found].filter((sessionId) => !deps.isPaused(sessionId));
|
|
605
|
+
};
|
|
606
|
+
/**
|
|
607
|
+
* Live jobs of the registered trees, plus the unowned ones every caller sees.
|
|
608
|
+
*
|
|
609
|
+
* One registry walk per owner is the whole answer the jobs contract allows:
|
|
610
|
+
* `list()` with no caller answers with unowned jobs only, and `list(owner)`
|
|
611
|
+
* answers for exactly one owner, because the registry preflights ownership
|
|
612
|
+
* instead of letting a reader enumerate another session's work.
|
|
613
|
+
*/
|
|
614
|
+
const liveJobs = (owners) => {
|
|
615
|
+
const jobs = ctx.get("jobs");
|
|
616
|
+
if (jobs === void 0) return [];
|
|
617
|
+
const seen = /* @__PURE__ */ new Set();
|
|
618
|
+
const collect = (owner) => {
|
|
619
|
+
for (const view of owner === void 0 ? jobs.list() : jobs.list(owner)) {
|
|
620
|
+
if (view.status !== "running" && view.status !== "stopping") continue;
|
|
621
|
+
seen.add(String(view.id));
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
collect();
|
|
625
|
+
for (const owner of owners) collect(owner);
|
|
626
|
+
return [...seen];
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* The aggregate. Every registered session is accounted for: the ones on
|
|
630
|
+
* their way, the ones that will not get there in this process, the trees
|
|
631
|
+
* that hold work a restart cannot resume, and the jobs nothing here can stop.
|
|
632
|
+
* The park also names the session it belongs to, so a client can address its
|
|
633
|
+
* reads there rather than at whatever session it is showing.
|
|
634
|
+
*/
|
|
635
|
+
const status = async (signal, sharedWalks) => {
|
|
636
|
+
const phases = /* @__PURE__ */ new Map();
|
|
637
|
+
for (const [sessionId, record] of registry.parked) phases.set(sessionId, {
|
|
638
|
+
phase: record.phase,
|
|
639
|
+
moving: false
|
|
640
|
+
});
|
|
641
|
+
const live = deps.live();
|
|
642
|
+
for (const [sessionId, park] of live) phases.set(sessionId, park);
|
|
643
|
+
for (const sessionId of unrecorded) phases.set(sessionId, {
|
|
644
|
+
phase: "draining",
|
|
645
|
+
moving: false
|
|
646
|
+
});
|
|
647
|
+
const unheld = deps.unheld();
|
|
648
|
+
for (const sessionId of [...walks.keys()]) if (!phases.has(sessionId)) walks.delete(sessionId);
|
|
649
|
+
let safe = 0;
|
|
650
|
+
let parking = 0;
|
|
651
|
+
const draining = [];
|
|
652
|
+
const stalled = [];
|
|
653
|
+
for (const [sessionId, park] of phases) {
|
|
654
|
+
if (unheld.has(sessionId)) {
|
|
655
|
+
stalled.push(String(sessionId));
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (park.phase === "safe") {
|
|
659
|
+
safe += 1;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
(park.moving ? draining : stalled).push(String(sessionId));
|
|
663
|
+
if (park.moving && park.phase === "parking") parking += 1;
|
|
664
|
+
}
|
|
665
|
+
const unresumable = [];
|
|
666
|
+
const unreadTrees = [];
|
|
667
|
+
const owners = [...phases.keys()];
|
|
668
|
+
const parkedAt = owners.find((sessionId) => !unheld.has(sessionId)) ?? owners[0];
|
|
669
|
+
const trees = await Promise.all(owners.map(async (sessionId) => [sessionId, await walkTree(sessionId, signal, sharedWalks)]));
|
|
670
|
+
for (const [sessionId, tree] of trees) {
|
|
671
|
+
if (tree.unresumable) unresumable.push(String(sessionId));
|
|
672
|
+
if (tree.unread) unreadTrees.push(String(sessionId));
|
|
673
|
+
}
|
|
674
|
+
const blind = [];
|
|
675
|
+
if (ctx.get("subagents") === void 0) blind.push("trees");
|
|
676
|
+
if (ctx.get("jobs") === void 0) blind.push("jobs");
|
|
677
|
+
const registered = phases.size;
|
|
678
|
+
return {
|
|
679
|
+
verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 && !trees.some(([, tree]) => tree.running) ? "safe" : "waiting",
|
|
680
|
+
registered,
|
|
681
|
+
safe,
|
|
682
|
+
...parkedAt === void 0 ? {} : { parkedAt: String(parkedAt) },
|
|
683
|
+
draining: draining.sort(),
|
|
684
|
+
parking,
|
|
685
|
+
stalled: stalled.sort(),
|
|
686
|
+
unresumable: [...new Set(unresumable)].sort(),
|
|
687
|
+
unreadTrees: unreadTrees.sort(),
|
|
688
|
+
jobs: liveJobs(owners),
|
|
689
|
+
blind
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
return {
|
|
693
|
+
pressAll: async (signal) => {
|
|
694
|
+
for (let round = 0; round < PRESS_ROUNDS; round += 1) {
|
|
695
|
+
const targets = await runningTargets(signal);
|
|
696
|
+
if (targets.length === 0) break;
|
|
697
|
+
for (const sessionId of targets) {
|
|
698
|
+
signal?.throwIfAborted();
|
|
699
|
+
switch (await deps.press(sessionId, signal)) {
|
|
700
|
+
case "registered":
|
|
701
|
+
notResumable.delete(sessionId);
|
|
702
|
+
markUnrecorded(sessionId, false);
|
|
703
|
+
deps.look(sessionId);
|
|
704
|
+
break;
|
|
705
|
+
case "unrecordable":
|
|
706
|
+
markUnrecorded(sessionId, true);
|
|
707
|
+
break;
|
|
708
|
+
case "not-resumable":
|
|
709
|
+
notResumable.add(sessionId);
|
|
710
|
+
break;
|
|
711
|
+
default: break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const aggregate = await status(signal);
|
|
716
|
+
const answer = aggregate.verdict === "safe" ? "Every session is paused." : aggregate.verdict === "idle" ? "Nothing is running." : "Pausing is in progress.";
|
|
717
|
+
const blocked = notResumable.size;
|
|
718
|
+
return unrecorded.size === 0 && blocked === 0 ? {
|
|
719
|
+
kind: "success",
|
|
720
|
+
text: answer
|
|
721
|
+
} : {
|
|
722
|
+
kind: "error",
|
|
723
|
+
text: `${answer} ${String(blocked)} session${blocked === 1 ? "" : "s"} could not be paused because its route is not resumable.`
|
|
724
|
+
};
|
|
725
|
+
},
|
|
726
|
+
readState: async (signal, walks) => await status(signal, walks),
|
|
727
|
+
markUnrecorded
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region lib/types/release-order.js
|
|
732
|
+
/**
|
|
733
|
+
* The order a tree release walks: parent before child, shallowest first.
|
|
734
|
+
* @module dsh-pause-session/release-order
|
|
735
|
+
*/
|
|
736
|
+
/**
|
|
737
|
+
* Sequence a batch release parent before child.
|
|
738
|
+
* @param ctx - host context carrying the optional subagent catalog.
|
|
739
|
+
* @param sessionIds - every recorded session, in registry order.
|
|
740
|
+
* @param signal - cancellation for each catalog read.
|
|
741
|
+
* @returns the parent of each child, and one list per depth level.
|
|
742
|
+
*/
|
|
743
|
+
async function releaseOrder(ctx, sessionIds, signal) {
|
|
744
|
+
const owned = new Set(sessionIds);
|
|
745
|
+
const parentOf = /* @__PURE__ */ new Map();
|
|
746
|
+
const subagents = ctx.get("subagents");
|
|
747
|
+
if (subagents !== void 0) for (const sessionId of sessionIds) {
|
|
748
|
+
signal?.throwIfAborted();
|
|
749
|
+
try {
|
|
750
|
+
for (const child of await subagents.listChildren(sessionId, signal)) if (owned.has(child.id)) parentOf.set(child.id, sessionId);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (signal?.aborted === true) throw error;
|
|
753
|
+
ctx.logger.warn(`pause: could not read the children of "${sessionId}": ${describeError(error)}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const depthOf = (sessionId) => {
|
|
757
|
+
let steps = 0;
|
|
758
|
+
const seen = /* @__PURE__ */ new Set([sessionId]);
|
|
759
|
+
for (let at = parentOf.get(sessionId); at !== void 0; at = parentOf.get(at)) {
|
|
760
|
+
if (seen.has(at)) break;
|
|
761
|
+
seen.add(at);
|
|
762
|
+
steps += 1;
|
|
763
|
+
}
|
|
764
|
+
return steps;
|
|
765
|
+
};
|
|
766
|
+
const levels = /* @__PURE__ */ new Map();
|
|
767
|
+
for (const sessionId of sessionIds) {
|
|
768
|
+
const level = depthOf(sessionId);
|
|
769
|
+
const bucket = levels.get(level);
|
|
770
|
+
if (bucket === void 0) levels.set(level, [sessionId]);
|
|
771
|
+
else bucket.push(sessionId);
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
parents: parentOf,
|
|
775
|
+
levels: [...levels.keys()].sort((left, right) => left - right).map((level) => levels.get(level) ?? [])
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region lib/types/composition.js
|
|
780
|
+
/**
|
|
781
|
+
* Rebuild the scoped Agent composition a parked Session ran under.
|
|
782
|
+
*
|
|
783
|
+
* Activation is not only a persisted Session: the Agent preset supplies the
|
|
784
|
+
* scoped tools, prompt sections, skills, and — for a subagent child — the
|
|
785
|
+
* delegation context, persona, and tool restriction that were in force before
|
|
786
|
+
* the park. A resume that loads the log without that setup starts a different
|
|
787
|
+
* Agent on the same history.
|
|
788
|
+
*
|
|
789
|
+
* The preset a Session runs is the `agentPreset` projection, never the
|
|
790
|
+
* creation-time header alone: a blank Session may select another preset before
|
|
791
|
+
* its first turn, and the projection records the selection that actually ran.
|
|
792
|
+
* A child with no independent selection joins its live parent's preset
|
|
793
|
+
* generation; one that selected independently mounts the projected preset.
|
|
794
|
+
* The durable child descriptor supplies the persona, tool filter, and route
|
|
795
|
+
* that are reapplied only on cold resume.
|
|
796
|
+
*
|
|
797
|
+
* Both services are optional. A profile without `agentPresets` has no scoped
|
|
798
|
+
* preset composition to restore, and this module leaves the resume unchanged.
|
|
799
|
+
* A profile that mounts one but cannot read the Session projection cannot
|
|
800
|
+
* prove what composition the Session ran, so it fails the resume instead of
|
|
801
|
+
* silently starting a different Agent.
|
|
802
|
+
* @module dsh-pause-session/composition
|
|
803
|
+
*/
|
|
804
|
+
/**
|
|
805
|
+
* A stored subagent route that no resume may reactivate.
|
|
806
|
+
*
|
|
807
|
+
* A one-shot child is terminal by contract; activating it as a root Agent
|
|
808
|
+
* would turn a finished delegation into an ordinary conversation that happens
|
|
809
|
+
* to share its Session id.
|
|
810
|
+
*/
|
|
811
|
+
var UnresumableSessionError = class extends Error {
|
|
812
|
+
constructor(sessionId) {
|
|
813
|
+
super(`subagent session "${String(sessionId)}" has a one-shot route and cannot be resumed`);
|
|
814
|
+
this.name = "UnresumableSessionError";
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
/**
|
|
818
|
+
* Whether the child's own log selected a preset before its descriptor.
|
|
819
|
+
*
|
|
820
|
+
* The creation-time selection precedes the first `subagent/descriptor`; a
|
|
821
|
+
* selection after the descriptor belongs to the child's own later history and
|
|
822
|
+
* does not decide which composition cold resume joins. This mirrors the
|
|
823
|
+
* subagent runtime's own cold-resume rule.
|
|
824
|
+
* @param events - the child's own log suffix, without the fork-inherited prefix.
|
|
825
|
+
* @returns whether the child carries an independent preset selection.
|
|
826
|
+
*/
|
|
827
|
+
function hasIndependentPresetSelection(events) {
|
|
828
|
+
for (const event of events) {
|
|
829
|
+
if (event.type === "subagent/descriptor") return false;
|
|
830
|
+
if (event.type === "agent-preset/selected") return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Whether a local Session may be parked under a promise of a later resume.
|
|
836
|
+
*
|
|
837
|
+
* A normal root Session has no child descriptor and is resumable. A local
|
|
838
|
+
* subagent child is resumable only when its own durable descriptor is
|
|
839
|
+
* `continuable`; a historical `one-shot` child is terminal and must never be
|
|
840
|
+
* registered as a park the morning path would later have to refuse. External
|
|
841
|
+
* executions have no local Session, so callers never reach this check.
|
|
842
|
+
*
|
|
843
|
+
* A profile without the optional Session-query service cannot prove the
|
|
844
|
+
* descriptor's mode; it keeps the previous permissive behavior rather than
|
|
845
|
+
* dropping every subagent. The web profile mounts the query service.
|
|
846
|
+
* @param ctx - host context carrying the optional Session-query service.
|
|
847
|
+
* @param sessionId - the local Session to inspect.
|
|
848
|
+
* @param signal - cancellation for the observation read.
|
|
849
|
+
* @returns whether a park of this Session may be resumed.
|
|
850
|
+
* @throws the cancellation reason when `signal` aborts the read.
|
|
851
|
+
*/
|
|
852
|
+
async function isResumableSession(ctx, sessionId, signal) {
|
|
853
|
+
const query = ctx.get("sessionQuery");
|
|
854
|
+
if (query === void 0) return true;
|
|
855
|
+
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
856
|
+
try {
|
|
857
|
+
return foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.mode !== "one-shot";
|
|
858
|
+
} finally {
|
|
859
|
+
observation[Symbol.dispose]();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Read one parked Session's stored composition and build the resume options.
|
|
864
|
+
*
|
|
865
|
+
* The returned `setup` belongs to the unpublished Agent scope, so all
|
|
866
|
+
* registrations exist before `agent/created`. For a root Session the setup
|
|
867
|
+
* mounts the Session's projected preset, or the profile's default when the
|
|
868
|
+
* Session never selected one. For a child under a live parent it applies the
|
|
869
|
+
* child composition: an independent preset mounts separately, while an
|
|
870
|
+
* inherited composition joins the parent's current generation; the historical
|
|
871
|
+
* descriptor's persona and tool filter are reapplied in both cases.
|
|
872
|
+
* @param ctx - host context carrying the optional preset and Session-query services.
|
|
873
|
+
* @param sessionId - the parked Session being resumed.
|
|
874
|
+
* @param parent - the live direct parent, when a child is resumed under it.
|
|
875
|
+
* @param signal - cancellation for the observation read.
|
|
876
|
+
* @returns the Agent options and setup to pass to `agents.resume`.
|
|
877
|
+
* @throws {UnresumableSessionError} when the stored child route is one-shot.
|
|
878
|
+
* @throws when a mounted preset roster cannot read the Session projection, or
|
|
879
|
+
* a recorded preset cannot be restored.
|
|
880
|
+
*/
|
|
881
|
+
async function restoreComposition(ctx, sessionId, parent, signal) {
|
|
882
|
+
const presets = ctx.get("agentPresets");
|
|
883
|
+
const query = ctx.get("sessionQuery");
|
|
884
|
+
if (query === void 0) {
|
|
885
|
+
if (presets !== void 0) throw new Error("resuming a composed Agent requires the session-query service");
|
|
886
|
+
return {};
|
|
887
|
+
}
|
|
888
|
+
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
889
|
+
try {
|
|
890
|
+
const projections = observation.projections;
|
|
891
|
+
const ownEvents = observation.events.slice(observation.inheritedEventCount);
|
|
892
|
+
const descriptor = foldSubagentDescriptor(ownEvents);
|
|
893
|
+
if (descriptor?.mode === "one-shot") throw new UnresumableSessionError(sessionId);
|
|
894
|
+
if (presets === void 0 || projections === void 0) {
|
|
895
|
+
if (presets !== void 0) throw new Error("resuming a composed Agent requires projected Session state");
|
|
896
|
+
return {};
|
|
897
|
+
}
|
|
898
|
+
if (parent === void 0) {
|
|
899
|
+
const resolved = await presets.resolve(projections.values.agentPreset ?? void 0);
|
|
900
|
+
return { setup: async (agentCtx) => {
|
|
901
|
+
await presets.mount(agentCtx, resolved.id);
|
|
902
|
+
} };
|
|
903
|
+
}
|
|
904
|
+
const independentlySelected = hasIndependentPresetSelection(ownEvents);
|
|
905
|
+
const presetId = independentlySelected ? projections.values.agentPreset : void 0;
|
|
906
|
+
if (independentlySelected && typeof presetId !== "string") throw new Error(`subagent "${String(sessionId)}" has no projected agent preset to restore`);
|
|
907
|
+
const composition = {
|
|
908
|
+
...typeof presetId === "string" ? { agentPreset: presetId } : {},
|
|
909
|
+
...descriptor?.mode === "continuable" && descriptor.persona !== void 0 ? { persona: descriptor.persona } : {},
|
|
910
|
+
...descriptor?.mode === "continuable" && descriptor.toolFilter !== void 0 ? { toolFilter: descriptor.toolFilter } : {}
|
|
911
|
+
};
|
|
912
|
+
const agentOptions = descriptor?.mode === "continuable" ? {
|
|
913
|
+
...descriptor.agentProvider === void 0 ? {} : { provider: descriptor.agentProvider },
|
|
914
|
+
...descriptor.agentModel === void 0 ? {} : { model: descriptor.agentModel },
|
|
915
|
+
...descriptor.agentReasoningEffort === void 0 ? {} : { reasoningEffort: descriptor.agentReasoningEffort }
|
|
916
|
+
} : void 0;
|
|
917
|
+
return {
|
|
918
|
+
...agentOptions === void 0 ? {} : { agentOptions },
|
|
919
|
+
setup: async (agentCtx) => {
|
|
920
|
+
await applyChildComposition(agentCtx, parent, composition);
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
} finally {
|
|
924
|
+
observation[Symbol.dispose]();
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region lib/types/resume-text.js
|
|
929
|
+
/**
|
|
930
|
+
* User-facing copy and result shapes for the clean resume and tree release.
|
|
931
|
+
* @module dsh-pause-session/resume-text
|
|
932
|
+
*/
|
|
933
|
+
/**
|
|
934
|
+
* The one wake content a route that keeps an empty user message is given.
|
|
935
|
+
*/
|
|
936
|
+
const WAKE_MARKER = "[pause: resumed from a pause]";
|
|
937
|
+
/** Result copy for a resumed session; the model never sees it. */
|
|
938
|
+
const RESUMED_TEXT = "This session resumed from where it paused.";
|
|
939
|
+
/** Result copy for a registered session whose park did not finish cleanly. */
|
|
940
|
+
const UNCLEAN_TEXT = "pause did not resume this session: its pause did not finish cleanly, so it needs your decision.";
|
|
941
|
+
/** Result copy when nothing is parked under that session. */
|
|
942
|
+
const NOT_PARKED_TEXT = "pause has no pause recorded for this session.";
|
|
943
|
+
/** Result copy for a session that is parked but could not be loaded back. */
|
|
944
|
+
const ACTIVATION_FAILED_TEXT = "pause could not reopen this session, so it stayed paused.";
|
|
945
|
+
/** Result copy for a one-shot subagent route. */
|
|
946
|
+
const UNRESUMABLE_TEXT = "pause did not resume this session: it is a one-shot subagent route and cannot be resumed.";
|
|
947
|
+
/** Result copy for the review decision. */
|
|
948
|
+
const REVIEW_TEXT = "pause did not activate this session: it is still parked, and nothing was sent to a model. Open it from your session list when you want to read it.";
|
|
949
|
+
/** Copy for one goal opt-out change. */
|
|
950
|
+
const REARM_GOAL_ON_TEXT = "pause will re-open this session's goal when it resumes it.";
|
|
951
|
+
const REARM_GOAL_OFF_TEXT = "pause will leave this session's goal closed when it resumes it.";
|
|
952
|
+
/**
|
|
953
|
+
* How one tree-wide release answers the person who pressed it.
|
|
954
|
+
* @param released - the sessions this release covered, in release order.
|
|
955
|
+
* @returns one sentence about what came back and what did not.
|
|
956
|
+
*/
|
|
957
|
+
function summarizeTreeRelease(released) {
|
|
958
|
+
const resumed = released.filter((attempt) => attempt.resumed).length;
|
|
959
|
+
const head = resumed === 0 ? "pause resumed nothing from this tree" : resumed === 1 ? "pause resumed 1 session from where it stopped" : `pause resumed ${resumed} sessions from where they stopped`;
|
|
960
|
+
const refused = released.filter((attempt) => !attempt.resumed);
|
|
961
|
+
if (refused.length === 0) return `${head}.`;
|
|
962
|
+
const named = refused.filter((attempt) => attempt.refusal === "not-clean").map((attempt) => `"${String(attempt.sessionId)}"`);
|
|
963
|
+
return `${head}; ${named.length === 0 ? `${String(refused.length)} could not be resumed` : refused.length === named.length ? `${named.join(", ")} did not pause completely` : `${named.join(", ")} did not pause completely, and ${String(refused.length - named.length)} more could not be resumed`}.`;
|
|
964
|
+
}
|
|
965
|
+
//#endregion
|
|
966
|
+
//#region lib/types/resume-activation.js
|
|
967
|
+
/**
|
|
968
|
+
* Reactivate one parked session: restore its composition, give it the route its
|
|
969
|
+
* log recorded, deliver the wake it owes, and only then drop the gate and record.
|
|
970
|
+
* @module dsh-pause-session/resume-activation
|
|
971
|
+
*/
|
|
972
|
+
/**
|
|
973
|
+
* Build the activation closure.
|
|
974
|
+
* @param ctx - host context carrying the agent store and goal service.
|
|
975
|
+
* @param gate - the pause gate released after activation.
|
|
976
|
+
* @param registry - the durable list the release forgets from.
|
|
977
|
+
* @param options - the route knowledge the wake content depends on.
|
|
978
|
+
* @returns the activation methods the resume path uses.
|
|
979
|
+
*/
|
|
980
|
+
function createActivator(ctx, gate, registry, options) {
|
|
981
|
+
const routeOf = (session) => session.requestHeader()?.config;
|
|
982
|
+
const ensureRoute = (agent) => {
|
|
983
|
+
if (agent.model.current() !== void 0) return;
|
|
984
|
+
const config = routeOf(agent.session);
|
|
985
|
+
if (config === void 0) return;
|
|
986
|
+
agent.model.select({
|
|
987
|
+
provider: config.provider,
|
|
988
|
+
model: config.model,
|
|
989
|
+
...config.reasoningEffort === void 0 ? {} : { reasoningEffort: config.reasoningEffort }
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
const wakeContent = (provider) => options.silentWakeRoutes.includes(provider) ? [] : [{
|
|
993
|
+
type: "text",
|
|
994
|
+
text: WAKE_MARKER
|
|
995
|
+
}];
|
|
996
|
+
const wake = (agent, request) => {
|
|
997
|
+
if (request.kind === "none") return;
|
|
998
|
+
if (gate.isHeld(agent.id) || agent.status !== "idle") return;
|
|
999
|
+
if (!owesReply(agent.session.deriveMessages())) return;
|
|
1000
|
+
const provider = routeOf(agent.session)?.provider;
|
|
1001
|
+
if (provider === void 0) return;
|
|
1002
|
+
const content = request.kind === "message" ? [{
|
|
1003
|
+
type: "text",
|
|
1004
|
+
text: request.text
|
|
1005
|
+
}] : wakeContent(provider);
|
|
1006
|
+
agent.followup(createUserMessage({
|
|
1007
|
+
content,
|
|
1008
|
+
source: { kind: "pause" }
|
|
1009
|
+
}));
|
|
1010
|
+
};
|
|
1011
|
+
const rearmGoal = (agent) => {
|
|
1012
|
+
const goals = ctx.get("goals");
|
|
1013
|
+
if (goals === void 0) return;
|
|
1014
|
+
try {
|
|
1015
|
+
const goal = goals.get(agent);
|
|
1016
|
+
if (goal === void 0 || goal.phase !== "active" || goal.activation !== "disarmed") return;
|
|
1017
|
+
if (goal.roundsStarted >= goal.maxGoalRounds) return;
|
|
1018
|
+
goals.resume(agent, {
|
|
1019
|
+
id: goal.id,
|
|
1020
|
+
revision: goal.revision
|
|
1021
|
+
});
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
ctx.logger.warn(`pause: could not re-open the goal of "${agent.id}": ${describeError(error)}`);
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
const activate = async (sessionId, request, rearm, parentId, signal) => {
|
|
1027
|
+
const parent = parentId === void 0 ? void 0 : ctx.agents.get(parentId);
|
|
1028
|
+
let agent;
|
|
1029
|
+
try {
|
|
1030
|
+
const composition = await restoreComposition(ctx, sessionId, parent, signal);
|
|
1031
|
+
agent = (await ctx.root.agents.resume({
|
|
1032
|
+
resumeSessionId: sessionId,
|
|
1033
|
+
...parent === void 0 ? {} : { parentAgent: parent },
|
|
1034
|
+
...composition,
|
|
1035
|
+
...signal === void 0 ? {} : { signal }
|
|
1036
|
+
})).agent;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if (error instanceof UnresumableSessionError) return {
|
|
1039
|
+
sessionId,
|
|
1040
|
+
resumed: false,
|
|
1041
|
+
refusal: "unresumable",
|
|
1042
|
+
answer: {
|
|
1043
|
+
kind: "error",
|
|
1044
|
+
text: UNRESUMABLE_TEXT
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
ctx.logger.warn(`pause: could not open "${sessionId}" from its stored log: ${describeError(error)}`);
|
|
1048
|
+
return {
|
|
1049
|
+
sessionId,
|
|
1050
|
+
resumed: false,
|
|
1051
|
+
refusal: "activation-failed",
|
|
1052
|
+
answer: {
|
|
1053
|
+
kind: "error",
|
|
1054
|
+
text: ACTIVATION_FAILED_TEXT
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
ensureRoute(agent);
|
|
1059
|
+
wake(agent, request);
|
|
1060
|
+
try {
|
|
1061
|
+
await registry.forget(sessionId);
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
1064
|
+
return {
|
|
1065
|
+
sessionId,
|
|
1066
|
+
resumed: false,
|
|
1067
|
+
refusal: "unrecorded",
|
|
1068
|
+
answer: {
|
|
1069
|
+
kind: "error",
|
|
1070
|
+
text: UNRECORDED_TEXT
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
gate.resume(sessionId);
|
|
1075
|
+
if (rearm) rearmGoal(agent);
|
|
1076
|
+
return {
|
|
1077
|
+
sessionId,
|
|
1078
|
+
resumed: true,
|
|
1079
|
+
answer: {
|
|
1080
|
+
kind: "success",
|
|
1081
|
+
text: RESUMED_TEXT
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
};
|
|
1085
|
+
return {
|
|
1086
|
+
activate,
|
|
1087
|
+
ensureRoute,
|
|
1088
|
+
wake,
|
|
1089
|
+
rearmGoal
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region lib/types/resume.js
|
|
1094
|
+
/**
|
|
1095
|
+
* The clean resume path: bring back a session a previous process parked, and
|
|
1096
|
+
* let it answer the input that was already pending when it stopped.
|
|
1097
|
+
*
|
|
1098
|
+
* A resume invents no turn. At the park point the loop had claimed the
|
|
1099
|
+
* session's pending input onto the surface and built the request the gate then
|
|
1100
|
+
* held; after a restart that input is still the last thing the session says and
|
|
1101
|
+
* the model still owes it an answer. What is missing is only a reason for the
|
|
1102
|
+
* loop to build a request again, and the documented way to ask for one is a
|
|
1103
|
+
* wake input — a user message the loop claims.
|
|
1104
|
+
*
|
|
1105
|
+
* The wake is invisible only on the routes the composition declares in
|
|
1106
|
+
* `silentWakeRoutes`, whose conversion drops an empty user message. On every
|
|
1107
|
+
* other route the loop's request carries one visible marker instead, and the
|
|
1108
|
+
* marker reaches the model; that is an accepted cost of resuming at all.
|
|
1109
|
+
* Whether an empty user message survives is a property of the route's
|
|
1110
|
+
* conversion rather than of the loop, so the route is resolved per session —
|
|
1111
|
+
* from the request header the session itself persisted.
|
|
1112
|
+
*
|
|
1113
|
+
* The wake message is attributed to this plugin, never to the user: it is text
|
|
1114
|
+
* this module synthesized on the session's behalf, in the session's own log and
|
|
1115
|
+
* in front of the model, and a downstream consumer has to be able to tell it
|
|
1116
|
+
* apart from something the human typed.
|
|
1117
|
+
*
|
|
1118
|
+
* Only a cleanly parked session takes this path. A record that never reached
|
|
1119
|
+
* the park point, or whose flush did not finish, describes work whose durable
|
|
1120
|
+
* state nobody knows; waking it would spend its owner's money on a guess, and
|
|
1121
|
+
* deciding those sessions is the morning list's job, not this path's. That
|
|
1122
|
+
* holds whether or not the recorded session happens to be live in this process:
|
|
1123
|
+
* liveness is not the park outcome, and a session can be live without ever
|
|
1124
|
+
* having stopped.
|
|
1125
|
+
* @module dsh-pause-session/resume
|
|
1126
|
+
*/
|
|
1127
|
+
/**
|
|
1128
|
+
* Build the clean resume path.
|
|
1129
|
+
* @param ctx - host context carrying the agent store, the session store, and the goal service when one is mounted.
|
|
1130
|
+
* @param gate - the pause gate the resume releases.
|
|
1131
|
+
* @param registry - the durable list this path reads and forgets from.
|
|
1132
|
+
* @param options - the route knowledge the wake content depends on.
|
|
1133
|
+
* @param deps - the control-owned release order and the adopted-pause fact.
|
|
1134
|
+
* @returns the path the session control exposes.
|
|
1135
|
+
*/
|
|
1136
|
+
function installResumePath(ctx, gate, registry, options, deps) {
|
|
1137
|
+
/** The route the session's next request will use, as its own log recorded it. */
|
|
1138
|
+
const { activate, ensureRoute, wake, rearmGoal } = createActivator(ctx, gate, registry, options);
|
|
1139
|
+
/**
|
|
1140
|
+
* Load one unclean session back from its stored log.
|
|
1141
|
+
*
|
|
1142
|
+
* This is the morning list's own entry point, and it exists precisely because
|
|
1143
|
+
* the clean path must keep refusing these records: the refusal is the clean
|
|
1144
|
+
* path's guarantee, not a guard this entry can borrow. A user who has read the
|
|
1145
|
+
* row and chosen one of the decisions that resume it has made the decision the
|
|
1146
|
+
* clean path is not allowed to make for them. The release order is the same
|
|
1147
|
+
* one: the durable record goes first, then the gate — so a record that cannot
|
|
1148
|
+
* be dropped leaves the session parked.
|
|
1149
|
+
*
|
|
1150
|
+
* A record this process holds no gate for is still this list's to clear: the
|
|
1151
|
+
* session is live, so the decision's own wake is what asks for its request,
|
|
1152
|
+
* and dropping the record is what stops a later process from re-holding a
|
|
1153
|
+
* session the user has already decided.
|
|
1154
|
+
* @param sessionId - the listed session to bring back.
|
|
1155
|
+
* @param request - which wake the decision delivers; see {@link WakeRequest}.
|
|
1156
|
+
* @param finalize - whether the decision continues the session (`'release'`)
|
|
1157
|
+
* or only answers it (`'review'`); a review activates nothing and writes
|
|
1158
|
+
* nothing.
|
|
1159
|
+
* @param parentId - the direct parent a batch release already brought back, when there is one.
|
|
1160
|
+
* @param signal - cancellation for the cold load and setup.
|
|
1161
|
+
* @returns what happened, and the copy for whoever asked.
|
|
1162
|
+
*/
|
|
1163
|
+
const decideUnclean = async (sessionId, request, finalize, parentId, signal) => {
|
|
1164
|
+
const record = registry.get(sessionId);
|
|
1165
|
+
if (record === void 0) return {
|
|
1166
|
+
sessionId,
|
|
1167
|
+
resumed: false,
|
|
1168
|
+
refusal: "not-parked",
|
|
1169
|
+
answer: {
|
|
1170
|
+
kind: "success",
|
|
1171
|
+
text: NOT_PARKED_TEXT
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
if (finalize === "review") return {
|
|
1175
|
+
sessionId,
|
|
1176
|
+
resumed: false,
|
|
1177
|
+
refusal: "not-activated",
|
|
1178
|
+
answer: {
|
|
1179
|
+
kind: "success",
|
|
1180
|
+
text: REVIEW_TEXT
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
const live = ctx.agents.get(sessionId);
|
|
1184
|
+
if (live === void 0) return await activate(sessionId, request, record.rearmGoal, parentId, signal);
|
|
1185
|
+
if (!gate.isPaused(sessionId)) {
|
|
1186
|
+
ensureRoute(live);
|
|
1187
|
+
wake(live, request);
|
|
1188
|
+
try {
|
|
1189
|
+
await registry.forget(sessionId);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
1192
|
+
return {
|
|
1193
|
+
sessionId,
|
|
1194
|
+
resumed: false,
|
|
1195
|
+
refusal: "unrecorded",
|
|
1196
|
+
answer: {
|
|
1197
|
+
kind: "error",
|
|
1198
|
+
text: UNRECORDED_TEXT
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
if (record.rearmGoal) rearmGoal(live);
|
|
1203
|
+
return {
|
|
1204
|
+
sessionId,
|
|
1205
|
+
resumed: true,
|
|
1206
|
+
answer: {
|
|
1207
|
+
kind: "success",
|
|
1208
|
+
text: RESUMED_TEXT
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
const adopted = deps.adopted(sessionId);
|
|
1213
|
+
if (adopted) {
|
|
1214
|
+
ensureRoute(live);
|
|
1215
|
+
wake(live, request);
|
|
1216
|
+
}
|
|
1217
|
+
const released = await deps.release(sessionId, RESUMED_TEXT);
|
|
1218
|
+
if (released.kind === "error") return {
|
|
1219
|
+
sessionId,
|
|
1220
|
+
resumed: false,
|
|
1221
|
+
refusal: "unrecorded",
|
|
1222
|
+
answer: released
|
|
1223
|
+
};
|
|
1224
|
+
if (adopted && record.rearmGoal) rearmGoal(live);
|
|
1225
|
+
return {
|
|
1226
|
+
sessionId,
|
|
1227
|
+
resumed: true,
|
|
1228
|
+
answer: released
|
|
1229
|
+
};
|
|
1230
|
+
};
|
|
1231
|
+
/**
|
|
1232
|
+
* Resume one recorded session: the clean path's only entry point.
|
|
1233
|
+
*
|
|
1234
|
+
* The clean-only rule holds on every branch, live or not: a record that is
|
|
1235
|
+
* not `safe` is not a park that finished, so this path neither wakes it nor
|
|
1236
|
+
* drops it — whoever holds it stays held, and the row reaches the morning
|
|
1237
|
+
* list. A pause this process pressed itself is not a record at all: taking it
|
|
1238
|
+
* back is the press's own undo, and it delivers no wake.
|
|
1239
|
+
* @param sessionId - the recorded session to resume.
|
|
1240
|
+
* @param request - which wake this resume delivers; see {@link WakeRequest}.
|
|
1241
|
+
* @param parentId - the direct parent a batch release already brought back, when there is one.
|
|
1242
|
+
* @param signal - cancellation for the cold load and setup.
|
|
1243
|
+
* @returns what happened, and the copy for whoever asked.
|
|
1244
|
+
*/
|
|
1245
|
+
const resumeSession = async (sessionId, request, parentId, signal) => {
|
|
1246
|
+
const record = registry.get(sessionId);
|
|
1247
|
+
const live = ctx.agents.get(sessionId);
|
|
1248
|
+
if (live !== void 0) {
|
|
1249
|
+
if (!gate.isPaused(sessionId)) {
|
|
1250
|
+
if (record !== void 0 && record.phase !== "safe") return {
|
|
1251
|
+
sessionId,
|
|
1252
|
+
resumed: false,
|
|
1253
|
+
refusal: "not-clean",
|
|
1254
|
+
answer: {
|
|
1255
|
+
kind: "error",
|
|
1256
|
+
text: UNCLEAN_TEXT
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
if (record === void 0) return {
|
|
1260
|
+
sessionId,
|
|
1261
|
+
resumed: false,
|
|
1262
|
+
refusal: "not-parked",
|
|
1263
|
+
answer: {
|
|
1264
|
+
kind: "success",
|
|
1265
|
+
text: NOT_PARKED_TEXT
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
try {
|
|
1269
|
+
await registry.forget(sessionId);
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
1272
|
+
return {
|
|
1273
|
+
sessionId,
|
|
1274
|
+
resumed: false,
|
|
1275
|
+
refusal: "unrecorded",
|
|
1276
|
+
answer: {
|
|
1277
|
+
kind: "error",
|
|
1278
|
+
text: UNRECORDED_TEXT
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
ensureRoute(live);
|
|
1283
|
+
wake(live, request);
|
|
1284
|
+
if (record.rearmGoal) rearmGoal(live);
|
|
1285
|
+
return {
|
|
1286
|
+
sessionId,
|
|
1287
|
+
resumed: true,
|
|
1288
|
+
answer: {
|
|
1289
|
+
kind: "success",
|
|
1290
|
+
text: RESUMED_TEXT
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
const adopted = deps.adopted(sessionId);
|
|
1295
|
+
if (adopted && record !== void 0 && record.phase !== "safe") return {
|
|
1296
|
+
sessionId,
|
|
1297
|
+
resumed: false,
|
|
1298
|
+
refusal: "not-clean",
|
|
1299
|
+
answer: {
|
|
1300
|
+
kind: "error",
|
|
1301
|
+
text: UNCLEAN_TEXT
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
if (adopted) {
|
|
1305
|
+
ensureRoute(live);
|
|
1306
|
+
wake(live, request);
|
|
1307
|
+
}
|
|
1308
|
+
const released = await deps.release(sessionId, RESUMED_TEXT);
|
|
1309
|
+
if (released.kind === "error") return {
|
|
1310
|
+
sessionId,
|
|
1311
|
+
resumed: false,
|
|
1312
|
+
refusal: "unrecorded",
|
|
1313
|
+
answer: released
|
|
1314
|
+
};
|
|
1315
|
+
if (adopted && (record?.rearmGoal ?? true)) rearmGoal(live);
|
|
1316
|
+
return {
|
|
1317
|
+
sessionId,
|
|
1318
|
+
resumed: true,
|
|
1319
|
+
answer: released
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
if (record === void 0) return {
|
|
1323
|
+
sessionId,
|
|
1324
|
+
resumed: false,
|
|
1325
|
+
refusal: "not-parked",
|
|
1326
|
+
answer: {
|
|
1327
|
+
kind: "success",
|
|
1328
|
+
text: NOT_PARKED_TEXT
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
if (record.phase !== "safe") return {
|
|
1332
|
+
sessionId,
|
|
1333
|
+
resumed: false,
|
|
1334
|
+
refusal: "not-clean",
|
|
1335
|
+
answer: {
|
|
1336
|
+
kind: "error",
|
|
1337
|
+
text: UNCLEAN_TEXT
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
return await activate(sessionId, request, record.rearmGoal, parentId, signal);
|
|
1341
|
+
};
|
|
1342
|
+
/**
|
|
1343
|
+
* Order the recorded sessions so a session is released after the parent it
|
|
1344
|
+
* hangs from: waking a child needs its direct parent to be live first. The
|
|
1345
|
+
* relation is read from the parent catalogs themselves (which children this
|
|
1346
|
+
* session actually has), never guessed from ids; a branch whose catalog
|
|
1347
|
+
* cannot be read simply has no known parent and is released with the roots.
|
|
1348
|
+
* @param sessionIds - every recorded session, in registry order.
|
|
1349
|
+
* @param signal - cancellation for each catalog read.
|
|
1350
|
+
* @returns the parent of each child, and one list per depth level, shallowest first.
|
|
1351
|
+
*/
|
|
1352
|
+
return {
|
|
1353
|
+
resumeSession,
|
|
1354
|
+
decideUnclean,
|
|
1355
|
+
releaseTree: async (sessionId, signal) => {
|
|
1356
|
+
const registered = [sessionId, ...(await walkDescendants(ctx, sessionId, signal)).members.map((member) => member.id)].filter((member) => registry.get(member) !== void 0);
|
|
1357
|
+
if (registered.length === 0) return {
|
|
1358
|
+
members: 0,
|
|
1359
|
+
resumed: 0,
|
|
1360
|
+
refusals: [],
|
|
1361
|
+
answer: {
|
|
1362
|
+
kind: "success",
|
|
1363
|
+
text: NOT_PARKED_TEXT
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
const cancelling = registered.every((member) => {
|
|
1367
|
+
const record = registry.get(member);
|
|
1368
|
+
return record !== void 0 && record.phase !== "safe" && !deps.adopted(member);
|
|
1369
|
+
});
|
|
1370
|
+
const released = [];
|
|
1371
|
+
if (cancelling) for (const member of registered) {
|
|
1372
|
+
signal?.throwIfAborted();
|
|
1373
|
+
const answer = await deps.release(member, RESUMED_TEXT);
|
|
1374
|
+
released.push(answer.kind === "error" ? {
|
|
1375
|
+
sessionId: member,
|
|
1376
|
+
resumed: false,
|
|
1377
|
+
refusal: "unrecorded",
|
|
1378
|
+
answer
|
|
1379
|
+
} : {
|
|
1380
|
+
sessionId: member,
|
|
1381
|
+
resumed: true,
|
|
1382
|
+
answer
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
else {
|
|
1386
|
+
const release = await releaseOrder(ctx, registered, signal);
|
|
1387
|
+
for (const level of release.levels) {
|
|
1388
|
+
signal?.throwIfAborted();
|
|
1389
|
+
for (const member of level) {
|
|
1390
|
+
const record = registry.get(member);
|
|
1391
|
+
if (record !== void 0 && record.phase !== "safe") {
|
|
1392
|
+
released.push({
|
|
1393
|
+
sessionId: member,
|
|
1394
|
+
resumed: false,
|
|
1395
|
+
refusal: "not-clean",
|
|
1396
|
+
answer: {
|
|
1397
|
+
kind: "error",
|
|
1398
|
+
text: UNCLEAN_TEXT
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
released.push(await resumeSession(member, { kind: "silent" }, release.parents.get(member), signal));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const root = released.find((attempt) => attempt.sessionId === sessionId);
|
|
1408
|
+
const refusals = released.filter((attempt) => !attempt.resumed && attempt.refusal !== void 0).map((attempt) => ({
|
|
1409
|
+
sessionId: attempt.sessionId,
|
|
1410
|
+
refusal: attempt.refusal
|
|
1411
|
+
}));
|
|
1412
|
+
return {
|
|
1413
|
+
members: released.length,
|
|
1414
|
+
resumed: released.filter((attempt) => attempt.resumed).length,
|
|
1415
|
+
refusals,
|
|
1416
|
+
answer: root === void 0 ? {
|
|
1417
|
+
kind: "success",
|
|
1418
|
+
text: NOT_PARKED_TEXT
|
|
1419
|
+
} : {
|
|
1420
|
+
kind: refusals.length === 0 && root.resumed ? "success" : "error",
|
|
1421
|
+
text: summarizeTreeRelease(released)
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
},
|
|
1425
|
+
resumeParked: async (signal) => {
|
|
1426
|
+
const release = await releaseOrder(ctx, registry.list().map((entry) => entry.sessionId), signal);
|
|
1427
|
+
const attempts = [];
|
|
1428
|
+
for (const level of release.levels) {
|
|
1429
|
+
signal?.throwIfAborted();
|
|
1430
|
+
attempts.push(...await Promise.all(level.map((sessionId) => resumeSession(sessionId, { kind: "silent" }, release.parents.get(sessionId), signal))));
|
|
1431
|
+
}
|
|
1432
|
+
return attempts;
|
|
1433
|
+
},
|
|
1434
|
+
setRearmGoal: async (sessionId, rearmGoal) => {
|
|
1435
|
+
try {
|
|
1436
|
+
await registry.setRearmGoal(sessionId, rearmGoal);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
ctx.logger.warn(`pause: could not record the goal opt-out of "${sessionId}": ${describeError(error)}`);
|
|
1439
|
+
return {
|
|
1440
|
+
kind: "error",
|
|
1441
|
+
text: UNRECORDED_TEXT
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
kind: "success",
|
|
1446
|
+
text: rearmGoal ? REARM_GOAL_ON_TEXT : REARM_GOAL_OFF_TEXT
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
//#endregion
|
|
1452
|
+
//#region lib/types/triage.js
|
|
1453
|
+
/**
|
|
1454
|
+
* The morning decision list: every session that did not stop cleanly, the facts
|
|
1455
|
+
* its own log carries about where it stopped, and the listed verbs the user can
|
|
1456
|
+
* take on it.
|
|
1457
|
+
*
|
|
1458
|
+
* A session gets here by NOT being on the clean path. A record whose phase is
|
|
1459
|
+
* `safe` is woken silently by `./resume.ts` and never appears here; the other
|
|
1460
|
+
* three phases describe work nobody can vouch for, and guessing on them would
|
|
1461
|
+
* spend the owner's money on a guess. This module is the other half of that
|
|
1462
|
+
* rule, not a second copy of it: the reading is taken from the session's own
|
|
1463
|
+
* stored log through a read handle that claims no ownership and writes nothing,
|
|
1464
|
+
* so asking what happened cannot itself change what happened.
|
|
1465
|
+
*
|
|
1466
|
+
* WHAT ONE READ COSTS. The list is read when the panel opens and when a native
|
|
1467
|
+
* action changes the park, and what it publishes is about the end of a log that
|
|
1468
|
+
* only grows, so `./coldlog.ts` bounds each row's read to that end. A row whose
|
|
1469
|
+
* facts the bound cannot hold reports itself unreadable rather than publishing
|
|
1470
|
+
* a fragment.
|
|
1471
|
+
*
|
|
1472
|
+
* WHAT A ROW REPORTS. "Where it stopped" is the last event of the stored log
|
|
1473
|
+
* with the turn and step it belongs to. "Which tool was in flight" is the
|
|
1474
|
+
* `tool/call` events that no durable `tool/result` answers — a call event is
|
|
1475
|
+
* only written once the loop reached the dispatch, so an unanswered one is
|
|
1476
|
+
* exactly a tool the real world ran and the log never heard back from. The
|
|
1477
|
+
* harness turns each of those into a synthetic `TOOL_OUTCOME_UNKNOWN` error
|
|
1478
|
+
* result when it next reads the log; this module derives the same answer from
|
|
1479
|
+
* the stored events instead of writing it back. Nothing here is inferred from
|
|
1480
|
+
* the fact that the session is listed.
|
|
1481
|
+
*
|
|
1482
|
+
* WHAT THE ROW PROMISES. `wake` says the host can hand this session the request
|
|
1483
|
+
* it owes, and the wake that keeps that promise is `./resume.ts`'s. Both read
|
|
1484
|
+
* one rule from `./history.ts` over the same derivation the loop reads, so a
|
|
1485
|
+
* promise this list publishes is a promise that wake keeps. When the log cannot
|
|
1486
|
+
* be folded into that derivation, the promise is withheld and the reading is
|
|
1487
|
+
* reported; a withheld promise costs a user nothing, a broken one spends their
|
|
1488
|
+
* money.
|
|
1489
|
+
*
|
|
1490
|
+
* WHO MAY DECIDE. A decision carries the calling session and its target as wire
|
|
1491
|
+
* input, so this list acts only on rows its most recent read published to the
|
|
1492
|
+
* calling session: the caller's own read is what makes a target actionable.
|
|
1493
|
+
* Anything else is refused, not looked up.
|
|
1494
|
+
*
|
|
1495
|
+
* TWO OF THE FIVE VERBS PUT THE SESSION BACK ON THE CLEAN PATH. Continue and
|
|
1496
|
+
* continue-with-the-note go through `ResumePath.decideUnclean`, which activates
|
|
1497
|
+
* the session, wakes it and drops the durable record before the gate opens.
|
|
1498
|
+
*
|
|
1499
|
+
* THE OTHER THREE TOUCH NOTHING. Leave writes nothing at all: the record stays,
|
|
1500
|
+
* the session stays held, and the row is still on tomorrow's list. Review
|
|
1501
|
+
* answers without activating anything or writing anything — opening a session
|
|
1502
|
+
* to read it is the user's own action in their session history, and starting an
|
|
1503
|
+
* agent on their behalf is precisely what they declined. Goal flips the row's
|
|
1504
|
+
* own re-arm opt-out: it is the user path for the one thing a resume can spend
|
|
1505
|
+
* without the user watching it, and it takes effect on whatever resume comes
|
|
1506
|
+
* next. All five carry the same binding — the row must be one this caller was
|
|
1507
|
+
* shown — because each changes, or declines to change, durable state of a
|
|
1508
|
+
* parked session.
|
|
1509
|
+
* @module dsh-pause-session/triage
|
|
1510
|
+
*/
|
|
1511
|
+
/** Result copy for a decision the list itself could not carry out. */
|
|
1512
|
+
/** Result copy for leaving one listed session alone. */
|
|
1513
|
+
const TRIAGE_LEFT_TEXT = "pause left this session paused; it is still on the list.";
|
|
1514
|
+
/** Result copy for a decision aimed at a session that is not on the list. */
|
|
1515
|
+
const TRIAGE_NOT_LISTED_TEXT = "pause has no unfinished pause for this session.";
|
|
1516
|
+
/**
|
|
1517
|
+
* Result copy for a decision aimed at a row the calling session was not shown.
|
|
1518
|
+
* The list is the only thing that legitimizes a target, so a target nobody read
|
|
1519
|
+
* is refused rather than acted on.
|
|
1520
|
+
*/
|
|
1521
|
+
const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the decision list first.";
|
|
1522
|
+
/**
|
|
1523
|
+
* Model-facing text that goes with a wake when the user chooses to continue
|
|
1524
|
+
* with a note.
|
|
1525
|
+
*
|
|
1526
|
+
* It is written the way the harness writes its own recovery text: the same two
|
|
1527
|
+
* facts the synthetic tool result states (the call's outcome was never recorded;
|
|
1528
|
+
* verify before retrying anything with side effects), in the same voice, with no
|
|
1529
|
+
* mention of the stop mechanism. The harness's own version lives in the tool
|
|
1530
|
+
* result for that call, which is why this one does not repeat the call's name —
|
|
1531
|
+
* it warns about the step as a whole, and the specific result sits directly
|
|
1532
|
+
* above it in the history.
|
|
1533
|
+
*/
|
|
1534
|
+
const UNKNOWN_OUTCOME_NOTE = "A tool call from this step was interrupted: its call was recorded but no result was durably recorded, so its outcome is unknown. If it had side effects, verify the external state before doing anything else, and do not retry it blindly. Then continue with what I asked for.";
|
|
1535
|
+
/**
|
|
1536
|
+
* One row's facts, as the session's own log reports them.
|
|
1537
|
+
* @param ctx - host context carrying the registered message projections.
|
|
1538
|
+
* @param entry - the durable park record this row is built from.
|
|
1539
|
+
* @param events - the events the bounded read returned, in sequence order.
|
|
1540
|
+
* @param complete - whether those events are the whole stored log.
|
|
1541
|
+
* @returns the row, ready to publish, or `undefined` when the read cannot support it.
|
|
1542
|
+
*/
|
|
1543
|
+
function rowOf(ctx, entry, events, complete) {
|
|
1544
|
+
const { sessionId, phase, updatedAt, rearmGoal } = entry;
|
|
1545
|
+
const facts = logFacts(events, complete, ctx.sessions.messageProjections);
|
|
1546
|
+
if (facts === void 0) return void 0;
|
|
1547
|
+
const { last, traces, owed, wakeable } = facts;
|
|
1548
|
+
return {
|
|
1549
|
+
sessionId: String(sessionId),
|
|
1550
|
+
phase,
|
|
1551
|
+
updatedAt,
|
|
1552
|
+
reading: "read",
|
|
1553
|
+
...last === void 0 ? {} : {
|
|
1554
|
+
lastEvent: last.type,
|
|
1555
|
+
...positionOf(last)
|
|
1556
|
+
},
|
|
1557
|
+
traces,
|
|
1558
|
+
owed,
|
|
1559
|
+
rearmGoal,
|
|
1560
|
+
wakeable
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Mount the decision list over the durable registry and the clean resume path.
|
|
1565
|
+
*
|
|
1566
|
+
* The list is the registry's own complement: it asks for every record whose
|
|
1567
|
+
* phase is not `safe`, so a session that cleaned up is off the list by the same
|
|
1568
|
+
* fact that puts it on the silent path, and nothing has to be reconciled.
|
|
1569
|
+
* @param ctx - host context carrying the optional persistence service.
|
|
1570
|
+
* @param registry - the durable list of park outcomes.
|
|
1571
|
+
* @param resumePath - the path all three resuming decisions go through.
|
|
1572
|
+
* @param windowEvents - how many events of each stored log one read may fold.
|
|
1573
|
+
* @returns the list read and the decision verbs.
|
|
1574
|
+
*/
|
|
1575
|
+
function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW_EVENTS) {
|
|
1576
|
+
/**
|
|
1577
|
+
* The rows each calling session was last shown by its own read. A decision
|
|
1578
|
+
* names its target as wire input, and the channel carries no
|
|
1579
|
+
* origin of its own, so this is what ties a target to the list the user read:
|
|
1580
|
+
* only a row this caller has been shown is acted on.
|
|
1581
|
+
*/
|
|
1582
|
+
const shown = /* @__PURE__ */ new Map();
|
|
1583
|
+
const unreadable = (entry) => ({
|
|
1584
|
+
sessionId: String(entry.sessionId),
|
|
1585
|
+
phase: entry.phase,
|
|
1586
|
+
updatedAt: entry.updatedAt,
|
|
1587
|
+
reading: "unreadable",
|
|
1588
|
+
traces: [],
|
|
1589
|
+
owed: false,
|
|
1590
|
+
wakeable: false,
|
|
1591
|
+
rearmGoal: entry.rearmGoal
|
|
1592
|
+
});
|
|
1593
|
+
const readable = async (entry, signal) => {
|
|
1594
|
+
const read = await coldRead(ctx, entry.sessionId, windowEvents, signal);
|
|
1595
|
+
if (read.kind === "unreadable") return unreadable(entry);
|
|
1596
|
+
return rowOf(ctx, entry, read.events, read.complete) ?? unreadable(entry);
|
|
1597
|
+
};
|
|
1598
|
+
/**
|
|
1599
|
+
* One decision, as the resume path's own verbs plus the two local verbs that
|
|
1600
|
+
* change nothing but the record. Review is the list's own "do not run it"
|
|
1601
|
+
* answer: it activates nothing and writes nothing, so the park is exactly as
|
|
1602
|
+
* it was. The goal toggle is the user path for the one thing a resume can
|
|
1603
|
+
* spend unasked.
|
|
1604
|
+
*/
|
|
1605
|
+
const decide = async (caller, sessionId, action, signal) => {
|
|
1606
|
+
if (!(shown.get(caller)?.has(String(sessionId)) ?? false)) return {
|
|
1607
|
+
kind: "error",
|
|
1608
|
+
text: TRIAGE_UNSHOWN_TEXT
|
|
1609
|
+
};
|
|
1610
|
+
const record = registry.get(sessionId);
|
|
1611
|
+
if (record === void 0 || record.phase === "safe") return {
|
|
1612
|
+
kind: "error",
|
|
1613
|
+
text: TRIAGE_NOT_LISTED_TEXT
|
|
1614
|
+
};
|
|
1615
|
+
if (action === "leave") return {
|
|
1616
|
+
kind: "success",
|
|
1617
|
+
text: TRIAGE_LEFT_TEXT
|
|
1618
|
+
};
|
|
1619
|
+
if (action === "goal") return await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
|
|
1620
|
+
if (action === "review") return (await resumePath.decideUnclean(sessionId, { kind: "none" }, "review", void 0, signal)).answer;
|
|
1621
|
+
const wake = action === "continue" ? { kind: "silent" } : {
|
|
1622
|
+
kind: "message",
|
|
1623
|
+
text: UNKNOWN_OUTCOME_NOTE
|
|
1624
|
+
};
|
|
1625
|
+
return (await resumePath.decideUnclean(sessionId, wake, "release", void 0, signal)).answer;
|
|
1626
|
+
};
|
|
1627
|
+
return {
|
|
1628
|
+
read: async (caller, signal) => {
|
|
1629
|
+
signal?.throwIfAborted();
|
|
1630
|
+
const rows = await Promise.all([...registry.parked.values()].flatMap((entry) => entry.phase === "safe" ? [] : [readable({
|
|
1631
|
+
...entry,
|
|
1632
|
+
phase: entry.phase
|
|
1633
|
+
}, signal)]));
|
|
1634
|
+
shown.set(caller, new Set(rows.map((row) => row.sessionId)));
|
|
1635
|
+
return { rows };
|
|
1636
|
+
},
|
|
1637
|
+
decide
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region lib/types/snapshot.js
|
|
1642
|
+
/**
|
|
1643
|
+
* Pure projection of the durable park and each live tree into the native
|
|
1644
|
+
* snapshot. This module owns the phase fold and the goal opt-out; it does not
|
|
1645
|
+
* press, release, or decide anything.
|
|
1646
|
+
* @module dsh-pause-session/snapshot
|
|
1647
|
+
*/
|
|
1648
|
+
/** The phase a durable record holds for one session, live or adopted. */
|
|
1649
|
+
function recordedPhase(deps, sessionId) {
|
|
1650
|
+
return deps.marks.get(sessionId)?.phase ?? deps.registry.get(sessionId)?.phase;
|
|
1651
|
+
}
|
|
1652
|
+
/** Fold one session's mark and its registered descendants into the header reading. */
|
|
1653
|
+
async function treeStatus(deps, sessionId, mark, signal, knownWalk) {
|
|
1654
|
+
const walk = knownWalk ?? await walkDescendants(deps.ctx, sessionId, signal);
|
|
1655
|
+
const phases = [mark.phase];
|
|
1656
|
+
const extraRunning = [];
|
|
1657
|
+
for (const member of walk.members) {
|
|
1658
|
+
const phase = recordedPhase(deps, member.id);
|
|
1659
|
+
if (phase !== void 0) {
|
|
1660
|
+
phases.push(phase);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (member.mode !== "continuable") {
|
|
1664
|
+
const activity = deps.watch.activity(member.id);
|
|
1665
|
+
if (activity.turnOpen || activity.runningTools.length > 0) {
|
|
1666
|
+
phases.push("draining");
|
|
1667
|
+
extraRunning.push(...activity.runningTools);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
for (let at = 0; at < walk.unread.length + (walk.unreadWhole ? 1 : 0); at += 1) phases.push("parking");
|
|
1672
|
+
const folded = foldTreePhases(phases);
|
|
1673
|
+
const runningTools = [
|
|
1674
|
+
...mark.phase === "draining" ? deps.watch.activity(sessionId).runningTools : [],
|
|
1675
|
+
...walk.members.flatMap((member) => recordedPhase(deps, member.id) === "draining" ? deps.watch.activity(member.id).runningTools : []),
|
|
1676
|
+
...extraRunning
|
|
1677
|
+
];
|
|
1678
|
+
const record = deps.registry.get(sessionId);
|
|
1679
|
+
return {
|
|
1680
|
+
phase: folded.phase,
|
|
1681
|
+
safe: folded.safe,
|
|
1682
|
+
runningTools,
|
|
1683
|
+
...record === void 0 ? {} : { rearmGoal: record.rearmGoal }
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Read every live mark and the whole-app aggregate in one pass.
|
|
1688
|
+
* @param deps - the control-owned marks, watch, registry, and aggregate reader.
|
|
1689
|
+
* @param signal - cancellation for the tree walks and aggregate.
|
|
1690
|
+
* @returns the native snapshot.
|
|
1691
|
+
*/
|
|
1692
|
+
async function readSnapshot(deps, signal) {
|
|
1693
|
+
const sessions = {};
|
|
1694
|
+
const walks = /* @__PURE__ */ new Map();
|
|
1695
|
+
await Promise.all([...deps.marks].map(async ([sessionId, mark]) => {
|
|
1696
|
+
const walk = await walkDescendants(deps.ctx, sessionId, signal);
|
|
1697
|
+
if (deps.ctx.get("subagents") !== void 0) walks.set(sessionId, walk);
|
|
1698
|
+
sessions[String(sessionId)] = await treeStatus(deps, sessionId, mark, signal, walk);
|
|
1699
|
+
}));
|
|
1700
|
+
return {
|
|
1701
|
+
global: await deps.readGlobal(signal, walks),
|
|
1702
|
+
sessions
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
//#endregion
|
|
1706
|
+
//#region lib/types/park-state.js
|
|
1707
|
+
/**
|
|
1708
|
+
* One session's park episode: the durable phase writes, the activity walk that
|
|
1709
|
+
* settles them, and the press/release lifecycle. The control composes this with
|
|
1710
|
+
* the aggregate, the resume path, and the decision list.
|
|
1711
|
+
* @module dsh-pause-session/park-state
|
|
1712
|
+
*/
|
|
1713
|
+
/**
|
|
1714
|
+
* Mount the park episode state machine over the gate, registry, and activity fold.
|
|
1715
|
+
* @param ctx - host context carrying the session store and logger.
|
|
1716
|
+
* @param gate - the pause gate this state drives.
|
|
1717
|
+
* @param registry - the durable pause list every phase write goes to.
|
|
1718
|
+
* @param watch - the activity fold the park predicate reads.
|
|
1719
|
+
* @returns the park lifecycle the control composes.
|
|
1720
|
+
*/
|
|
1721
|
+
function installParkState(ctx, gate, registry, watch) {
|
|
1722
|
+
const marks = /* @__PURE__ */ new Map();
|
|
1723
|
+
const unrecorded = /* @__PURE__ */ new Set();
|
|
1724
|
+
const unheld = /* @__PURE__ */ new Set();
|
|
1725
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1726
|
+
let notification;
|
|
1727
|
+
let generation = 0;
|
|
1728
|
+
/** One snapshot per burst of host changes, not one per phase write. */
|
|
1729
|
+
const notify = () => {
|
|
1730
|
+
if (notification !== void 0) return;
|
|
1731
|
+
notification = setTimeout(() => {
|
|
1732
|
+
notification = void 0;
|
|
1733
|
+
for (const listener of [...listeners]) listener();
|
|
1734
|
+
}, 20);
|
|
1735
|
+
};
|
|
1736
|
+
const drop = (sessionId) => {
|
|
1737
|
+
marks.delete(sessionId);
|
|
1738
|
+
notify();
|
|
1739
|
+
};
|
|
1740
|
+
const commit = async (sessionId, mark, phase) => {
|
|
1741
|
+
try {
|
|
1742
|
+
await registry.record(sessionId, phase);
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
ctx.logger.warn(`pause: could not record the park state of "${sessionId}" as ${phase}: ${describeError(error)}`);
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
const changed = mark.phase !== phase;
|
|
1748
|
+
mark.phase = phase;
|
|
1749
|
+
if (changed) notify();
|
|
1750
|
+
return true;
|
|
1751
|
+
};
|
|
1752
|
+
const parked = (sessionId) => {
|
|
1753
|
+
const activity = watch.activity(sessionId);
|
|
1754
|
+
if (activity.runningTools.length > 0) return false;
|
|
1755
|
+
return gate.isHeld(sessionId) || !activity.turnOpen;
|
|
1756
|
+
};
|
|
1757
|
+
const advance = (sessionId) => {
|
|
1758
|
+
const mark = marks.get(sessionId);
|
|
1759
|
+
if (mark === void 0 || mark.phase !== "draining" || mark.advancing) return false;
|
|
1760
|
+
if (mark.origin === "registry") return false;
|
|
1761
|
+
if (!parked(sessionId)) return false;
|
|
1762
|
+
mark.advancing = true;
|
|
1763
|
+
return true;
|
|
1764
|
+
};
|
|
1765
|
+
const settle = async (sessionId, attempt) => {
|
|
1766
|
+
const mark = marks.get(sessionId);
|
|
1767
|
+
if (mark === void 0 || mark.generation !== attempt) return;
|
|
1768
|
+
const owned = () => marks.get(sessionId) === mark && mark.generation === attempt;
|
|
1769
|
+
const session = ctx.sessions.get(sessionId);
|
|
1770
|
+
try {
|
|
1771
|
+
if (session === void 0) {
|
|
1772
|
+
await commit(sessionId, mark, "parking");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
await commit(sessionId, mark, "parking");
|
|
1776
|
+
if (!owned()) return;
|
|
1777
|
+
try {
|
|
1778
|
+
if (!await ctx.sessions.flush(session)) {
|
|
1779
|
+
await commit(sessionId, mark, "flush-failed");
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
if (!owned()) return;
|
|
1784
|
+
ctx.logger.warn(`pause: flush failed for "${sessionId}": ${describeError(error)}`);
|
|
1785
|
+
await commit(sessionId, mark, "flush-failed");
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (!owned()) return;
|
|
1789
|
+
await commit(sessionId, mark, "safe");
|
|
1790
|
+
} finally {
|
|
1791
|
+
if (marks.get(sessionId) === mark && mark.phase === "draining") mark.advancing = false;
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
const look = (sessionId) => {
|
|
1795
|
+
if (marks.has(sessionId)) notify();
|
|
1796
|
+
if (!advance(sessionId)) return;
|
|
1797
|
+
const attempt = marks.get(sessionId)?.generation;
|
|
1798
|
+
if (attempt !== void 0) settle(sessionId, attempt);
|
|
1799
|
+
};
|
|
1800
|
+
const press = async (sessionId, signal, knownResumable = false) => {
|
|
1801
|
+
if (ctx.sessions.get(sessionId) === void 0) return "not-live";
|
|
1802
|
+
if (!knownResumable && !await isResumableSession(ctx, sessionId, signal)) return "not-resumable";
|
|
1803
|
+
if (!gate.pause(sessionId)) return "already-paused";
|
|
1804
|
+
const mark = {
|
|
1805
|
+
phase: "draining",
|
|
1806
|
+
generation: ++generation,
|
|
1807
|
+
origin: "press",
|
|
1808
|
+
advancing: false
|
|
1809
|
+
};
|
|
1810
|
+
marks.set(sessionId, mark);
|
|
1811
|
+
if (await commit(sessionId, mark, "draining")) {
|
|
1812
|
+
unheld.delete(sessionId);
|
|
1813
|
+
notify();
|
|
1814
|
+
return "registered";
|
|
1815
|
+
}
|
|
1816
|
+
drop(sessionId);
|
|
1817
|
+
gate.resume(sessionId);
|
|
1818
|
+
return "unrecordable";
|
|
1819
|
+
};
|
|
1820
|
+
const release = async (sessionId, text) => {
|
|
1821
|
+
if (!gate.isPaused(sessionId)) return {
|
|
1822
|
+
kind: "success",
|
|
1823
|
+
text
|
|
1824
|
+
};
|
|
1825
|
+
try {
|
|
1826
|
+
await registry.forget(sessionId);
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
1829
|
+
return {
|
|
1830
|
+
kind: "error",
|
|
1831
|
+
text: UNRECORDED_TEXT
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
gate.resume(sessionId);
|
|
1835
|
+
notify();
|
|
1836
|
+
return {
|
|
1837
|
+
kind: "success",
|
|
1838
|
+
text
|
|
1839
|
+
};
|
|
1840
|
+
};
|
|
1841
|
+
const sync = (sessionId) => {
|
|
1842
|
+
if (!gate.isPaused(sessionId)) {
|
|
1843
|
+
drop(sessionId);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
return {
|
|
1848
|
+
marks,
|
|
1849
|
+
unrecorded,
|
|
1850
|
+
unheld,
|
|
1851
|
+
subscribe: (listener) => {
|
|
1852
|
+
listeners.add(listener);
|
|
1853
|
+
return () => {
|
|
1854
|
+
listeners.delete(listener);
|
|
1855
|
+
};
|
|
1856
|
+
},
|
|
1857
|
+
notify,
|
|
1858
|
+
press,
|
|
1859
|
+
adopt: (sessionId, phase) => {
|
|
1860
|
+
marks.set(sessionId, {
|
|
1861
|
+
phase,
|
|
1862
|
+
generation: ++generation,
|
|
1863
|
+
origin: "registry",
|
|
1864
|
+
advancing: false
|
|
1865
|
+
});
|
|
1866
|
+
},
|
|
1867
|
+
look,
|
|
1868
|
+
drop,
|
|
1869
|
+
sync,
|
|
1870
|
+
release,
|
|
1871
|
+
markUnrecorded: (sessionId, blocked) => {
|
|
1872
|
+
if (blocked) unrecorded.add(sessionId);
|
|
1873
|
+
else unrecorded.delete(sessionId);
|
|
1874
|
+
},
|
|
1875
|
+
live: () => new Map([...marks].map(([sessionId, mark]) => [sessionId, {
|
|
1876
|
+
phase: mark.phase,
|
|
1877
|
+
moving: mark.origin === "press" && mark.phase !== "flush-failed" && ctx.sessions.get(sessionId) !== void 0
|
|
1878
|
+
}])),
|
|
1879
|
+
cleanup: () => {
|
|
1880
|
+
if (notification !== void 0) clearTimeout(notification);
|
|
1881
|
+
marks.clear();
|
|
1882
|
+
listeners.clear();
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
//#endregion
|
|
1887
|
+
//#region lib/types/control.js
|
|
1888
|
+
/**
|
|
1889
|
+
* The session control the browser half drives: the pause verb, the park state
|
|
1890
|
+
* machine behind it, and the live state channel that accompanies both.
|
|
1891
|
+
*
|
|
1892
|
+
* The host exposes this control through the native Typert Remote namespace:
|
|
1893
|
+
* `observe` streams snapshots and `act` / `triage` / `decide` drive the UI. No
|
|
1894
|
+
* slash command is registered. Phase changes wake the Remote stream through a
|
|
1895
|
+
* local listener set; the durable registry remains the source of truth for what
|
|
1896
|
+
* the snapshot reports.
|
|
1897
|
+
* @module dsh-pause-session/control
|
|
1898
|
+
*/
|
|
1899
|
+
/**
|
|
1900
|
+
* Whether this host can read one recorded session at all — a live session, or a
|
|
1901
|
+
* stored log the persistence backend can observe.
|
|
1902
|
+
* @param ctx - host context carrying the session store and the optional persistence service.
|
|
1903
|
+
* @param sessionId - the recorded session to look for.
|
|
1904
|
+
* @returns whether the session can be acted on here.
|
|
1905
|
+
*/
|
|
1906
|
+
async function sessionReadable(ctx, sessionId) {
|
|
1907
|
+
if (ctx.sessions.get(sessionId) !== void 0) return true;
|
|
1908
|
+
const persistence = ctx.get("sessionPersistence");
|
|
1909
|
+
if (persistence === void 0) return false;
|
|
1910
|
+
try {
|
|
1911
|
+
return await persistence.stat(sessionId) !== void 0;
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
ctx.logger.warn(`pause: could not look for the stored log of "${sessionId}": ${describeError(error)}`);
|
|
1914
|
+
return false;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
/** How resuming all parked sessions answers the caller. */
|
|
1918
|
+
function summarizeResume(attempts) {
|
|
1919
|
+
const resumed = attempts.filter((attempt) => attempt.resumed).length;
|
|
1920
|
+
const refused = attempts.length - resumed;
|
|
1921
|
+
const head = resumed === 0 ? "pause resumed no session" : resumed === 1 ? "pause resumed 1 session from where it stopped" : `pause resumed ${resumed} sessions from where they stopped`;
|
|
1922
|
+
if (refused === 0) return `${head}.`;
|
|
1923
|
+
return `${head}; ${attempts.filter((attempt) => attempt.refusal === "not-clean").length === refused ? `${refused} did not pause completely and need your decision` : `${refused} could not be resumed`}.`;
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Contribute the session control: the pause verb, the per-session status read,
|
|
1927
|
+
* the global resume verb, and — for every paused session that has a live agent
|
|
1928
|
+
* — the resume marker.
|
|
1929
|
+
* @param ctx - host context carrying the agent store, the
|
|
1930
|
+
* session store, and the projection registry.
|
|
1931
|
+
* @param gate - the pause gate this control drives and reflects.
|
|
1932
|
+
* @param watch - the per-session activity fold the park predicate reads.
|
|
1933
|
+
* @param registry - the durable pause list every phase change is written to.
|
|
1934
|
+
* @param options - route knowledge the clean resume path needs, and the decision list's read bound.
|
|
1935
|
+
* @returns the mount-time adoption step and the morning resume path.
|
|
1936
|
+
*/
|
|
1937
|
+
function installSessionControl(ctx, gate, watch, registry, options) {
|
|
1938
|
+
const park = installParkState(ctx, gate, registry, watch);
|
|
1939
|
+
/**
|
|
1940
|
+
* Press one session's pause and register its whole tree behind it.
|
|
1941
|
+
*
|
|
1942
|
+
* "One session's work" includes the subagents it delegated to, nested ones
|
|
1943
|
+
* included, and the user does not pause them one by one. So the press walks
|
|
1944
|
+
* the session's own catalog tree and gives every member the same durable
|
|
1945
|
+
* treatment the session gets: {@link press} registers what it can hold, and a
|
|
1946
|
+
* member whose record cannot be written is named here rather than counted as
|
|
1947
|
+
* paused.
|
|
1948
|
+
*
|
|
1949
|
+
* A tree the walk could not read is the one case where the press does less
|
|
1950
|
+
* than the tree: the session itself is still registered — the user pressed a
|
|
1951
|
+
* session, and its park is real — and the answer says the subagents were not
|
|
1952
|
+
* covered rather than counting a tree nobody read.
|
|
1953
|
+
* @param sessionId - the session whose tree the user pressed.
|
|
1954
|
+
* @returns which members are registered and which could not be.
|
|
1955
|
+
*/
|
|
1956
|
+
const pressTree = async (sessionId, signal) => {
|
|
1957
|
+
if (ctx.sessions.get(sessionId) === void 0) return {
|
|
1958
|
+
members: 0,
|
|
1959
|
+
registered: 0,
|
|
1960
|
+
rootRegistered: false,
|
|
1961
|
+
unrecorded: [],
|
|
1962
|
+
unread: false,
|
|
1963
|
+
unresumable: [],
|
|
1964
|
+
unreadBranches: []
|
|
1965
|
+
};
|
|
1966
|
+
const walk = await walkDescendants(ctx, sessionId, signal);
|
|
1967
|
+
if (walk.unreadWhole) {
|
|
1968
|
+
const pressed = await park.press(sessionId, signal);
|
|
1969
|
+
if (pressed === "registered") park.look(sessionId);
|
|
1970
|
+
globalPause.markUnrecorded(sessionId, pressed === "unrecordable");
|
|
1971
|
+
return {
|
|
1972
|
+
members: pressed === "registered" ? 1 : 0,
|
|
1973
|
+
registered: pressed === "registered" ? 1 : 0,
|
|
1974
|
+
rootRegistered: pressed === "registered",
|
|
1975
|
+
unrecorded: pressed === "unrecordable" ? [sessionId] : [],
|
|
1976
|
+
unread: true,
|
|
1977
|
+
unresumable: [],
|
|
1978
|
+
unreadBranches: []
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
const registered = [];
|
|
1982
|
+
const unrecorded = [];
|
|
1983
|
+
const unresumable = walk.members.filter((member) => member.mode !== "continuable").map((member) => member.id);
|
|
1984
|
+
const unreadBranches = walk.unread.map((branch) => branch.id);
|
|
1985
|
+
const members = walk.members.filter((member) => member.mode === "continuable");
|
|
1986
|
+
for (const id of [sessionId, ...members.map((member) => member.id)]) {
|
|
1987
|
+
const pressed = await park.press(id, signal, id !== sessionId);
|
|
1988
|
+
if (pressed === "registered") {
|
|
1989
|
+
registered.push(id);
|
|
1990
|
+
park.look(id);
|
|
1991
|
+
} else if (pressed === "unrecordable") unrecorded.push(id);
|
|
1992
|
+
globalPause.markUnrecorded(id, pressed === "unrecordable");
|
|
1993
|
+
}
|
|
1994
|
+
return {
|
|
1995
|
+
members: registered.length + unrecorded.length,
|
|
1996
|
+
registered: registered.length,
|
|
1997
|
+
rootRegistered: registered.includes(sessionId),
|
|
1998
|
+
unrecorded,
|
|
1999
|
+
unread: unreadBranches.length > 0,
|
|
2000
|
+
unresumable,
|
|
2001
|
+
unreadBranches
|
|
2002
|
+
};
|
|
2003
|
+
};
|
|
2004
|
+
const resumePath = installResumePath(ctx, gate, registry, options, {
|
|
2005
|
+
release: park.release,
|
|
2006
|
+
adopted: (sessionId) => park.marks.get(sessionId)?.origin === "registry"
|
|
2007
|
+
});
|
|
2008
|
+
const triage = mountTriageList(ctx, registry, resumePath, options.maxListedLogEvents);
|
|
2009
|
+
const globalPause = mountGlobalPause(ctx, watch, registry, {
|
|
2010
|
+
isPaused: (sessionId) => gate.isPaused(sessionId),
|
|
2011
|
+
press: park.press,
|
|
2012
|
+
look: park.look,
|
|
2013
|
+
live: park.live,
|
|
2014
|
+
unheld: () => park.unheld
|
|
2015
|
+
});
|
|
2016
|
+
const readSnapshot$1 = (signal) => readSnapshot({
|
|
2017
|
+
ctx,
|
|
2018
|
+
marks: park.marks,
|
|
2019
|
+
watch,
|
|
2020
|
+
registry,
|
|
2021
|
+
readGlobal: globalPause.readState
|
|
2022
|
+
}, signal);
|
|
2023
|
+
ctx.effect(function* () {
|
|
2024
|
+
yield gate.onChange((sessionId) => {
|
|
2025
|
+
park.sync(sessionId);
|
|
2026
|
+
});
|
|
2027
|
+
yield gate.onHeld((sessionId) => {
|
|
2028
|
+
queueMicrotask(() => {
|
|
2029
|
+
park.look(sessionId);
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
yield watch.onChange((sessionId) => {
|
|
2033
|
+
park.look(sessionId);
|
|
2034
|
+
});
|
|
2035
|
+
yield ctx.on("agent/created", ({ agent }) => {
|
|
2036
|
+
park.sync(agent.id);
|
|
2037
|
+
});
|
|
2038
|
+
yield async () => {
|
|
2039
|
+
park.cleanup();
|
|
2040
|
+
};
|
|
2041
|
+
}, "pause: session events");
|
|
2042
|
+
return {
|
|
2043
|
+
readSnapshot: readSnapshot$1,
|
|
2044
|
+
subscribe: park.subscribe,
|
|
2045
|
+
pressTree: async (sessionId, signal) => await pressTree(sessionId, signal),
|
|
2046
|
+
releaseTree: async (sessionId, signal) => await resumePath.releaseTree(sessionId, signal),
|
|
2047
|
+
pauseAll: async (signal) => {
|
|
2048
|
+
const answer = await globalPause.pressAll(signal);
|
|
2049
|
+
park.notify();
|
|
2050
|
+
return answer;
|
|
2051
|
+
},
|
|
2052
|
+
cancelAll: async (signal) => {
|
|
2053
|
+
const outcomes = [];
|
|
2054
|
+
for (let round = 0; round < 3; round += 1) {
|
|
2055
|
+
const before = registry.parked.size;
|
|
2056
|
+
for (const sessionId of [...registry.parked.keys()]) {
|
|
2057
|
+
signal?.throwIfAborted();
|
|
2058
|
+
outcomes.push((await resumePath.releaseTree(sessionId, signal)).answer);
|
|
2059
|
+
}
|
|
2060
|
+
if (registry.parked.size === before) break;
|
|
2061
|
+
}
|
|
2062
|
+
park.notify();
|
|
2063
|
+
const failed = outcomes.filter((answer) => answer.kind === "error");
|
|
2064
|
+
return failed.length === 0 ? {
|
|
2065
|
+
kind: "success",
|
|
2066
|
+
text: "Every pause was released."
|
|
2067
|
+
} : {
|
|
2068
|
+
kind: "error",
|
|
2069
|
+
text: failed.map((answer) => answer.text).join(" ")
|
|
2070
|
+
};
|
|
2071
|
+
},
|
|
2072
|
+
resumeAll: async (signal) => {
|
|
2073
|
+
const answer = {
|
|
2074
|
+
kind: "success",
|
|
2075
|
+
text: summarizeResume(await resumePath.resumeParked(signal))
|
|
2076
|
+
};
|
|
2077
|
+
park.notify();
|
|
2078
|
+
return answer;
|
|
2079
|
+
},
|
|
2080
|
+
toggleGoal: async (sessionId) => {
|
|
2081
|
+
const record = registry.get(sessionId);
|
|
2082
|
+
if (record === void 0) return {
|
|
2083
|
+
kind: "error",
|
|
2084
|
+
text: NOT_PARKED_TEXT
|
|
2085
|
+
};
|
|
2086
|
+
const answer = await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
|
|
2087
|
+
park.notify();
|
|
2088
|
+
return answer;
|
|
2089
|
+
},
|
|
2090
|
+
readTriage: async (caller, signal) => await triage.read(caller, signal),
|
|
2091
|
+
decideTriage: async (caller, target, action, signal) => {
|
|
2092
|
+
const answer = await triage.decide(caller, target, action, signal);
|
|
2093
|
+
park.notify();
|
|
2094
|
+
return answer;
|
|
2095
|
+
},
|
|
2096
|
+
adopt: async () => {
|
|
2097
|
+
for (const entry of registry.list()) {
|
|
2098
|
+
if (!await sessionReadable(ctx, entry.sessionId)) {
|
|
2099
|
+
park.unheld.add(entry.sessionId);
|
|
2100
|
+
ctx.logger.warn(`pause: the pause recorded for "${entry.sessionId}" is not held: this host cannot read that session`);
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
if (gate.pause(entry.sessionId)) park.adopt(entry.sessionId, entry.phase);
|
|
2104
|
+
}
|
|
2105
|
+
for (const agent of ctx.agents.list()) park.sync(agent.id);
|
|
2106
|
+
}
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region lib/types/gate.js
|
|
2111
|
+
/**
|
|
2112
|
+
* The pause gate: one session's hold at the model-request waterfall.
|
|
2113
|
+
*
|
|
2114
|
+
* The hold sits on the `llm/stream` waterfall, which is the only point that
|
|
2115
|
+
* satisfies all four conditions at once: the previous step's tool calls have
|
|
2116
|
+
* drained, the request is already built, the session's pending input is
|
|
2117
|
+
* committed, and — because nothing pulls the adapter — no timer is armed
|
|
2118
|
+
* anywhere. Machine sleep, network loss, and the adapter's stream-idle
|
|
2119
|
+
* watchdog therefore cannot touch a parked request.
|
|
2120
|
+
* @module dsh-pause-session/gate
|
|
2121
|
+
*/
|
|
2122
|
+
/** Message of the failure a held request ends with when its plugin unloads first. */
|
|
2123
|
+
const PLUGIN_UNLOADED = "pause: plugin lifetime ended while a request was held";
|
|
2124
|
+
/** The abort signal's own error, or a stable one when the abort carried no reason. */
|
|
2125
|
+
function abortError(signal) {
|
|
2126
|
+
return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("pause: request aborted while held");
|
|
2127
|
+
}
|
|
2128
|
+
/** A latch without `Promise.withResolvers`, which this project's ES2022 lib predates. */
|
|
2129
|
+
function latch() {
|
|
2130
|
+
let open = () => {};
|
|
2131
|
+
return {
|
|
2132
|
+
promise: new Promise((resolve) => {
|
|
2133
|
+
open = resolve;
|
|
2134
|
+
}),
|
|
2135
|
+
open
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* Resolve once the session is released or any of `signals` aborts. No timer is
|
|
2140
|
+
* armed: the wait ends only by release or by an abort, so a parked request
|
|
2141
|
+
* cannot be woken by the clock. Listeners are removed once the wait settles.
|
|
2142
|
+
*/
|
|
2143
|
+
function raceRelease(released, signals) {
|
|
2144
|
+
const wake = latch();
|
|
2145
|
+
const onAbort = () => {
|
|
2146
|
+
wake.open();
|
|
2147
|
+
};
|
|
2148
|
+
const watching = signals.filter((signal) => !signal.aborted);
|
|
2149
|
+
if (watching.length !== signals.length) wake.open();
|
|
2150
|
+
for (const signal of watching) signal.addEventListener("abort", onAbort, { once: true });
|
|
2151
|
+
return Promise.race([released, wake.promise]).finally(() => {
|
|
2152
|
+
for (const signal of watching) signal.removeEventListener("abort", onAbort);
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
/** Per-session pause state and the waterfall listener that enforces it. */
|
|
2156
|
+
var PauseGate = class {
|
|
2157
|
+
options;
|
|
2158
|
+
/** Paused sessions; membership IS the pause state. */
|
|
2159
|
+
paused = /* @__PURE__ */ new Map();
|
|
2160
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2161
|
+
heldListeners = /* @__PURE__ */ new Set();
|
|
2162
|
+
/**
|
|
2163
|
+
* @param options - the plugin-lifetime signal every held request races.
|
|
2164
|
+
*/
|
|
2165
|
+
constructor(options) {
|
|
2166
|
+
this.options = options;
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Subscribe to pause-state changes.
|
|
2170
|
+
* @param listener - called with the session whose state changed and its new state.
|
|
2171
|
+
* @returns the unsubscribe function.
|
|
2172
|
+
*/
|
|
2173
|
+
onChange(listener) {
|
|
2174
|
+
this.listeners.add(listener);
|
|
2175
|
+
return () => {
|
|
2176
|
+
this.listeners.delete(listener);
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Subscribe to "a request arrived at / left the gate" for a paused session.
|
|
2181
|
+
*
|
|
2182
|
+
* This is the moment a session can become parked, so it is also the moment a
|
|
2183
|
+
* park observer has to look. Listeners run synchronously inside the request
|
|
2184
|
+
* path: they must only record that a look is owed.
|
|
2185
|
+
* @param listener - called with the session and whether it now holds a request.
|
|
2186
|
+
* @returns the unsubscribe function.
|
|
2187
|
+
*/
|
|
2188
|
+
onHeld(listener) {
|
|
2189
|
+
this.heldListeners.add(listener);
|
|
2190
|
+
return () => {
|
|
2191
|
+
this.heldListeners.delete(listener);
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* @param sessionId - session to inspect.
|
|
2196
|
+
* @returns whether the session is currently paused.
|
|
2197
|
+
*/
|
|
2198
|
+
isPaused(sessionId) {
|
|
2199
|
+
return this.paused.has(sessionId);
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* @param sessionId - session to inspect.
|
|
2203
|
+
* @returns whether a model request of that session is waiting at the gate
|
|
2204
|
+
* right now. False for a session that is paused but whose loop has not built
|
|
2205
|
+
* its next request yet — a running tool, for instance, is still draining.
|
|
2206
|
+
*/
|
|
2207
|
+
isHeld(sessionId) {
|
|
2208
|
+
return (this.paused.get(sessionId)?.holding ?? 0) > 0;
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Enter one request that is about to wait. The count rises synchronously with
|
|
2212
|
+
* {@link hold}, so an observer notified at that moment reads `isHeld` as true.
|
|
2213
|
+
* @param pause - the session's pause record.
|
|
2214
|
+
* @returns the exit that lowers the count again.
|
|
2215
|
+
*/
|
|
2216
|
+
enter(pause) {
|
|
2217
|
+
pause.holding += 1;
|
|
2218
|
+
this.notifyHeld(pause.sessionId, true);
|
|
2219
|
+
return () => {
|
|
2220
|
+
pause.holding -= 1;
|
|
2221
|
+
if (pause.holding === 0) this.notifyHeld(pause.sessionId, false);
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Pause one session: its next model request is held until {@link resume}. A
|
|
2226
|
+
* request already past the waterfall keeps running — pausing never
|
|
2227
|
+
* interrupts a model call or a tool call that has started.
|
|
2228
|
+
* @param sessionId - session to pause.
|
|
2229
|
+
* @returns whether this call changed the state.
|
|
2230
|
+
*/
|
|
2231
|
+
pause(sessionId) {
|
|
2232
|
+
if (this.paused.has(sessionId)) return false;
|
|
2233
|
+
const release = latch();
|
|
2234
|
+
this.paused.set(sessionId, {
|
|
2235
|
+
sessionId,
|
|
2236
|
+
released: release.promise,
|
|
2237
|
+
release: release.open,
|
|
2238
|
+
holding: 0
|
|
2239
|
+
});
|
|
2240
|
+
this.notify(sessionId, true);
|
|
2241
|
+
return true;
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Resume one session: release everything it holds and stop holding what
|
|
2245
|
+
* follows. The requests sent from then on are the ones built before the
|
|
2246
|
+
* hold, so a resumed session continues from the point it stopped.
|
|
2247
|
+
* @param sessionId - session to resume.
|
|
2248
|
+
* @returns whether this call changed the state.
|
|
2249
|
+
*/
|
|
2250
|
+
resume(sessionId) {
|
|
2251
|
+
const pause = this.paused.get(sessionId);
|
|
2252
|
+
if (pause === void 0) return false;
|
|
2253
|
+
this.paused.delete(sessionId);
|
|
2254
|
+
pause.release();
|
|
2255
|
+
this.notify(sessionId, false);
|
|
2256
|
+
return true;
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* The wait one model request must finish before it may delegate, and the exit
|
|
2260
|
+
* that reports the request as no longer waiting.
|
|
2261
|
+
* @param request - the request reaching the waterfall.
|
|
2262
|
+
* @returns the wait plus its release, or `undefined` when this gate does not
|
|
2263
|
+
* hold that request.
|
|
2264
|
+
*/
|
|
2265
|
+
hold(request) {
|
|
2266
|
+
if (request.purpose !== void 0) return void 0;
|
|
2267
|
+
const sessionId = request.sessionId;
|
|
2268
|
+
if (sessionId === void 0) return void 0;
|
|
2269
|
+
const pause = this.paused.get(sessionId);
|
|
2270
|
+
if (pause === void 0) return void 0;
|
|
2271
|
+
const left = this.enter(pause);
|
|
2272
|
+
return {
|
|
2273
|
+
wait: raceRelease(pause.released, request.signal === void 0 ? [this.options.lifetime] : [request.signal, this.options.lifetime]),
|
|
2274
|
+
left
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2278
|
+
* The `llm/stream` listener.
|
|
2279
|
+
*
|
|
2280
|
+
* `ctx.waterfall` composes listeners synchronously, so this returns an async
|
|
2281
|
+
* iterator synchronously — an `async` listener would hand the loop a promise
|
|
2282
|
+
* where it iterates a stream. One generator body reaches `next()` at most
|
|
2283
|
+
* once, which is what makes delegation single-shot per HELD request; a flag
|
|
2284
|
+
* shared across requests would also short-circuit everything issued after
|
|
2285
|
+
* the release.
|
|
2286
|
+
* @param request - the full request, already built and frozen by the loop.
|
|
2287
|
+
* @param next - the downstream waterfall chain.
|
|
2288
|
+
* @returns the stream, delayed until this request may delegate.
|
|
2289
|
+
*/
|
|
2290
|
+
stream = (request, next) => {
|
|
2291
|
+
const gate = this;
|
|
2292
|
+
return (async function* gated() {
|
|
2293
|
+
const held = gate.hold(request);
|
|
2294
|
+
try {
|
|
2295
|
+
if (held !== void 0) await held.wait;
|
|
2296
|
+
if (request.signal?.aborted === true) throw abortError(request.signal);
|
|
2297
|
+
if (gate.options.lifetime.aborted) throw new Error(PLUGIN_UNLOADED);
|
|
2298
|
+
yield* next();
|
|
2299
|
+
} finally {
|
|
2300
|
+
held?.left();
|
|
2301
|
+
}
|
|
2302
|
+
})();
|
|
2303
|
+
};
|
|
2304
|
+
notify(sessionId, paused) {
|
|
2305
|
+
for (const listener of [...this.listeners]) listener(sessionId, paused);
|
|
2306
|
+
}
|
|
2307
|
+
notifyHeld(sessionId, held) {
|
|
2308
|
+
for (const listener of [...this.heldListeners]) listener(sessionId, held);
|
|
2309
|
+
}
|
|
2310
|
+
};
|
|
2311
|
+
//#endregion
|
|
2312
|
+
//#region lib/types/park.js
|
|
2313
|
+
/**
|
|
2314
|
+
* What a session is doing between "pause was asked for" and "its next model
|
|
2315
|
+
* request is standing still at the gate".
|
|
2316
|
+
*
|
|
2317
|
+
* Two facts decide that, and each has its own owner:
|
|
2318
|
+
*
|
|
2319
|
+
* - Whether a turn is open is derived state the harness already maintains:
|
|
2320
|
+
* the agent registry owns the `turnBoundary` session projection, which folds
|
|
2321
|
+
* `turn/start`, `turn/end`, `step/start`, and `step/end` into
|
|
2322
|
+
* `openTurnStartSeq`. Reading it through `ctx.agents` is a synchronous
|
|
2323
|
+
* projection read, not a scan of the event log (synchronous history reads are
|
|
2324
|
+
* prohibited for new code).
|
|
2325
|
+
* - Which tool is in flight is a log-derived per-session fact, so it is a
|
|
2326
|
+
* session projection too. The projection folds each committed
|
|
2327
|
+
* `tool/call` → `tool/result` pairing and keeps the calls in order; the
|
|
2328
|
+
* framework owns replay, caching, and disposal. This module listens only to
|
|
2329
|
+
* committed events so it can wake the control, never to keep a second copy
|
|
2330
|
+
* of the state itself.
|
|
2331
|
+
* @module dsh-pause-session/park
|
|
2332
|
+
*/
|
|
2333
|
+
/** One tool call the session has committed but not yet answered. */
|
|
2334
|
+
const openCallSchema = z.object({
|
|
2335
|
+
/** The provider-issued id pairing the call with the result that has not arrived. */
|
|
2336
|
+
callId: z.string(),
|
|
2337
|
+
/** The tool's own name, as the model called it. */
|
|
2338
|
+
name: z.string()
|
|
2339
|
+
});
|
|
2340
|
+
/**
|
|
2341
|
+
* The host-only fold behind {@link ParkWatch}. It is registered on the
|
|
2342
|
+
* plugin's fiber; unload removes it with every other registration.
|
|
2343
|
+
*/
|
|
2344
|
+
const parkActivityProjection = {
|
|
2345
|
+
key: "dsh-pause-session/activity",
|
|
2346
|
+
stateSchema: z.array(openCallSchema),
|
|
2347
|
+
init: () => [],
|
|
2348
|
+
apply: (state, event) => {
|
|
2349
|
+
if (event.type === "tool/call") return [...state, {
|
|
2350
|
+
callId: String(event.data.callId),
|
|
2351
|
+
name: event.data.name
|
|
2352
|
+
}];
|
|
2353
|
+
if (event.type === "tool/result") {
|
|
2354
|
+
const callId = String(event.data.message.toolCallId);
|
|
2355
|
+
const at = state.findIndex((call) => call.callId === callId);
|
|
2356
|
+
if (at < 0) return state;
|
|
2357
|
+
return [...state.slice(0, at), ...state.slice(at + 1)];
|
|
2358
|
+
}
|
|
2359
|
+
return state;
|
|
2360
|
+
},
|
|
2361
|
+
stateVersion: 1
|
|
2362
|
+
};
|
|
2363
|
+
/** Whether the agent registry's `turnBoundary` fold reports an open turn. */
|
|
2364
|
+
function turnOpen(ctx, session) {
|
|
2365
|
+
return ctx.agents.turnBoundaryOf(session).openTurnStartSeq !== null;
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Mount the per-session activity projection and its notification edge.
|
|
2369
|
+
* @param ctx - host context carrying the session store and the projection registry.
|
|
2370
|
+
* @returns the watch the park state is derived from.
|
|
2371
|
+
*/
|
|
2372
|
+
async function mountParkWatch(ctx) {
|
|
2373
|
+
const activity = await ctx.sessionProjections.register(parkActivityProjection);
|
|
2374
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2375
|
+
const notify = (sessionId) => {
|
|
2376
|
+
for (const listener of [...listeners]) listener(sessionId);
|
|
2377
|
+
};
|
|
2378
|
+
ctx.effect(() => ctx.on("session/event", (session, event) => {
|
|
2379
|
+
switch (event.type) {
|
|
2380
|
+
case "tool/call":
|
|
2381
|
+
case "tool/result":
|
|
2382
|
+
notify(session.id);
|
|
2383
|
+
return;
|
|
2384
|
+
case "turn/start":
|
|
2385
|
+
case "turn/end":
|
|
2386
|
+
notify(session.id);
|
|
2387
|
+
return;
|
|
2388
|
+
default: return;
|
|
2389
|
+
}
|
|
2390
|
+
}), "pause: session activity changes");
|
|
2391
|
+
return {
|
|
2392
|
+
activity: (sessionId) => {
|
|
2393
|
+
const session = ctx.sessions.get(sessionId);
|
|
2394
|
+
if (session === void 0) return {
|
|
2395
|
+
turnOpen: false,
|
|
2396
|
+
runningTools: []
|
|
2397
|
+
};
|
|
2398
|
+
return {
|
|
2399
|
+
turnOpen: turnOpen(ctx, session),
|
|
2400
|
+
runningTools: activity.stateOf(session).map((call) => call.name)
|
|
2401
|
+
};
|
|
2402
|
+
},
|
|
2403
|
+
onChange: (listener) => {
|
|
2404
|
+
listeners.add(listener);
|
|
2405
|
+
return () => {
|
|
2406
|
+
listeners.delete(listener);
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
//#endregion
|
|
2412
|
+
//#region lib/types/remote.js
|
|
2413
|
+
/**
|
|
2414
|
+
* Native Remote transport for the pause controls.
|
|
2415
|
+
*
|
|
2416
|
+
* This module is deliberately self-contained: it imports only Cordis and the
|
|
2417
|
+
* Typert decorators. The Typert generator analyzes this file alone so a
|
|
2418
|
+
* third-party package can emit its Host reflection and Client contribution
|
|
2419
|
+
* without pulling the plugin's DSH-typed modules into the generation face.
|
|
2420
|
+
*
|
|
2421
|
+
* The UI uses this namespace on Web and Desktop alike. No slash command is
|
|
2422
|
+
* registered; the stream is the live status channel and the methods below are
|
|
2423
|
+
* the button actions.
|
|
2424
|
+
* @module dsh-pause-session/remote
|
|
2425
|
+
*/
|
|
2426
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
2427
|
+
var useValue = arguments.length > 2;
|
|
2428
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
2429
|
+
return useValue ? value : void 0;
|
|
2430
|
+
};
|
|
2431
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
2432
|
+
function accept(f) {
|
|
2433
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
2434
|
+
return f;
|
|
2435
|
+
}
|
|
2436
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
2437
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
2438
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
2439
|
+
var _, done = false;
|
|
2440
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
2441
|
+
var context = {};
|
|
2442
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
2443
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
2444
|
+
context.addInitializer = function(f) {
|
|
2445
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
2446
|
+
extraInitializers.push(accept(f || null));
|
|
2447
|
+
};
|
|
2448
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
2449
|
+
get: descriptor.get,
|
|
2450
|
+
set: descriptor.set
|
|
2451
|
+
} : descriptor[key], context);
|
|
2452
|
+
if (kind === "accessor") {
|
|
2453
|
+
if (result === void 0) continue;
|
|
2454
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
2455
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
2456
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
2457
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
2458
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
2459
|
+
else descriptor[key] = _;
|
|
2460
|
+
}
|
|
2461
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
2462
|
+
done = true;
|
|
2463
|
+
};
|
|
2464
|
+
/** Host callbacks the Remote service delegates to. */
|
|
2465
|
+
/**
|
|
2466
|
+
* Refuse a Remote call made by a principal other than the owner. In-process
|
|
2467
|
+
* callers (tests, the Host itself) have no invocation and are trusted.
|
|
2468
|
+
* @param ctx - the service Context.
|
|
2469
|
+
*/
|
|
2470
|
+
function ownerOnly(ctx) {
|
|
2471
|
+
const invocation = ctx.invocation;
|
|
2472
|
+
if (invocation === void 0) return;
|
|
2473
|
+
if (invocation.peer.principal?.kind !== "owner") throw new RemoteError("pause/owner-only", "pause controls are available only to the owner", {});
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Record one state-changing Remote action in the host log. The principal is
|
|
2477
|
+
* the owning user for owner calls and trusted in-process invocations; no wire
|
|
2478
|
+
* field can forge it.
|
|
2479
|
+
* @param ctx - the service Context.
|
|
2480
|
+
* @param action - the action pressed.
|
|
2481
|
+
* @param sessionId - the addressed Session.
|
|
2482
|
+
*/
|
|
2483
|
+
function logAction(ctx, action, sessionId) {
|
|
2484
|
+
const invocation = ctx.invocation;
|
|
2485
|
+
const principal = principalOf(invocation);
|
|
2486
|
+
ctx.logger.info(`pause: ${action} "${sessionId}"`, {
|
|
2487
|
+
action,
|
|
2488
|
+
sessionId,
|
|
2489
|
+
principal: principal?.kind ?? "owner"
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
/** Host service exposed to the browser as the `pauseSession` Remote namespace. */
|
|
2493
|
+
let PauseRemote = (() => {
|
|
2494
|
+
let _classSuper = TypertRemoteService;
|
|
2495
|
+
let _instanceExtraInitializers = [];
|
|
2496
|
+
let _snapshot_decorators;
|
|
2497
|
+
let _observe_decorators;
|
|
2498
|
+
let _act_decorators;
|
|
2499
|
+
let _triage_decorators;
|
|
2500
|
+
let _decide_decorators;
|
|
2501
|
+
return class PauseRemote extends _classSuper {
|
|
2502
|
+
static {
|
|
2503
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
2504
|
+
_snapshot_decorators = [Remote("snapshot")];
|
|
2505
|
+
_observe_decorators = [Remote({ mode: "stream" })];
|
|
2506
|
+
_act_decorators = [Remote("act")];
|
|
2507
|
+
_triage_decorators = [Remote("triage")];
|
|
2508
|
+
_decide_decorators = [Remote("decide")];
|
|
2509
|
+
__esDecorate(this, null, _snapshot_decorators, {
|
|
2510
|
+
kind: "method",
|
|
2511
|
+
name: "snapshot",
|
|
2512
|
+
static: false,
|
|
2513
|
+
private: false,
|
|
2514
|
+
access: {
|
|
2515
|
+
has: (obj) => "snapshot" in obj,
|
|
2516
|
+
get: (obj) => obj.snapshot
|
|
2517
|
+
},
|
|
2518
|
+
metadata: _metadata
|
|
2519
|
+
}, null, _instanceExtraInitializers);
|
|
2520
|
+
__esDecorate(this, null, _observe_decorators, {
|
|
2521
|
+
kind: "method",
|
|
2522
|
+
name: "observe",
|
|
2523
|
+
static: false,
|
|
2524
|
+
private: false,
|
|
2525
|
+
access: {
|
|
2526
|
+
has: (obj) => "observe" in obj,
|
|
2527
|
+
get: (obj) => obj.observe
|
|
2528
|
+
},
|
|
2529
|
+
metadata: _metadata
|
|
2530
|
+
}, null, _instanceExtraInitializers);
|
|
2531
|
+
__esDecorate(this, null, _act_decorators, {
|
|
2532
|
+
kind: "method",
|
|
2533
|
+
name: "act",
|
|
2534
|
+
static: false,
|
|
2535
|
+
private: false,
|
|
2536
|
+
access: {
|
|
2537
|
+
has: (obj) => "act" in obj,
|
|
2538
|
+
get: (obj) => obj.act
|
|
2539
|
+
},
|
|
2540
|
+
metadata: _metadata
|
|
2541
|
+
}, null, _instanceExtraInitializers);
|
|
2542
|
+
__esDecorate(this, null, _triage_decorators, {
|
|
2543
|
+
kind: "method",
|
|
2544
|
+
name: "triage",
|
|
2545
|
+
static: false,
|
|
2546
|
+
private: false,
|
|
2547
|
+
access: {
|
|
2548
|
+
has: (obj) => "triage" in obj,
|
|
2549
|
+
get: (obj) => obj.triage
|
|
2550
|
+
},
|
|
2551
|
+
metadata: _metadata
|
|
2552
|
+
}, null, _instanceExtraInitializers);
|
|
2553
|
+
__esDecorate(this, null, _decide_decorators, {
|
|
2554
|
+
kind: "method",
|
|
2555
|
+
name: "decide",
|
|
2556
|
+
static: false,
|
|
2557
|
+
private: false,
|
|
2558
|
+
access: {
|
|
2559
|
+
has: (obj) => "decide" in obj,
|
|
2560
|
+
get: (obj) => obj.decide
|
|
2561
|
+
},
|
|
2562
|
+
metadata: _metadata
|
|
2563
|
+
}, null, _instanceExtraInitializers);
|
|
2564
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
2565
|
+
enumerable: true,
|
|
2566
|
+
configurable: true,
|
|
2567
|
+
writable: true,
|
|
2568
|
+
value: _metadata
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
api = __runInitializers(this, _instanceExtraInitializers);
|
|
2572
|
+
/** One snapshot per host version, shared by every connected observer. */
|
|
2573
|
+
snapshotCache;
|
|
2574
|
+
/** Bumped by every host notification; each observer compares it to its own cursor. */
|
|
2575
|
+
version = 0;
|
|
2576
|
+
/** Observers waiting for the next version. */
|
|
2577
|
+
waiters = /* @__PURE__ */ new Set();
|
|
2578
|
+
unsubscribe;
|
|
2579
|
+
/**
|
|
2580
|
+
* @param ctx - host context owning the service.
|
|
2581
|
+
* @param api - the pause engine behind the wire methods.
|
|
2582
|
+
*/
|
|
2583
|
+
constructor(ctx, api) {
|
|
2584
|
+
super(ctx, "pauseSession", { namespace: "pauseSession" });
|
|
2585
|
+
this.api = api;
|
|
2586
|
+
this.unsubscribe = this.api.subscribe(() => {
|
|
2587
|
+
this.version += 1;
|
|
2588
|
+
this.snapshotCache = void 0;
|
|
2589
|
+
for (const wake of [...this.waiters]) wake();
|
|
2590
|
+
this.waiters.clear();
|
|
2591
|
+
});
|
|
2592
|
+
ctx.effect(() => this.unsubscribe, "pause: remote updates");
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* One exact snapshot, used by tests and by a reconnect that cannot wait for
|
|
2596
|
+
* the first stream item.
|
|
2597
|
+
* @param signal - carrier cancellation.
|
|
2598
|
+
* @returns the current aggregate and session states.
|
|
2599
|
+
*/
|
|
2600
|
+
async snapshot(signal) {
|
|
2601
|
+
ownerOnly(this.ctx);
|
|
2602
|
+
return await this.api.snapshot(signal);
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* The snapshot for one host version, computed once even when several clients
|
|
2606
|
+
* are watching. A new notification discards the cached promise.
|
|
2607
|
+
* @param version - the host version this snapshot answers.
|
|
2608
|
+
* @param signal - stream cancellation.
|
|
2609
|
+
* @returns the shared snapshot promise.
|
|
2610
|
+
*/
|
|
2611
|
+
snapshotFor(version, signal) {
|
|
2612
|
+
if (this.snapshotCache?.version === version) return this.snapshotCache.value;
|
|
2613
|
+
const value = this.api.snapshot(signal);
|
|
2614
|
+
this.snapshotCache = {
|
|
2615
|
+
version,
|
|
2616
|
+
value
|
|
2617
|
+
};
|
|
2618
|
+
return value;
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Live status: the opening snapshot, then one new snapshot whenever the host
|
|
2622
|
+
* reports a change.
|
|
2623
|
+
* @param signal - stream cancellation.
|
|
2624
|
+
* @returns the snapshot stream.
|
|
2625
|
+
*/
|
|
2626
|
+
async *observe(signal) {
|
|
2627
|
+
ownerOnly(this.ctx);
|
|
2628
|
+
let seen = -1;
|
|
2629
|
+
for (;;) {
|
|
2630
|
+
signal?.throwIfAborted();
|
|
2631
|
+
if (this.version !== seen) {
|
|
2632
|
+
seen = this.version;
|
|
2633
|
+
yield await this.snapshotFor(seen, signal);
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2636
|
+
await new Promise((resolve, reject) => {
|
|
2637
|
+
const finish = () => {
|
|
2638
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2639
|
+
this.waiters.delete(finish);
|
|
2640
|
+
resolve();
|
|
2641
|
+
};
|
|
2642
|
+
const onAbort = () => {
|
|
2643
|
+
this.waiters.delete(finish);
|
|
2644
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2645
|
+
reject(signal?.reason);
|
|
2646
|
+
};
|
|
2647
|
+
this.waiters.add(finish);
|
|
2648
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2649
|
+
if (this.version !== seen) finish();
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Pause, resume, press every tree, resume every clean park, or flip one
|
|
2655
|
+
* Session's goal re-arm.
|
|
2656
|
+
* @param request - the button action.
|
|
2657
|
+
* @param signal - carrier cancellation.
|
|
2658
|
+
* @returns the user-facing outcome.
|
|
2659
|
+
*/
|
|
2660
|
+
async act(request, signal) {
|
|
2661
|
+
ownerOnly(this.ctx);
|
|
2662
|
+
logAction(this.ctx, request.action, request.sessionId);
|
|
2663
|
+
return await this.api.act(request, signal);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Read the morning decision list.
|
|
2667
|
+
* @param request - the calling Session whose list is read.
|
|
2668
|
+
* @param signal - read cancellation.
|
|
2669
|
+
* @returns the listed rows.
|
|
2670
|
+
*/
|
|
2671
|
+
async triage(request, signal) {
|
|
2672
|
+
ownerOnly(this.ctx);
|
|
2673
|
+
return await this.api.triage(request, signal);
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Take one decision on one listed Session.
|
|
2677
|
+
* @param request - the caller, target, and decision.
|
|
2678
|
+
* @param signal - load cancellation.
|
|
2679
|
+
* @returns the user-facing outcome.
|
|
2680
|
+
*/
|
|
2681
|
+
async decide(request, signal) {
|
|
2682
|
+
ownerOnly(this.ctx);
|
|
2683
|
+
logAction(this.ctx, request.decision, request.target);
|
|
2684
|
+
return await this.api.decide(request, signal);
|
|
2685
|
+
}
|
|
2686
|
+
};
|
|
2687
|
+
})();
|
|
2688
|
+
//#endregion
|
|
2689
|
+
//#region lib/types/remote-adapter.js
|
|
2690
|
+
/**
|
|
2691
|
+
* Wire adapter between the pause engine and the generated Remote namespace.
|
|
2692
|
+
*
|
|
2693
|
+
* The engine speaks in branded Session ids and user-facing answer copy; the Remote
|
|
2694
|
+
* contract is plain JSON. Mapping lives here so neither side depends on the
|
|
2695
|
+
* other's types.
|
|
2696
|
+
* @module dsh-pause-session/remote-adapter
|
|
2697
|
+
*/
|
|
2698
|
+
/** Map the engine's aggregate status to the wire shape. */
|
|
2699
|
+
function globalOf(status) {
|
|
2700
|
+
return {
|
|
2701
|
+
verdict: status.verdict,
|
|
2702
|
+
registered: status.registered,
|
|
2703
|
+
safe: status.safe,
|
|
2704
|
+
...status.parkedAt === void 0 ? {} : { parkedAt: String(status.parkedAt) },
|
|
2705
|
+
draining: status.draining.map(String),
|
|
2706
|
+
parking: status.parking,
|
|
2707
|
+
stalled: status.stalled.map(String),
|
|
2708
|
+
unresumable: status.unresumable.map(String),
|
|
2709
|
+
unreadTrees: status.unreadTrees.map(String),
|
|
2710
|
+
jobs: status.jobs.map(String),
|
|
2711
|
+
blind: [...status.blind]
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
/** Map one engine status to the wire shape. */
|
|
2715
|
+
function sessionOf(status) {
|
|
2716
|
+
return {
|
|
2717
|
+
phase: status.phase,
|
|
2718
|
+
runningTools: [...status.runningTools],
|
|
2719
|
+
...status.rearmGoal === void 0 ? {} : { rearmGoal: status.rearmGoal }
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
/** Map one engine triage row to the wire shape. */
|
|
2723
|
+
function triageRowOf(row) {
|
|
2724
|
+
return {
|
|
2725
|
+
sessionId: row.sessionId,
|
|
2726
|
+
phase: row.phase,
|
|
2727
|
+
updatedAt: row.updatedAt,
|
|
2728
|
+
reading: row.reading,
|
|
2729
|
+
...row.lastEvent === void 0 ? {} : { lastEvent: row.lastEvent },
|
|
2730
|
+
...row.turn === void 0 ? {} : { turn: row.turn },
|
|
2731
|
+
...row.step === void 0 ? {} : { step: row.step },
|
|
2732
|
+
traces: row.traces.map((trace) => ({
|
|
2733
|
+
tool: trace.tool,
|
|
2734
|
+
callId: trace.callId
|
|
2735
|
+
})),
|
|
2736
|
+
owed: row.owed,
|
|
2737
|
+
wakeable: row.wakeable,
|
|
2738
|
+
rearmGoal: row.rearmGoal
|
|
2739
|
+
};
|
|
2740
|
+
}
|
|
2741
|
+
/** Map the engine's snapshot to the wire shape. */
|
|
2742
|
+
function snapshotOf(snapshot) {
|
|
2743
|
+
return {
|
|
2744
|
+
global: globalOf(snapshot.global),
|
|
2745
|
+
sessions: Object.fromEntries(Object.entries(snapshot.sessions).map(([id, status]) => [id, sessionOf(status)]))
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
/** Convert one engine answer to the wire outcome. */
|
|
2749
|
+
function outcomeOf(answer) {
|
|
2750
|
+
return {
|
|
2751
|
+
ok: answer.kind === "success",
|
|
2752
|
+
message: answer.text ?? (answer.kind === "success" ? "Done." : "Pause action failed.")
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
/** Convert a press outcome to the wire outcome. */
|
|
2756
|
+
function pressOutcomeOf(outcome) {
|
|
2757
|
+
const names = (ids) => ids.map((id) => `"${String(id)}"`).join(", ");
|
|
2758
|
+
if (outcome.unrecorded.length > 0) return {
|
|
2759
|
+
ok: false,
|
|
2760
|
+
message: `pause could not record ${names(outcome.unrecorded)}.`
|
|
2761
|
+
};
|
|
2762
|
+
if (!outcome.rootRegistered) return {
|
|
2763
|
+
ok: false,
|
|
2764
|
+
message: "pause did not register this session."
|
|
2765
|
+
};
|
|
2766
|
+
const caveats = [];
|
|
2767
|
+
if (outcome.unresumable.length > 0) caveats.push(`these subagents cannot be resumed: ${names(outcome.unresumable)}.`);
|
|
2768
|
+
if (outcome.unreadBranches.length > 0) caveats.push(`these subagent branches could not be read: ${names(outcome.unreadBranches)}.`);
|
|
2769
|
+
if (outcome.unread && outcome.unreadBranches.length === 0) caveats.push("The subagent tree could not be fully read.");
|
|
2770
|
+
return {
|
|
2771
|
+
ok: true,
|
|
2772
|
+
message: `Paused ${String(outcome.registered)} session${outcome.registered === 1 ? "" : "s"}.` + (caveats.length === 0 ? "" : ` ${caveats.join(" ")}`)
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Build the Remote callbacks over the mounted pause control.
|
|
2777
|
+
* @param control - the engine's host control surface.
|
|
2778
|
+
* @returns the wire API.
|
|
2779
|
+
*/
|
|
2780
|
+
function remoteApiOf(control) {
|
|
2781
|
+
return {
|
|
2782
|
+
snapshot: async (signal) => snapshotOf(await control.readSnapshot(signal)),
|
|
2783
|
+
subscribe: (listener) => control.subscribe(listener),
|
|
2784
|
+
act: async (request, signal) => {
|
|
2785
|
+
const sessionId = SessionId(request.sessionId);
|
|
2786
|
+
switch (request.action) {
|
|
2787
|
+
case "pause": return pressOutcomeOf(await control.pressTree(sessionId, signal));
|
|
2788
|
+
case "resume": return outcomeOf((await control.releaseTree(sessionId, signal)).answer);
|
|
2789
|
+
case "pause-all": return outcomeOf(await control.pauseAll(signal));
|
|
2790
|
+
case "resume-all": return outcomeOf(await control.resumeAll(signal));
|
|
2791
|
+
case "cancel-all": return outcomeOf(await control.cancelAll(signal));
|
|
2792
|
+
case "goal": return outcomeOf(await control.toggleGoal(sessionId, signal));
|
|
2793
|
+
}
|
|
2794
|
+
},
|
|
2795
|
+
triage: async (request, signal) => {
|
|
2796
|
+
return (await control.readTriage(SessionId(request.caller), signal)).rows.map(triageRowOf);
|
|
2797
|
+
},
|
|
2798
|
+
decide: async (request, signal) => {
|
|
2799
|
+
const action = request.decision;
|
|
2800
|
+
return outcomeOf(await control.decideTriage(SessionId(request.caller), SessionId(request.target), action, signal));
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* The registry declaration. `version: 1` and no `compatibleVersions`: the
|
|
2806
|
+
* domain layer rejects a stored unit stamped with any other version rather than
|
|
2807
|
+
* migrating it, so an unrecognized registry fails loud at open. Validation is
|
|
2808
|
+
* the rejecting default for the same reason — a record this build cannot read
|
|
2809
|
+
* must stop the mount, not disappear from the list.
|
|
2810
|
+
*/
|
|
2811
|
+
const parkRegistryDomain = defineDomain({
|
|
2812
|
+
name: "pause_registry",
|
|
2813
|
+
version: 1,
|
|
2814
|
+
tables: { sessions: domainTable(z.object({
|
|
2815
|
+
/** How far that session got before its process ended; only `safe` is clean. */
|
|
2816
|
+
phase: z.enum(PARK_PHASES),
|
|
2817
|
+
/** When this record was last written — a phase change, or a goal opt-out flip. */
|
|
2818
|
+
updatedAt: z.number().int().nonnegative(),
|
|
2819
|
+
/**
|
|
2820
|
+
* Whether resuming this session may re-arm its goal. Re-arming is opt-OUT
|
|
2821
|
+
* because a re-armed goal continues on its own and spends the owner's money;
|
|
2822
|
+
* a record written before this field existed reads as `true`, which is the
|
|
2823
|
+
* product default (a goal session comes back running its rounds).
|
|
2824
|
+
*
|
|
2825
|
+
* Every phase write of one park episode folds through the stored record, so
|
|
2826
|
+
* the opt-out a user sets while the session is still draining survives the
|
|
2827
|
+
* `parking` and `safe` writes that follow it.
|
|
2828
|
+
*/
|
|
2829
|
+
rearmGoal: z.boolean().default(true)
|
|
2830
|
+
})) }
|
|
2831
|
+
});
|
|
2832
|
+
/**
|
|
2833
|
+
* Open the registry over the mounted storage. The caller owns the handle's
|
|
2834
|
+
* lifetime; this registers the close as an effect on `ctx`, so the unit is
|
|
2835
|
+
* released with the plugin.
|
|
2836
|
+
* @param ctx - plugin context carrying `storageDomain`.
|
|
2837
|
+
* @returns the open registry.
|
|
2838
|
+
* @throws whatever `open` rejects with — a `StorageError` for an unreadable
|
|
2839
|
+
* unit, a `DomainError('invalid-record')` for a record this build cannot read.
|
|
2840
|
+
*/
|
|
2841
|
+
async function openParkRegistry(ctx) {
|
|
2842
|
+
const domain = await ctx.storageDomain.open(parkRegistryDomain);
|
|
2843
|
+
ctx.effect(() => () => domain.close(), "pause: registry");
|
|
2844
|
+
const sessions = domain.table("sessions");
|
|
2845
|
+
/** The entries the cached view was built from; replaced whenever they differ. */
|
|
2846
|
+
let cachedFrom = [];
|
|
2847
|
+
let cached;
|
|
2848
|
+
const entryOf = (sessionId) => {
|
|
2849
|
+
const record = sessions.get(sessionId);
|
|
2850
|
+
return record === void 0 ? void 0 : {
|
|
2851
|
+
sessionId,
|
|
2852
|
+
phase: record.phase,
|
|
2853
|
+
updatedAt: record.updatedAt,
|
|
2854
|
+
rearmGoal: record.rearmGoal
|
|
2855
|
+
};
|
|
2856
|
+
};
|
|
2857
|
+
const entriesNow = () => [...sessions.entries()].map(([sessionId, record]) => ({
|
|
2858
|
+
sessionId,
|
|
2859
|
+
phase: record.phase,
|
|
2860
|
+
updatedAt: record.updatedAt,
|
|
2861
|
+
rearmGoal: record.rearmGoal
|
|
2862
|
+
}));
|
|
2863
|
+
return {
|
|
2864
|
+
list: entriesNow,
|
|
2865
|
+
get parked() {
|
|
2866
|
+
const entries = entriesNow();
|
|
2867
|
+
if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) => {
|
|
2868
|
+
const previous = cachedFrom[at];
|
|
2869
|
+
return previous !== void 0 && entry.sessionId === previous.sessionId && entry.phase === previous.phase && entry.updatedAt === previous.updatedAt && entry.rearmGoal === previous.rearmGoal;
|
|
2870
|
+
})) return cached;
|
|
2871
|
+
cachedFrom = entries;
|
|
2872
|
+
cached = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
|
2873
|
+
return cached;
|
|
2874
|
+
},
|
|
2875
|
+
get: entryOf,
|
|
2876
|
+
record: async (sessionId, phase) => {
|
|
2877
|
+
const updatedAt = Date.now();
|
|
2878
|
+
if (sessions.get(sessionId) !== void 0) try {
|
|
2879
|
+
await sessions.update(sessionId, (current) => ({
|
|
2880
|
+
...current,
|
|
2881
|
+
phase,
|
|
2882
|
+
updatedAt
|
|
2883
|
+
}));
|
|
2884
|
+
return;
|
|
2885
|
+
} catch (error) {
|
|
2886
|
+
if (!(error instanceof DomainError) || error.code !== "missing-key") throw error;
|
|
2887
|
+
}
|
|
2888
|
+
await sessions.put(sessionId, {
|
|
2889
|
+
phase,
|
|
2890
|
+
updatedAt,
|
|
2891
|
+
rearmGoal: true
|
|
2892
|
+
});
|
|
2893
|
+
},
|
|
2894
|
+
setRearmGoal: async (sessionId, rearmGoal) => {
|
|
2895
|
+
await sessions.update(sessionId, (current) => ({
|
|
2896
|
+
...current,
|
|
2897
|
+
rearmGoal,
|
|
2898
|
+
updatedAt: Date.now()
|
|
2899
|
+
}));
|
|
2900
|
+
},
|
|
2901
|
+
forget: async (sessionId) => {
|
|
2902
|
+
await sessions.delete(sessionId);
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
//#endregion
|
|
2907
|
+
//#region lib/types/index.js
|
|
2908
|
+
/**
|
|
2909
|
+
* Host half of dsh-pause-session.
|
|
2910
|
+
*
|
|
2911
|
+
* Mounts the pause gate — the per-session hold at the model-request waterfall —
|
|
2912
|
+
* the activity fold the park predicate reads, the durable park registry, the
|
|
2913
|
+
* session control the browser half drives, and the clean resume path that
|
|
2914
|
+
* answers the input a parked session left pending.
|
|
2915
|
+
*
|
|
2916
|
+
* The registry is read before anything is gated, so a session a previous
|
|
2917
|
+
* process left parked is held again by this one without any user action; a
|
|
2918
|
+
* registry this build cannot read fails the mount loudly rather than silently
|
|
2919
|
+
* resuming every session it named.
|
|
2920
|
+
* @module dsh-pause-session
|
|
2921
|
+
*/
|
|
2922
|
+
/** Plugin name; the Loader row and the bundle patch both use it. */
|
|
2923
|
+
const name = "dsh-pause-session";
|
|
2924
|
+
/**
|
|
2925
|
+
* Required host services: the model runtime to gate, the stores the control
|
|
2926
|
+
* needs, the projection registry the park predicate reads turn state from, and
|
|
2927
|
+
* the profile's storage, which holds the pause registry.
|
|
2928
|
+
*/
|
|
2929
|
+
const inject = [
|
|
2930
|
+
"llm",
|
|
2931
|
+
"agents",
|
|
2932
|
+
"sessions",
|
|
2933
|
+
"sessionProjections",
|
|
2934
|
+
"storageDomain"
|
|
2935
|
+
];
|
|
2936
|
+
/**
|
|
2937
|
+
* Provider routes whose request conversion drops an empty user message, so the
|
|
2938
|
+
* clean resume path's wake input never reaches the model on them.
|
|
2939
|
+
*
|
|
2940
|
+
* The list is deployment knowledge, not a constant this plugin may assume:
|
|
2941
|
+
* whether an empty user message survives is decided by the adapter (and, for
|
|
2942
|
+
* the pi-ai family, by the provider API that adapter is configured for), and a
|
|
2943
|
+
* plugin cannot introspect an adapter. The default names the routes the shipped
|
|
2944
|
+
* DeepSeek adapter registers; a profile that routes a session elsewhere gets
|
|
2945
|
+
* the visible marker instead of a silent empty turn until it declares that
|
|
2946
|
+
* route here.
|
|
2947
|
+
*/
|
|
2948
|
+
const DEFAULT_SILENT_WAKE_ROUTES = ["deepseek-official", "deepseek-account"];
|
|
2949
|
+
/** Validated plugin configuration. */
|
|
2950
|
+
const ConfigShape = z.object({
|
|
2951
|
+
/** Provider routes known to drop an empty user message; see {@link DEFAULT_SILENT_WAKE_ROUTES}. */
|
|
2952
|
+
silentWakeRoutes: z.array(z.string()).default([...DEFAULT_SILENT_WAKE_ROUTES]),
|
|
2953
|
+
/**
|
|
2954
|
+
* How many events of a parked session's stored log the morning list folds per
|
|
2955
|
+
* row, counted back from the end. The list reads a session's stop position
|
|
2956
|
+
* and its unknown tool outcomes, both facts about the log's end, so this
|
|
2957
|
+
* bounds what a panel left open costs without changing what a row says about
|
|
2958
|
+
* a log that fits inside it. A row whose log needs more than this is reported
|
|
2959
|
+
* unreadable rather than guessed at; see `./coldlog.ts`.
|
|
2960
|
+
*/
|
|
2961
|
+
maxListedLogEvents: z.number().int().min(1).max(TRIAGE_LOG_MAX_EVENTS).default(TRIAGE_WINDOW_EVENTS)
|
|
2962
|
+
});
|
|
2963
|
+
/**
|
|
2964
|
+
* The Loader hands a row's config through this schema, and an empty row config
|
|
2965
|
+
* arrives as `undefined`; folding it to `{}` first is what lets the field
|
|
2966
|
+
* defaults above apply instead of failing the mount.
|
|
2967
|
+
*/
|
|
2968
|
+
const Config = z.preprocess((value) => value ?? {}, ConfigShape);
|
|
2969
|
+
/**
|
|
2970
|
+
* Mount the durable pause registry, the pause gate, the activity fold, their
|
|
2971
|
+
* session control, and the clean resume path.
|
|
2972
|
+
*
|
|
2973
|
+
* The registry opens first on purpose: if it cannot be read, nothing else is
|
|
2974
|
+
* registered, so a failed mount leaves no half-installed control behind.
|
|
2975
|
+
* @param ctx - the plugin's host context.
|
|
2976
|
+
* @param config - validated route knowledge for the resume path's wake content.
|
|
2977
|
+
*/
|
|
2978
|
+
async function apply(ctx, config = Config.parse({})) {
|
|
2979
|
+
const registry = await openParkRegistry(ctx);
|
|
2980
|
+
const lifetime = new AbortController();
|
|
2981
|
+
ctx.effect(() => () => {
|
|
2982
|
+
lifetime.abort(/* @__PURE__ */ new Error("pause: plugin unloaded"));
|
|
2983
|
+
}, "pause: lifetime");
|
|
2984
|
+
const gate = new PauseGate({ lifetime: lifetime.signal });
|
|
2985
|
+
ctx.effect(() => ctx.on("llm/stream", (request, next) => gate.stream(request, next)), "pause: pause gate");
|
|
2986
|
+
const control = installSessionControl(ctx, gate, await mountParkWatch(ctx), registry, {
|
|
2987
|
+
silentWakeRoutes: config.silentWakeRoutes,
|
|
2988
|
+
maxListedLogEvents: config.maxListedLogEvents
|
|
2989
|
+
});
|
|
2990
|
+
new PauseRemote(ctx, remoteApiOf(control));
|
|
2991
|
+
await control.adopt();
|
|
2992
|
+
}
|
|
2993
|
+
//#endregion
|
|
2994
|
+
export { Config, DEFAULT_SILENT_WAKE_ROUTES, PauseRemote, apply, inject, name };
|
|
2995
|
+
|
|
2996
|
+
//# sourceMappingURL=index.js.map
|