@junghanacs/entwurf 0.45.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/BASELINE.md +110 -1
- package/CHANGELOG.md +249 -0
- package/CONTRIBUTING.md +1 -1
- package/ENTWURF-CLI.json +1229 -0
- package/ENTWURF.json +63 -15
- package/FAQ.md +4 -4
- package/README.md +132 -188
- package/VERIFY.md +18 -11
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +4 -4
- package/docs/setup-clean-host.md +94 -22
- package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
- package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
- package/mcp/entwurf-bridge/src/index.ts +28 -8
- package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
- package/native/win32-x64/manifest.json +1 -1
- package/package.json +16 -20
- package/pi/pi-durable/bootstrap.mjs +179 -6
- package/pi/pi-durable/carrier/runtime.js +327 -57
- package/pi/pi-durable/carrier/session-name.js +11 -0
- package/pi/pi-durable/carrier/sessions.js +96 -20
- package/pi/pi-durable/carrier/subagent.js +5 -2
- package/pi/pi-durable/carrier/tui.js +60 -22
- package/pi/pi-durable/overlay/upstream-pin.json +26 -19
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +73 -69
- package/pi-extensions/lib/entwurf-v2-contract.ts +4 -4
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
- package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
- package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
- package/pi-extensions/lib/herdr-resume-call.ts +810 -0
- package/pi-extensions/lib/meta-session.ts +13 -8
- package/pi-extensions/lib/mux-resume-call.ts +77 -4
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
- package/pi-extensions/lib/pi-durable-location.ts +446 -0
- package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
- package/pi-extensions/lib/resume-launch-identity.ts +31 -12
- package/pi-extensions/meta-bridge-pi-durable.ts +30 -16
- package/run.sh +552 -699
- package/scripts/check-claude-launch.ts +341 -0
- package/scripts/check-entwurf-v2-contract.ts +4 -4
- package/scripts/check-entwurf-v2-surface.ts +2 -2
- package/scripts/check-fresh-call-dispatch.ts +70 -9
- package/scripts/check-fresh-cut-gate.sh +23 -9
- package/scripts/check-gate-qualification.ts +19 -14
- package/scripts/check-herdr-activation.ts +301 -23
- package/scripts/check-herdr-fresh-call.ts +161 -25
- package/scripts/check-herdr-plugin-build.ts +173 -1
- package/scripts/check-herdr-plugin.ts +28 -2
- package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
- package/scripts/check-install-surface.ts +1236 -6
- package/scripts/check-meta-doctor-oracle.sh +1 -1
- package/scripts/check-meta-facts.ts +20 -1
- package/scripts/check-mux-resume-call.ts +28 -0
- package/scripts/check-peer-facts.ts +39 -2
- package/scripts/cli-help.mjs +222 -0
- package/scripts/dev-bin.sh +39 -2
- package/scripts/herdr-activation.mjs +28 -6
- package/scripts/herdr-operator-link.mjs +389 -0
- package/scripts/herdr-plugin-activate.mjs +5 -0
- package/scripts/herdr-plugin-deactivate.mjs +18 -5
- package/scripts/herdr-rails.mjs +43 -7
- package/scripts/herdr-runtime.mjs +27 -0
- package/scripts/meta-bridge-fresh-cut.ts +1 -27
- package/scripts/meta-bridge-prune.ts +1 -1
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/agent-guidance.json +4 -4
- package/scripts/mutants/claude-launch.json +142 -0
- package/scripts/mutants/cli-help-source.json +420 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/dev-gate-dispatch.json +109 -0
- package/scripts/mutants/fresh-call-dispatch.json +38 -2
- package/scripts/mutants/fresh-cut.json +15 -0
- package/scripts/mutants/herdr-activation.json +128 -0
- package/scripts/mutants/herdr-fresh-call.json +278 -5
- package/scripts/mutants/herdr-operator-link.json +318 -0
- package/scripts/mutants/herdr-plugin-build.json +55 -0
- package/scripts/mutants/herdr-plugin.json +17 -0
- package/scripts/mutants/herdr-resume-call.json +288 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
- package/scripts/mutants/meta-facts.json +15 -0
- package/scripts/mutants/mux-resume-call.json +63 -2
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/peer-facts.json +15 -0
- package/scripts/mutants/pi-durable.json +1167 -3
- package/scripts/mutants/pi-floor.json +22 -5
- package/scripts/mutants/source-install.json +35 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +255 -6
- package/scripts/peer-facts.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +66 -0
- package/scripts/smoke-meta-prune.sh +23 -0
- package/scripts/smoke-omp-fresh-live.ts +12 -3
- package/scripts/smoke-omp-receive-live.ts +7 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
- package/team.json.example +39 -0
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
|
|
2
1
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
clampThinkingLevel,
|
|
4
|
+
cleanupSessionResources,
|
|
5
|
+
getSupportedThinkingLevels,
|
|
6
|
+
|
|
7
|
+
} from "@earendil-works/pi-ai";
|
|
4
8
|
import {
|
|
5
9
|
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
|
|
13
|
+
|
|
9
14
|
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
Harness,
|
|
13
19
|
|
|
20
|
+
LiveDoc,
|
|
14
21
|
|
|
22
|
+
|
|
15
23
|
ROOT_CONVERSATION_ID,
|
|
16
24
|
|
|
17
25
|
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
18
29
|
} from "@earendil-works/pi-durable";
|
|
19
30
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
20
31
|
import { ModelRuntime } from "entwurf-pi-dist:core/model-runtime.js";
|
|
@@ -26,7 +37,7 @@ import {
|
|
|
26
37
|
ExecutionEnvs,
|
|
27
38
|
findInitialAgentModel,
|
|
28
39
|
} from "./harness-setup.js";
|
|
29
|
-
import {
|
|
40
|
+
import { selectSession } from "./sessions.js";
|
|
30
41
|
import { Subagent } from "./subagent.js";
|
|
31
42
|
|
|
32
43
|
const context = BACKGROUND_CONTEXT;
|
|
@@ -53,8 +64,19 @@ const context = BACKGROUND_CONTEXT;
|
|
|
53
64
|
/** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
|
|
54
65
|
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
57
72
|
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
58
80
|
|
|
59
81
|
|
|
60
82
|
|
|
@@ -67,6 +89,11 @@ const context = BACKGROUND_CONTEXT;
|
|
|
67
89
|
|
|
68
90
|
|
|
69
91
|
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
70
97
|
|
|
71
98
|
|
|
72
99
|
|
|
@@ -96,6 +123,8 @@ const context = BACKGROUND_CONTEXT;
|
|
|
96
123
|
|
|
97
124
|
|
|
98
125
|
|
|
126
|
+
|
|
127
|
+
|
|
99
128
|
|
|
100
129
|
|
|
101
130
|
|
|
@@ -144,7 +173,42 @@ function titleOf(entry ) {
|
|
|
144
173
|
return { title: text.replace(/\s+/g, " ").trim() };
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
|
|
176
|
+
/** What failed, for an owner end's `cause`. */
|
|
177
|
+
function causeOf(error ) {
|
|
178
|
+
return error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The shown conversation's active transcript and the newest entry ID in it. */
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
/** A read's active transcript and the watch attached right after the read. */
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
/** The newest entry ID of `entries`; a head marker comes first, so the last entry need not be the newest. */
|
|
194
|
+
function newestOf(entries ) {
|
|
195
|
+
return entries.reduce((newest, entry) => Math.max(newest, entry.id), Number.NEGATIVE_INFINITY);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What the TUI renders: the watched documents with the active transcript as items. */
|
|
199
|
+
function transcriptOf(view , entries ) {
|
|
200
|
+
return {
|
|
201
|
+
conversation: view.conversation,
|
|
202
|
+
docs: view.docs,
|
|
203
|
+
items: entries.map((entry) => ({ kind: "entry" , entry })),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function openDurable(options ) {
|
|
208
|
+
// The type requires it; a JS caller is refused here, before the session root is created.
|
|
209
|
+
if (options.fence === undefined) {
|
|
210
|
+
throw new Error("fence-required: an owned durable open takes the owner fence before its native lease");
|
|
211
|
+
}
|
|
148
212
|
if (options.model !== undefined && (options.continueSession || options.session !== undefined)) {
|
|
149
213
|
throw new Error("An explicit model applies only to a new session, not to a continued one");
|
|
150
214
|
}
|
|
@@ -152,12 +216,16 @@ export async function openDurable(options = {})
|
|
|
152
216
|
throw new Error("A chosen session and the newest one are two different sessions; name one");
|
|
153
217
|
}
|
|
154
218
|
const location = await selectSession(
|
|
219
|
+
options.fence,
|
|
155
220
|
options.cwd ?? process.cwd(),
|
|
156
221
|
options.continueSession ?? false,
|
|
157
222
|
options.session,
|
|
158
223
|
);
|
|
224
|
+
const ticket = location.ticket;
|
|
159
225
|
const envs = new ExecutionEnvs(location.cwd);
|
|
160
226
|
let harness ;
|
|
227
|
+
// The storage handle Harness.open is handed: if Harness.open itself rejects, this is what was opened.
|
|
228
|
+
let backendHandle ;
|
|
161
229
|
try {
|
|
162
230
|
const modelRuntime = await ModelRuntime.create();
|
|
163
231
|
const settingsManager = SettingsManager.create(location.cwd);
|
|
@@ -167,19 +235,25 @@ export async function openDurable(options = {})
|
|
|
167
235
|
registry.install(Subagent);
|
|
168
236
|
for (const extension of options.extensions ?? []) registry.install(extension);
|
|
169
237
|
|
|
170
|
-
|
|
171
|
-
|
|
238
|
+
// SDK 1.5 hands one tagged `Report` (what happened by `kind`, plus its `error`), not pi 1.4's (error, info).
|
|
239
|
+
const pendingReports = [];
|
|
240
|
+
let report = (reported) => pendingReports.push(reported);
|
|
241
|
+
// Recorded BEFORE the call: once the open is attempted, no later outcome proves that nothing was opened.
|
|
242
|
+
ticket.storageOpenAttempted = true;
|
|
243
|
+
const backend = await openNodeSqliteStorage(location.database);
|
|
244
|
+
backendHandle = backend;
|
|
172
245
|
harness = await Harness.open(
|
|
173
|
-
|
|
246
|
+
backend,
|
|
174
247
|
{
|
|
175
248
|
models: modelRuntime,
|
|
176
249
|
registry,
|
|
177
250
|
settings,
|
|
178
251
|
env: envs.env,
|
|
179
|
-
onReport: (
|
|
252
|
+
onReport: (reported) => report(reported),
|
|
180
253
|
},
|
|
181
254
|
context,
|
|
182
255
|
);
|
|
256
|
+
await ticket.storageOpened();
|
|
183
257
|
const initial = location.created
|
|
184
258
|
? await findInitialAgentModel(settingsManager, modelRuntime, options.model)
|
|
185
259
|
: undefined;
|
|
@@ -220,7 +294,23 @@ export async function openDurable(options = {})
|
|
|
220
294
|
cursor = page.next;
|
|
221
295
|
} while (cursor !== undefined);
|
|
222
296
|
let current = root;
|
|
223
|
-
|
|
297
|
+
// The shown conversation's transcript is pi 1.4's active one (#155 D1). One official read gives the active range
|
|
298
|
+
// (`Conversation.context().entries`); the watch attached right after it starts at the next entry ID, so what is
|
|
299
|
+
// committed between the two is its first value. Nothing here derives an active range: when one changes, it is
|
|
300
|
+
// read again.
|
|
301
|
+
const seed = async (conversation ) => {
|
|
302
|
+
const read = await conversation.context(context);
|
|
303
|
+
const newest = newestOf(read.entries);
|
|
304
|
+
const attached = await conversation.watch(
|
|
305
|
+
context,
|
|
306
|
+
Number.isFinite(newest) ? { from: (newest + 1) } : undefined,
|
|
307
|
+
);
|
|
308
|
+
return { watch: attached, shown: { entries: read.entries, newest } };
|
|
309
|
+
};
|
|
310
|
+
const first = await seed(root);
|
|
311
|
+
// The shown watch: the first one until `install` makes it so, then each one a switch or a new read installs.
|
|
312
|
+
let watch ;
|
|
313
|
+
let shown = first.shown;
|
|
224
314
|
const models = () =>
|
|
225
315
|
modelRuntime.getAvailableSnapshot().map((model) => ({
|
|
226
316
|
provider: model.provider,
|
|
@@ -231,7 +321,8 @@ export async function openDurable(options = {})
|
|
|
231
321
|
|
|
232
322
|
let state = {
|
|
233
323
|
session: { id: location.id, directory: location.directory, cwd: location.cwd },
|
|
234
|
-
conversation:
|
|
324
|
+
conversation: first.watch.value,
|
|
325
|
+
transcript: transcriptOf(first.watch.value, shown.entries),
|
|
235
326
|
conversations: summaries,
|
|
236
327
|
models: models(),
|
|
237
328
|
notices: [],
|
|
@@ -254,13 +345,16 @@ export async function openDurable(options = {})
|
|
|
254
345
|
update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
|
|
255
346
|
};
|
|
256
347
|
const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
|
|
257
|
-
report = (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
348
|
+
report = (reported) => {
|
|
349
|
+
// The error the SDK reported, never the Report itself: that would read `[object Object]`.
|
|
350
|
+
const message = causeOf(reported.error);
|
|
351
|
+
// A failing Session runs its close listeners first and reports the cause right after, as `storage_failed`:
|
|
352
|
+
// that report names the failure the close listener recorded.
|
|
353
|
+
if (reported.kind === "storage_failed" && state.failure !== undefined && state.failure.error === undefined) {
|
|
354
|
+
update({ failure: { error: message } });
|
|
355
|
+
} else notice(reported.kind === "storage_failed" ? "error" : "warning", `${reported.kind}: ${message}`);
|
|
262
356
|
};
|
|
263
|
-
for (const
|
|
357
|
+
for (const reported of pendingReports) report(reported);
|
|
264
358
|
const failedListeners = new Set ();
|
|
265
359
|
// The Session closes itself when its Storage fails. Its `closed` settles only after every running task returned,
|
|
266
360
|
// which a task ignoring its abort can delay without end, so the failure is shown from the close listener itself.
|
|
@@ -275,29 +369,124 @@ export async function openDurable(options = {})
|
|
|
275
369
|
}
|
|
276
370
|
}
|
|
277
371
|
});
|
|
278
|
-
let
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
372
|
+
let closing ;
|
|
373
|
+
// A watch that ends by itself while the Session runs leaves its part of the screen frozen: say so rather than look
|
|
374
|
+
// healthy (#155). Ending at close, at a failure the Session already shows, or by being replaced, is no news.
|
|
375
|
+
const ended = (part , end ) => {
|
|
376
|
+
if (closing !== undefined || state.failure !== undefined) return;
|
|
377
|
+
if (end.kind !== "failed" && end.kind !== "listener_error") return;
|
|
378
|
+
update({ stalled: { ...state.stalled, [part]: causeOf(end.error) } });
|
|
379
|
+
};
|
|
380
|
+
// The watch whose entries wait: its conversation is being read again, or that read failed. A failed read holds
|
|
381
|
+
// the transcript where it was, said on screen, until a switch installs a new watch; there is no retry.
|
|
382
|
+
let held ;
|
|
383
|
+
// The shown conversation read again, one read at a time. It is installed only while `trigger` is still the shown
|
|
384
|
+
// watch: a switch made meanwhile wins, and the late read is dropped (#155 D1).
|
|
385
|
+
const reseed = (trigger ) => {
|
|
386
|
+
held = trigger;
|
|
387
|
+
const conversation = current;
|
|
388
|
+
void seed(conversation).then(
|
|
389
|
+
(next) => {
|
|
390
|
+
if (watch !== trigger) {
|
|
391
|
+
void next.watch.stop();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
install(conversation, next);
|
|
395
|
+
},
|
|
396
|
+
(error) => {
|
|
397
|
+
// A read refused by the close or the Session's own failure is no news, as `ended` has it.
|
|
398
|
+
if (watch !== trigger || closing !== undefined || state.failure !== undefined) return;
|
|
399
|
+
update({ stalled: { ...state.stalled, conversation: causeOf(error) } });
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
// One value of the shown watch. Entries past the newest one shown join the transcript in ID order. A head marker
|
|
404
|
+
// among them (a compaction or a reset) or a window starting past the newest one shown (it dropped entries the
|
|
405
|
+
// transcript lacks) is a new active range: read again, nothing appended until it lands.
|
|
406
|
+
const advance = (view ) => {
|
|
407
|
+
if (held !== undefined && held === watch) {
|
|
408
|
+
update({ conversation: view });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const fresh = view.entries.filter((entry) => entry.id > shown.newest);
|
|
412
|
+
const restarts = fresh.some((entry) => entry.head !== undefined);
|
|
413
|
+
const skipped = view.before !== undefined && view.before > shown.newest;
|
|
414
|
+
if (restarts || skipped) {
|
|
415
|
+
update({ conversation: view });
|
|
416
|
+
reseed(watch);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// A value without new entries (a document change, such as a streaming answer's progress) leaves the
|
|
420
|
+
// transcript as it is.
|
|
421
|
+
if (fresh.length === 0) {
|
|
422
|
+
update({ conversation: view });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
shown = { entries: [...shown.entries, ...fresh], newest: newestOf(fresh) };
|
|
426
|
+
update({ conversation: view, transcript: transcriptOf(view, shown.entries) });
|
|
427
|
+
};
|
|
428
|
+
// Every later value of the shown watch; a replaced watch's late frames are dropped.
|
|
429
|
+
const follow = (handle ) => {
|
|
430
|
+
handle.start(async (call) => {
|
|
431
|
+
if (handle !== watch) return;
|
|
432
|
+
if (call.kind === "failed") {
|
|
433
|
+
notice("warning", `The conversation view could not update: ${causeOf(call.error)}`);
|
|
434
|
+
return;
|
|
290
435
|
}
|
|
436
|
+
advance(call.value);
|
|
437
|
+
});
|
|
438
|
+
void handle.closed.then((end) => {
|
|
439
|
+
if (handle === watch) ended("conversation", end);
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
// Show `conversation` from a read: its active entries, then the watch's first value, which holds what was
|
|
443
|
+
// committed between the read and the attach and is taken exactly once here, then the watch's later values.
|
|
444
|
+
const install = (conversation , next ) => {
|
|
445
|
+
// The close and the Session's own failure end what is shown, as a switch does: a read landing after either
|
|
446
|
+
// is let go, its watch stopped (the Session may already have ended it), and nothing is patched or started.
|
|
447
|
+
if (closing !== undefined || state.failure !== undefined) {
|
|
448
|
+
void next.watch.stop();
|
|
449
|
+
return;
|
|
291
450
|
}
|
|
292
|
-
|
|
451
|
+
const previous = watch;
|
|
452
|
+
current = conversation;
|
|
453
|
+
watch = next.watch;
|
|
454
|
+
shown = next.shown;
|
|
455
|
+
void previous?.stop();
|
|
456
|
+
// A new watch replaces a stalled one; the task graph's state is its own.
|
|
457
|
+
const stalled = state.stalled?.tasks === undefined ? undefined : { tasks: state.stalled.tasks };
|
|
458
|
+
update({ conversation: next.watch.value, transcript: transcriptOf(next.watch.value, shown.entries), stalled });
|
|
459
|
+
advance(next.watch.value);
|
|
460
|
+
follow(next.watch);
|
|
461
|
+
};
|
|
462
|
+
install(root, first);
|
|
463
|
+
// Subagents appear as their conversations are created. A commit observer only records; it calls no Session API.
|
|
464
|
+
// Its end decides nothing: SDK 1.5 ends it `failed` ("session closed") at a normal close as well (#155).
|
|
465
|
+
const commits = harness.observeCommits({
|
|
466
|
+
advance: (publication) => {
|
|
467
|
+
let conversations = state.conversations;
|
|
468
|
+
for (const change of publication.changes) {
|
|
469
|
+
if (change.type === "conversation") {
|
|
470
|
+
conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
|
|
471
|
+
} else if (change.type === "entry" && change.value.kind === "pi.user") {
|
|
472
|
+
const id = change.value.conversationId;
|
|
473
|
+
conversations = conversations.map((summary) =>
|
|
474
|
+
summary.id === id && summary.title === undefined
|
|
475
|
+
? { ...summary, ...titleOf(change.value) }
|
|
476
|
+
: summary,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (conversations !== state.conversations) update({ conversations });
|
|
481
|
+
},
|
|
482
|
+
end: () => {},
|
|
293
483
|
});
|
|
294
484
|
|
|
295
|
-
let tasks
|
|
296
|
-
let unsubscribeTasks = () => {};
|
|
485
|
+
let tasks ;
|
|
297
486
|
const closeTasks = () => {
|
|
298
|
-
|
|
299
|
-
tasks?.dispose();
|
|
487
|
+
const graph = tasks;
|
|
300
488
|
tasks = undefined;
|
|
489
|
+
void graph?.stop();
|
|
301
490
|
};
|
|
302
491
|
|
|
303
492
|
let queue = Promise.resolve();
|
|
@@ -306,15 +495,21 @@ export async function openDurable(options = {})
|
|
|
306
495
|
queue = queue.then(operation).catch(fail);
|
|
307
496
|
return queue;
|
|
308
497
|
};
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
498
|
+
// A run that ended without an answer stays on screen as its conversation's `pi.live.runFailure` (SDK 1.5), so an
|
|
499
|
+
// input that run settled adds no notice of its own; one settled without such a run (stale, say) still does.
|
|
500
|
+
const watchAnswer = (submission , conversation ) => {
|
|
501
|
+
void submission
|
|
502
|
+
.wait(context)
|
|
503
|
+
.then(async (settled) => {
|
|
504
|
+
if (settled.status !== "unanswered" || settled.reason === "aborted") return;
|
|
505
|
+
const live = await opened.snapshot(LiveDoc, conversation.id, context);
|
|
506
|
+
if (live?.runFailure?.inputs.includes(submission.id)) return;
|
|
312
507
|
notice(
|
|
313
508
|
"error",
|
|
314
509
|
`No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
|
|
315
510
|
);
|
|
316
|
-
}
|
|
317
|
-
|
|
511
|
+
})
|
|
512
|
+
.catch(fail);
|
|
318
513
|
};
|
|
319
514
|
const agentModel = () => {
|
|
320
515
|
const ref = agentOf(state.conversation).model;
|
|
@@ -329,7 +524,8 @@ export async function openDurable(options = {})
|
|
|
329
524
|
if (state.failure !== undefined) {
|
|
330
525
|
throw new Error("The session failed and takes no input. Close it, then reopen it with --continue.");
|
|
331
526
|
}
|
|
332
|
-
|
|
527
|
+
const target = current;
|
|
528
|
+
watchAnswer(await target.submit({ type: "input", content: text, whenBusy }, context), target);
|
|
333
529
|
}),
|
|
334
530
|
compact: (instructions) =>
|
|
335
531
|
command(async () => {
|
|
@@ -383,12 +579,8 @@ export async function openDurable(options = {})
|
|
|
383
579
|
command(async () => {
|
|
384
580
|
const next = await opened.conversation(id, context);
|
|
385
581
|
if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
conversation.dispose();
|
|
389
|
-
current = next;
|
|
390
|
-
conversation = nextState;
|
|
391
|
-
unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
|
|
582
|
+
// The same read as the open: the whole active transcript, whatever this process showed of it before.
|
|
583
|
+
install(next, await seed(next));
|
|
392
584
|
}),
|
|
393
585
|
};
|
|
394
586
|
|
|
@@ -399,14 +591,17 @@ export async function openDurable(options = {})
|
|
|
399
591
|
}
|
|
400
592
|
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
|
|
401
593
|
// The task graph is observed until close; its panel starts open and /tasks hides it.
|
|
402
|
-
const graph = await opened.
|
|
594
|
+
const graph = await opened.watchTaskGraph(context);
|
|
403
595
|
tasks = graph;
|
|
404
596
|
update({ tasks: graph.value });
|
|
405
|
-
|
|
597
|
+
graph.start(async (call) => {
|
|
598
|
+
if (call.kind === "value") update({ tasks: call.value });
|
|
599
|
+
else notice("warning", `The task graph could not update: ${causeOf(call.error)}`);
|
|
600
|
+
});
|
|
601
|
+
void graph.closed.then((end) => ended("tasks", end));
|
|
406
602
|
// Recovered work from an interrupted turn continues now.
|
|
407
603
|
harness.resume();
|
|
408
604
|
|
|
409
|
-
let closing ;
|
|
410
605
|
return {
|
|
411
606
|
view: {
|
|
412
607
|
current: () => state,
|
|
@@ -435,24 +630,99 @@ export async function openDurable(options = {})
|
|
|
435
630
|
closing ??= (async () => {
|
|
436
631
|
// First: the Session runs its close listeners inside its own close call.
|
|
437
632
|
unsubscribeClose();
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
conversation.dispose();
|
|
633
|
+
void watch?.stop();
|
|
634
|
+
commits.detach();
|
|
441
635
|
closeTasks();
|
|
636
|
+
// The resource end the fence records (#157 B3). Storage is `closed` only when the first actual close
|
|
637
|
+
// resolved, no failure was seen before it, and the Session's own terminal result is not `failed`.
|
|
638
|
+
const failures = [];
|
|
639
|
+
let storage = "failed";
|
|
640
|
+
let envCleanup ;
|
|
641
|
+
let lease = "released";
|
|
442
642
|
try {
|
|
443
643
|
// Close writes no outcome: a running turn resumes with --continue.
|
|
444
644
|
await opened.close(context);
|
|
445
|
-
|
|
645
|
+
// SDK 1.5 tells the end by `kind`; pi 1.4's `reason` is absent there, and a type-stripped carrier would
|
|
646
|
+
// read it as undefined and call every end closed (#155).
|
|
647
|
+
const end = await opened.closed;
|
|
648
|
+
if (state.failure === undefined && end.kind !== "failed") storage = "closed";
|
|
649
|
+
try {
|
|
650
|
+
await envs.cleanup(context);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
envCleanup = "failed";
|
|
653
|
+
failures.push(error);
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
failures.push(error);
|
|
446
657
|
} finally {
|
|
447
|
-
|
|
658
|
+
// The provider connections the turns left open for reuse: pi-ai keeps a Codex WebSocket per provider
|
|
659
|
+
// session on a five-minute idle timer, and pi's AgentSession releases them on dispose. Without the same
|
|
660
|
+
// release here a closed host stays alive until that timer (#157 S3 LIVE-R1). No id: the provider session
|
|
661
|
+
// ids are docs of the storage this process alone opened, and a host process holds this one Session.
|
|
662
|
+
try {
|
|
663
|
+
cleanupSessionResources();
|
|
664
|
+
} catch (error) {
|
|
665
|
+
failures.push(error);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
await location.release();
|
|
669
|
+
} catch (error) {
|
|
670
|
+
lease = "failed";
|
|
671
|
+
failures.push(error);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const failure = failures[0] ?? (storage === "closed" ? undefined : "the session failed before close");
|
|
675
|
+
const record = {
|
|
676
|
+
storage,
|
|
677
|
+
lease,
|
|
678
|
+
...(envCleanup === undefined ? {} : { envCleanup }),
|
|
679
|
+
...(failure === undefined ? {} : { cause: causeOf(failure) }),
|
|
680
|
+
};
|
|
681
|
+
// An env cleanup failure is the caller's error, not the resource's: the end stays `closed`.
|
|
682
|
+
try {
|
|
683
|
+
if (storage === "closed" && lease === "released") await ticket.closed(record);
|
|
684
|
+
else await ticket.closeFailed(record);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
failures.push(error);
|
|
448
687
|
}
|
|
688
|
+
if (failures.length === 1) throw failures[0];
|
|
689
|
+
if (failures.length > 1) throw new AggregateError(failures, "The durable session did not close cleanly");
|
|
449
690
|
})();
|
|
450
691
|
return closing;
|
|
451
692
|
},
|
|
452
693
|
};
|
|
453
694
|
} catch (error) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
695
|
+
// An init failure (#157 B3). Only a storage open that was never attempted, with the lease verifiably released,
|
|
696
|
+
// ends `closed`; once attempted, neither a settled Harness close nor a released lease proves the resource ended.
|
|
697
|
+
let storage = ticket.storageOpenAttempted ? "unknown" : "never-opened";
|
|
698
|
+
// The init error first, then every cleanup failure: none is dropped.
|
|
699
|
+
const failures = [error];
|
|
700
|
+
// The first close of what was opened: the Harness once it opened, else the storage handle Harness.open was
|
|
701
|
+
// handed and rejected. A close that settles is no verified close here; one that rejects is a failed storage.
|
|
702
|
+
const closable = harness ?? backendHandle;
|
|
703
|
+
if (closable !== undefined) {
|
|
704
|
+
try {
|
|
705
|
+
await closable.close(context);
|
|
706
|
+
} catch (closeError) {
|
|
707
|
+
storage = "failed";
|
|
708
|
+
failures.push(closeError);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let lease = "released";
|
|
712
|
+
try {
|
|
713
|
+
await location.release();
|
|
714
|
+
} catch (releaseError) {
|
|
715
|
+
lease = "failed";
|
|
716
|
+
failures.push(releaseError);
|
|
717
|
+
}
|
|
718
|
+
const ending = { storage, lease, cause: failures.map(causeOf).join("; ") };
|
|
719
|
+
try {
|
|
720
|
+
if (storage === "never-opened" && lease === "released") await ticket.closed(ending);
|
|
721
|
+
else await ticket.closeFailed(ending);
|
|
722
|
+
} catch (fenceError) {
|
|
723
|
+
failures.push(fenceError);
|
|
724
|
+
}
|
|
725
|
+
if (failures.length === 1) throw error;
|
|
726
|
+
throw new AggregateError(failures, `${causeOf(error)}; its cleanup or owner end failed too`);
|
|
457
727
|
}
|
|
458
728
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable session name grammar (#157 G3 B-L1), alone in its own file: no imports and no I/O, so the
|
|
3
|
+
* bootstrap can hold a `--session` value to it before an operator module prepares the environment, without
|
|
4
|
+
* loading the session store, the SDK config or a lock library first. sessions.ts selects by this same predicate.
|
|
5
|
+
*/
|
|
6
|
+
const SESSION_NAME = /^\d{13}-[0-9a-f-]{36}$/u;
|
|
7
|
+
|
|
8
|
+
/** Whether `name` has the exact shape of a session directory name: `<13-digit ms>-<uuid>`. */
|
|
9
|
+
export function isSessionName(name ) {
|
|
10
|
+
return SESSION_NAME.test(name);
|
|
11
|
+
}
|