dsh-pause-session 0.1.0 → 0.2.1
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 +51 -20
- package/DEVELOPMENT.md +25 -2
- package/README.md +41 -31
- package/README.zh.md +31 -21
- package/REHEARSAL.md +6 -6
- package/lib/client.js +1125 -1375
- package/lib/client.js.map +1 -1
- package/lib/index.js +1107 -1081
- package/lib/index.js.map +1 -1
- 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 +25 -158
- package/lib/types/client/global.d.ts +39 -239
- package/lib/types/client/index.d.ts +6 -5
- package/lib/types/client/locales.d.ts +3 -4
- package/lib/types/client/pill.d.ts +5 -5
- package/lib/types/client/remote.d.ts +77 -0
- package/lib/types/client/view.d.ts +19 -5
- package/lib/types/coldlog.d.ts +1 -1
- package/lib/types/composition.d.ts +35 -61
- package/lib/types/control.d.ts +41 -60
- package/lib/types/global.d.ts +6 -4
- package/lib/types/index.d.ts +2 -2
- package/lib/types/park-state.d.ts +51 -0
- package/lib/types/registry.d.ts +1 -1
- package/lib/types/release-order.d.ts +25 -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 +36 -0
- package/lib/types/resume-text.d.ts +67 -0
- package/lib/types/resume.d.ts +7 -104
- package/lib/types/shared.d.ts +20 -123
- package/lib/types/snapshot.d.ts +29 -0
- package/lib/types/triage.d.ts +8 -9
- package/package.json +38 -19
- package/lib/types/client/burst.d.ts +0 -253
package/lib/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { SessionId, deriveEventMessage, foldRequestHeader, foldSurface, interruptedTurnClosers } from "@deepseek-ai/dsh-session";
|
|
3
|
-
import {
|
|
4
|
-
import { applyChildComposition, foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
5
4
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { Remote, RemoteError, TypertRemoteService, principalOf, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
|
+
import { foldSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
6
7
|
import { DomainError, defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
7
8
|
//#region lib/types/history.js
|
|
8
9
|
/**
|
|
@@ -72,101 +73,16 @@ function routeNamed(events) {
|
|
|
72
73
|
//#endregion
|
|
73
74
|
//#region lib/types/shared.js
|
|
74
75
|
/**
|
|
75
|
-
* The identities both halves of dsh-pause-session name, the
|
|
76
|
-
* share, and the one spelling a host log line gives a caught value.
|
|
76
|
+
* The identities both halves of dsh-pause-session name, the phase vocabulary
|
|
77
|
+
* they share, and the one spelling a host log line gives a caught value.
|
|
77
78
|
*
|
|
78
79
|
* The browser bundle may value-import only the shell's baseline module table,
|
|
79
|
-
* so this module imports nothing
|
|
80
|
-
*
|
|
81
|
-
* identities from the same strings.
|
|
82
|
-
*
|
|
83
|
-
* A third-party plugin's only live channel to the browser is the command
|
|
84
|
-
* registry — it can add neither a `ctx.remote` namespace nor a forwarded event,
|
|
85
|
-
* both of which are compiled into the harness's own API assembly. A command
|
|
86
|
-
* descriptor's only free-form field is its `description`, and a command
|
|
87
|
-
* result's only free-form field is its `text`, so the park state travels as a
|
|
88
|
-
* prefix plus `;`-separated fields on one of those. The text stays
|
|
89
|
-
* human-readable: the same words are what the slash-command menu shows.
|
|
90
|
-
*
|
|
91
|
-
* Every value inside a field is escaped, so a decoded name is the exact name
|
|
92
|
-
* that was encoded. Session ids, tool names, and call ids are the host's own
|
|
93
|
-
* data and may contain the `;`, `,`, `/`, or newline a field is separated by; a
|
|
94
|
-
* folded spelling would decode as a different name — a different row, looked up
|
|
95
|
-
* again.
|
|
80
|
+
* so this module imports nothing. The native Remote payloads are the live
|
|
81
|
+
* vocabulary; there is no command encoding or slash-command surface here.
|
|
96
82
|
* @module dsh-pause-session/shared
|
|
97
83
|
*/
|
|
98
|
-
/**
|
|
99
|
-
const
|
|
100
|
-
/** Command that resumes the receiving session; registered only while that session is paused. */
|
|
101
|
-
const RESUME_COMMAND = "pause-resume";
|
|
102
|
-
/** Command that resumes every parked session the registry remembers. */
|
|
103
|
-
const RESUME_ALL_COMMAND = "pause-resume-all";
|
|
104
|
-
/** Command that registers and parks every running session tree in one action. */
|
|
105
|
-
const PAUSE_ALL_COMMAND = "pause-all";
|
|
106
|
-
/** Command that reports the whole-app park state; registered globally, always. */
|
|
107
|
-
const STATE_ALL_COMMAND = "pause-state-all";
|
|
108
|
-
/** Command that reports the receiving session's park state; registered only while it is paused. */
|
|
109
|
-
const STATUS_COMMAND = "pause-state";
|
|
110
|
-
/** Command that turns the receiving session's own goal re-arm on or off; registered only while it is paused. */
|
|
111
|
-
const GOAL_COMMAND = "pause-goal";
|
|
112
|
-
/** Command that reports every session that did not stop cleanly. */
|
|
113
|
-
const TRIAGE_COMMAND = "pause-triage";
|
|
114
|
-
/** Command that resumes one session from the decision list exactly as the clean path would. */
|
|
115
|
-
const TRIAGE_CONTINUE_COMMAND = "pause-triage-continue";
|
|
116
|
-
/** Command that resumes one session from the decision list with the unknown-outcome note. */
|
|
117
|
-
const TRIAGE_NOTE_COMMAND = "pause-triage-note";
|
|
118
|
-
/** Command that opens one session from the decision list without spending a request. */
|
|
119
|
-
const TRIAGE_REVIEW_COMMAND = "pause-triage-review";
|
|
120
|
-
/** Command that leaves one session from the decision list exactly as it is. */
|
|
121
|
-
const TRIAGE_LEAVE_COMMAND = "pause-triage-leave";
|
|
122
|
-
/** Command that turns one listed session's goal re-arm on or off. */
|
|
123
|
-
const TRIAGE_GOAL_COMMAND = "pause-triage-goal";
|
|
124
|
-
/** Stable definition id of the pause command, matched by identity rather than by copy. */
|
|
125
|
-
const PAUSE_DEFINITION_ID = "dsh-pause-session/pause";
|
|
126
|
-
/** Stable definition id of the resume command: the host's own "this session is paused" fact. */
|
|
127
|
-
const RESUME_DEFINITION_ID = "dsh-pause-session/resume";
|
|
128
|
-
/** Stable definition id of the global resume command, matched by identity rather than by copy. */
|
|
129
|
-
const RESUME_ALL_DEFINITION_ID = "dsh-pause-session/resume-all";
|
|
130
|
-
/** Stable definition id of the whole-app pause command. */
|
|
131
|
-
const PAUSE_ALL_DEFINITION_ID = "dsh-pause-session/pause-all";
|
|
132
|
-
/** Stable definition id of the whole-app status read. */
|
|
133
|
-
const STATE_ALL_DEFINITION_ID = "dsh-pause-session/state-all";
|
|
134
|
-
/** Stable definition id of the status read, matched by identity rather than by copy. */
|
|
135
|
-
const STATUS_DEFINITION_ID = "dsh-pause-session/status";
|
|
136
|
-
/** Stable definition id of the paused session's own goal re-arm toggle. */
|
|
137
|
-
const GOAL_DEFINITION_ID = "dsh-pause-session/goal";
|
|
138
|
-
/** Stable definition id of the decision-list read. */
|
|
139
|
-
const TRIAGE_DEFINITION_ID = "dsh-pause-session/triage";
|
|
140
|
-
/** Stable definition id of the decision-list "continue" verb. */
|
|
141
|
-
const TRIAGE_CONTINUE_DEFINITION_ID = "dsh-pause-session/triage-continue";
|
|
142
|
-
/** Stable definition id of the decision-list "continue with the unknown-outcome note" verb. */
|
|
143
|
-
const TRIAGE_NOTE_DEFINITION_ID = "dsh-pause-session/triage-note";
|
|
144
|
-
/** Stable definition id of the decision-list "open without activating" verb. */
|
|
145
|
-
const TRIAGE_REVIEW_DEFINITION_ID = "dsh-pause-session/triage-review";
|
|
146
|
-
/** Stable definition id of the decision-list "leave it alone today" verb. */
|
|
147
|
-
const TRIAGE_LEAVE_DEFINITION_ID = "dsh-pause-session/triage-leave";
|
|
148
|
-
/** Stable definition id of the decision-list goal re-arm toggle. */
|
|
149
|
-
const TRIAGE_GOAL_DEFINITION_ID = "dsh-pause-session/triage-goal";
|
|
150
|
-
/** First token of every encoded status, so a descriptor is never parsed by accident. */
|
|
151
|
-
const STATUS_PREFIX = "pause: ";
|
|
152
|
-
/**
|
|
153
|
-
* One list field's text when the list is empty. A value that would itself spell
|
|
154
|
-
* this marker is escaped by {@link escapeField}, so an empty list and a list
|
|
155
|
-
* holding the marker stay different texts.
|
|
156
|
-
*/
|
|
157
|
-
const EMPTY_FIELD = "-";
|
|
158
|
-
/**
|
|
159
|
-
* One field or item value, ready to sit inside an encoded text: every separator
|
|
160
|
-
* (`;`, `,`, `/`, newline) and the escape character itself are escaped, and a
|
|
161
|
-
* value that would spell {@link EMPTY_FIELD} is escaped so it is not read as an
|
|
162
|
-
* empty list.
|
|
163
|
-
* @param text - the value to encode, as the host holds it.
|
|
164
|
-
* @returns the value as it appears inside an encoded field.
|
|
165
|
-
*/
|
|
166
|
-
function escapeField(text) {
|
|
167
|
-
const escaped = text.replace(/[\\;,\n/]/g, (character) => character === "\n" ? "\\n" : `\\${character}`);
|
|
168
|
-
return escaped.startsWith(EMPTY_FIELD) ? `\\${escaped}` : escaped;
|
|
169
|
-
}
|
|
84
|
+
/** Result copy for a press or a release the durable registry refused to record. */
|
|
85
|
+
const UNRECORDED_TEXT = "pause could not write the pause registry, so it did not change the pause state.";
|
|
170
86
|
/**
|
|
171
87
|
* One log line's text for a caught value.
|
|
172
88
|
* @param error - the caught value.
|
|
@@ -227,84 +143,6 @@ function foldTreePhases(phases) {
|
|
|
227
143
|
safe: worst === "safe"
|
|
228
144
|
};
|
|
229
145
|
}
|
|
230
|
-
/** The one word each phase is published as, and the whole decoding vocabulary. */
|
|
231
|
-
const PHASE_WORD = {
|
|
232
|
-
draining: "pausing",
|
|
233
|
-
parking: "saving",
|
|
234
|
-
safe: "paused",
|
|
235
|
-
"flush-failed": "save failed"
|
|
236
|
-
};
|
|
237
|
-
/**
|
|
238
|
-
* The two states of one record's goal re-arm field, in the one spelling both
|
|
239
|
-
* the per-session status and the decision list publish it with.
|
|
240
|
-
*/
|
|
241
|
-
const GOAL_ON = "on";
|
|
242
|
-
const GOAL_OFF = "off";
|
|
243
|
-
/**
|
|
244
|
-
* Encode one status for a command descriptor.
|
|
245
|
-
* @param status - the state to publish.
|
|
246
|
-
* @returns the descriptor text, e.g. `pause: draining; tools: edit, bash; goal: on`.
|
|
247
|
-
*/
|
|
248
|
-
function encodeParkStatus(status) {
|
|
249
|
-
return `${`${STATUS_PREFIX}${PHASE_WORD[status.phase]}`}${status.runningTools.length === 0 ? "" : `; tools: ${status.runningTools.map(escapeField).join(", ")}`}${status.rearmGoal === void 0 ? "" : `; goal: ${status.rearmGoal ? GOAL_ON : GOAL_OFF}`}`;
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* First token of every encoded whole-app status. A different prefix from the
|
|
253
|
-
* per-session status on purpose: one decoder must never read the other's text.
|
|
254
|
-
*/
|
|
255
|
-
const GLOBAL_STATUS_PREFIX = "pause all: ";
|
|
256
|
-
/** Encode one list field. */
|
|
257
|
-
function encodeNames(names) {
|
|
258
|
-
return names.length === 0 ? EMPTY_FIELD : names.map(escapeField).join(", ");
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* Encode one optional session id: the empty marker when the park names none.
|
|
262
|
-
* @param id - the session id, as the host holds it.
|
|
263
|
-
* @returns the id as it appears inside an encoded field.
|
|
264
|
-
*/
|
|
265
|
-
function encodeId(id) {
|
|
266
|
-
return id === void 0 ? EMPTY_FIELD : escapeField(id);
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Encode one whole-app status for a command result.
|
|
270
|
-
* @param status - the state to publish.
|
|
271
|
-
* @returns the result text, e.g.
|
|
272
|
-
* `pause all: waiting; registered: 3; safe: 1; parked-at: overnight; draining: overnight; parking: 1; stalled: -; unresumable: -; unread-trees: -; jobs: bash-1; blind: -`.
|
|
273
|
-
*/
|
|
274
|
-
function encodeGlobalStatus(status) {
|
|
275
|
-
return `${GLOBAL_STATUS_PREFIX}${status.verdict}; registered: ${status.registered}; safe: ${status.safe}; parked-at: ${encodeId(status.parkedAt)}; draining: ${encodeNames(status.draining)}; parking: ${status.parking}; stalled: ${encodeNames(status.stalled)}; unresumable: ${encodeNames(status.unresumable)}; unread-trees: ${encodeNames(status.unreadTrees)}; jobs: ${encodeNames(status.jobs)}; blind: ${encodeNames(status.blind)}`;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* First token of every encoded decision list. Separate from the per-session and
|
|
279
|
-
* whole-app status prefixes on purpose, for the same reason those two are
|
|
280
|
-
* separate: one decoder must never read another's text.
|
|
281
|
-
*/
|
|
282
|
-
const TRIAGE_PREFIX = "pause triage: ";
|
|
283
|
-
/** How one row is spelled in the published list; the wake field's state when the host can hand over the owed request. */
|
|
284
|
-
const WAKE_YES = "yes";
|
|
285
|
-
/** One row's traces, as a list of `tool/callId` items. */
|
|
286
|
-
function encodeTraces(traces) {
|
|
287
|
-
if (traces.length === 0) return EMPTY_FIELD;
|
|
288
|
-
return traces.map((trace) => `${escapeField(trace.tool)}/${escapeField(trace.callId)}`).join(", ");
|
|
289
|
-
}
|
|
290
|
-
/** One row's stop position, encoded as `<last event>` plus the step it belongs to. */
|
|
291
|
-
function encodeStop(row) {
|
|
292
|
-
if (row.lastEvent === void 0) return EMPTY_FIELD;
|
|
293
|
-
const position = row.turn === void 0 ? "" : `/turn ${String(row.turn)}${row.step === void 0 ? "" : ` step ${String(row.step)}`}`;
|
|
294
|
-
return `${escapeField(row.lastEvent)}${position}`;
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Encode one decision list for a command result.
|
|
298
|
-
* @param list - the rows to publish.
|
|
299
|
-
* @returns the result text, e.g. `pause triage: 1; row: overnight; phase: draining; stopped: tool/call/turn 1 step 1; tools: gated; wake: yes`.
|
|
300
|
-
*/
|
|
301
|
-
function encodeTriageList(list) {
|
|
302
|
-
const head = `${TRIAGE_PREFIX}${String(list.rows.length)}`;
|
|
303
|
-
return list.rows.reduce((text, row) => {
|
|
304
|
-
const stopped = row.reading === "unreadable" ? "unreadable" : encodeStop(row);
|
|
305
|
-
return `${text}; row: ${escapeField(row.sessionId)}; phase: ${row.phase}; stopped: ${stopped}; traces: ${row.reading === "unreadable" ? EMPTY_FIELD : encodeTraces(row.traces)}; tools: ${row.reading === "unreadable" || row.traces.length === 0 ? EMPTY_FIELD : encodeNames(row.traces.map((trace) => trace.tool))}; owed: ${row.owed ? WAKE_YES : "no"}; wake: ${row.wakeable ? WAKE_YES : "no"}; goal: ${row.rearmGoal ? GOAL_ON : GOAL_OFF}`;
|
|
306
|
-
}, head);
|
|
307
|
-
}
|
|
308
146
|
//#endregion
|
|
309
147
|
//#region lib/types/coldlog.js
|
|
310
148
|
/**
|
|
@@ -316,7 +154,7 @@ function encodeTriageList(list) {
|
|
|
316
154
|
* list only has to name where the session stopped, which tool call the log
|
|
317
155
|
* never heard back from, and whether an answer is still owed — all of them
|
|
318
156
|
* facts about the log's END. Reading from seq 0 to the end for every row, on
|
|
319
|
-
* every
|
|
157
|
+
* every time the panel opens, would make a long
|
|
320
158
|
* session's cost grow with a log nobody is asking about.
|
|
321
159
|
*
|
|
322
160
|
* WHAT THE BOUND BUYS. A stored log is append-only, so the tail is stable: the
|
|
@@ -355,8 +193,9 @@ const TRIAGE_WINDOW_EVENTS = 2e3;
|
|
|
355
193
|
const TRIAGE_LOG_MAX_EVENTS = 1e6;
|
|
356
194
|
/**
|
|
357
195
|
* Events that are not the session's own story: the bookkeeping of the pause
|
|
358
|
-
* command
|
|
359
|
-
* They are real and durable, but "it stopped right
|
|
196
|
+
* legacy command records a previous surface wrote, and the pending-input
|
|
197
|
+
* splices the inbox writes. They are real and durable, but "it stopped right
|
|
198
|
+
* after the action finished"
|
|
360
199
|
* is not what "where did it stop" means.
|
|
361
200
|
*/
|
|
362
201
|
const BOOKKEEPING_EVENTS = /* @__PURE__ */ new Set([
|
|
@@ -530,7 +369,7 @@ async function eventCount(handle, signal) {
|
|
|
530
369
|
if (await reaches(handle, mid, signal)) low = mid;
|
|
531
370
|
else high = mid;
|
|
532
371
|
}
|
|
533
|
-
return low;
|
|
372
|
+
return await reaches(handle, 0, signal) ? low + 1 : 0;
|
|
534
373
|
}
|
|
535
374
|
/**
|
|
536
375
|
* Read one session's stored log, bounded to the events the list's facts need.
|
|
@@ -579,155 +418,6 @@ async function coldRead(ctx, sessionId, windowEvents = TRIAGE_WINDOW_EVENTS, sig
|
|
|
579
418
|
}
|
|
580
419
|
}
|
|
581
420
|
//#endregion
|
|
582
|
-
//#region lib/types/composition.js
|
|
583
|
-
/**
|
|
584
|
-
* Rebuild the scoped Agent composition a parked Session ran under.
|
|
585
|
-
*
|
|
586
|
-
* Activation is not only a persisted Session: the Agent preset supplies the
|
|
587
|
-
* scoped tools, prompt sections, skills, and — for a subagent child — the
|
|
588
|
-
* delegation context, persona, and tool restriction that were in force before
|
|
589
|
-
* the park. A resume that loads the log without that setup starts a different
|
|
590
|
-
* Agent on the same history.
|
|
591
|
-
*
|
|
592
|
-
* The preset a Session runs is the `agentPreset` projection, never the
|
|
593
|
-
* creation-time header alone: a blank Session may select another preset before
|
|
594
|
-
* its first turn, and the projection records the selection that actually ran.
|
|
595
|
-
* A child with no independent selection joins its live parent's preset
|
|
596
|
-
* generation; one that selected independently mounts the projected preset.
|
|
597
|
-
* The durable child descriptor supplies the persona, tool filter, and route
|
|
598
|
-
* that are reapplied only on cold resume.
|
|
599
|
-
*
|
|
600
|
-
* Both services are optional. A profile without `agentPresets` has no scoped
|
|
601
|
-
* preset composition to restore, and this module leaves the resume unchanged.
|
|
602
|
-
* A profile that mounts one but cannot read the Session projection cannot
|
|
603
|
-
* prove what composition the Session ran, so it fails the resume instead of
|
|
604
|
-
* silently starting a different Agent.
|
|
605
|
-
* @module dsh-pause-session/composition
|
|
606
|
-
*/
|
|
607
|
-
/**
|
|
608
|
-
* A stored subagent route that no resume may reactivate.
|
|
609
|
-
*
|
|
610
|
-
* A one-shot child is terminal by contract; activating it as a root Agent
|
|
611
|
-
* would turn a finished delegation into an ordinary conversation that happens
|
|
612
|
-
* to share its Session id.
|
|
613
|
-
*/
|
|
614
|
-
var UnresumableSessionError = class extends Error {
|
|
615
|
-
constructor(sessionId) {
|
|
616
|
-
super(`subagent session "${String(sessionId)}" has a one-shot route and cannot be resumed`);
|
|
617
|
-
this.name = "UnresumableSessionError";
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
/**
|
|
621
|
-
* Whether the child's own log selected a preset before its descriptor.
|
|
622
|
-
*
|
|
623
|
-
* The creation-time selection precedes the first `subagent/descriptor`; a
|
|
624
|
-
* selection after the descriptor belongs to the child's own later history and
|
|
625
|
-
* does not decide which composition cold resume joins. This mirrors the
|
|
626
|
-
* subagent runtime's own cold-resume rule.
|
|
627
|
-
* @param events - the child's own log suffix, without the fork-inherited prefix.
|
|
628
|
-
* @returns whether the child carries an independent preset selection.
|
|
629
|
-
*/
|
|
630
|
-
function hasIndependentPresetSelection(events) {
|
|
631
|
-
for (const event of events) {
|
|
632
|
-
if (event.type === "subagent/descriptor") return false;
|
|
633
|
-
if (event.type === "agent-preset/selected") return true;
|
|
634
|
-
}
|
|
635
|
-
return false;
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Whether a local Session may be parked under a promise of a later resume.
|
|
639
|
-
*
|
|
640
|
-
* A normal root Session has no child descriptor and is resumable. A local
|
|
641
|
-
* subagent child is resumable only when its own durable descriptor is
|
|
642
|
-
* `continuable`; a historical `one-shot` child is terminal and must never be
|
|
643
|
-
* registered as a park the morning path would later have to refuse. External
|
|
644
|
-
* executions have no local Session, so callers never reach this check.
|
|
645
|
-
*
|
|
646
|
-
* A profile without the optional Session-query service cannot prove the
|
|
647
|
-
* descriptor's mode; it keeps the previous permissive behavior rather than
|
|
648
|
-
* dropping every subagent. The web profile mounts the query service.
|
|
649
|
-
* @param ctx - host context carrying the optional Session-query service.
|
|
650
|
-
* @param sessionId - the local Session to inspect.
|
|
651
|
-
* @param signal - cancellation for the observation read.
|
|
652
|
-
* @returns whether a park of this Session may be resumed.
|
|
653
|
-
* @throws the cancellation reason when `signal` aborts the read.
|
|
654
|
-
*/
|
|
655
|
-
async function isResumableSession(ctx, sessionId, signal) {
|
|
656
|
-
const query = ctx.get("sessionQuery");
|
|
657
|
-
if (query === void 0) return true;
|
|
658
|
-
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
659
|
-
try {
|
|
660
|
-
return foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.mode !== "one-shot";
|
|
661
|
-
} finally {
|
|
662
|
-
observation[Symbol.dispose]();
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
/**
|
|
666
|
-
* Read one parked Session's stored composition and build the resume options.
|
|
667
|
-
*
|
|
668
|
-
* The returned `setup` belongs to the unpublished Agent scope, so all
|
|
669
|
-
* registrations exist before `agent/created`. For a root Session the setup
|
|
670
|
-
* mounts the Session's projected preset, or the profile's default when the
|
|
671
|
-
* Session never selected one. For a child under a live parent it applies the
|
|
672
|
-
* child composition: an independent preset mounts separately, while an
|
|
673
|
-
* inherited composition joins the parent's current generation; the historical
|
|
674
|
-
* descriptor's persona and tool filter are reapplied in both cases.
|
|
675
|
-
* @param ctx - host context carrying the optional preset and Session-query services.
|
|
676
|
-
* @param sessionId - the parked Session being resumed.
|
|
677
|
-
* @param parent - the live direct parent, when a child is resumed under it.
|
|
678
|
-
* @param signal - cancellation for the observation read.
|
|
679
|
-
* @returns the Agent options and setup to pass to `agents.resume`.
|
|
680
|
-
* @throws {UnresumableSessionError} when the stored child route is one-shot.
|
|
681
|
-
* @throws when a mounted preset roster cannot read the Session projection, or
|
|
682
|
-
* a recorded preset cannot be restored.
|
|
683
|
-
*/
|
|
684
|
-
async function restoreComposition(ctx, sessionId, parent, signal) {
|
|
685
|
-
const presets = ctx.get("agentPresets");
|
|
686
|
-
const query = ctx.get("sessionQuery");
|
|
687
|
-
if (query === void 0) {
|
|
688
|
-
if (presets !== void 0) throw new Error("resuming a composed Agent requires the session-query service");
|
|
689
|
-
return {};
|
|
690
|
-
}
|
|
691
|
-
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
692
|
-
try {
|
|
693
|
-
const projections = observation.projections;
|
|
694
|
-
const ownEvents = observation.events.slice(observation.inheritedEventCount);
|
|
695
|
-
const descriptor = foldSubagentDescriptor(ownEvents);
|
|
696
|
-
if (descriptor?.mode === "one-shot") throw new UnresumableSessionError(sessionId);
|
|
697
|
-
if (presets === void 0 || projections === void 0) {
|
|
698
|
-
if (presets !== void 0) throw new Error("resuming a composed Agent requires projected Session state");
|
|
699
|
-
return {};
|
|
700
|
-
}
|
|
701
|
-
if (parent === void 0) {
|
|
702
|
-
const resolved = await presets.resolve(projections.values.agentPreset ?? void 0);
|
|
703
|
-
return { setup: async (agentCtx) => {
|
|
704
|
-
await presets.mount(agentCtx, resolved.id);
|
|
705
|
-
} };
|
|
706
|
-
}
|
|
707
|
-
const independentlySelected = hasIndependentPresetSelection(ownEvents);
|
|
708
|
-
const presetId = independentlySelected ? projections.values.agentPreset : void 0;
|
|
709
|
-
if (independentlySelected && typeof presetId !== "string") throw new Error(`subagent "${String(sessionId)}" has no projected agent preset to restore`);
|
|
710
|
-
const composition = {
|
|
711
|
-
...typeof presetId === "string" ? { agentPreset: presetId } : {},
|
|
712
|
-
...descriptor?.mode === "continuable" && descriptor.persona !== void 0 ? { persona: descriptor.persona } : {},
|
|
713
|
-
...descriptor?.mode === "continuable" && descriptor.toolFilter !== void 0 ? { toolFilter: descriptor.toolFilter } : {}
|
|
714
|
-
};
|
|
715
|
-
const agentOptions = descriptor?.mode === "continuable" ? {
|
|
716
|
-
...descriptor.agentProvider === void 0 ? {} : { provider: descriptor.agentProvider },
|
|
717
|
-
...descriptor.agentModel === void 0 ? {} : { model: descriptor.agentModel },
|
|
718
|
-
...descriptor.agentReasoningEffort === void 0 ? {} : { reasoningEffort: descriptor.agentReasoningEffort }
|
|
719
|
-
} : void 0;
|
|
720
|
-
return {
|
|
721
|
-
...agentOptions === void 0 ? {} : { agentOptions },
|
|
722
|
-
setup: async (agentCtx) => {
|
|
723
|
-
await applyChildComposition(agentCtx, parent, composition);
|
|
724
|
-
}
|
|
725
|
-
};
|
|
726
|
-
} finally {
|
|
727
|
-
observation[Symbol.dispose]();
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
//#endregion
|
|
731
421
|
//#region lib/types/tree.js
|
|
732
422
|
/**
|
|
733
423
|
* One session's subagent tree, read from the parent catalogs.
|
|
@@ -830,11 +520,16 @@ async function walkDescendants(ctx, rootSessionId, signal) {
|
|
|
830
520
|
*/
|
|
831
521
|
const PRESS_ROUNDS = 4;
|
|
832
522
|
/** Fold one shared tree walk into the two doubts this report publishes. */
|
|
833
|
-
function readingOf(walk) {
|
|
523
|
+
function readingOf(walk, watch) {
|
|
834
524
|
return {
|
|
835
525
|
members: walk.members.map((member) => member.id),
|
|
836
526
|
unresumable: walk.members.some((member) => member.mode !== "continuable") || walk.unread.some((branch) => branch.reason === "unsupported"),
|
|
837
|
-
unread: walk.unreadWhole || walk.unread.some((branch) => branch.reason !== "unsupported")
|
|
527
|
+
unread: walk.unreadWhole || walk.unread.some((branch) => branch.reason !== "unsupported"),
|
|
528
|
+
running: walk.members.some((member) => {
|
|
529
|
+
if (member.mode === "continuable") return false;
|
|
530
|
+
const activity = watch.activity(member.id);
|
|
531
|
+
return activity.turnOpen || activity.runningTools.length > 0;
|
|
532
|
+
})
|
|
838
533
|
};
|
|
839
534
|
}
|
|
840
535
|
/**
|
|
@@ -853,6 +548,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
853
548
|
* instead of quietly leaving them out of a park it then calls safe.
|
|
854
549
|
*/
|
|
855
550
|
const unrecorded = /* @__PURE__ */ new Set();
|
|
551
|
+
const notResumable = /* @__PURE__ */ new Set();
|
|
856
552
|
/** Record or clear one session's "pressed and the registry refused" state. */
|
|
857
553
|
const markUnrecorded = (sessionId, flag) => {
|
|
858
554
|
if (flag) unrecorded.add(sessionId);
|
|
@@ -879,15 +575,18 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
879
575
|
* answer; the cache keeps that read from repeating a catalog round-trip it
|
|
880
576
|
* has already paid for.
|
|
881
577
|
*/
|
|
882
|
-
const walkTree = async (sessionId, signal) => {
|
|
578
|
+
const walkTree = async (sessionId, signal, shared) => {
|
|
883
579
|
signal?.throwIfAborted();
|
|
580
|
+
const provided = shared?.get(sessionId);
|
|
581
|
+
if (provided !== void 0) return readingOf(provided, watch);
|
|
884
582
|
const cached = walks.get(sessionId);
|
|
885
583
|
if (cached !== void 0) return cached;
|
|
886
584
|
const reading = ctx.get("subagents") === void 0 ? {
|
|
887
585
|
members: [],
|
|
888
586
|
unresumable: false,
|
|
889
|
-
unread: true
|
|
890
|
-
|
|
587
|
+
unread: true,
|
|
588
|
+
running: false
|
|
589
|
+
} : readingOf(await walkDescendants(ctx, sessionId, signal), watch);
|
|
891
590
|
walks.set(sessionId, reading);
|
|
892
591
|
return reading;
|
|
893
592
|
};
|
|
@@ -934,7 +633,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
934
633
|
* The park also names the session it belongs to, so a client can address its
|
|
935
634
|
* reads there rather than at whatever session it is showing.
|
|
936
635
|
*/
|
|
937
|
-
const status = async (signal) => {
|
|
636
|
+
const status = async (signal, sharedWalks) => {
|
|
938
637
|
const phases = /* @__PURE__ */ new Map();
|
|
939
638
|
for (const [sessionId, record] of registry.parked) phases.set(sessionId, {
|
|
940
639
|
phase: record.phase,
|
|
@@ -968,7 +667,7 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
968
667
|
const unreadTrees = [];
|
|
969
668
|
const owners = [...phases.keys()];
|
|
970
669
|
const parkedAt = owners.find((sessionId) => !unheld.has(sessionId)) ?? owners[0];
|
|
971
|
-
const trees = await Promise.all(owners.map(async (sessionId) => [sessionId, await walkTree(sessionId, signal)]));
|
|
670
|
+
const trees = await Promise.all(owners.map(async (sessionId) => [sessionId, await walkTree(sessionId, signal, sharedWalks)]));
|
|
972
671
|
for (const [sessionId, tree] of trees) {
|
|
973
672
|
if (tree.unresumable) unresumable.push(String(sessionId));
|
|
974
673
|
if (tree.unread) unreadTrees.push(String(sessionId));
|
|
@@ -978,14 +677,14 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
978
677
|
if (ctx.get("jobs") === void 0) blind.push("jobs");
|
|
979
678
|
const registered = phases.size;
|
|
980
679
|
return {
|
|
981
|
-
verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 ? "safe" : "waiting",
|
|
680
|
+
verdict: registered === 0 ? "idle" : safe === registered && unreadTrees.length === 0 && !trees.some(([, tree]) => tree.running) ? "safe" : "waiting",
|
|
982
681
|
registered,
|
|
983
682
|
safe,
|
|
984
683
|
...parkedAt === void 0 ? {} : { parkedAt: String(parkedAt) },
|
|
985
684
|
draining: draining.sort(),
|
|
986
685
|
parking,
|
|
987
686
|
stalled: stalled.sort(),
|
|
988
|
-
unresumable: unresumable.sort(),
|
|
687
|
+
unresumable: [...new Set(unresumable)].sort(),
|
|
989
688
|
unreadTrees: unreadTrees.sort(),
|
|
990
689
|
jobs: liveJobs(owners),
|
|
991
690
|
blind
|
|
@@ -1000,104 +699,197 @@ function mountGlobalPause(ctx, watch, registry, deps) {
|
|
|
1000
699
|
signal?.throwIfAborted();
|
|
1001
700
|
switch (await deps.press(sessionId, signal)) {
|
|
1002
701
|
case "registered":
|
|
702
|
+
notResumable.delete(sessionId);
|
|
1003
703
|
markUnrecorded(sessionId, false);
|
|
1004
704
|
deps.look(sessionId);
|
|
1005
705
|
break;
|
|
1006
706
|
case "unrecordable":
|
|
1007
707
|
markUnrecorded(sessionId, true);
|
|
1008
708
|
break;
|
|
709
|
+
case "not-resumable":
|
|
710
|
+
notResumable.add(sessionId);
|
|
711
|
+
break;
|
|
1009
712
|
default: break;
|
|
1010
713
|
}
|
|
1011
714
|
}
|
|
1012
715
|
}
|
|
1013
|
-
const
|
|
1014
|
-
|
|
716
|
+
const aggregate = await status(signal);
|
|
717
|
+
const answer = aggregate.verdict === "safe" ? "Every session is paused." : aggregate.verdict === "idle" ? "Nothing is running." : "Pausing is in progress.";
|
|
718
|
+
const blocked = notResumable.size;
|
|
719
|
+
return unrecorded.size === 0 && blocked === 0 ? {
|
|
1015
720
|
kind: "success",
|
|
1016
721
|
text: answer
|
|
1017
722
|
} : {
|
|
1018
723
|
kind: "error",
|
|
1019
|
-
text: answer
|
|
724
|
+
text: `${answer} ${String(blocked)} session${blocked === 1 ? "" : "s"} could not be paused because its route is not resumable.`
|
|
1020
725
|
};
|
|
1021
726
|
},
|
|
1022
|
-
|
|
1023
|
-
kind: "success",
|
|
1024
|
-
text: encodeGlobalStatus(await status(signal))
|
|
1025
|
-
}),
|
|
727
|
+
readState: async (signal, walks) => await status(signal, walks),
|
|
1026
728
|
markUnrecorded
|
|
1027
729
|
};
|
|
1028
730
|
}
|
|
1029
731
|
//#endregion
|
|
1030
|
-
//#region lib/types/
|
|
732
|
+
//#region lib/types/release-order.js
|
|
1031
733
|
/**
|
|
1032
|
-
* The
|
|
1033
|
-
* let it answer the input that was already pending when it stopped.
|
|
734
|
+
* The order a batch release walks: parent before child, shallowest first.
|
|
1034
735
|
*
|
|
1035
|
-
* A
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
736
|
+
* A single tree release already has the pre-order from its own walk, so this
|
|
737
|
+
* module serves the batch path, where the recorded sessions may span several
|
|
738
|
+
* trees. `listDescendants` stamps each row with its distance from the listing
|
|
739
|
+
* root, so one catalog read per recorded root places every member. A session
|
|
740
|
+
* the walk never reached is its own root and releases with the first level.
|
|
741
|
+
* @module dsh-pause-session/release-order
|
|
742
|
+
*/
|
|
743
|
+
/**
|
|
744
|
+
* Sequence a batch release parent before child.
|
|
745
|
+
* @param ctx - host context carrying the optional subagent catalog.
|
|
746
|
+
* @param sessionIds - every recorded session, in registry order.
|
|
747
|
+
* @param signal - cancellation for each catalog read.
|
|
748
|
+
* @returns one list per depth level, shallowest first.
|
|
749
|
+
*/
|
|
750
|
+
async function releaseOrder(ctx, sessionIds, signal) {
|
|
751
|
+
const depth = /* @__PURE__ */ new Map();
|
|
752
|
+
const subagents = ctx.get("subagents");
|
|
753
|
+
if (subagents !== void 0) {
|
|
754
|
+
const owned = new Set(sessionIds);
|
|
755
|
+
for (const sessionId of sessionIds) {
|
|
756
|
+
signal?.throwIfAborted();
|
|
757
|
+
if (depth.has(sessionId)) continue;
|
|
758
|
+
depth.set(sessionId, 0);
|
|
759
|
+
try {
|
|
760
|
+
for (const row of await subagents.listDescendants(sessionId, signal)) {
|
|
761
|
+
if (row.kind !== "child" || !owned.has(row.id)) continue;
|
|
762
|
+
depth.set(row.id, row.depth);
|
|
763
|
+
}
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (signal?.aborted === true) throw error;
|
|
766
|
+
ctx.logger.warn(`pause: could not read the descendants of "${sessionId}": ${describeError(error)}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const levels = /* @__PURE__ */ new Map();
|
|
771
|
+
for (const sessionId of sessionIds) {
|
|
772
|
+
const level = depth.get(sessionId) ?? 0;
|
|
773
|
+
const bucket = levels.get(level);
|
|
774
|
+
if (bucket === void 0) levels.set(level, [sessionId]);
|
|
775
|
+
else bucket.push(sessionId);
|
|
776
|
+
}
|
|
777
|
+
return { levels: [...levels.keys()].sort((left, right) => left - right).map((level) => levels.get(level) ?? []) };
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region lib/types/composition.js
|
|
781
|
+
/**
|
|
782
|
+
* What a parked Session needs to come back as itself.
|
|
1041
783
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
* from the request header the session itself persisted.
|
|
784
|
+
* A root Session is loaded directly: the Agent preset it ran supplies the
|
|
785
|
+
* scoped tools, prompt sections, and skills, so the resume mounts that preset
|
|
786
|
+
* before the Agent is published. A subagent child is not loaded here at all —
|
|
787
|
+
* the subagent runtime owns its activation, composition, and tree membership,
|
|
788
|
+
* and cold-resumes it when this plugin delivers the wake through
|
|
789
|
+
* `ctx.subagents.prompt`.
|
|
1049
790
|
*
|
|
1050
|
-
* The
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
|
|
791
|
+
* The two cases are told apart by one read of the Session's own log, which is
|
|
792
|
+
* the same read the press-time resumability check already pays for.
|
|
793
|
+
* @module dsh-pause-session/composition
|
|
794
|
+
*/
|
|
795
|
+
/**
|
|
796
|
+
* Whether a local Session may be parked under a promise of a later resume.
|
|
1054
797
|
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
798
|
+
* A normal root Session has no child descriptor and is resumable. A local
|
|
799
|
+
* subagent child is resumable only when its own durable descriptor is
|
|
800
|
+
* `continuable`; a historical `one-shot` child is terminal and must never be
|
|
801
|
+
* registered as a park the morning path would later have to refuse. External
|
|
802
|
+
* executions have no local Session, so callers never reach this check.
|
|
803
|
+
*
|
|
804
|
+
* A profile without the optional Session-query service cannot prove the
|
|
805
|
+
* descriptor's mode; it keeps the previous permissive behavior rather than
|
|
806
|
+
* dropping every subagent. The web profile mounts the query service.
|
|
807
|
+
* @param ctx - host context carrying the optional Session-query service.
|
|
808
|
+
* @param sessionId - the local Session to inspect.
|
|
809
|
+
* @param signal - cancellation for the observation read.
|
|
810
|
+
* @returns whether a park of this Session may be resumed.
|
|
811
|
+
* @throws the cancellation reason when `signal` aborts the read.
|
|
812
|
+
*/
|
|
813
|
+
async function isResumableSession(ctx, sessionId, signal) {
|
|
814
|
+
const query = ctx.get("sessionQuery");
|
|
815
|
+
if (query === void 0) return true;
|
|
816
|
+
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
817
|
+
try {
|
|
818
|
+
return foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount))?.mode !== "one-shot";
|
|
819
|
+
} finally {
|
|
820
|
+
observation[Symbol.dispose]();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Read one parked Session's own log and decide how it comes back.
|
|
825
|
+
*
|
|
826
|
+
* A child returns as `child` with no setup: the subagent runtime restores the
|
|
827
|
+
* composition from the descriptor and the preset projection, and mounting a
|
|
828
|
+
* second one here would start a different Agent on the same history.
|
|
829
|
+
*
|
|
830
|
+
* A root resolves the preset it ran — the projection, never the creation-time
|
|
831
|
+
* header alone, because a blank Session may select another preset before its
|
|
832
|
+
* first turn — and hands back the setup that mounts it. A profile with no
|
|
833
|
+
* preset roster has no scoped composition to restore and resumes unchanged.
|
|
834
|
+
* @param ctx - host context carrying the optional preset and Session-query services.
|
|
835
|
+
* @param sessionId - the parked Session being resumed.
|
|
836
|
+
* @param signal - cancellation for the observation read.
|
|
837
|
+
* @returns how this Session comes back.
|
|
838
|
+
* @throws when a mounted preset roster cannot read the Session projection.
|
|
839
|
+
*/
|
|
840
|
+
async function readParkedComposition(ctx, sessionId, signal) {
|
|
841
|
+
const presets = ctx.get("agentPresets");
|
|
842
|
+
const query = ctx.get("sessionQuery");
|
|
843
|
+
if (query === void 0) {
|
|
844
|
+
if (presets !== void 0) throw new Error("resuming a composed Agent requires the session-query service");
|
|
845
|
+
return { kind: "root" };
|
|
846
|
+
}
|
|
847
|
+
const observation = await query.observeSession(sessionId, { ...signal === void 0 ? {} : { signal } });
|
|
848
|
+
try {
|
|
849
|
+
if (foldSubagentDescriptor(observation.events.slice(observation.inheritedEventCount)) !== void 0 || observation.header.origin === "subagent") return { kind: "child" };
|
|
850
|
+
if (presets === void 0) return { kind: "root" };
|
|
851
|
+
const projections = observation.projections;
|
|
852
|
+
if (projections === void 0) throw new Error("resuming a composed Agent requires projected Session state");
|
|
853
|
+
const resolved = await presets.resolve(projections.values.agentPreset ?? void 0);
|
|
854
|
+
return {
|
|
855
|
+
kind: "root",
|
|
856
|
+
setup: async (agentCtx) => {
|
|
857
|
+
await presets.mount(agentCtx, resolved.id);
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
} finally {
|
|
861
|
+
observation[Symbol.dispose]();
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
//#endregion
|
|
865
|
+
//#region lib/types/resume-text.js
|
|
866
|
+
/**
|
|
867
|
+
* User-facing copy and result shapes for the clean resume and tree release.
|
|
868
|
+
* @module dsh-pause-session/resume-text
|
|
1063
869
|
*/
|
|
1064
870
|
/**
|
|
1065
|
-
* The one wake content a route that keeps an empty user message is given.
|
|
1066
|
-
* names its producer, because it lands in the same user turn as the input the
|
|
1067
|
-
* model owes an answer to and must not read as part of it.
|
|
871
|
+
* The one wake content a route that keeps an empty user message is given.
|
|
1068
872
|
*/
|
|
1069
873
|
const WAKE_MARKER = "[pause: resumed from a pause]";
|
|
1070
874
|
/** Result copy for a resumed session; the model never sees it. */
|
|
1071
875
|
const RESUMED_TEXT = "This session resumed from where it paused.";
|
|
1072
|
-
/**
|
|
1073
|
-
* Result copy for a registered session this path refuses: its durable state is
|
|
1074
|
-
* not the outcome of a completed park, so waking it would be a guess.
|
|
1075
|
-
*/
|
|
876
|
+
/** Result copy for a registered session whose park did not finish cleanly. */
|
|
1076
877
|
const UNCLEAN_TEXT = "pause did not resume this session: its pause did not finish cleanly, so it needs your decision.";
|
|
1077
878
|
/** Result copy when nothing is parked under that session. */
|
|
1078
879
|
const NOT_PARKED_TEXT = "pause has no pause recorded for this session.";
|
|
1079
880
|
/** Result copy for a session that is parked but could not be loaded back. */
|
|
1080
881
|
const ACTIVATION_FAILED_TEXT = "pause could not reopen this session, so it stayed paused.";
|
|
1081
|
-
/** Result copy for a one-shot subagent route
|
|
882
|
+
/** Result copy for a one-shot subagent route. */
|
|
1082
883
|
const UNRESUMABLE_TEXT = "pause did not resume this session: it is a one-shot subagent route and cannot be resumed.";
|
|
1083
|
-
/**
|
|
1084
|
-
* Result copy for the review decision. It is the one answer that promises
|
|
1085
|
-
* nothing happened, because opening a session to read it is the user's own
|
|
1086
|
-
* action in their session list — and activating its agent here would start a
|
|
1087
|
-
* session they asked only to look at.
|
|
1088
|
-
*/
|
|
884
|
+
/** Result copy for the review decision. */
|
|
1089
885
|
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.";
|
|
1090
|
-
/** Result copy for a change the durable registry refused to record. */
|
|
1091
|
-
const UNRECORDED_TEXT$1 = "pause could not write the pause registry, so it did not change the pause state.";
|
|
1092
886
|
/** Copy for one goal opt-out change. */
|
|
1093
887
|
const REARM_GOAL_ON_TEXT = "pause will re-open this session's goal when it resumes it.";
|
|
1094
888
|
const REARM_GOAL_OFF_TEXT = "pause will leave this session's goal closed when it resumes it.";
|
|
1095
889
|
/**
|
|
1096
|
-
* How one tree-wide release answers the person who pressed it.
|
|
1097
|
-
* sentence about all of it: what came back, and — by name, because those rows
|
|
1098
|
-
* are what the morning list is for — what did not.
|
|
890
|
+
* How one tree-wide release answers the person who pressed it.
|
|
1099
891
|
* @param released - the sessions this release covered, in release order.
|
|
1100
|
-
* @returns
|
|
892
|
+
* @returns one sentence about what came back and what did not.
|
|
1101
893
|
*/
|
|
1102
894
|
function summarizeTreeRelease(released) {
|
|
1103
895
|
const resumed = released.filter((attempt) => attempt.resumed).length;
|
|
@@ -1107,30 +899,32 @@ function summarizeTreeRelease(released) {
|
|
|
1107
899
|
const named = refused.filter((attempt) => attempt.refusal === "not-clean").map((attempt) => `"${String(attempt.sessionId)}"`);
|
|
1108
900
|
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`}.`;
|
|
1109
901
|
}
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region lib/types/resume-activation.js
|
|
1110
904
|
/**
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
905
|
+
* Reactivate one parked Session and deliver the wake it owes.
|
|
906
|
+
*
|
|
907
|
+
* The two Session kinds come back through different owners. An ordinary
|
|
908
|
+
* Session is loaded by this plugin and given the route its own log recorded.
|
|
909
|
+
* A subagent child belongs to the subagent runtime: delivering the wake through
|
|
910
|
+
* `ctx.subagents.prompt` is what cold-resumes it as a managed activation, under
|
|
911
|
+
* its live tree root, with its persisted composition and tree membership
|
|
912
|
+
* restored by the runtime itself.
|
|
913
|
+
*
|
|
914
|
+
* Both paths keep the same release order: the durable record goes first, then
|
|
915
|
+
* the gate, so a record that cannot be dropped leaves the Session parked.
|
|
916
|
+
* @module dsh-pause-session/resume-activation
|
|
1118
917
|
*/
|
|
1119
|
-
|
|
1120
|
-
|
|
918
|
+
/**
|
|
919
|
+
* Build the activation closure.
|
|
920
|
+
* @param ctx - host context carrying the agent store, subagent runtime, and goal service.
|
|
921
|
+
* @param gate - the pause gate released after activation.
|
|
922
|
+
* @param registry - the durable list the release forgets from.
|
|
923
|
+
* @param options - the route knowledge the root wake content depends on.
|
|
924
|
+
* @returns the activation methods the resume path uses.
|
|
925
|
+
*/
|
|
926
|
+
function createActivator(ctx, gate, registry, options) {
|
|
1121
927
|
const routeOf = (session) => session.requestHeader()?.config;
|
|
1122
|
-
/**
|
|
1123
|
-
* Give an agent that was loaded back without one the route its own log
|
|
1124
|
-
* recorded. The loop refuses to build a request without a provider and model,
|
|
1125
|
-
* and the persisted header is what the app's own activation path uses too, so
|
|
1126
|
-
* this resumes onto the route the session stopped on.
|
|
1127
|
-
*
|
|
1128
|
-
* 0.2.2 replaced the installed route selection with the durable model-selection
|
|
1129
|
-
* fold: `agent.model.current()` already returns a route replayed from the
|
|
1130
|
-
* Session's own request headers, and `select()` records the header's route when
|
|
1131
|
-
* that fold and the Agent options are both empty. The old per-agent
|
|
1132
|
-
* `installModelSelection(agentCtx, selection)` no longer accepts a selection.
|
|
1133
|
-
*/
|
|
1134
928
|
const ensureRoute = (agent) => {
|
|
1135
929
|
if (agent.model.current() !== void 0) return;
|
|
1136
930
|
const config = routeOf(agent.session);
|
|
@@ -1141,22 +935,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1141
935
|
...config.reasoningEffort === void 0 ? {} : { reasoningEffort: config.reasoningEffort }
|
|
1142
936
|
});
|
|
1143
937
|
};
|
|
1144
|
-
/** What one wake input carries for the route in use. */
|
|
1145
938
|
const wakeContent = (provider) => options.silentWakeRoutes.includes(provider) ? [] : [{
|
|
1146
939
|
type: "text",
|
|
1147
940
|
text: WAKE_MARKER
|
|
1148
941
|
}];
|
|
1149
|
-
/**
|
|
1150
|
-
* Ask the loop for the request it owes this session. Nothing is woken when a
|
|
1151
|
-
* request is already standing at the gate or the loop is already running: the
|
|
1152
|
-
* continuation is in flight, and a second wake would duplicate it. The content
|
|
1153
|
-
* is the route's business only for the silent wake; a note is the user's
|
|
1154
|
-
* decision and goes on the wire as written, on every route. Either way the
|
|
1155
|
-
* message is attributed to this plugin: the wake is this path speaking on the
|
|
1156
|
-
* session's behalf, not the user's own input.
|
|
1157
|
-
* @param agent - the live agent to wake.
|
|
1158
|
-
* @param request - which wake this resume delivers.
|
|
1159
|
-
*/
|
|
1160
942
|
const wake = (agent, request) => {
|
|
1161
943
|
if (request.kind === "none") return;
|
|
1162
944
|
if (gate.isHeld(agent.id) || agent.status !== "idle") return;
|
|
@@ -1172,16 +954,6 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1172
954
|
source: { kind: "pause" }
|
|
1173
955
|
}));
|
|
1174
956
|
};
|
|
1175
|
-
/**
|
|
1176
|
-
* Re-open one session's goal. Activation is process-local and is not part of
|
|
1177
|
-
* the durable goal, so a restart leaves a healthy goal disarmed; only that
|
|
1178
|
-
* case is repaired here. A goal the owner paused, blocked, or completed is a
|
|
1179
|
-
* decision, and this path does not take decisions back. The wake (when there
|
|
1180
|
-
* is one) runs first on purpose: the goal round driver queues the next round
|
|
1181
|
-
* as soon as the agent is idle, and that round must not join the request that
|
|
1182
|
-
* is answering the input already pending.
|
|
1183
|
-
* @param agent - the agent whose goal may be re-armed.
|
|
1184
|
-
*/
|
|
1185
957
|
const rearmGoal = (agent) => {
|
|
1186
958
|
const goals = ctx.get("goals");
|
|
1187
959
|
if (goals === void 0) return;
|
|
@@ -1197,44 +969,29 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1197
969
|
ctx.logger.warn(`pause: could not re-open the goal of "${agent.id}": ${describeError(error)}`);
|
|
1198
970
|
}
|
|
1199
971
|
};
|
|
1200
|
-
/**
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
* @param signal - cancellation for the stored composition read and load.
|
|
1216
|
-
*/
|
|
1217
|
-
const activate = async (sessionId, request, rearm, parentId, signal) => {
|
|
1218
|
-
const parent = parentId === void 0 ? void 0 : ctx.agents.get(parentId);
|
|
972
|
+
/** Drop the durable record, then the gate: the order every release keeps. */
|
|
973
|
+
const releaseRecord = async (sessionId) => {
|
|
974
|
+
try {
|
|
975
|
+
await registry.forget(sessionId);
|
|
976
|
+
} catch (error) {
|
|
977
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
978
|
+
return {
|
|
979
|
+
kind: "error",
|
|
980
|
+
text: UNRECORDED_TEXT
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
gate.resume(sessionId);
|
|
984
|
+
};
|
|
985
|
+
/** Load an ordinary Session, give it its recorded route, and deliver the wake. */
|
|
986
|
+
const activateRoot = async (sessionId, request, rearm, composition, signal) => {
|
|
1219
987
|
let agent;
|
|
1220
988
|
try {
|
|
1221
|
-
const composition = await restoreComposition(ctx, sessionId, parent, signal);
|
|
1222
989
|
agent = (await ctx.root.agents.resume({
|
|
1223
990
|
resumeSessionId: sessionId,
|
|
1224
|
-
...
|
|
1225
|
-
...composition,
|
|
991
|
+
...composition.setup === void 0 ? {} : { setup: composition.setup },
|
|
1226
992
|
...signal === void 0 ? {} : { signal }
|
|
1227
993
|
})).agent;
|
|
1228
994
|
} catch (error) {
|
|
1229
|
-
if (error instanceof UnresumableSessionError) return {
|
|
1230
|
-
sessionId,
|
|
1231
|
-
resumed: false,
|
|
1232
|
-
refusal: "unresumable",
|
|
1233
|
-
answer: {
|
|
1234
|
-
kind: "error",
|
|
1235
|
-
text: UNRESUMABLE_TEXT
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
995
|
ctx.logger.warn(`pause: could not open "${sessionId}" from its stored log: ${describeError(error)}`);
|
|
1239
996
|
return {
|
|
1240
997
|
sessionId,
|
|
@@ -1248,22 +1005,78 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1248
1005
|
}
|
|
1249
1006
|
ensureRoute(agent);
|
|
1250
1007
|
wake(agent, request);
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1008
|
+
const refused = await releaseRecord(sessionId);
|
|
1009
|
+
if (refused !== void 0) return {
|
|
1010
|
+
sessionId,
|
|
1011
|
+
resumed: false,
|
|
1012
|
+
refusal: "unrecorded",
|
|
1013
|
+
answer: refused
|
|
1014
|
+
};
|
|
1015
|
+
if (rearm) rearmGoal(agent);
|
|
1016
|
+
return {
|
|
1017
|
+
sessionId,
|
|
1018
|
+
resumed: true,
|
|
1019
|
+
answer: {
|
|
1020
|
+
kind: "success",
|
|
1021
|
+
text: RESUMED_TEXT
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
};
|
|
1025
|
+
/**
|
|
1026
|
+
* Hand a subagent child the wake through the subagent runtime.
|
|
1027
|
+
*
|
|
1028
|
+
* The runtime owns the activation, so it is also the only thing that can
|
|
1029
|
+
* cold-resume the child under its live tree root. An empty message would be
|
|
1030
|
+
* dropped by a route whose conversion discards it, and the route is unknown
|
|
1031
|
+
* before the child is live, so the marker is the content that always wakes it.
|
|
1032
|
+
*/
|
|
1033
|
+
const activateChild = async (sessionId, request, rearm, signal) => {
|
|
1034
|
+
const subagents = ctx.get("subagents");
|
|
1035
|
+
if (subagents === void 0) {
|
|
1036
|
+
ctx.logger.warn(`pause: subagent runtime is not mounted; opening "${sessionId}" as a root Session`);
|
|
1037
|
+
return await activateRoot(sessionId, request, rearm, { kind: "root" }, signal);
|
|
1038
|
+
}
|
|
1039
|
+
const content = [{
|
|
1040
|
+
type: "text",
|
|
1041
|
+
text: request.kind === "message" ? request.text : WAKE_MARKER
|
|
1042
|
+
}];
|
|
1043
|
+
try {
|
|
1044
|
+
await subagents.prompt({
|
|
1045
|
+
requestId: randomUUID(),
|
|
1046
|
+
childSessionId: sessionId,
|
|
1047
|
+
delivery: "queue",
|
|
1048
|
+
content
|
|
1049
|
+
}, signal ?? new AbortController().signal);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
if (remoteErrorOf(error)?.code === "subagent/not-resumable") return {
|
|
1052
|
+
sessionId,
|
|
1053
|
+
resumed: false,
|
|
1054
|
+
refusal: "unresumable",
|
|
1259
1055
|
answer: {
|
|
1260
1056
|
kind: "error",
|
|
1261
|
-
text:
|
|
1057
|
+
text: UNRESUMABLE_TEXT
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
ctx.logger.warn(`pause: could not continue subagent "${sessionId}": ${describeError(error)}`);
|
|
1061
|
+
return {
|
|
1062
|
+
sessionId,
|
|
1063
|
+
resumed: false,
|
|
1064
|
+
refusal: "activation-failed",
|
|
1065
|
+
answer: {
|
|
1066
|
+
kind: "error",
|
|
1067
|
+
text: ACTIVATION_FAILED_TEXT
|
|
1262
1068
|
}
|
|
1263
1069
|
};
|
|
1264
1070
|
}
|
|
1265
|
-
|
|
1266
|
-
if (
|
|
1071
|
+
const refused = await releaseRecord(sessionId);
|
|
1072
|
+
if (refused !== void 0) return {
|
|
1073
|
+
sessionId,
|
|
1074
|
+
resumed: false,
|
|
1075
|
+
refusal: "unrecorded",
|
|
1076
|
+
answer: refused
|
|
1077
|
+
};
|
|
1078
|
+
const agent = ctx.agents.get(sessionId);
|
|
1079
|
+
if (rearm && agent !== void 0) rearmGoal(agent);
|
|
1267
1080
|
return {
|
|
1268
1081
|
sessionId,
|
|
1269
1082
|
resumed: true,
|
|
@@ -1273,6 +1086,78 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1273
1086
|
}
|
|
1274
1087
|
};
|
|
1275
1088
|
};
|
|
1089
|
+
const activate = async (sessionId, request, rearm, signal) => {
|
|
1090
|
+
let composition;
|
|
1091
|
+
try {
|
|
1092
|
+
composition = await readParkedComposition(ctx, sessionId, signal);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
ctx.logger.warn(`pause: could not read the composition of "${sessionId}": ${describeError(error)}`);
|
|
1095
|
+
return {
|
|
1096
|
+
sessionId,
|
|
1097
|
+
resumed: false,
|
|
1098
|
+
refusal: "activation-failed",
|
|
1099
|
+
answer: {
|
|
1100
|
+
kind: "error",
|
|
1101
|
+
text: ACTIVATION_FAILED_TEXT
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
return composition.kind === "child" ? await activateChild(sessionId, request, rearm, signal) : await activateRoot(sessionId, request, rearm, composition, signal);
|
|
1106
|
+
};
|
|
1107
|
+
return {
|
|
1108
|
+
activate,
|
|
1109
|
+
ensureRoute,
|
|
1110
|
+
wake,
|
|
1111
|
+
rearmGoal
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
//#endregion
|
|
1115
|
+
//#region lib/types/resume.js
|
|
1116
|
+
/**
|
|
1117
|
+
* The clean resume path: bring back a session a previous process parked, and
|
|
1118
|
+
* let it answer the input that was already pending when it stopped.
|
|
1119
|
+
*
|
|
1120
|
+
* A resume invents no turn. At the park point the loop had claimed the
|
|
1121
|
+
* session's pending input onto the surface and built the request the gate then
|
|
1122
|
+
* held; after a restart that input is still the last thing the session says and
|
|
1123
|
+
* the model still owes it an answer. What is missing is only a reason for the
|
|
1124
|
+
* loop to build a request again, and the documented way to ask for one is a
|
|
1125
|
+
* wake input — a user message the loop claims.
|
|
1126
|
+
*
|
|
1127
|
+
* The wake is invisible only on the routes the composition declares in
|
|
1128
|
+
* `silentWakeRoutes`, whose conversion drops an empty user message. On every
|
|
1129
|
+
* other route the loop's request carries one visible marker instead, and the
|
|
1130
|
+
* marker reaches the model; that is an accepted cost of resuming at all.
|
|
1131
|
+
* Whether an empty user message survives is a property of the route's
|
|
1132
|
+
* conversion rather than of the loop, so the route is resolved per session —
|
|
1133
|
+
* from the request header the session itself persisted.
|
|
1134
|
+
*
|
|
1135
|
+
* The wake message is attributed to this plugin, never to the user: it is text
|
|
1136
|
+
* this module synthesized on the session's behalf, in the session's own log and
|
|
1137
|
+
* in front of the model, and a downstream consumer has to be able to tell it
|
|
1138
|
+
* apart from something the human typed.
|
|
1139
|
+
*
|
|
1140
|
+
* Only a cleanly parked session takes this path. A record that never reached
|
|
1141
|
+
* the park point, or whose flush did not finish, describes work whose durable
|
|
1142
|
+
* state nobody knows; waking it would spend its owner's money on a guess, and
|
|
1143
|
+
* deciding those sessions is the morning list's job, not this path's. That
|
|
1144
|
+
* holds whether or not the recorded session happens to be live in this process:
|
|
1145
|
+
* liveness is not the park outcome, and a session can be live without ever
|
|
1146
|
+
* having stopped.
|
|
1147
|
+
* @module dsh-pause-session/resume
|
|
1148
|
+
*/
|
|
1149
|
+
/**
|
|
1150
|
+
* Build the clean resume path.
|
|
1151
|
+
* @param ctx - host context carrying the agent store, the session store, and the goal service when one is mounted.
|
|
1152
|
+
* @param gate - the pause gate the resume releases.
|
|
1153
|
+
* @param registry - the durable list this path reads and forgets from.
|
|
1154
|
+
* @param options - the route knowledge the wake content depends on.
|
|
1155
|
+
* @param deps - the control-owned release order and the adopted-pause fact.
|
|
1156
|
+
* @returns the path the session control exposes.
|
|
1157
|
+
*/
|
|
1158
|
+
function installResumePath(ctx, gate, registry, options, deps) {
|
|
1159
|
+
/** The route the session's next request will use, as its own log recorded it. */
|
|
1160
|
+
const { activate, ensureRoute, wake, rearmGoal } = createActivator(ctx, gate, registry, options);
|
|
1276
1161
|
/**
|
|
1277
1162
|
* Load one unclean session back from its stored log.
|
|
1278
1163
|
*
|
|
@@ -1293,11 +1178,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1293
1178
|
* @param finalize - whether the decision continues the session (`'release'`)
|
|
1294
1179
|
* or only answers it (`'review'`); a review activates nothing and writes
|
|
1295
1180
|
* nothing.
|
|
1296
|
-
* @param parentId - the direct parent a batch release already brought back, when there is one.
|
|
1297
1181
|
* @param signal - cancellation for the cold load and setup.
|
|
1298
1182
|
* @returns what happened, and the copy for whoever asked.
|
|
1299
1183
|
*/
|
|
1300
|
-
const decideUnclean = async (sessionId, request, finalize,
|
|
1184
|
+
const decideUnclean = async (sessionId, request, finalize, signal) => {
|
|
1301
1185
|
const record = registry.get(sessionId);
|
|
1302
1186
|
if (record === void 0) return {
|
|
1303
1187
|
sessionId,
|
|
@@ -1318,7 +1202,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1318
1202
|
}
|
|
1319
1203
|
};
|
|
1320
1204
|
const live = ctx.agents.get(sessionId);
|
|
1321
|
-
if (live === void 0) return await activate(sessionId, request, record.rearmGoal,
|
|
1205
|
+
if (live === void 0) return await activate(sessionId, request, record.rearmGoal, signal);
|
|
1322
1206
|
if (!gate.isPaused(sessionId)) {
|
|
1323
1207
|
ensureRoute(live);
|
|
1324
1208
|
wake(live, request);
|
|
@@ -1332,7 +1216,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1332
1216
|
refusal: "unrecorded",
|
|
1333
1217
|
answer: {
|
|
1334
1218
|
kind: "error",
|
|
1335
|
-
text: UNRECORDED_TEXT
|
|
1219
|
+
text: UNRECORDED_TEXT
|
|
1336
1220
|
}
|
|
1337
1221
|
};
|
|
1338
1222
|
}
|
|
@@ -1375,11 +1259,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1375
1259
|
* back is the press's own undo, and it delivers no wake.
|
|
1376
1260
|
* @param sessionId - the recorded session to resume.
|
|
1377
1261
|
* @param request - which wake this resume delivers; see {@link WakeRequest}.
|
|
1378
|
-
* @param parentId - the direct parent a batch release already brought back, when there is one.
|
|
1379
1262
|
* @param signal - cancellation for the cold load and setup.
|
|
1380
1263
|
* @returns what happened, and the copy for whoever asked.
|
|
1381
1264
|
*/
|
|
1382
|
-
const resumeSession = async (sessionId, request,
|
|
1265
|
+
const resumeSession = async (sessionId, request, signal) => {
|
|
1383
1266
|
const record = registry.get(sessionId);
|
|
1384
1267
|
const live = ctx.agents.get(sessionId);
|
|
1385
1268
|
if (live !== void 0) {
|
|
@@ -1412,7 +1295,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1412
1295
|
refusal: "unrecorded",
|
|
1413
1296
|
answer: {
|
|
1414
1297
|
kind: "error",
|
|
1415
|
-
text: UNRECORDED_TEXT
|
|
1298
|
+
text: UNRECORDED_TEXT
|
|
1416
1299
|
}
|
|
1417
1300
|
};
|
|
1418
1301
|
}
|
|
@@ -1474,52 +1357,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1474
1357
|
text: UNCLEAN_TEXT
|
|
1475
1358
|
}
|
|
1476
1359
|
};
|
|
1477
|
-
return await activate(sessionId, request, record.rearmGoal,
|
|
1478
|
-
};
|
|
1479
|
-
/**
|
|
1480
|
-
* Order the recorded sessions so a session is released after the parent it
|
|
1481
|
-
* hangs from: waking a child needs its direct parent to be live first. The
|
|
1482
|
-
* relation is read from the parent catalogs themselves (which children this
|
|
1483
|
-
* session actually has), never guessed from ids; a branch whose catalog
|
|
1484
|
-
* cannot be read simply has no known parent and is released with the roots.
|
|
1485
|
-
* @param sessionIds - every recorded session, in registry order.
|
|
1486
|
-
* @param signal - cancellation for each catalog read.
|
|
1487
|
-
* @returns the parent of each child, and one list per depth level, shallowest first.
|
|
1488
|
-
*/
|
|
1489
|
-
const releaseOrder = async (sessionIds, signal) => {
|
|
1490
|
-
const owned = new Set(sessionIds);
|
|
1491
|
-
const parentOf = /* @__PURE__ */ new Map();
|
|
1492
|
-
const subagents = ctx.get("subagents");
|
|
1493
|
-
if (subagents !== void 0) for (const sessionId of sessionIds) {
|
|
1494
|
-
signal?.throwIfAborted();
|
|
1495
|
-
try {
|
|
1496
|
-
for (const child of await subagents.listChildren(sessionId, signal)) if (owned.has(child.id)) parentOf.set(child.id, sessionId);
|
|
1497
|
-
} catch (error) {
|
|
1498
|
-
if (signal?.aborted === true) throw error;
|
|
1499
|
-
ctx.logger.warn(`pause: could not read the children of "${sessionId}": ${describeError(error)}`);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
const depthOf = (sessionId) => {
|
|
1503
|
-
let steps = 0;
|
|
1504
|
-
const seen = /* @__PURE__ */ new Set([sessionId]);
|
|
1505
|
-
for (let at = parentOf.get(sessionId); at !== void 0; at = parentOf.get(at)) {
|
|
1506
|
-
if (seen.has(at)) break;
|
|
1507
|
-
seen.add(at);
|
|
1508
|
-
steps += 1;
|
|
1509
|
-
}
|
|
1510
|
-
return steps;
|
|
1511
|
-
};
|
|
1512
|
-
const levels = /* @__PURE__ */ new Map();
|
|
1513
|
-
for (const sessionId of sessionIds) {
|
|
1514
|
-
const level = depthOf(sessionId);
|
|
1515
|
-
const bucket = levels.get(level);
|
|
1516
|
-
if (bucket === void 0) levels.set(level, [sessionId]);
|
|
1517
|
-
else bucket.push(sessionId);
|
|
1518
|
-
}
|
|
1519
|
-
return {
|
|
1520
|
-
parents: parentOf,
|
|
1521
|
-
levels: [...levels.keys()].sort((left, right) => left - right).map((level) => levels.get(level) ?? [])
|
|
1522
|
-
};
|
|
1360
|
+
return await activate(sessionId, request, record.rearmGoal, signal);
|
|
1523
1361
|
};
|
|
1524
1362
|
return {
|
|
1525
1363
|
resumeSession,
|
|
@@ -1535,7 +1373,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1535
1373
|
text: NOT_PARKED_TEXT
|
|
1536
1374
|
}
|
|
1537
1375
|
};
|
|
1538
|
-
const cancelling = registered.every((member) =>
|
|
1376
|
+
const cancelling = registered.every((member) => {
|
|
1377
|
+
const record = registry.get(member);
|
|
1378
|
+
return record !== void 0 && record.phase !== "safe" && !deps.adopted(member);
|
|
1379
|
+
});
|
|
1539
1380
|
const released = [];
|
|
1540
1381
|
if (cancelling) for (const member of registered) {
|
|
1541
1382
|
signal?.throwIfAborted();
|
|
@@ -1551,27 +1392,22 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1551
1392
|
answer
|
|
1552
1393
|
});
|
|
1553
1394
|
}
|
|
1554
|
-
else {
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
answer: {
|
|
1566
|
-
kind: "error",
|
|
1567
|
-
text: UNCLEAN_TEXT
|
|
1568
|
-
}
|
|
1569
|
-
});
|
|
1570
|
-
continue;
|
|
1395
|
+
else for (const member of registered) {
|
|
1396
|
+
signal?.throwIfAborted();
|
|
1397
|
+
const record = registry.get(member);
|
|
1398
|
+
if (record !== void 0 && record.phase !== "safe") {
|
|
1399
|
+
released.push({
|
|
1400
|
+
sessionId: member,
|
|
1401
|
+
resumed: false,
|
|
1402
|
+
refusal: "not-clean",
|
|
1403
|
+
answer: {
|
|
1404
|
+
kind: "error",
|
|
1405
|
+
text: UNCLEAN_TEXT
|
|
1571
1406
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1407
|
+
});
|
|
1408
|
+
continue;
|
|
1574
1409
|
}
|
|
1410
|
+
released.push(await resumeSession(member, { kind: "silent" }, signal));
|
|
1575
1411
|
}
|
|
1576
1412
|
const root = released.find((attempt) => attempt.sessionId === sessionId);
|
|
1577
1413
|
const refusals = released.filter((attempt) => !attempt.resumed && attempt.refusal !== void 0).map((attempt) => ({
|
|
@@ -1592,11 +1428,11 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1592
1428
|
};
|
|
1593
1429
|
},
|
|
1594
1430
|
resumeParked: async (signal) => {
|
|
1595
|
-
const release = await releaseOrder(registry.list().map((entry) => entry.sessionId), signal);
|
|
1431
|
+
const release = await releaseOrder(ctx, registry.list().map((entry) => entry.sessionId), signal);
|
|
1596
1432
|
const attempts = [];
|
|
1597
1433
|
for (const level of release.levels) {
|
|
1598
1434
|
signal?.throwIfAborted();
|
|
1599
|
-
attempts.push(...await Promise.all(level.map((sessionId) => resumeSession(sessionId, { kind: "silent" },
|
|
1435
|
+
attempts.push(...await Promise.all(level.map((sessionId) => resumeSession(sessionId, { kind: "silent" }, signal))));
|
|
1600
1436
|
}
|
|
1601
1437
|
return attempts;
|
|
1602
1438
|
},
|
|
@@ -1607,7 +1443,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1607
1443
|
ctx.logger.warn(`pause: could not record the goal opt-out of "${sessionId}": ${describeError(error)}`);
|
|
1608
1444
|
return {
|
|
1609
1445
|
kind: "error",
|
|
1610
|
-
text: UNRECORDED_TEXT
|
|
1446
|
+
text: UNRECORDED_TEXT
|
|
1611
1447
|
};
|
|
1612
1448
|
}
|
|
1613
1449
|
return {
|
|
@@ -1632,8 +1468,8 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1632
1468
|
* stored log through a read handle that claims no ownership and writes nothing,
|
|
1633
1469
|
* so asking what happened cannot itself change what happened.
|
|
1634
1470
|
*
|
|
1635
|
-
* WHAT ONE READ COSTS. The list is read
|
|
1636
|
-
*
|
|
1471
|
+
* WHAT ONE READ COSTS. The list is read when the panel opens and when a native
|
|
1472
|
+
* action changes the park, and what it publishes is about the end of a log that
|
|
1637
1473
|
* only grows, so `./coldlog.ts` bounds each row's read to that end. A row whose
|
|
1638
1474
|
* facts the bound cannot hold reports itself unreadable rather than publishing
|
|
1639
1475
|
* a fragment.
|
|
@@ -1656,10 +1492,10 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1656
1492
|
* reported; a withheld promise costs a user nothing, a broken one spends their
|
|
1657
1493
|
* money.
|
|
1658
1494
|
*
|
|
1659
|
-
* WHO MAY DECIDE. A decision
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
1495
|
+
* WHO MAY DECIDE. A decision carries the calling session and its target as wire
|
|
1496
|
+
* input, so this list acts only on rows its most recent read published to the
|
|
1497
|
+
* calling session: the caller's own read is what makes a target actionable.
|
|
1498
|
+
* Anything else is refused, not looked up.
|
|
1663
1499
|
*
|
|
1664
1500
|
* TWO OF THE FIVE VERBS PUT THE SESSION BACK ON THE CLEAN PATH. Continue and
|
|
1665
1501
|
* continue-with-the-note go through `ResumePath.decideUnclean`, which activates
|
|
@@ -1677,6 +1513,7 @@ function installResumePath(ctx, gate, registry, options, deps) {
|
|
|
1677
1513
|
* parked session.
|
|
1678
1514
|
* @module dsh-pause-session/triage
|
|
1679
1515
|
*/
|
|
1516
|
+
/** Result copy for a decision the list itself could not carry out. */
|
|
1680
1517
|
/** Result copy for leaving one listed session alone. */
|
|
1681
1518
|
const TRIAGE_LEFT_TEXT = "pause left this session paused; it is still on the list.";
|
|
1682
1519
|
/** Result copy for a decision aimed at a session that is not on the list. */
|
|
@@ -1686,7 +1523,7 @@ const TRIAGE_NOT_LISTED_TEXT = "pause has no unfinished pause for this session."
|
|
|
1686
1523
|
* The list is the only thing that legitimizes a target, so a target nobody read
|
|
1687
1524
|
* is refused rather than acted on.
|
|
1688
1525
|
*/
|
|
1689
|
-
const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the list first
|
|
1526
|
+
const TRIAGE_UNSHOWN_TEXT = "pause did not act: this Session has not been shown that row. Read the decision list first.";
|
|
1690
1527
|
/**
|
|
1691
1528
|
* Model-facing text that goes with a wake when the user chooses to continue
|
|
1692
1529
|
* with a note.
|
|
@@ -1743,7 +1580,7 @@ function rowOf(ctx, entry, events, complete) {
|
|
|
1743
1580
|
function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW_EVENTS) {
|
|
1744
1581
|
/**
|
|
1745
1582
|
* The rows each calling session was last shown by its own read. A decision
|
|
1746
|
-
* names its target as
|
|
1583
|
+
* names its target as wire input, and the channel carries no
|
|
1747
1584
|
* origin of its own, so this is what ties a target to the list the user read:
|
|
1748
1585
|
* only a row this caller has been shown is acted on.
|
|
1749
1586
|
*/
|
|
@@ -1785,12 +1622,12 @@ function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW
|
|
|
1785
1622
|
text: TRIAGE_LEFT_TEXT
|
|
1786
1623
|
};
|
|
1787
1624
|
if (action === "goal") return await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
|
|
1788
|
-
if (action === "review") return (await resumePath.decideUnclean(sessionId, { kind: "none" }, "review",
|
|
1625
|
+
if (action === "review") return (await resumePath.decideUnclean(sessionId, { kind: "none" }, "review", signal)).answer;
|
|
1789
1626
|
const wake = action === "continue" ? { kind: "silent" } : {
|
|
1790
1627
|
kind: "message",
|
|
1791
1628
|
text: UNKNOWN_OUTCOME_NOTE
|
|
1792
1629
|
};
|
|
1793
|
-
return (await resumePath.decideUnclean(sessionId, wake, "release",
|
|
1630
|
+
return (await resumePath.decideUnclean(sessionId, wake, "release", signal)).answer;
|
|
1794
1631
|
};
|
|
1795
1632
|
return {
|
|
1796
1633
|
read: async (caller, signal) => {
|
|
@@ -1806,201 +1643,105 @@ function mountTriageList(ctx, registry, resumePath, windowEvents = TRIAGE_WINDOW
|
|
|
1806
1643
|
};
|
|
1807
1644
|
}
|
|
1808
1645
|
//#endregion
|
|
1809
|
-
//#region lib/types/
|
|
1810
|
-
/**
|
|
1811
|
-
* The session control the browser half drives: the pause verb, the park state
|
|
1812
|
-
* machine behind it, and the live state channel that accompanies both.
|
|
1813
|
-
*
|
|
1814
|
-
* A third-party plugin can add neither a `ctx.remote` namespace nor a forwarded
|
|
1815
|
-
* event: the Client's Remote namespaces and the forwarded-event allowlist are
|
|
1816
|
-
* compiled into the harness's own API assembly. The documented Client-callable
|
|
1817
|
-
* Host entry point is therefore a session command run through
|
|
1818
|
-
* `ctx.remote.commands.execute`, and the one live channel such a plugin can
|
|
1819
|
-
* publish through is the command registry itself — `commands/change` is a
|
|
1820
|
-
* forwarded event, and `commands.list(sessionId)` is a Client-callable read.
|
|
1821
|
-
*
|
|
1822
|
-
* That same registry is how a phase change leaves this process: a transition
|
|
1823
|
-
* re-registers the status verb, which emits the change a client needs to read
|
|
1824
|
-
* the new phase. It is the only way a third-party plugin can announce anything,
|
|
1825
|
-
* and it is what makes the flush that resolves `parking` into `safe` observable
|
|
1826
|
-
* at all — that step changes no registration of its own.
|
|
1827
|
-
*
|
|
1828
|
-
* So the resume definition IS the paused marker: it is registered on a paused
|
|
1829
|
-
* session's own agent context and removed on resume, and the browser half reads
|
|
1830
|
-
* "paused" from that registration rather than inventing it. A descriptor cannot
|
|
1831
|
-
* change after registration, so the park state rides a companion status
|
|
1832
|
-
* definition (registered beside the marker) whose answer is derived per call
|
|
1833
|
-
* rather than frozen into a descriptor — see `./shared.ts`. That one read
|
|
1834
|
-
* carries the record's goal opt-out too, so the session-header control can show
|
|
1835
|
-
* and switch it while the session is parked and clean — the state in which a
|
|
1836
|
-
* silent morning resume would otherwise spend the user's rounds unasked. The
|
|
1837
|
-
* switch is addressed to the session it changes, so it needs no listed row.
|
|
1838
|
-
*
|
|
1839
|
-
* The header control is the user's button for ONE session's work, and that work
|
|
1840
|
-
* includes the subagents under it: the press registers the session and its
|
|
1841
|
-
* descendant tree, the release takes the same tree back, and the status the
|
|
1842
|
-
* header shows is the worst phase among the members the press registered. The
|
|
1843
|
-
* whole-app control covers every running tree and answers for all of them; this
|
|
1844
|
-
* one answers for the tree it owns.
|
|
1845
|
-
*
|
|
1846
|
-
* A mark is not a park. A session is parked only once nothing is left to drain:
|
|
1847
|
-
* no call in flight AND either its next model request is held at the gate or no
|
|
1848
|
-
* turn is open at all (there will be no next request to hold). Only then is the
|
|
1849
|
-
* session flushed, at the session level — `SessionHandle.flush()` does not
|
|
1850
|
-
* drain the routed live buffer, so it would record a park that a crash erases.
|
|
1851
|
-
* The "safe to close" phase appears only after that flush resolves.
|
|
1852
|
-
*
|
|
1853
|
-
* Every phase a pause reaches is written to the durable registry before it is
|
|
1854
|
-
* published — including the move into `parking`, which the status command may
|
|
1855
|
-
* therefore never serve ahead of the record. A restart re-holds exactly the
|
|
1856
|
-
* sessions the last process left parked, and a phase that could not be recorded
|
|
1857
|
-
* is never shown as cleaner than the record says.
|
|
1858
|
-
*
|
|
1859
|
-
* A park verb changes state, so it acts only on a person's command: a command
|
|
1860
|
-
* dispatched from inside a model turn is refused. The listed verbs add the
|
|
1861
|
-
* one binding their target needs — the row must be one the calling session was
|
|
1862
|
-
* shown — and that rule lives with the list (`./triage.ts`).
|
|
1863
|
-
* @module dsh-pause-session/control
|
|
1864
|
-
*/
|
|
1646
|
+
//#region lib/types/snapshot.js
|
|
1865
1647
|
/**
|
|
1866
|
-
*
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
* @param agents - how many subagents this press registered alongside the session.
|
|
1871
|
-
* @returns the copy for the person who pressed.
|
|
1648
|
+
* Pure projection of the durable park and each live tree into the native
|
|
1649
|
+
* snapshot. This module owns the phase fold and the goal opt-out; it does not
|
|
1650
|
+
* press, release, or decide anything.
|
|
1651
|
+
* @module dsh-pause-session/snapshot
|
|
1872
1652
|
*/
|
|
1873
|
-
|
|
1874
|
-
|
|
1653
|
+
/** The phase a durable record holds for one session, live or adopted. */
|
|
1654
|
+
function recordedPhase(deps, sessionId) {
|
|
1655
|
+
return deps.marks.get(sessionId)?.phase ?? deps.registry.get(sessionId)?.phase;
|
|
1875
1656
|
}
|
|
1876
|
-
/**
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1657
|
+
/** Fold one session's mark and its registered descendants into the header reading. */
|
|
1658
|
+
async function treeStatus(deps, sessionId, mark, signal, knownWalk) {
|
|
1659
|
+
const walk = knownWalk ?? await walkDescendants(deps.ctx, sessionId, signal);
|
|
1660
|
+
const phases = [mark.phase];
|
|
1661
|
+
const extraRunning = [];
|
|
1662
|
+
for (const member of walk.members) {
|
|
1663
|
+
const phase = recordedPhase(deps, member.id);
|
|
1664
|
+
if (phase !== void 0) {
|
|
1665
|
+
phases.push(phase);
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
if (member.mode !== "continuable") {
|
|
1669
|
+
const activity = deps.watch.activity(member.id);
|
|
1670
|
+
if (activity.turnOpen || activity.runningTools.length > 0) {
|
|
1671
|
+
phases.push("draining");
|
|
1672
|
+
extraRunning.push(...activity.runningTools);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
for (let at = 0; at < walk.unread.length + (walk.unreadWhole ? 1 : 0); at += 1) phases.push("parking");
|
|
1677
|
+
const folded = foldTreePhases(phases);
|
|
1678
|
+
const runningTools = [
|
|
1679
|
+
...mark.phase === "draining" ? deps.watch.activity(sessionId).runningTools : [],
|
|
1680
|
+
...walk.members.flatMap((member) => recordedPhase(deps, member.id) === "draining" ? deps.watch.activity(member.id).runningTools : []),
|
|
1681
|
+
...extraRunning
|
|
1682
|
+
];
|
|
1683
|
+
const record = deps.registry.get(sessionId);
|
|
1684
|
+
return {
|
|
1685
|
+
phase: folded.phase,
|
|
1686
|
+
safe: folded.safe,
|
|
1687
|
+
runningTools,
|
|
1688
|
+
...record === void 0 ? {} : { rearmGoal: record.rearmGoal }
|
|
1689
|
+
};
|
|
1895
1690
|
}
|
|
1896
1691
|
/**
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
1900
|
-
*
|
|
1692
|
+
* Read every live mark and the whole-app aggregate in one pass.
|
|
1693
|
+
* @param deps - the control-owned marks, watch, registry, and aggregate reader.
|
|
1694
|
+
* @param signal - cancellation for the tree walks and aggregate.
|
|
1695
|
+
* @returns the native snapshot.
|
|
1901
1696
|
*/
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
*/
|
|
1915
|
-
function modelIssued(ctx) {
|
|
1916
|
-
try {
|
|
1917
|
-
return ctx.agents.currentInitiator() !== void 0;
|
|
1918
|
-
} catch {
|
|
1919
|
-
return true;
|
|
1920
|
-
}
|
|
1697
|
+
async function readSnapshot(deps, signal) {
|
|
1698
|
+
const sessions = {};
|
|
1699
|
+
const walks = /* @__PURE__ */ new Map();
|
|
1700
|
+
await Promise.all([...deps.marks].map(async ([sessionId, mark]) => {
|
|
1701
|
+
const walk = await walkDescendants(deps.ctx, sessionId, signal);
|
|
1702
|
+
if (deps.ctx.get("subagents") !== void 0) walks.set(sessionId, walk);
|
|
1703
|
+
sessions[String(sessionId)] = await treeStatus(deps, sessionId, mark, signal, walk);
|
|
1704
|
+
}));
|
|
1705
|
+
return {
|
|
1706
|
+
global: await deps.readGlobal(signal, walks),
|
|
1707
|
+
sessions
|
|
1708
|
+
};
|
|
1921
1709
|
}
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region lib/types/park-state.js
|
|
1922
1712
|
/**
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
* @
|
|
1927
|
-
* @returns whether the session can be acted on here.
|
|
1713
|
+
* One session's park episode: the durable phase writes, the activity walk that
|
|
1714
|
+
* settles them, and the press/release lifecycle. The control composes this with
|
|
1715
|
+
* the aggregate, the resume path, and the decision list.
|
|
1716
|
+
* @module dsh-pause-session/park-state
|
|
1928
1717
|
*/
|
|
1929
|
-
async function sessionReadable(ctx, sessionId) {
|
|
1930
|
-
if (ctx.sessions.get(sessionId) !== void 0) return true;
|
|
1931
|
-
const persistence = ctx.get("sessionPersistence");
|
|
1932
|
-
if (persistence === void 0) return false;
|
|
1933
|
-
try {
|
|
1934
|
-
return await persistence.stat(sessionId) !== void 0;
|
|
1935
|
-
} catch (error) {
|
|
1936
|
-
ctx.logger.warn(`pause: could not look for the stored log of "${sessionId}": ${describeError(error)}`);
|
|
1937
|
-
return true;
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
/** How resuming all parked sessions answers the command that asked for it. */
|
|
1941
|
-
function summarizeResume(attempts) {
|
|
1942
|
-
const resumed = attempts.filter((attempt) => attempt.resumed).length;
|
|
1943
|
-
const refused = attempts.length - resumed;
|
|
1944
|
-
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`;
|
|
1945
|
-
if (refused === 0) return `${head}.`;
|
|
1946
|
-
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`}.`;
|
|
1947
|
-
}
|
|
1948
1718
|
/**
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
* @param
|
|
1953
|
-
*
|
|
1954
|
-
* @
|
|
1955
|
-
* @param watch - the per-session activity fold the park predicate reads.
|
|
1956
|
-
* @param registry - the durable pause list every phase change is written to.
|
|
1957
|
-
* @param options - route knowledge the clean resume path needs, and the decision list's read bound.
|
|
1958
|
-
* @returns the mount-time adoption step and the morning resume path.
|
|
1719
|
+
* Mount the park episode state machine over the gate, registry, and activity fold.
|
|
1720
|
+
* @param ctx - host context carrying the session store and logger.
|
|
1721
|
+
* @param gate - the pause gate this state drives.
|
|
1722
|
+
* @param registry - the durable pause list every phase write goes to.
|
|
1723
|
+
* @param watch - the activity fold the park predicate reads.
|
|
1724
|
+
* @returns the park lifecycle the control composes.
|
|
1959
1725
|
*/
|
|
1960
|
-
function
|
|
1961
|
-
/** Marks per paused session; presence here mirrors the gate's own membership. */
|
|
1726
|
+
function installParkState(ctx, gate, registry, watch) {
|
|
1962
1727
|
const marks = /* @__PURE__ */ new Map();
|
|
1963
|
-
|
|
1964
|
-
const markers = /* @__PURE__ */ new Map();
|
|
1965
|
-
/**
|
|
1966
|
-
* Records this process did not hold again because the session behind them
|
|
1967
|
-
* cannot be read here. Nothing can be parked, released, or decided for them
|
|
1968
|
-
* from this process, so they are not marks — and the aggregate names them
|
|
1969
|
-
* instead of counting them among the sessions this park owns.
|
|
1970
|
-
*/
|
|
1728
|
+
const unrecorded = /* @__PURE__ */ new Set();
|
|
1971
1729
|
const unheld = /* @__PURE__ */ new Set();
|
|
1972
|
-
|
|
1730
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1731
|
+
let notification;
|
|
1973
1732
|
let generation = 0;
|
|
1733
|
+
/** One snapshot per burst of host changes, not one per phase write. */
|
|
1734
|
+
const notify = () => {
|
|
1735
|
+
if (notification !== void 0) return;
|
|
1736
|
+
notification = setTimeout(() => {
|
|
1737
|
+
notification = void 0;
|
|
1738
|
+
for (const listener of [...listeners]) listener();
|
|
1739
|
+
}, 20);
|
|
1740
|
+
};
|
|
1974
1741
|
const drop = (sessionId) => {
|
|
1975
1742
|
marks.delete(sessionId);
|
|
1976
|
-
|
|
1977
|
-
if (marker === void 0) return;
|
|
1978
|
-
markers.delete(sessionId);
|
|
1979
|
-
Promise.resolve(marker.fiber.dispose()).catch((error) => {
|
|
1980
|
-
ctx.logger.warn(`pause: session control cleanup failed: ${describeError(error)}`);
|
|
1981
|
-
});
|
|
1743
|
+
notify();
|
|
1982
1744
|
};
|
|
1983
|
-
/**
|
|
1984
|
-
* Write one phase to the durable registry, then publish it on the mark and
|
|
1985
|
-
* announce it on the command channel.
|
|
1986
|
-
*
|
|
1987
|
-
* The order is the whole point: a phase is published only once a later
|
|
1988
|
-
* process would read it back. A failed write leaves the mark on its previous
|
|
1989
|
-
* phase, which is never cleaner than what the registry holds — the direction
|
|
1990
|
-
* that matters, because "safe to close" is a promise about surviving the
|
|
1991
|
-
* process, not about this process's memory.
|
|
1992
|
-
*
|
|
1993
|
-
* A phase that actually changed is also the moment every client is owed a
|
|
1994
|
-
* fresh read: the announcement is what carries `parking` → `safe` out of this
|
|
1995
|
-
* process, since the flush that resolves it changes no registration by
|
|
1996
|
-
* itself. Announcing only on a change is also what keeps the channel quiet —
|
|
1997
|
-
* a write that repeats the phase a mark already holds tells a reader nothing,
|
|
1998
|
-
* so it must not cost one.
|
|
1999
|
-
* @param sessionId - the paused session.
|
|
2000
|
-
* @param mark - its live mark, updated in place on success.
|
|
2001
|
-
* @param phase - the phase to record and publish.
|
|
2002
|
-
* @returns whether the phase became durable.
|
|
2003
|
-
*/
|
|
2004
1745
|
const commit = async (sessionId, mark, phase) => {
|
|
2005
1746
|
try {
|
|
2006
1747
|
await registry.record(sessionId, phase);
|
|
@@ -2010,124 +1751,14 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2010
1751
|
}
|
|
2011
1752
|
const changed = mark.phase !== phase;
|
|
2012
1753
|
mark.phase = phase;
|
|
2013
|
-
if (changed)
|
|
1754
|
+
if (changed) notify();
|
|
2014
1755
|
return true;
|
|
2015
1756
|
};
|
|
2016
|
-
/** Whether this session is standing still at the park point. */
|
|
2017
1757
|
const parked = (sessionId) => {
|
|
2018
1758
|
const activity = watch.activity(sessionId);
|
|
2019
1759
|
if (activity.runningTools.length > 0) return false;
|
|
2020
1760
|
return gate.isHeld(sessionId) || !activity.turnOpen;
|
|
2021
1761
|
};
|
|
2022
|
-
/**
|
|
2023
|
-
* The phase a durable record holds for one session right now, whether or not
|
|
2024
|
-
* this process holds it. A member whose session this host cannot read still
|
|
2025
|
-
* has a record, and that record is the only thing anyone knows about it.
|
|
2026
|
-
*/
|
|
2027
|
-
const recordedPhase = (sessionId) => marks.get(sessionId)?.phase ?? registry.get(sessionId)?.phase;
|
|
2028
|
-
/**
|
|
2029
|
-
* The state this session's header reports: the worst phase among the members
|
|
2030
|
-
* the session's own press registered, folded with the session's own.
|
|
2031
|
-
*
|
|
2032
|
-
* The control speaks for a tree, so it may not say "safe to close" while a
|
|
2033
|
-
* member of that tree is still draining, stalled, or not readable — the user
|
|
2034
|
-
* would close the lid on work this press started. A branch the walk could not
|
|
2035
|
-
* read may hold a member nobody can see, so an unread branch counts as
|
|
2036
|
-
* unfinished rather than as empty.
|
|
2037
|
-
* @param mark - the session's own mark, whose phase the fold starts from.
|
|
2038
|
-
* @returns the phase to publish and the goal opt-out that rides beside it.
|
|
2039
|
-
*/
|
|
2040
|
-
const treeStatus = async (sessionId, mark, signal) => {
|
|
2041
|
-
const walk = await walkDescendants(ctx, sessionId, signal);
|
|
2042
|
-
const phases = [mark.phase];
|
|
2043
|
-
for (const member of walk.members) {
|
|
2044
|
-
const phase = recordedPhase(member.id);
|
|
2045
|
-
if (phase !== void 0) phases.push(phase);
|
|
2046
|
-
}
|
|
2047
|
-
for (let at = 0; at < walk.unread.length + (walk.unreadWhole ? 1 : 0); at += 1) phases.push("parking");
|
|
2048
|
-
const folded = foldTreePhases(phases);
|
|
2049
|
-
const record = registry.get(sessionId);
|
|
2050
|
-
return {
|
|
2051
|
-
phase: folded.phase,
|
|
2052
|
-
safe: folded.safe,
|
|
2053
|
-
runningTools: mark.phase === "draining" ? watch.activity(sessionId).runningTools : [],
|
|
2054
|
-
...record === void 0 ? {} : { rearmGoal: record.rearmGoal }
|
|
2055
|
-
};
|
|
2056
|
-
};
|
|
2057
|
-
/**
|
|
2058
|
-
* Ensure one paused session has its three verbs registered. Registration is
|
|
2059
|
-
* once per park: the status is answered by the command, not frozen into the
|
|
2060
|
-
* descriptor, so a phase change needs no re-registration to be readable — the
|
|
2061
|
-
* one {@link ParkMarkers.announce} performs is the announcement itself and
|
|
2062
|
-
* nothing more.
|
|
2063
|
-
*/
|
|
2064
|
-
const publish = (sessionId) => {
|
|
2065
|
-
const mark = marks.get(sessionId);
|
|
2066
|
-
if (mark === void 0 || markers.has(sessionId)) return;
|
|
2067
|
-
const agent = ctx.agents.get(sessionId);
|
|
2068
|
-
if (agent === void 0) return;
|
|
2069
|
-
const status = async (signal) => encodeParkStatus(await treeStatus(sessionId, mark, signal));
|
|
2070
|
-
const definition = () => ({
|
|
2071
|
-
definitionId: CommandDefinitionId(STATUS_DEFINITION_ID),
|
|
2072
|
-
name: STATUS_COMMAND,
|
|
2073
|
-
description: "Report this session's pause state",
|
|
2074
|
-
handler: async (invocation) => ({
|
|
2075
|
-
kind: "success",
|
|
2076
|
-
text: await status(invocation.signal)
|
|
2077
|
-
})
|
|
2078
|
-
});
|
|
2079
|
-
/** Re-register the status verb, which is the announcement every phase change rides. */
|
|
2080
|
-
let announce;
|
|
2081
|
-
const fiber = agent.ctx.inject(["commands"], (scope) => {
|
|
2082
|
-
scope.commands.register({
|
|
2083
|
-
definitionId: CommandDefinitionId(RESUME_DEFINITION_ID),
|
|
2084
|
-
name: RESUME_COMMAND,
|
|
2085
|
-
description: "Resume this paused session and its subagents",
|
|
2086
|
-
handler: async (invocation) => modelIssued(ctx) ? {
|
|
2087
|
-
kind: "error",
|
|
2088
|
-
text: UNATTENDED_TEXT
|
|
2089
|
-
} : (await resumePath.releaseTree(invocation.agent.id, invocation.signal)).answer
|
|
2090
|
-
});
|
|
2091
|
-
/** The status verb's current registration; {@link announce} replaces it. */
|
|
2092
|
-
let registered = scope.commands.register(definition());
|
|
2093
|
-
announce = () => {
|
|
2094
|
-
registered();
|
|
2095
|
-
registered = scope.commands.register(definition());
|
|
2096
|
-
};
|
|
2097
|
-
scope.commands.register({
|
|
2098
|
-
definitionId: CommandDefinitionId(GOAL_DEFINITION_ID),
|
|
2099
|
-
name: GOAL_COMMAND,
|
|
2100
|
-
description: "Turn this paused Session's goal re-arm on or off",
|
|
2101
|
-
handler: async () => {
|
|
2102
|
-
if (modelIssued(ctx)) return {
|
|
2103
|
-
kind: "error",
|
|
2104
|
-
text: UNATTENDED_TEXT
|
|
2105
|
-
};
|
|
2106
|
-
const record = registry.get(sessionId);
|
|
2107
|
-
if (record === void 0) return {
|
|
2108
|
-
kind: "error",
|
|
2109
|
-
text: NOT_PARKED_TEXT
|
|
2110
|
-
};
|
|
2111
|
-
return await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
|
|
2112
|
-
}
|
|
2113
|
-
});
|
|
2114
|
-
});
|
|
2115
|
-
markers.set(sessionId, {
|
|
2116
|
-
fiber,
|
|
2117
|
-
announce: () => {
|
|
2118
|
-
announce?.();
|
|
2119
|
-
}
|
|
2120
|
-
});
|
|
2121
|
-
};
|
|
2122
|
-
/**
|
|
2123
|
-
* Walk one session's mark as far forward as its current activity allows.
|
|
2124
|
-
*
|
|
2125
|
-
* The move is published by `settle`, once the write that makes `parking`
|
|
2126
|
-
* durable has resolved; until then the mark keeps answering with `draining`,
|
|
2127
|
-
* which is less than the truth rather than more than the record can back.
|
|
2128
|
-
* @param sessionId - the marked session to re-derive.
|
|
2129
|
-
* @returns whether this call moved the mark out of `draining` (and so owes a flush).
|
|
2130
|
-
*/
|
|
2131
1762
|
const advance = (sessionId) => {
|
|
2132
1763
|
const mark = marks.get(sessionId);
|
|
2133
1764
|
if (mark === void 0 || mark.phase !== "draining" || mark.advancing) return false;
|
|
@@ -2136,20 +1767,9 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2136
1767
|
mark.advancing = true;
|
|
2137
1768
|
return true;
|
|
2138
1769
|
};
|
|
2139
|
-
/**
|
|
2140
|
-
* Flush a parked session at the session level and publish what actually
|
|
2141
|
-
* happened.
|
|
2142
|
-
*
|
|
2143
|
-
* The flush is the promise that survives a crash: a handle's own flush would
|
|
2144
|
-
* leave the routed live buffer — the pending input — in memory. `safe` is
|
|
2145
|
-
* therefore reserved for a flush this process ran and that resolved; a
|
|
2146
|
-
* session that is no longer in the store cannot be flushed at all, so its
|
|
2147
|
-
* park stops at the phase that says the flush never happened.
|
|
2148
|
-
*/
|
|
2149
1770
|
const settle = async (sessionId, attempt) => {
|
|
2150
1771
|
const mark = marks.get(sessionId);
|
|
2151
1772
|
if (mark === void 0 || mark.generation !== attempt) return;
|
|
2152
|
-
/** Whether this attempt still owns the mark: a cancel or a newer press replaces or drops it. */
|
|
2153
1773
|
const owned = () => marks.get(sessionId) === mark && mark.generation === attempt;
|
|
2154
1774
|
const session = ctx.sessions.get(sessionId);
|
|
2155
1775
|
try {
|
|
@@ -2160,7 +1780,10 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2160
1780
|
await commit(sessionId, mark, "parking");
|
|
2161
1781
|
if (!owned()) return;
|
|
2162
1782
|
try {
|
|
2163
|
-
await ctx.sessions.flush(session)
|
|
1783
|
+
if (!await ctx.sessions.flush(session)) {
|
|
1784
|
+
await commit(sessionId, mark, "flush-failed");
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
2164
1787
|
} catch (error) {
|
|
2165
1788
|
if (!owned()) return;
|
|
2166
1789
|
ctx.logger.warn(`pause: flush failed for "${sessionId}": ${describeError(error)}`);
|
|
@@ -2173,33 +1796,12 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2173
1796
|
if (marks.get(sessionId) === mark && mark.phase === "draining") mark.advancing = false;
|
|
2174
1797
|
}
|
|
2175
1798
|
};
|
|
2176
|
-
/** Look at one marked session and act on what it is doing. */
|
|
2177
1799
|
const look = (sessionId) => {
|
|
2178
|
-
|
|
1800
|
+
if (marks.has(sessionId)) notify();
|
|
2179
1801
|
if (!advance(sessionId)) return;
|
|
2180
1802
|
const attempt = marks.get(sessionId)?.generation;
|
|
2181
1803
|
if (attempt !== void 0) settle(sessionId, attempt);
|
|
2182
1804
|
};
|
|
2183
|
-
/**
|
|
2184
|
-
* Press one session's pause and make it durable.
|
|
2185
|
-
*
|
|
2186
|
-
* The press IS a durable promise: a pause that cannot be recorded is taken
|
|
2187
|
-
* back here rather than left as one a restart silently forgets. A session a
|
|
2188
|
-
* pause already owns is left exactly where it is — including one adopted
|
|
2189
|
-
* from an earlier process, whose outcome is not this process's to re-derive.
|
|
2190
|
-
*
|
|
2191
|
-
* A session with nothing live is left alone entirely. It has no request to
|
|
2192
|
-
* hold and no session-level flush to run, so a record for it could only be
|
|
2193
|
-
* one of two lies: `safe`, a park that never happened but that the clean path
|
|
2194
|
-
* would silently resume, or a phase that never settles, which would keep
|
|
2195
|
-
* "safe to close" from ever appearing. Registering the running tree therefore
|
|
2196
|
-
* means registering the members a pause can actually hold and flush.
|
|
2197
|
-
* @param sessionId - the session to pause.
|
|
2198
|
-
* @param signal - cancellation for the resumability read and registry write.
|
|
2199
|
-
* @param knownResumable - whether the caller already proved the local route is
|
|
2200
|
-
* `continuable` from the parent catalog, avoiding a second Session read.
|
|
2201
|
-
* @returns what the press did; only `registered` created a new park.
|
|
2202
|
-
*/
|
|
2203
1805
|
const press = async (sessionId, signal, knownResumable = false) => {
|
|
2204
1806
|
if (ctx.sessions.get(sessionId) === void 0) return "not-live";
|
|
2205
1807
|
if (!knownResumable && !await isResumableSession(ctx, sessionId, signal)) return "not-resumable";
|
|
@@ -2213,12 +1815,132 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2213
1815
|
marks.set(sessionId, mark);
|
|
2214
1816
|
if (await commit(sessionId, mark, "draining")) {
|
|
2215
1817
|
unheld.delete(sessionId);
|
|
1818
|
+
notify();
|
|
2216
1819
|
return "registered";
|
|
2217
1820
|
}
|
|
2218
|
-
drop(sessionId);
|
|
2219
|
-
gate.resume(sessionId);
|
|
2220
|
-
return "unrecordable";
|
|
1821
|
+
drop(sessionId);
|
|
1822
|
+
gate.resume(sessionId);
|
|
1823
|
+
return "unrecordable";
|
|
1824
|
+
};
|
|
1825
|
+
const release = async (sessionId, text) => {
|
|
1826
|
+
if (!gate.isPaused(sessionId)) return {
|
|
1827
|
+
kind: "success",
|
|
1828
|
+
text
|
|
1829
|
+
};
|
|
1830
|
+
try {
|
|
1831
|
+
await registry.forget(sessionId);
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
1834
|
+
return {
|
|
1835
|
+
kind: "error",
|
|
1836
|
+
text: UNRECORDED_TEXT
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
gate.resume(sessionId);
|
|
1840
|
+
notify();
|
|
1841
|
+
return {
|
|
1842
|
+
kind: "success",
|
|
1843
|
+
text
|
|
1844
|
+
};
|
|
1845
|
+
};
|
|
1846
|
+
const sync = (sessionId) => {
|
|
1847
|
+
if (!gate.isPaused(sessionId)) {
|
|
1848
|
+
drop(sessionId);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
return {
|
|
1853
|
+
marks,
|
|
1854
|
+
unrecorded,
|
|
1855
|
+
unheld,
|
|
1856
|
+
subscribe: (listener) => {
|
|
1857
|
+
listeners.add(listener);
|
|
1858
|
+
return () => {
|
|
1859
|
+
listeners.delete(listener);
|
|
1860
|
+
};
|
|
1861
|
+
},
|
|
1862
|
+
notify,
|
|
1863
|
+
press,
|
|
1864
|
+
adopt: (sessionId, phase) => {
|
|
1865
|
+
marks.set(sessionId, {
|
|
1866
|
+
phase,
|
|
1867
|
+
generation: ++generation,
|
|
1868
|
+
origin: "registry",
|
|
1869
|
+
advancing: false
|
|
1870
|
+
});
|
|
1871
|
+
},
|
|
1872
|
+
look,
|
|
1873
|
+
drop,
|
|
1874
|
+
sync,
|
|
1875
|
+
release,
|
|
1876
|
+
markUnrecorded: (sessionId, blocked) => {
|
|
1877
|
+
if (blocked) unrecorded.add(sessionId);
|
|
1878
|
+
else unrecorded.delete(sessionId);
|
|
1879
|
+
},
|
|
1880
|
+
live: () => new Map([...marks].map(([sessionId, mark]) => [sessionId, {
|
|
1881
|
+
phase: mark.phase,
|
|
1882
|
+
moving: mark.origin === "press" && mark.phase !== "flush-failed" && ctx.sessions.get(sessionId) !== void 0
|
|
1883
|
+
}])),
|
|
1884
|
+
cleanup: () => {
|
|
1885
|
+
if (notification !== void 0) clearTimeout(notification);
|
|
1886
|
+
marks.clear();
|
|
1887
|
+
listeners.clear();
|
|
1888
|
+
}
|
|
2221
1889
|
};
|
|
1890
|
+
}
|
|
1891
|
+
//#endregion
|
|
1892
|
+
//#region lib/types/control.js
|
|
1893
|
+
/**
|
|
1894
|
+
* The session control the browser half drives: the pause verb, the park state
|
|
1895
|
+
* machine behind it, and the live state channel that accompanies both.
|
|
1896
|
+
*
|
|
1897
|
+
* The host exposes this control through the native Typert Remote namespace:
|
|
1898
|
+
* `observe` streams snapshots and `act` / `triage` / `decide` drive the UI. No
|
|
1899
|
+
* slash command is registered. Phase changes wake the Remote stream through a
|
|
1900
|
+
* local listener set; the durable registry remains the source of truth for what
|
|
1901
|
+
* the snapshot reports.
|
|
1902
|
+
* @module dsh-pause-session/control
|
|
1903
|
+
*/
|
|
1904
|
+
/**
|
|
1905
|
+
* Whether this host can read one recorded session at all — a live session, or a
|
|
1906
|
+
* stored log the persistence backend can observe.
|
|
1907
|
+
* @param ctx - host context carrying the session store and the optional persistence service.
|
|
1908
|
+
* @param sessionId - the recorded session to look for.
|
|
1909
|
+
* @returns whether the session can be acted on here.
|
|
1910
|
+
*/
|
|
1911
|
+
async function sessionReadable(ctx, sessionId) {
|
|
1912
|
+
if (ctx.sessions.get(sessionId) !== void 0) return true;
|
|
1913
|
+
const persistence = ctx.get("sessionPersistence");
|
|
1914
|
+
if (persistence === void 0) return false;
|
|
1915
|
+
try {
|
|
1916
|
+
return await persistence.stat(sessionId) !== void 0;
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
ctx.logger.warn(`pause: could not look for the stored log of "${sessionId}": ${describeError(error)}`);
|
|
1919
|
+
return false;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
/** How resuming all parked sessions answers the caller. */
|
|
1923
|
+
function summarizeResume(attempts) {
|
|
1924
|
+
const resumed = attempts.filter((attempt) => attempt.resumed).length;
|
|
1925
|
+
const refused = attempts.length - resumed;
|
|
1926
|
+
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`;
|
|
1927
|
+
if (refused === 0) return `${head}.`;
|
|
1928
|
+
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`}.`;
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Contribute the session control: the pause verb, the per-session status read,
|
|
1932
|
+
* the global resume verb, and — for every paused session that has a live agent
|
|
1933
|
+
* — the resume marker.
|
|
1934
|
+
* @param ctx - host context carrying the agent store, the
|
|
1935
|
+
* session store, and the projection registry.
|
|
1936
|
+
* @param gate - the pause gate this control drives and reflects.
|
|
1937
|
+
* @param watch - the per-session activity fold the park predicate reads.
|
|
1938
|
+
* @param registry - the durable pause list every phase change is written to.
|
|
1939
|
+
* @param options - route knowledge the clean resume path needs, and the decision list's read bound.
|
|
1940
|
+
* @returns the mount-time adoption step and the morning resume path.
|
|
1941
|
+
*/
|
|
1942
|
+
function installSessionControl(ctx, gate, watch, registry, options) {
|
|
1943
|
+
const park = installParkState(ctx, gate, registry, watch);
|
|
2222
1944
|
/**
|
|
2223
1945
|
* Press one session's pause and register its whole tree behind it.
|
|
2224
1946
|
*
|
|
@@ -2242,29 +1964,35 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2242
1964
|
registered: 0,
|
|
2243
1965
|
rootRegistered: false,
|
|
2244
1966
|
unrecorded: [],
|
|
2245
|
-
unread: false
|
|
1967
|
+
unread: false,
|
|
1968
|
+
unresumable: [],
|
|
1969
|
+
unreadBranches: []
|
|
2246
1970
|
};
|
|
2247
1971
|
const walk = await walkDescendants(ctx, sessionId, signal);
|
|
2248
1972
|
if (walk.unreadWhole) {
|
|
2249
|
-
const pressed = await press(sessionId, signal);
|
|
2250
|
-
if (pressed === "registered") look(sessionId);
|
|
1973
|
+
const pressed = await park.press(sessionId, signal);
|
|
1974
|
+
if (pressed === "registered") park.look(sessionId);
|
|
2251
1975
|
globalPause.markUnrecorded(sessionId, pressed === "unrecordable");
|
|
2252
1976
|
return {
|
|
2253
1977
|
members: pressed === "registered" ? 1 : 0,
|
|
2254
1978
|
registered: pressed === "registered" ? 1 : 0,
|
|
2255
1979
|
rootRegistered: pressed === "registered",
|
|
2256
1980
|
unrecorded: pressed === "unrecordable" ? [sessionId] : [],
|
|
2257
|
-
unread: true
|
|
1981
|
+
unread: true,
|
|
1982
|
+
unresumable: [],
|
|
1983
|
+
unreadBranches: []
|
|
2258
1984
|
};
|
|
2259
1985
|
}
|
|
2260
1986
|
const registered = [];
|
|
2261
1987
|
const unrecorded = [];
|
|
1988
|
+
const unresumable = walk.members.filter((member) => member.mode !== "continuable").map((member) => member.id);
|
|
1989
|
+
const unreadBranches = walk.unread.map((branch) => branch.id);
|
|
2262
1990
|
const members = walk.members.filter((member) => member.mode === "continuable");
|
|
2263
1991
|
for (const id of [sessionId, ...members.map((member) => member.id)]) {
|
|
2264
|
-
const pressed = await press(id, signal, id !== sessionId);
|
|
1992
|
+
const pressed = await park.press(id, signal, id !== sessionId);
|
|
2265
1993
|
if (pressed === "registered") {
|
|
2266
1994
|
registered.push(id);
|
|
2267
|
-
look(id);
|
|
1995
|
+
park.look(id);
|
|
2268
1996
|
} else if (pressed === "unrecordable") unrecorded.push(id);
|
|
2269
1997
|
globalPause.markUnrecorded(id, pressed === "unrecordable");
|
|
2270
1998
|
}
|
|
@@ -2273,214 +2001,115 @@ function installSessionControl(ctx, gate, watch, registry, options) {
|
|
|
2273
2001
|
registered: registered.length,
|
|
2274
2002
|
rootRegistered: registered.includes(sessionId),
|
|
2275
2003
|
unrecorded,
|
|
2276
|
-
unread:
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
const sync = (sessionId) => {
|
|
2280
|
-
if (!gate.isPaused(sessionId)) {
|
|
2281
|
-
drop(sessionId);
|
|
2282
|
-
return;
|
|
2283
|
-
}
|
|
2284
|
-
publish(sessionId);
|
|
2285
|
-
};
|
|
2286
|
-
/**
|
|
2287
|
-
* Take the pause back: the durable record goes first, so a process that dies
|
|
2288
|
-
* in between does not re-hold a session the user has already released. A
|
|
2289
|
-
* record that cannot be dropped leaves the session paused, which is the state
|
|
2290
|
-
* the registry still describes.
|
|
2291
|
-
*/
|
|
2292
|
-
const release = async (sessionId, text) => {
|
|
2293
|
-
if (!gate.isPaused(sessionId)) return {
|
|
2294
|
-
kind: "success",
|
|
2295
|
-
text
|
|
2296
|
-
};
|
|
2297
|
-
try {
|
|
2298
|
-
await registry.forget(sessionId);
|
|
2299
|
-
} catch (error) {
|
|
2300
|
-
ctx.logger.warn(`pause: could not drop "${sessionId}" from the pause registry: ${describeError(error)}`);
|
|
2301
|
-
return {
|
|
2302
|
-
kind: "error",
|
|
2303
|
-
text: UNRECORDED_TEXT
|
|
2304
|
-
};
|
|
2305
|
-
}
|
|
2306
|
-
gate.resume(sessionId);
|
|
2307
|
-
return {
|
|
2308
|
-
kind: "success",
|
|
2309
|
-
text
|
|
2004
|
+
unread: unreadBranches.length > 0,
|
|
2005
|
+
unresumable,
|
|
2006
|
+
unreadBranches
|
|
2310
2007
|
};
|
|
2311
2008
|
};
|
|
2312
2009
|
const resumePath = installResumePath(ctx, gate, registry, options, {
|
|
2313
|
-
release,
|
|
2314
|
-
adopted: (sessionId) => marks.get(sessionId)?.origin === "registry"
|
|
2010
|
+
release: park.release,
|
|
2011
|
+
adopted: (sessionId) => park.marks.get(sessionId)?.origin === "registry"
|
|
2315
2012
|
});
|
|
2316
2013
|
const triage = mountTriageList(ctx, registry, resumePath, options.maxListedLogEvents);
|
|
2317
|
-
/**
|
|
2318
|
-
* The one handler the four decision verbs share. The list is addressed by
|
|
2319
|
-
* session like every other command on this channel, so the row the user
|
|
2320
|
-
* decided on travels as the command's own input. The calling session is what
|
|
2321
|
-
* makes that row a target: the list acts only on rows it published to this
|
|
2322
|
-
* caller, and never on a model's command.
|
|
2323
|
-
*/
|
|
2324
|
-
const decide = (action) => async (invocation) => {
|
|
2325
|
-
if (modelIssued(ctx)) return {
|
|
2326
|
-
kind: "error",
|
|
2327
|
-
text: UNATTENDED_TEXT
|
|
2328
|
-
};
|
|
2329
|
-
const target = invocation.rawInput.trim();
|
|
2330
|
-
if (target.length === 0) return {
|
|
2331
|
-
kind: "error",
|
|
2332
|
-
text: TRIAGE_NOT_LISTED_TEXT
|
|
2333
|
-
};
|
|
2334
|
-
return await triage.decide(invocation.agent.id, SessionId(target), action, invocation.signal);
|
|
2335
|
-
};
|
|
2336
2014
|
const globalPause = mountGlobalPause(ctx, watch, registry, {
|
|
2337
2015
|
isPaused: (sessionId) => gate.isPaused(sessionId),
|
|
2338
|
-
press,
|
|
2339
|
-
look,
|
|
2340
|
-
live:
|
|
2341
|
-
|
|
2342
|
-
moving: mark.origin === "press" && mark.phase !== "flush-failed" && ctx.sessions.get(sessionId) !== void 0
|
|
2343
|
-
}])),
|
|
2344
|
-
unheld: () => unheld
|
|
2016
|
+
press: park.press,
|
|
2017
|
+
look: park.look,
|
|
2018
|
+
live: park.live,
|
|
2019
|
+
unheld: () => park.unheld
|
|
2345
2020
|
});
|
|
2021
|
+
const readSnapshot$1 = (signal) => readSnapshot({
|
|
2022
|
+
ctx,
|
|
2023
|
+
marks: park.marks,
|
|
2024
|
+
watch,
|
|
2025
|
+
registry,
|
|
2026
|
+
readGlobal: globalPause.readState
|
|
2027
|
+
}, signal);
|
|
2346
2028
|
ctx.effect(function* () {
|
|
2347
|
-
yield ctx.commands.register({
|
|
2348
|
-
definitionId: CommandDefinitionId(PAUSE_DEFINITION_ID),
|
|
2349
|
-
name: PAUSE_COMMAND,
|
|
2350
|
-
description: "Pause this Session and its subagents before their next model request",
|
|
2351
|
-
handler: async (invocation) => {
|
|
2352
|
-
if (modelIssued(ctx)) return {
|
|
2353
|
-
kind: "error",
|
|
2354
|
-
text: UNATTENDED_TEXT
|
|
2355
|
-
};
|
|
2356
|
-
const sessionId = invocation.agent.id;
|
|
2357
|
-
const pressed = await pressTree(sessionId, invocation.signal);
|
|
2358
|
-
if (pressed.unrecorded.length > 0) return {
|
|
2359
|
-
kind: "error",
|
|
2360
|
-
text: unrecordedTreeText(pressed.unrecorded)
|
|
2361
|
-
};
|
|
2362
|
-
if (!pressed.rootRegistered) return {
|
|
2363
|
-
kind: "error",
|
|
2364
|
-
text: NOT_RESUMABLE_PARK_TEXT
|
|
2365
|
-
};
|
|
2366
|
-
const agents = pressed.rootRegistered ? pressed.registered - 1 : pressed.registered;
|
|
2367
|
-
if (pressed.unread) return {
|
|
2368
|
-
kind: "success",
|
|
2369
|
-
text: `${pausedText(agents)} ${TREE_UNREAD_TEXT}`
|
|
2370
|
-
};
|
|
2371
|
-
return {
|
|
2372
|
-
kind: "success",
|
|
2373
|
-
text: pausedText(agents)
|
|
2374
|
-
};
|
|
2375
|
-
}
|
|
2376
|
-
});
|
|
2377
|
-
yield ctx.commands.register({
|
|
2378
|
-
definitionId: CommandDefinitionId(RESUME_ALL_DEFINITION_ID),
|
|
2379
|
-
name: RESUME_ALL_COMMAND,
|
|
2380
|
-
description: "Resume every paused session",
|
|
2381
|
-
handler: async (invocation) => modelIssued(ctx) ? {
|
|
2382
|
-
kind: "error",
|
|
2383
|
-
text: UNATTENDED_TEXT
|
|
2384
|
-
} : {
|
|
2385
|
-
kind: "success",
|
|
2386
|
-
text: summarizeResume(await resumePath.resumeParked(invocation.signal))
|
|
2387
|
-
}
|
|
2388
|
-
});
|
|
2389
|
-
yield ctx.commands.register({
|
|
2390
|
-
definitionId: CommandDefinitionId(PAUSE_ALL_DEFINITION_ID),
|
|
2391
|
-
name: PAUSE_ALL_COMMAND,
|
|
2392
|
-
description: "Pause every running Session and its subagents",
|
|
2393
|
-
handler: async (invocation) => modelIssued(ctx) ? {
|
|
2394
|
-
kind: "error",
|
|
2395
|
-
text: UNATTENDED_TEXT
|
|
2396
|
-
} : globalPause.pressAll(invocation.signal)
|
|
2397
|
-
});
|
|
2398
|
-
yield ctx.commands.register({
|
|
2399
|
-
definitionId: CommandDefinitionId(STATE_ALL_DEFINITION_ID),
|
|
2400
|
-
name: STATE_ALL_COMMAND,
|
|
2401
|
-
description: "Report whether every session is paused",
|
|
2402
|
-
handler: async (invocation) => globalPause.readStatus(invocation.signal)
|
|
2403
|
-
});
|
|
2404
|
-
yield ctx.commands.register({
|
|
2405
|
-
definitionId: CommandDefinitionId(TRIAGE_DEFINITION_ID),
|
|
2406
|
-
name: TRIAGE_COMMAND,
|
|
2407
|
-
description: "List sessions that did not pause completely",
|
|
2408
|
-
handler: async (invocation) => ({
|
|
2409
|
-
kind: "success",
|
|
2410
|
-
text: encodeTriageList(await triage.read(invocation.agent.id, invocation.signal))
|
|
2411
|
-
})
|
|
2412
|
-
});
|
|
2413
|
-
yield ctx.commands.register({
|
|
2414
|
-
definitionId: CommandDefinitionId(TRIAGE_CONTINUE_DEFINITION_ID),
|
|
2415
|
-
name: TRIAGE_CONTINUE_COMMAND,
|
|
2416
|
-
description: "Resume a listed session: <session id>",
|
|
2417
|
-
handler: decide("continue")
|
|
2418
|
-
});
|
|
2419
|
-
yield ctx.commands.register({
|
|
2420
|
-
definitionId: CommandDefinitionId(TRIAGE_NOTE_DEFINITION_ID),
|
|
2421
|
-
name: TRIAGE_NOTE_COMMAND,
|
|
2422
|
-
description: "Resume a listed session with a note that its tool result is unknown: <session id>",
|
|
2423
|
-
handler: decide("note")
|
|
2424
|
-
});
|
|
2425
|
-
yield ctx.commands.register({
|
|
2426
|
-
definitionId: CommandDefinitionId(TRIAGE_REVIEW_DEFINITION_ID),
|
|
2427
|
-
name: TRIAGE_REVIEW_COMMAND,
|
|
2428
|
-
description: "Open a listed session without running it: <session id>",
|
|
2429
|
-
handler: decide("review")
|
|
2430
|
-
});
|
|
2431
|
-
yield ctx.commands.register({
|
|
2432
|
-
definitionId: CommandDefinitionId(TRIAGE_LEAVE_DEFINITION_ID),
|
|
2433
|
-
name: TRIAGE_LEAVE_COMMAND,
|
|
2434
|
-
description: "Keep a listed session paused: <session id>",
|
|
2435
|
-
handler: decide("leave")
|
|
2436
|
-
});
|
|
2437
|
-
yield ctx.commands.register({
|
|
2438
|
-
definitionId: CommandDefinitionId(TRIAGE_GOAL_DEFINITION_ID),
|
|
2439
|
-
name: TRIAGE_GOAL_COMMAND,
|
|
2440
|
-
description: "Turn a listed Session's goal re-arm on or off: <session id>",
|
|
2441
|
-
handler: decide("goal")
|
|
2442
|
-
});
|
|
2443
2029
|
yield gate.onChange((sessionId) => {
|
|
2444
|
-
sync(sessionId);
|
|
2030
|
+
park.sync(sessionId);
|
|
2445
2031
|
});
|
|
2446
2032
|
yield gate.onHeld((sessionId) => {
|
|
2447
2033
|
queueMicrotask(() => {
|
|
2448
|
-
look(sessionId);
|
|
2034
|
+
park.look(sessionId);
|
|
2449
2035
|
});
|
|
2450
2036
|
});
|
|
2451
2037
|
yield watch.onChange((sessionId) => {
|
|
2452
|
-
look(sessionId);
|
|
2038
|
+
park.look(sessionId);
|
|
2453
2039
|
});
|
|
2454
2040
|
yield ctx.on("agent/created", ({ agent }) => {
|
|
2455
|
-
sync(agent.id);
|
|
2456
|
-
});
|
|
2457
|
-
yield ctx.on("agent/disposed", ({ agent }) => {
|
|
2458
|
-
markers.delete(agent.id);
|
|
2041
|
+
park.sync(agent.id);
|
|
2459
2042
|
});
|
|
2460
2043
|
yield async () => {
|
|
2461
|
-
|
|
2462
|
-
markers.clear();
|
|
2463
|
-
marks.clear();
|
|
2464
|
-
await Promise.all(pending);
|
|
2044
|
+
park.cleanup();
|
|
2465
2045
|
};
|
|
2466
|
-
}, "pause: session
|
|
2467
|
-
return {
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2046
|
+
}, "pause: session events");
|
|
2047
|
+
return {
|
|
2048
|
+
readSnapshot: readSnapshot$1,
|
|
2049
|
+
subscribe: park.subscribe,
|
|
2050
|
+
pressTree: async (sessionId, signal) => await pressTree(sessionId, signal),
|
|
2051
|
+
releaseTree: async (sessionId, signal) => await resumePath.releaseTree(sessionId, signal),
|
|
2052
|
+
pauseAll: async (signal) => {
|
|
2053
|
+
const answer = await globalPause.pressAll(signal);
|
|
2054
|
+
park.notify();
|
|
2055
|
+
return answer;
|
|
2056
|
+
},
|
|
2057
|
+
cancelAll: async (signal) => {
|
|
2058
|
+
const outcomes = [];
|
|
2059
|
+
for (let round = 0; round < 3; round += 1) {
|
|
2060
|
+
const before = registry.parked.size;
|
|
2061
|
+
for (const sessionId of [...registry.parked.keys()]) {
|
|
2062
|
+
signal?.throwIfAborted();
|
|
2063
|
+
outcomes.push((await resumePath.releaseTree(sessionId, signal)).answer);
|
|
2064
|
+
}
|
|
2065
|
+
if (registry.parked.size === before) break;
|
|
2473
2066
|
}
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2067
|
+
park.notify();
|
|
2068
|
+
const failed = outcomes.filter((answer) => answer.kind === "error");
|
|
2069
|
+
return failed.length === 0 ? {
|
|
2070
|
+
kind: "success",
|
|
2071
|
+
text: "Every pause was released."
|
|
2072
|
+
} : {
|
|
2073
|
+
kind: "error",
|
|
2074
|
+
text: failed.map((answer) => answer.text).join(" ")
|
|
2075
|
+
};
|
|
2076
|
+
},
|
|
2077
|
+
resumeAll: async (signal) => {
|
|
2078
|
+
const answer = {
|
|
2079
|
+
kind: "success",
|
|
2080
|
+
text: summarizeResume(await resumePath.resumeParked(signal))
|
|
2081
|
+
};
|
|
2082
|
+
park.notify();
|
|
2083
|
+
return answer;
|
|
2084
|
+
},
|
|
2085
|
+
toggleGoal: async (sessionId) => {
|
|
2086
|
+
const record = registry.get(sessionId);
|
|
2087
|
+
if (record === void 0) return {
|
|
2088
|
+
kind: "error",
|
|
2089
|
+
text: NOT_PARKED_TEXT
|
|
2090
|
+
};
|
|
2091
|
+
const answer = await resumePath.setRearmGoal(sessionId, !record.rearmGoal);
|
|
2092
|
+
park.notify();
|
|
2093
|
+
return answer;
|
|
2094
|
+
},
|
|
2095
|
+
readTriage: async (caller, signal) => await triage.read(caller, signal),
|
|
2096
|
+
decideTriage: async (caller, target, action, signal) => {
|
|
2097
|
+
const answer = await triage.decide(caller, target, action, signal);
|
|
2098
|
+
park.notify();
|
|
2099
|
+
return answer;
|
|
2100
|
+
},
|
|
2101
|
+
adopt: async () => {
|
|
2102
|
+
for (const entry of registry.list()) {
|
|
2103
|
+
if (!await sessionReadable(ctx, entry.sessionId)) {
|
|
2104
|
+
park.unheld.add(entry.sessionId);
|
|
2105
|
+
ctx.logger.warn(`pause: the pause recorded for "${entry.sessionId}" is not held: this host cannot read that session`);
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (gate.pause(entry.sessionId)) park.adopt(entry.sessionId, entry.phase);
|
|
2109
|
+
}
|
|
2110
|
+
for (const agent of ctx.agents.list()) park.sync(agent.id);
|
|
2481
2111
|
}
|
|
2482
|
-
|
|
2483
|
-
} };
|
|
2112
|
+
};
|
|
2484
2113
|
}
|
|
2485
2114
|
//#endregion
|
|
2486
2115
|
//#region lib/types/gate.js
|
|
@@ -2784,6 +2413,399 @@ async function mountParkWatch(ctx) {
|
|
|
2784
2413
|
}
|
|
2785
2414
|
};
|
|
2786
2415
|
}
|
|
2416
|
+
//#endregion
|
|
2417
|
+
//#region lib/types/remote.js
|
|
2418
|
+
/**
|
|
2419
|
+
* Native Remote transport for the pause controls.
|
|
2420
|
+
*
|
|
2421
|
+
* This module is deliberately self-contained: it imports only Cordis and the
|
|
2422
|
+
* Typert decorators. The Typert generator analyzes this file alone so a
|
|
2423
|
+
* third-party package can emit its Host reflection and Client contribution
|
|
2424
|
+
* without pulling the plugin's DSH-typed modules into the generation face.
|
|
2425
|
+
*
|
|
2426
|
+
* The UI uses this namespace on Web and Desktop alike. No slash command is
|
|
2427
|
+
* registered; the stream is the live status channel and the methods below are
|
|
2428
|
+
* the button actions.
|
|
2429
|
+
* @module dsh-pause-session/remote
|
|
2430
|
+
*/
|
|
2431
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
2432
|
+
var useValue = arguments.length > 2;
|
|
2433
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
2434
|
+
return useValue ? value : void 0;
|
|
2435
|
+
};
|
|
2436
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
2437
|
+
function accept(f) {
|
|
2438
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
2439
|
+
return f;
|
|
2440
|
+
}
|
|
2441
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
2442
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
2443
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
2444
|
+
var _, done = false;
|
|
2445
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
2446
|
+
var context = {};
|
|
2447
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
2448
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
2449
|
+
context.addInitializer = function(f) {
|
|
2450
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
2451
|
+
extraInitializers.push(accept(f || null));
|
|
2452
|
+
};
|
|
2453
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
2454
|
+
get: descriptor.get,
|
|
2455
|
+
set: descriptor.set
|
|
2456
|
+
} : descriptor[key], context);
|
|
2457
|
+
if (kind === "accessor") {
|
|
2458
|
+
if (result === void 0) continue;
|
|
2459
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
2460
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
2461
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
2462
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
2463
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
2464
|
+
else descriptor[key] = _;
|
|
2465
|
+
}
|
|
2466
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
2467
|
+
done = true;
|
|
2468
|
+
};
|
|
2469
|
+
/** Host callbacks the Remote service delegates to. */
|
|
2470
|
+
/**
|
|
2471
|
+
* Refuse a Remote call made by a principal other than the owner. In-process
|
|
2472
|
+
* callers (tests, the Host itself) have no invocation and are trusted.
|
|
2473
|
+
* @param ctx - the service Context.
|
|
2474
|
+
*/
|
|
2475
|
+
function ownerOnly(ctx) {
|
|
2476
|
+
const invocation = ctx.invocation;
|
|
2477
|
+
if (invocation === void 0) return;
|
|
2478
|
+
if (invocation.peer.principal?.kind !== "owner") throw new RemoteError("pause/owner-only", "pause controls are available only to the owner", {});
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Record one state-changing Remote action in the host log. The principal is
|
|
2482
|
+
* the owning user for owner calls and trusted in-process invocations; no wire
|
|
2483
|
+
* field can forge it.
|
|
2484
|
+
* @param ctx - the service Context.
|
|
2485
|
+
* @param action - the action pressed.
|
|
2486
|
+
* @param sessionId - the addressed Session.
|
|
2487
|
+
*/
|
|
2488
|
+
function logAction(ctx, action, sessionId) {
|
|
2489
|
+
const invocation = ctx.invocation;
|
|
2490
|
+
const principal = principalOf(invocation);
|
|
2491
|
+
ctx.logger.info(`pause: ${action} "${sessionId}"`, {
|
|
2492
|
+
action,
|
|
2493
|
+
sessionId,
|
|
2494
|
+
principal: principal?.kind ?? "owner"
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
/** Host service exposed to the browser as the `pauseSession` Remote namespace. */
|
|
2498
|
+
let PauseRemote = (() => {
|
|
2499
|
+
let _classSuper = TypertRemoteService;
|
|
2500
|
+
let _instanceExtraInitializers = [];
|
|
2501
|
+
let _snapshot_decorators;
|
|
2502
|
+
let _observe_decorators;
|
|
2503
|
+
let _act_decorators;
|
|
2504
|
+
let _triage_decorators;
|
|
2505
|
+
let _decide_decorators;
|
|
2506
|
+
return class PauseRemote extends _classSuper {
|
|
2507
|
+
static {
|
|
2508
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
2509
|
+
_snapshot_decorators = [Remote("snapshot")];
|
|
2510
|
+
_observe_decorators = [Remote({ mode: "stream" })];
|
|
2511
|
+
_act_decorators = [Remote("act")];
|
|
2512
|
+
_triage_decorators = [Remote("triage")];
|
|
2513
|
+
_decide_decorators = [Remote("decide")];
|
|
2514
|
+
__esDecorate(this, null, _snapshot_decorators, {
|
|
2515
|
+
kind: "method",
|
|
2516
|
+
name: "snapshot",
|
|
2517
|
+
static: false,
|
|
2518
|
+
private: false,
|
|
2519
|
+
access: {
|
|
2520
|
+
has: (obj) => "snapshot" in obj,
|
|
2521
|
+
get: (obj) => obj.snapshot
|
|
2522
|
+
},
|
|
2523
|
+
metadata: _metadata
|
|
2524
|
+
}, null, _instanceExtraInitializers);
|
|
2525
|
+
__esDecorate(this, null, _observe_decorators, {
|
|
2526
|
+
kind: "method",
|
|
2527
|
+
name: "observe",
|
|
2528
|
+
static: false,
|
|
2529
|
+
private: false,
|
|
2530
|
+
access: {
|
|
2531
|
+
has: (obj) => "observe" in obj,
|
|
2532
|
+
get: (obj) => obj.observe
|
|
2533
|
+
},
|
|
2534
|
+
metadata: _metadata
|
|
2535
|
+
}, null, _instanceExtraInitializers);
|
|
2536
|
+
__esDecorate(this, null, _act_decorators, {
|
|
2537
|
+
kind: "method",
|
|
2538
|
+
name: "act",
|
|
2539
|
+
static: false,
|
|
2540
|
+
private: false,
|
|
2541
|
+
access: {
|
|
2542
|
+
has: (obj) => "act" in obj,
|
|
2543
|
+
get: (obj) => obj.act
|
|
2544
|
+
},
|
|
2545
|
+
metadata: _metadata
|
|
2546
|
+
}, null, _instanceExtraInitializers);
|
|
2547
|
+
__esDecorate(this, null, _triage_decorators, {
|
|
2548
|
+
kind: "method",
|
|
2549
|
+
name: "triage",
|
|
2550
|
+
static: false,
|
|
2551
|
+
private: false,
|
|
2552
|
+
access: {
|
|
2553
|
+
has: (obj) => "triage" in obj,
|
|
2554
|
+
get: (obj) => obj.triage
|
|
2555
|
+
},
|
|
2556
|
+
metadata: _metadata
|
|
2557
|
+
}, null, _instanceExtraInitializers);
|
|
2558
|
+
__esDecorate(this, null, _decide_decorators, {
|
|
2559
|
+
kind: "method",
|
|
2560
|
+
name: "decide",
|
|
2561
|
+
static: false,
|
|
2562
|
+
private: false,
|
|
2563
|
+
access: {
|
|
2564
|
+
has: (obj) => "decide" in obj,
|
|
2565
|
+
get: (obj) => obj.decide
|
|
2566
|
+
},
|
|
2567
|
+
metadata: _metadata
|
|
2568
|
+
}, null, _instanceExtraInitializers);
|
|
2569
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
2570
|
+
enumerable: true,
|
|
2571
|
+
configurable: true,
|
|
2572
|
+
writable: true,
|
|
2573
|
+
value: _metadata
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
api = __runInitializers(this, _instanceExtraInitializers);
|
|
2577
|
+
/** One snapshot per host version, shared by every connected observer. */
|
|
2578
|
+
snapshotCache;
|
|
2579
|
+
/** Bumped by every host notification; each observer compares it to its own cursor. */
|
|
2580
|
+
version = 0;
|
|
2581
|
+
/** Observers waiting for the next version. */
|
|
2582
|
+
waiters = /* @__PURE__ */ new Set();
|
|
2583
|
+
unsubscribe;
|
|
2584
|
+
/**
|
|
2585
|
+
* @param ctx - host context owning the service.
|
|
2586
|
+
* @param api - the pause engine behind the wire methods.
|
|
2587
|
+
*/
|
|
2588
|
+
constructor(ctx, api) {
|
|
2589
|
+
super(ctx, "pauseSession", { namespace: "pauseSession" });
|
|
2590
|
+
this.api = api;
|
|
2591
|
+
this.unsubscribe = this.api.subscribe(() => {
|
|
2592
|
+
this.version += 1;
|
|
2593
|
+
this.snapshotCache = void 0;
|
|
2594
|
+
for (const wake of [...this.waiters]) wake();
|
|
2595
|
+
this.waiters.clear();
|
|
2596
|
+
});
|
|
2597
|
+
ctx.effect(() => this.unsubscribe, "pause: remote updates");
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* One exact snapshot, used by tests and by a reconnect that cannot wait for
|
|
2601
|
+
* the first stream item.
|
|
2602
|
+
* @param signal - carrier cancellation.
|
|
2603
|
+
* @returns the current aggregate and session states.
|
|
2604
|
+
*/
|
|
2605
|
+
async snapshot(signal) {
|
|
2606
|
+
ownerOnly(this.ctx);
|
|
2607
|
+
return await this.api.snapshot(signal);
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* The snapshot for one host version, computed once even when several clients
|
|
2611
|
+
* are watching. A new notification discards the cached promise.
|
|
2612
|
+
* @param version - the host version this snapshot answers.
|
|
2613
|
+
* @param signal - stream cancellation.
|
|
2614
|
+
* @returns the shared snapshot promise.
|
|
2615
|
+
*/
|
|
2616
|
+
snapshotFor(version, signal) {
|
|
2617
|
+
if (this.snapshotCache?.version === version) return this.snapshotCache.value;
|
|
2618
|
+
const value = this.api.snapshot(signal);
|
|
2619
|
+
this.snapshotCache = {
|
|
2620
|
+
version,
|
|
2621
|
+
value
|
|
2622
|
+
};
|
|
2623
|
+
return value;
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Live status: the opening snapshot, then one new snapshot whenever the host
|
|
2627
|
+
* reports a change.
|
|
2628
|
+
* @param signal - stream cancellation.
|
|
2629
|
+
* @returns the snapshot stream.
|
|
2630
|
+
*/
|
|
2631
|
+
async *observe(signal) {
|
|
2632
|
+
ownerOnly(this.ctx);
|
|
2633
|
+
let seen = -1;
|
|
2634
|
+
for (;;) {
|
|
2635
|
+
signal?.throwIfAborted();
|
|
2636
|
+
if (this.version !== seen) {
|
|
2637
|
+
seen = this.version;
|
|
2638
|
+
yield await this.snapshotFor(seen, signal);
|
|
2639
|
+
continue;
|
|
2640
|
+
}
|
|
2641
|
+
await new Promise((resolve, reject) => {
|
|
2642
|
+
const finish = () => {
|
|
2643
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2644
|
+
this.waiters.delete(finish);
|
|
2645
|
+
resolve();
|
|
2646
|
+
};
|
|
2647
|
+
const onAbort = () => {
|
|
2648
|
+
this.waiters.delete(finish);
|
|
2649
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2650
|
+
reject(signal?.reason);
|
|
2651
|
+
};
|
|
2652
|
+
this.waiters.add(finish);
|
|
2653
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2654
|
+
if (this.version !== seen) finish();
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Pause, resume, press every tree, resume every clean park, or flip one
|
|
2660
|
+
* Session's goal re-arm.
|
|
2661
|
+
* @param request - the button action.
|
|
2662
|
+
* @param signal - carrier cancellation.
|
|
2663
|
+
* @returns the user-facing outcome.
|
|
2664
|
+
*/
|
|
2665
|
+
async act(request, signal) {
|
|
2666
|
+
ownerOnly(this.ctx);
|
|
2667
|
+
logAction(this.ctx, request.action, request.sessionId);
|
|
2668
|
+
return await this.api.act(request, signal);
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Read the morning decision list.
|
|
2672
|
+
* @param request - the calling Session whose list is read.
|
|
2673
|
+
* @param signal - read cancellation.
|
|
2674
|
+
* @returns the listed rows.
|
|
2675
|
+
*/
|
|
2676
|
+
async triage(request, signal) {
|
|
2677
|
+
ownerOnly(this.ctx);
|
|
2678
|
+
return await this.api.triage(request, signal);
|
|
2679
|
+
}
|
|
2680
|
+
/**
|
|
2681
|
+
* Take one decision on one listed Session.
|
|
2682
|
+
* @param request - the caller, target, and decision.
|
|
2683
|
+
* @param signal - load cancellation.
|
|
2684
|
+
* @returns the user-facing outcome.
|
|
2685
|
+
*/
|
|
2686
|
+
async decide(request, signal) {
|
|
2687
|
+
ownerOnly(this.ctx);
|
|
2688
|
+
logAction(this.ctx, request.decision, request.target);
|
|
2689
|
+
return await this.api.decide(request, signal);
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
})();
|
|
2693
|
+
//#endregion
|
|
2694
|
+
//#region lib/types/remote-adapter.js
|
|
2695
|
+
/**
|
|
2696
|
+
* Wire adapter between the pause engine and the generated Remote namespace.
|
|
2697
|
+
*
|
|
2698
|
+
* The engine speaks in branded Session ids and user-facing answer copy; the Remote
|
|
2699
|
+
* contract is plain JSON. Mapping lives here so neither side depends on the
|
|
2700
|
+
* other's types.
|
|
2701
|
+
* @module dsh-pause-session/remote-adapter
|
|
2702
|
+
*/
|
|
2703
|
+
/** Map the engine's aggregate status to the wire shape. */
|
|
2704
|
+
function globalOf(status) {
|
|
2705
|
+
return {
|
|
2706
|
+
verdict: status.verdict,
|
|
2707
|
+
registered: status.registered,
|
|
2708
|
+
safe: status.safe,
|
|
2709
|
+
...status.parkedAt === void 0 ? {} : { parkedAt: String(status.parkedAt) },
|
|
2710
|
+
draining: status.draining.map(String),
|
|
2711
|
+
parking: status.parking,
|
|
2712
|
+
stalled: status.stalled.map(String),
|
|
2713
|
+
unresumable: status.unresumable.map(String),
|
|
2714
|
+
unreadTrees: status.unreadTrees.map(String),
|
|
2715
|
+
jobs: status.jobs.map(String),
|
|
2716
|
+
blind: [...status.blind]
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
/** Map one engine status to the wire shape. */
|
|
2720
|
+
function sessionOf(status) {
|
|
2721
|
+
return {
|
|
2722
|
+
phase: status.phase,
|
|
2723
|
+
runningTools: [...status.runningTools],
|
|
2724
|
+
...status.rearmGoal === void 0 ? {} : { rearmGoal: status.rearmGoal }
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
/** Map one engine triage row to the wire shape. */
|
|
2728
|
+
function triageRowOf(row) {
|
|
2729
|
+
return {
|
|
2730
|
+
sessionId: row.sessionId,
|
|
2731
|
+
phase: row.phase,
|
|
2732
|
+
updatedAt: row.updatedAt,
|
|
2733
|
+
reading: row.reading,
|
|
2734
|
+
...row.lastEvent === void 0 ? {} : { lastEvent: row.lastEvent },
|
|
2735
|
+
...row.turn === void 0 ? {} : { turn: row.turn },
|
|
2736
|
+
...row.step === void 0 ? {} : { step: row.step },
|
|
2737
|
+
traces: row.traces.map((trace) => ({
|
|
2738
|
+
tool: trace.tool,
|
|
2739
|
+
callId: trace.callId
|
|
2740
|
+
})),
|
|
2741
|
+
owed: row.owed,
|
|
2742
|
+
wakeable: row.wakeable,
|
|
2743
|
+
rearmGoal: row.rearmGoal
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
/** Map the engine's snapshot to the wire shape. */
|
|
2747
|
+
function snapshotOf(snapshot) {
|
|
2748
|
+
return {
|
|
2749
|
+
global: globalOf(snapshot.global),
|
|
2750
|
+
sessions: Object.fromEntries(Object.entries(snapshot.sessions).map(([id, status]) => [id, sessionOf(status)]))
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
/** Convert one engine answer to the wire outcome. */
|
|
2754
|
+
function outcomeOf(answer) {
|
|
2755
|
+
return {
|
|
2756
|
+
ok: answer.kind === "success",
|
|
2757
|
+
message: answer.text ?? (answer.kind === "success" ? "Done." : "Pause action failed.")
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
/** Convert a press outcome to the wire outcome. */
|
|
2761
|
+
function pressOutcomeOf(outcome) {
|
|
2762
|
+
const names = (ids) => ids.map((id) => `"${String(id)}"`).join(", ");
|
|
2763
|
+
if (outcome.unrecorded.length > 0) return {
|
|
2764
|
+
ok: false,
|
|
2765
|
+
message: `pause could not record ${names(outcome.unrecorded)}.`
|
|
2766
|
+
};
|
|
2767
|
+
if (!outcome.rootRegistered) return {
|
|
2768
|
+
ok: false,
|
|
2769
|
+
message: "pause did not register this session."
|
|
2770
|
+
};
|
|
2771
|
+
const caveats = [];
|
|
2772
|
+
if (outcome.unresumable.length > 0) caveats.push(`these subagents cannot be resumed: ${names(outcome.unresumable)}.`);
|
|
2773
|
+
if (outcome.unreadBranches.length > 0) caveats.push(`these subagent branches could not be read: ${names(outcome.unreadBranches)}.`);
|
|
2774
|
+
if (outcome.unread && outcome.unreadBranches.length === 0) caveats.push("The subagent tree could not be fully read.");
|
|
2775
|
+
return {
|
|
2776
|
+
ok: true,
|
|
2777
|
+
message: `Paused ${String(outcome.registered)} session${outcome.registered === 1 ? "" : "s"}.` + (caveats.length === 0 ? "" : ` ${caveats.join(" ")}`)
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Build the Remote callbacks over the mounted pause control.
|
|
2782
|
+
* @param control - the engine's host control surface.
|
|
2783
|
+
* @returns the wire API.
|
|
2784
|
+
*/
|
|
2785
|
+
function remoteApiOf(control) {
|
|
2786
|
+
return {
|
|
2787
|
+
snapshot: async (signal) => snapshotOf(await control.readSnapshot(signal)),
|
|
2788
|
+
subscribe: (listener) => control.subscribe(listener),
|
|
2789
|
+
act: async (request, signal) => {
|
|
2790
|
+
const sessionId = SessionId(request.sessionId);
|
|
2791
|
+
switch (request.action) {
|
|
2792
|
+
case "pause": return pressOutcomeOf(await control.pressTree(sessionId, signal));
|
|
2793
|
+
case "resume": return outcomeOf((await control.releaseTree(sessionId, signal)).answer);
|
|
2794
|
+
case "pause-all": return outcomeOf(await control.pauseAll(signal));
|
|
2795
|
+
case "resume-all": return outcomeOf(await control.resumeAll(signal));
|
|
2796
|
+
case "cancel-all": return outcomeOf(await control.cancelAll(signal));
|
|
2797
|
+
case "goal": return outcomeOf(await control.toggleGoal(sessionId, signal));
|
|
2798
|
+
}
|
|
2799
|
+
},
|
|
2800
|
+
triage: async (request, signal) => {
|
|
2801
|
+
return (await control.readTriage(SessionId(request.caller), signal)).rows.map(triageRowOf);
|
|
2802
|
+
},
|
|
2803
|
+
decide: async (request, signal) => {
|
|
2804
|
+
const action = request.decision;
|
|
2805
|
+
return outcomeOf(await control.decideTriage(SessionId(request.caller), SessionId(request.target), action, signal));
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2787
2809
|
/**
|
|
2788
2810
|
* The registry declaration. `version: 1` and no `compatibleVersions`: the
|
|
2789
2811
|
* domain layer rejects a stored unit stamped with any other version rather than
|
|
@@ -2847,7 +2869,10 @@ async function openParkRegistry(ctx) {
|
|
|
2847
2869
|
list: entriesNow,
|
|
2848
2870
|
get parked() {
|
|
2849
2871
|
const entries = entriesNow();
|
|
2850
|
-
if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) =>
|
|
2872
|
+
if (cached !== void 0 && entries.length === cachedFrom.length && entries.every((entry, at) => {
|
|
2873
|
+
const previous = cachedFrom[at];
|
|
2874
|
+
return previous !== void 0 && entry.sessionId === previous.sessionId && entry.phase === previous.phase && entry.updatedAt === previous.updatedAt && entry.rearmGoal === previous.rearmGoal;
|
|
2875
|
+
})) return cached;
|
|
2851
2876
|
cachedFrom = entries;
|
|
2852
2877
|
cached = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
|
2853
2878
|
return cached;
|
|
@@ -2908,7 +2933,6 @@ const name = "dsh-pause-session";
|
|
|
2908
2933
|
*/
|
|
2909
2934
|
const inject = [
|
|
2910
2935
|
"llm",
|
|
2911
|
-
"commands",
|
|
2912
2936
|
"agents",
|
|
2913
2937
|
"sessions",
|
|
2914
2938
|
"sessionProjections",
|
|
@@ -2964,12 +2988,14 @@ async function apply(ctx, config = Config.parse({})) {
|
|
|
2964
2988
|
}, "pause: lifetime");
|
|
2965
2989
|
const gate = new PauseGate({ lifetime: lifetime.signal });
|
|
2966
2990
|
ctx.effect(() => ctx.on("llm/stream", (request, next) => gate.stream(request, next)), "pause: pause gate");
|
|
2967
|
-
|
|
2991
|
+
const control = installSessionControl(ctx, gate, await mountParkWatch(ctx), registry, {
|
|
2968
2992
|
silentWakeRoutes: config.silentWakeRoutes,
|
|
2969
2993
|
maxListedLogEvents: config.maxListedLogEvents
|
|
2970
|
-
})
|
|
2994
|
+
});
|
|
2995
|
+
new PauseRemote(ctx, remoteApiOf(control));
|
|
2996
|
+
await control.adopt();
|
|
2971
2997
|
}
|
|
2972
2998
|
//#endregion
|
|
2973
|
-
export { Config, DEFAULT_SILENT_WAKE_ROUTES, apply, inject, name };
|
|
2999
|
+
export { Config, DEFAULT_SILENT_WAKE_ROUTES, PauseRemote, apply, inject, name };
|
|
2974
3000
|
|
|
2975
3001
|
//# sourceMappingURL=index.js.map
|