@hheei/pi-subagents 0.7.0 → 0.8.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/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE +10 -0
- package/README.md +13 -13
- package/dist/child-control.d.ts +0 -8
- package/dist/child-control.js +40 -8
- package/dist/commands.js +1 -1
- package/dist/controller-ipc.d.ts +38 -0
- package/dist/controller-ipc.js +292 -0
- package/dist/controller-main.d.ts +1 -0
- package/dist/controller-main.js +26 -0
- package/dist/controller-role.d.ts +40 -0
- package/dist/controller-role.js +37 -0
- package/dist/controller-socket.d.ts +6 -0
- package/dist/controller-socket.js +43 -0
- package/dist/controller.d.ts +25 -0
- package/dist/controller.js +217 -0
- package/dist/delegation.d.ts +73 -0
- package/dist/delegation.js +71 -0
- package/dist/domain.d.ts +20 -18
- package/dist/domain.js +6 -1
- package/dist/extension.js +43 -26
- package/dist/host-adapter.js +1 -1
- package/dist/launch-spec.d.ts +1 -3
- package/dist/launch-spec.js +38 -15
- package/dist/lease.d.ts +0 -1
- package/dist/lease.js +8 -11
- package/dist/mailbox.js +110 -34
- package/dist/manager.d.ts +52 -6
- package/dist/manager.js +568 -32
- package/dist/mcp/app.html +948 -0
- package/dist/mcp/delegate.d.ts +30 -0
- package/dist/mcp/delegate.js +44 -0
- package/dist/mcp/views.d.ts +5 -0
- package/dist/mcp/views.js +12 -0
- package/dist/mcp-server.d.ts +6 -0
- package/dist/mcp-server.js +287 -0
- package/dist/mcp-stdio.d.ts +2 -0
- package/dist/mcp-stdio.js +111 -0
- package/dist/process-lock.d.ts +1 -1
- package/dist/process-lock.js +4 -2
- package/dist/protocol.d.ts +5 -5
- package/dist/protocol.js +3 -4
- package/dist/receipts.d.ts +80 -0
- package/dist/receipts.js +326 -0
- package/dist/runtime.d.ts +5 -26
- package/dist/runtime.js +15 -57
- package/dist/session-bootstrap.js +2 -6
- package/dist/state-db.d.ts +11 -4
- package/dist/state-db.js +102 -24
- package/dist/state.js +12 -3
- package/dist/tool-activation.d.ts +2 -2
- package/dist/tool-activation.js +5 -5
- package/dist/tool-presentation.d.ts +2 -6
- package/dist/tool-presentation.js +75 -122
- package/dist/tools.d.ts +0 -3
- package/dist/tools.js +104 -132
- package/package.json +12 -5
- package/dist/json-lines.d.ts +0 -14
- package/dist/json-lines.js +0 -95
package/dist/manager.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
2
4
|
import { errorMessage, isRecord } from "@hheei/pi-ext-core";
|
|
3
5
|
import { attachRetainedChild } from "./child-process.js";
|
|
6
|
+
import { agentView, delegationView, } from "./delegation.js";
|
|
4
7
|
import { isOperationError } from "./domain.js";
|
|
5
8
|
import { stripHindsightContent } from "./launch-spec.js";
|
|
6
9
|
import { commitLease, createConversationLease, RUNTIME_ENDED_RESUME, } from "./lease.js";
|
|
7
10
|
import { BridgeError, isChildInputPayload, isChildLifecyclePayload, isContactReportPayload, } from "./protocol.js";
|
|
11
|
+
import { abandonReceipts, latestReceiptForChild, latestReceiptSummaries, maxRunBoundary, readReceiptRevision, readStoredReceipt, reserveReceipt, settleReceipts, sweepControlReceipts, terminalReceipt, updateReceipt, waitForReceipt, } from "./receipts.js";
|
|
8
12
|
import { findSessionFile, planSessionPlacement } from "./session-bootstrap.js";
|
|
9
13
|
async function readSessionJsonlEntries(filePath) {
|
|
10
14
|
const content = await readFile(filePath, "utf8");
|
|
@@ -22,10 +26,53 @@ async function readSessionJsonlEntries(filePath) {
|
|
|
22
26
|
}
|
|
23
27
|
return entries;
|
|
24
28
|
}
|
|
29
|
+
/** One transcript page, parsed only through the requested window. */
|
|
30
|
+
async function readSessionJsonlPage(filePath, cursor, limit) {
|
|
31
|
+
const entries = [];
|
|
32
|
+
let index = 0;
|
|
33
|
+
let past = false;
|
|
34
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
35
|
+
stream.on("error", () => {
|
|
36
|
+
// The async iterator rejects with the same error; the listener keeps it from escaping.
|
|
37
|
+
});
|
|
38
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
39
|
+
try {
|
|
40
|
+
for await (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (trimmed.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(trimmed);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (index >= cursor) {
|
|
52
|
+
if (entries.length >= limit) {
|
|
53
|
+
past = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
entries.push(parsed);
|
|
57
|
+
}
|
|
58
|
+
index += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
lines.close();
|
|
66
|
+
stream.destroy();
|
|
67
|
+
}
|
|
68
|
+
if (index === 0 && entries.length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
return { entries, ...(past ? { nextCursor: cursor + entries.length } : {}) };
|
|
71
|
+
}
|
|
25
72
|
import { createStateProjector, parseTranscriptFacts, transcriptFacts, } from "./state.js";
|
|
26
73
|
/**
|
|
27
74
|
* How long an idle child keeps its runtime. A background child holds a whole Pi process and its
|
|
28
|
-
* session lock, so an idle one is reclaimed after this window and a later `
|
|
75
|
+
* session lock, so an idle one is reclaimed after this window and a later `agents_delegate` starts it
|
|
29
76
|
* again from the same session.
|
|
30
77
|
*/
|
|
31
78
|
export const SUBAGENT_IDLE_TIMEOUT_MS = 15_000;
|
|
@@ -56,6 +103,18 @@ function acknowledgedPath(response) {
|
|
|
56
103
|
}
|
|
57
104
|
return response.path;
|
|
58
105
|
}
|
|
106
|
+
/** A compaction request is a control operation, not a turn. */
|
|
107
|
+
function isCompactCommand(task) {
|
|
108
|
+
const trimmed = task.trim();
|
|
109
|
+
return trimmed === "/compact" || trimmed.startsWith("/compact ");
|
|
110
|
+
}
|
|
111
|
+
/** The child names the compaction a request started or joined; without it, nothing can settle it. */
|
|
112
|
+
function acknowledgedControl(response) {
|
|
113
|
+
if (!isRecord(response) || typeof response.controlId !== "string" || response.controlId === "") {
|
|
114
|
+
throw new BridgeError("invalid_state", "Child did not confirm which compaction it runs");
|
|
115
|
+
}
|
|
116
|
+
return response.controlId;
|
|
117
|
+
}
|
|
59
118
|
function publicChild(record, live, path = record.sessionPath) {
|
|
60
119
|
return {
|
|
61
120
|
id: record.subagentId,
|
|
@@ -131,6 +190,8 @@ export class SubagentManager {
|
|
|
131
190
|
* process is not this child's runtime any more, so it is neither live nor ours to close.
|
|
132
191
|
*/
|
|
133
192
|
#leftSessions = new Set();
|
|
193
|
+
/** Aborted on dispose, so no wait or delivery survives the session it belongs to. */
|
|
194
|
+
#disposed = new AbortController();
|
|
134
195
|
/** Bumped by every inbound bridge signal, so a reclaim in flight can tell the child moved. */
|
|
135
196
|
#activity = new Map();
|
|
136
197
|
#projectors = new Map();
|
|
@@ -140,6 +201,16 @@ export class SubagentManager {
|
|
|
140
201
|
#working = new Set();
|
|
141
202
|
/** A settled event from an older run cannot clear a newer synchronous start signal. */
|
|
142
203
|
#workVersions = new Map();
|
|
204
|
+
/** Highest run boundary reserved for a child that has not reached that turn yet. */
|
|
205
|
+
#lastClaim = new Map();
|
|
206
|
+
/** The compaction a child's last confirmed input controls, instead of a run boundary. */
|
|
207
|
+
#lastControl = new Map();
|
|
208
|
+
#recentSettlement = new Map();
|
|
209
|
+
/**
|
|
210
|
+
* Waiting delegations of this process, by child. Occupancy lives here and not in the ledger: a
|
|
211
|
+
* stored `pending` row is history, and a waiter of a previous process does not exist any more.
|
|
212
|
+
*/
|
|
213
|
+
#waiters = new Map();
|
|
143
214
|
#settledPanelCloseTimers = new Map();
|
|
144
215
|
#pendingAutoReports = new Map();
|
|
145
216
|
#autoReportBatchTimer;
|
|
@@ -162,11 +233,14 @@ export class SubagentManager {
|
|
|
162
233
|
};
|
|
163
234
|
}
|
|
164
235
|
dispose() {
|
|
236
|
+
// Ending the session ends its waits: a pending tool call must not outlive the manager.
|
|
237
|
+
this.#disposed.abort();
|
|
165
238
|
if (this.#autoReportBatchTimer !== undefined) {
|
|
166
239
|
clearTimeout(this.#autoReportBatchTimer);
|
|
167
240
|
this.#autoReportBatchTimer = undefined;
|
|
168
241
|
}
|
|
169
242
|
this.#pendingAutoReports.clear();
|
|
243
|
+
this.#waiters.clear();
|
|
170
244
|
for (const timer of this.#settledPanelCloseTimers.values()) {
|
|
171
245
|
clearTimeout(timer);
|
|
172
246
|
}
|
|
@@ -186,6 +260,7 @@ export class SubagentManager {
|
|
|
186
260
|
this.#autoReportBatchTimer = undefined;
|
|
187
261
|
}
|
|
188
262
|
this.#pendingAutoReports.clear();
|
|
263
|
+
this.#waiters.clear();
|
|
189
264
|
this.#processes.clear();
|
|
190
265
|
this.#attachments.clear();
|
|
191
266
|
this.#leftSessions.clear();
|
|
@@ -194,6 +269,9 @@ export class SubagentManager {
|
|
|
194
269
|
this.#paths.clear();
|
|
195
270
|
this.#working.clear();
|
|
196
271
|
this.#workVersions.clear();
|
|
272
|
+
this.#lastClaim.clear();
|
|
273
|
+
this.#lastControl.clear();
|
|
274
|
+
this.#recentSettlement.clear();
|
|
197
275
|
this.#notify();
|
|
198
276
|
}
|
|
199
277
|
/**
|
|
@@ -347,10 +425,7 @@ export class SubagentManager {
|
|
|
347
425
|
const current = await this.#deps.registry.get(id);
|
|
348
426
|
if (current === undefined)
|
|
349
427
|
return undefined;
|
|
350
|
-
const identity = current.runtime?.runtimeIdentity;
|
|
351
428
|
const next = await commitLease(this.#deps.registry, id, { type: "runtime-gone", outcome });
|
|
352
|
-
if (identity !== undefined && next.runtime === undefined)
|
|
353
|
-
this.#deps.tokens?.forget(identity);
|
|
354
429
|
if (outcome === "drop" &&
|
|
355
430
|
current.state === "running" &&
|
|
356
431
|
current.intent !== "stopped" &&
|
|
@@ -546,6 +621,12 @@ export class SubagentManager {
|
|
|
546
621
|
state: "done",
|
|
547
622
|
};
|
|
548
623
|
});
|
|
624
|
+
this.#recordSettlement({
|
|
625
|
+
childId: id,
|
|
626
|
+
status: "failed",
|
|
627
|
+
message: "Subagent finished without a result.",
|
|
628
|
+
workVersion,
|
|
629
|
+
});
|
|
549
630
|
return;
|
|
550
631
|
}
|
|
551
632
|
let message;
|
|
@@ -575,7 +656,7 @@ export class SubagentManager {
|
|
|
575
656
|
facts?.contextTokens !== undefined &&
|
|
576
657
|
facts.contextTokens >= 100_000) {
|
|
577
658
|
const kTokens = `${Math.round(facts.contextTokens / 1000)}k`;
|
|
578
|
-
message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via
|
|
659
|
+
message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agents_delegate to trim memory.]`;
|
|
579
660
|
}
|
|
580
661
|
const lastDelivered = this.#lastDeliveredTurn.get(id);
|
|
581
662
|
if (lastDelivered !== undefined &&
|
|
@@ -601,8 +682,20 @@ export class SubagentManager {
|
|
|
601
682
|
projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
|
|
602
683
|
}
|
|
603
684
|
this.#scheduleSettledPanelClose(id);
|
|
685
|
+
// The receipt is the durable record of this turn: it is written before delivery is
|
|
686
|
+
// coordinated, so a delivery failure cannot leave a settled turn looking unfinished.
|
|
687
|
+
const settled = this.#recordSettlement({
|
|
688
|
+
childId: id,
|
|
689
|
+
status: isError ? "failed" : isBlocked ? "blocked" : "completed",
|
|
690
|
+
message,
|
|
691
|
+
workVersion,
|
|
692
|
+
});
|
|
693
|
+
// Without a durable settlement there is no record to deliver against: the turn stays
|
|
694
|
+
// unsettled and undelivered rather than being pushed and then pushed again on a retry.
|
|
695
|
+
if (settled === undefined)
|
|
696
|
+
return;
|
|
604
697
|
let deliveryOk = true;
|
|
605
|
-
if (this.#deps.channel !== undefined) {
|
|
698
|
+
if (!this.#claimedByWaiter(settled) && this.#deps.channel !== undefined) {
|
|
606
699
|
const contactResult = await this.contactParent(id, reason, message, updatedRecord);
|
|
607
700
|
if (isOperationError(contactResult)) {
|
|
608
701
|
deliveryOk = false;
|
|
@@ -614,8 +707,8 @@ export class SubagentManager {
|
|
|
614
707
|
});
|
|
615
708
|
}
|
|
616
709
|
/** Reads a child's session entries; used by transcript inspection fallbacks. */
|
|
617
|
-
async getEntries(id) {
|
|
618
|
-
return this.#entries(id);
|
|
710
|
+
async getEntries(id, range) {
|
|
711
|
+
return this.#entries(id, range?.cursor ?? 0, range?.limit ?? 20);
|
|
619
712
|
}
|
|
620
713
|
/** A child is live while its bridge connection is up, or while a process we started still runs. */
|
|
621
714
|
#isLive(id) {
|
|
@@ -698,18 +791,137 @@ export class SubagentManager {
|
|
|
698
791
|
return entries === undefined ? undefined : transcriptFacts(entries, expectedInput);
|
|
699
792
|
}
|
|
700
793
|
/** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
|
|
701
|
-
async #entries(id) {
|
|
794
|
+
async #entries(id, cursor = 0, limit = 20) {
|
|
702
795
|
if (this.#deps.bridge.isConnected(id)) {
|
|
703
|
-
const response = await this.#request(id, "get_entries").catch(() => undefined);
|
|
704
|
-
if (isRecord(response) &&
|
|
705
|
-
|
|
796
|
+
const response = await this.#request(id, "get_entries", { cursor, limit }).catch(() => undefined);
|
|
797
|
+
if (isRecord(response) &&
|
|
798
|
+
Array.isArray(response.entries) &&
|
|
799
|
+
(response.entries.length > 0 || cursor > 0)) {
|
|
800
|
+
const nextCursor = typeof response.nextCursor === "number" ? response.nextCursor : undefined;
|
|
801
|
+
return {
|
|
802
|
+
entries: response.entries,
|
|
803
|
+
...(nextCursor === undefined ? {} : { nextCursor }),
|
|
804
|
+
};
|
|
706
805
|
}
|
|
707
806
|
}
|
|
708
|
-
|
|
807
|
+
const record = await this.#deps.registry.get(id).catch(() => undefined);
|
|
808
|
+
if (record === undefined)
|
|
809
|
+
return undefined;
|
|
810
|
+
const sessionPath = record.sessionPath ??
|
|
811
|
+
(await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
|
|
812
|
+
if (sessionPath === undefined)
|
|
813
|
+
return undefined;
|
|
814
|
+
return readSessionJsonlPage(sessionPath, cursor, limit);
|
|
709
815
|
}
|
|
710
816
|
#request(id, operation, payload) {
|
|
711
817
|
return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
|
|
712
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* Binds one confirmed input to a Pi work version. A steer into a turn that already started
|
|
821
|
+
* (`agent_start` has incremented the version) shares that version. Anything still queued
|
|
822
|
+
* reserves the next version, so a later completion cannot answer both.
|
|
823
|
+
*/
|
|
824
|
+
#restoreWorkVersion(id) {
|
|
825
|
+
const stored = maxRunBoundary(this.#deps.registry.path, this.#deps.parentSessionId, id);
|
|
826
|
+
const current = this.#workVersions.get(id) ?? 0;
|
|
827
|
+
if (stored > current)
|
|
828
|
+
this.#workVersions.set(id, stored);
|
|
829
|
+
const claim = this.#lastClaim.get(id) ?? 0;
|
|
830
|
+
if (stored > claim)
|
|
831
|
+
this.#lastClaim.set(id, stored);
|
|
832
|
+
}
|
|
833
|
+
#claimRunBoundary(id, joining) {
|
|
834
|
+
// A steer into a turn that already started shares that turn's version. Anything else opens
|
|
835
|
+
// the next one, which is exactly the version the child's `agent_start` will announce, even
|
|
836
|
+
// when that announcement wins the race against this dispatch's own acknowledgement.
|
|
837
|
+
const current = this.#workVersions.get(id) ?? 0;
|
|
838
|
+
const claimed = joining && current > 0 ? current : current + 1;
|
|
839
|
+
this.#lastClaim.set(id, claimed);
|
|
840
|
+
return claimed;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Binds one confirmed input to what can settle it. A compaction is a control operation: it opens
|
|
844
|
+
* no turn, so it is bound to the child's operation id rather than to a work version that would
|
|
845
|
+
* never arrive.
|
|
846
|
+
*/
|
|
847
|
+
#claimDispatch(id, response, claim) {
|
|
848
|
+
if (claim === undefined) {
|
|
849
|
+
this.#lastClaim.delete(id);
|
|
850
|
+
this.#lastControl.set(id, acknowledgedControl(response));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
this.#lastControl.delete(id);
|
|
854
|
+
this.#lastClaim.set(id, claim);
|
|
855
|
+
}
|
|
856
|
+
#claimedSubmission(id) {
|
|
857
|
+
const controlId = this.#lastControl.get(id);
|
|
858
|
+
if (controlId !== undefined)
|
|
859
|
+
return { controlId };
|
|
860
|
+
const workVersion = this.#lastClaim.get(id);
|
|
861
|
+
if (workVersion === undefined) {
|
|
862
|
+
throw new Error(`Delegation for ${id} was not bound to a run`);
|
|
863
|
+
}
|
|
864
|
+
const recent = this.#recentSettlement.get(id);
|
|
865
|
+
return recent?.workVersion === workVersion ? { workVersion, settled: recent } : { workVersion };
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Records a settlement in the ledger and returns what it settled. `undefined` means the write
|
|
869
|
+
* itself failed: nothing was recorded, so no consumer may treat the turn as delivered and the
|
|
870
|
+
* caller that can retry is told about it instead.
|
|
871
|
+
*/
|
|
872
|
+
#recordSettlement(event) {
|
|
873
|
+
this.#recentSettlement.set(event.childId, event);
|
|
874
|
+
// The ledger is the manager's own, so a settlement is durable before any consumer sees it and
|
|
875
|
+
// no caller has to wire this up for delegated turns to settle.
|
|
876
|
+
try {
|
|
877
|
+
return settleReceipts(this.#deps.registry.path, this.#deps.parentSessionId, event);
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
diagnose(`failed to record settlement for ${event.childId}: ${errorMessage(error)}`);
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* True when a waiting tool call owns this settlement. A steer into the turn it is waiting on is
|
|
886
|
+
* answered by the same settlement, so pushing it would deliver that turn twice; a compaction has
|
|
887
|
+
* no boundary of its own and stays a separate result.
|
|
888
|
+
*/
|
|
889
|
+
#claimedByWaiter(settled) {
|
|
890
|
+
return settled.some((receipt) => {
|
|
891
|
+
const childId = receipt.childId;
|
|
892
|
+
if (childId === undefined)
|
|
893
|
+
return false;
|
|
894
|
+
const waiter = this.#waiters.get(childId);
|
|
895
|
+
if (waiter === undefined || waiter.aborted)
|
|
896
|
+
return false;
|
|
897
|
+
if (waiter.delegationId === receipt.delegationId)
|
|
898
|
+
return true;
|
|
899
|
+
return waiter.runBoundary !== undefined && receipt.runBoundary === waiter.runBoundary;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Delivers a settled turn whose waiting tool call went away. The result is already durable, so
|
|
904
|
+
* this is the same automatic delivery an unwatched turn gets, not a second copy of it.
|
|
905
|
+
*/
|
|
906
|
+
async #pushSettled(receipt) {
|
|
907
|
+
const childId = receipt.childId;
|
|
908
|
+
if (this.#deps.channel === undefined || childId === undefined)
|
|
909
|
+
return;
|
|
910
|
+
const text = receipt.resultText;
|
|
911
|
+
if (text === undefined)
|
|
912
|
+
return;
|
|
913
|
+
const record = await this.#deps.registry.get(childId);
|
|
914
|
+
if (record === undefined)
|
|
915
|
+
return;
|
|
916
|
+
const reason = receipt.status === "completed"
|
|
917
|
+
? "success"
|
|
918
|
+
: receipt.status === "blocked"
|
|
919
|
+
? "blocked"
|
|
920
|
+
: "error";
|
|
921
|
+
await this.contactParent(childId, reason, text, record).catch((error) => {
|
|
922
|
+
diagnose(`failed to deliver the settled turn of ${childId}: ${errorMessage(error)}`);
|
|
923
|
+
});
|
|
924
|
+
}
|
|
713
925
|
#notify() {
|
|
714
926
|
for (const listener of this.#listeners) {
|
|
715
927
|
try {
|
|
@@ -726,6 +938,14 @@ export class SubagentManager {
|
|
|
726
938
|
* until a later send starts it from its recorded session.
|
|
727
939
|
*/
|
|
728
940
|
async recover() {
|
|
941
|
+
// A compaction that was in flight when this process started cannot be confirmed or replayed, so
|
|
942
|
+
// it ends as uncertain instead of leaving a waiter on a report that may never come.
|
|
943
|
+
try {
|
|
944
|
+
sweepControlReceipts(this.#deps.registry.path, this.#deps.parentSessionId, "Compaction was not confirmed before this parent session restarted");
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
diagnose(`failed to sweep unconfirmed compactions: ${errorMessage(error)}`);
|
|
948
|
+
}
|
|
729
949
|
const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state === "running");
|
|
730
950
|
const outcomes = await Promise.all(records.map(async (record) => {
|
|
731
951
|
this.#working.add(record.subagentId);
|
|
@@ -766,9 +986,14 @@ export class SubagentManager {
|
|
|
766
986
|
return;
|
|
767
987
|
}
|
|
768
988
|
await this.#releaseRuntime(record.subagentId, "parent-exit");
|
|
989
|
+
// The interrupted turn will not finish. Leaving it accepted lets the next run reuse its work
|
|
990
|
+
// boundary and settle the old receipt with the new answer.
|
|
991
|
+
this.#restoreWorkVersion(record.subagentId);
|
|
992
|
+
abandonReceipts(this.#deps.registry.path, this.#deps.parentSessionId, record.subagentId, "Delegation was interrupted because the background agent ended with the parent session");
|
|
769
993
|
}
|
|
770
994
|
/** Reads a connected child's real state and puts the record back in step with it. */
|
|
771
995
|
async #adopt(id) {
|
|
996
|
+
this.#restoreWorkVersion(id);
|
|
772
997
|
const workVersion = this.#workVersions.get(id) ?? 0;
|
|
773
998
|
const projector = this.#projectors.get(id);
|
|
774
999
|
const record = await this.#deps.registry.get(id);
|
|
@@ -849,13 +1074,214 @@ export class SubagentManager {
|
|
|
849
1074
|
}
|
|
850
1075
|
return { presentation: "panel" };
|
|
851
1076
|
}
|
|
1077
|
+
/**
|
|
1078
|
+
* One dispatch path for MCP's new-child and existing-child forms. This only confirms input
|
|
1079
|
+
* delivery; waiting for the specific turn's final report belongs to the MCP delegation receipt,
|
|
1080
|
+
* not to spawn/send or the child's durable identity.
|
|
1081
|
+
*/
|
|
1082
|
+
async submitDelegation(input, onTarget) {
|
|
1083
|
+
if (stripHindsightContent(input.task).trim() === "") {
|
|
1084
|
+
return failure("delegate", "Task must not be empty", undefined, undefined, [], false);
|
|
1085
|
+
}
|
|
1086
|
+
if (input.id !== undefined) {
|
|
1087
|
+
if (input.id.trim() === "") {
|
|
1088
|
+
return failure("delegate", "Child id must not be empty", undefined, undefined, [], false);
|
|
1089
|
+
}
|
|
1090
|
+
onTarget?.(input.id);
|
|
1091
|
+
// Ignore agent and cwd entirely: this child's launch config was frozen at spawn.
|
|
1092
|
+
const child = await this.send(input.id, input.task);
|
|
1093
|
+
return isOperationError(child)
|
|
1094
|
+
? child
|
|
1095
|
+
: { child, action: "reuse", ...this.#claimedSubmission(child.id) };
|
|
1096
|
+
}
|
|
1097
|
+
if (input.agent === undefined || input.agent.trim() === "") {
|
|
1098
|
+
return failure("delegate", "Agent is required without an id", undefined, undefined, [], false);
|
|
1099
|
+
}
|
|
1100
|
+
const spawned = await this.#spawnWith({
|
|
1101
|
+
task: input.task,
|
|
1102
|
+
agent: input.agent,
|
|
1103
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
1104
|
+
}, (config) => onTarget?.(config.subagentId));
|
|
1105
|
+
if (isOperationError(spawned))
|
|
1106
|
+
return spawned;
|
|
1107
|
+
return {
|
|
1108
|
+
child: spawned.child,
|
|
1109
|
+
action: "spawn",
|
|
1110
|
+
...this.#claimedSubmission(spawned.child.id),
|
|
1111
|
+
...(spawned.note === undefined ? {} : { note: spawned.note }),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* The acceptance path both consumers share: reserve this delegation's revision, dispatch it into
|
|
1116
|
+
* the agent session, then either wait for that revision's settlement or return its identity.
|
|
1117
|
+
* Waiting never holds the per-agent chain, so settlements and later steers still get in.
|
|
1118
|
+
*/
|
|
1119
|
+
async delegate(input, signal, onAccepted) {
|
|
1120
|
+
const wait = input.wait ?? true;
|
|
1121
|
+
const task = stripHindsightContent(input.task).trim();
|
|
1122
|
+
if (task === "") {
|
|
1123
|
+
return failure("delegate", "Task must not be empty", undefined, undefined, [], false);
|
|
1124
|
+
}
|
|
1125
|
+
if (input.id !== undefined && input.id.trim() === "") {
|
|
1126
|
+
return failure("delegate", "Child id must not be empty", undefined, undefined, [], false);
|
|
1127
|
+
}
|
|
1128
|
+
if (input.id === undefined && (input.agent === undefined || input.agent.trim() === "")) {
|
|
1129
|
+
return failure("delegate", "Agent is required without an id", undefined, undefined, [], false);
|
|
1130
|
+
}
|
|
1131
|
+
const filePath = this.#deps.registry.path;
|
|
1132
|
+
const parentSessionId = this.#deps.parentSessionId;
|
|
1133
|
+
if (input.id !== undefined && (await this.#deps.registry.get(input.id)) === undefined) {
|
|
1134
|
+
// Nothing is reserved for a target that does not exist: a revision is only ever spent on
|
|
1135
|
+
// work this parent actually accepted.
|
|
1136
|
+
return failure("delegate", "Unknown child", input.id);
|
|
1137
|
+
}
|
|
1138
|
+
const active = input.id === undefined ? undefined : this.#waiters.get(input.id);
|
|
1139
|
+
if (wait && active !== undefined) {
|
|
1140
|
+
// Refused before anything is dispatched: a conflicted wait must not steer first.
|
|
1141
|
+
return failure("delegate", `Child ${input.id ?? ""} already has a waiting delegation ${active.delegationId}`, input.id);
|
|
1142
|
+
}
|
|
1143
|
+
const target = {};
|
|
1144
|
+
const delivery = wait ? "pending" : "detached";
|
|
1145
|
+
let submitted;
|
|
1146
|
+
try {
|
|
1147
|
+
submitted = await this.submitDelegation(input, (childId) => {
|
|
1148
|
+
target.receipt = this.#reserveDelegation(filePath, parentSessionId, childId, task, delivery);
|
|
1149
|
+
// Registered before dispatch, so a settlement that lands during it already knows the
|
|
1150
|
+
// tool result owns this turn and does not push the same answer as well.
|
|
1151
|
+
if (wait) {
|
|
1152
|
+
this.#waiters.set(childId, {
|
|
1153
|
+
delegationId: target.receipt.delegationId,
|
|
1154
|
+
aborted: false,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
catch (error) {
|
|
1160
|
+
// A refused reservation happened before any dispatch, so nothing has to be undone.
|
|
1161
|
+
return failure("delegate", errorMessage(error), input.id, undefined, [], true);
|
|
1162
|
+
}
|
|
1163
|
+
const receipt = target.receipt;
|
|
1164
|
+
if (receipt === undefined) {
|
|
1165
|
+
// Without a reserved revision nothing was accepted, so this is the dispatch's own refusal.
|
|
1166
|
+
if (isOperationError(submitted))
|
|
1167
|
+
return submitted;
|
|
1168
|
+
return failure("delegate", "Delegation was accepted without a reserved revision");
|
|
1169
|
+
}
|
|
1170
|
+
if (isOperationError(submitted)) {
|
|
1171
|
+
// Only this call's own occupancy is released: a wait:false steer never owns one, so
|
|
1172
|
+
// deleting here would let a second waiter take over a delivery that is already claimed.
|
|
1173
|
+
if (wait)
|
|
1174
|
+
this.#waiters.delete(receipt.childId ?? "");
|
|
1175
|
+
const uncertain = submitted.sideEffects.length > 0 && !submitted.safeToRetry;
|
|
1176
|
+
const failed = updateReceipt(filePath, parentSessionId, receipt.delegationId, {
|
|
1177
|
+
from: ["dispatching"],
|
|
1178
|
+
status: uncertain ? "uncertain" : "failed",
|
|
1179
|
+
resultText: submitted.reason,
|
|
1180
|
+
safeToRetry: submitted.safeToRetry,
|
|
1181
|
+
sideEffects: submitted.sideEffects,
|
|
1182
|
+
});
|
|
1183
|
+
return {
|
|
1184
|
+
...delegationView(failed ?? receipt),
|
|
1185
|
+
error: {
|
|
1186
|
+
reason: submitted.reason,
|
|
1187
|
+
safeToRetry: submitted.safeToRetry,
|
|
1188
|
+
sideEffects: submitted.sideEffects,
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
const childId = submitted.child.id;
|
|
1193
|
+
const bound = this.#bindDispatch(filePath, parentSessionId, receipt, submitted);
|
|
1194
|
+
if (!wait) {
|
|
1195
|
+
return agentView(bound, submitted.child, { includeOutput: bound.status !== "accepted" });
|
|
1196
|
+
}
|
|
1197
|
+
if (bound.status !== "accepted") {
|
|
1198
|
+
this.#waiters.delete(childId);
|
|
1199
|
+
return agentView(bound, submitted.child, { includeOutput: true });
|
|
1200
|
+
}
|
|
1201
|
+
const waiter = this.#waiters.get(childId);
|
|
1202
|
+
if (waiter !== undefined && bound.runBoundary !== undefined) {
|
|
1203
|
+
waiter.runBoundary = bound.runBoundary;
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
onAccepted?.(await agentView(bound, submitted.child));
|
|
1207
|
+
const settled = await waitForReceipt(filePath, parentSessionId, receipt.delegationId, signal === undefined
|
|
1208
|
+
? this.#disposed.signal
|
|
1209
|
+
: AbortSignal.any([signal, this.#disposed.signal]));
|
|
1210
|
+
// The runtime moved on while the turn was in flight, so the projection is read fresh.
|
|
1211
|
+
const latest = await this.#deps.registry.get(childId);
|
|
1212
|
+
return await agentView(settled, latest === undefined ? submitted.child : publicChild(latest, false), { includeOutput: true });
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
// The wait ended, not the delegation. A result the tool result can no longer carry goes
|
|
1216
|
+
// back to the automatic path, so the turn is delivered exactly once either way.
|
|
1217
|
+
if (waiter !== undefined)
|
|
1218
|
+
waiter.aborted = true;
|
|
1219
|
+
const current = readStoredReceipt(filePath, parentSessionId, receipt.delegationId) ?? bound;
|
|
1220
|
+
if (!this.#disposed.signal.aborted && terminalReceipt(current.status)) {
|
|
1221
|
+
await this.#pushSettled(current);
|
|
1222
|
+
}
|
|
1223
|
+
if (signal?.aborted === true || this.#disposed.signal.aborted) {
|
|
1224
|
+
return delegationView(current, submitted.child);
|
|
1225
|
+
}
|
|
1226
|
+
throw error;
|
|
1227
|
+
}
|
|
1228
|
+
finally {
|
|
1229
|
+
if (this.#waiters.get(childId) === waiter)
|
|
1230
|
+
this.#waiters.delete(childId);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
#reserveDelegation(filePath, parentSessionId, childId, task, delivery) {
|
|
1234
|
+
const reserved = reserveReceipt(filePath, { parentSessionId, task, childId, delivery });
|
|
1235
|
+
const dispatching = updateReceipt(filePath, parentSessionId, reserved.delegationId, {
|
|
1236
|
+
from: ["reserved"],
|
|
1237
|
+
status: "dispatching",
|
|
1238
|
+
});
|
|
1239
|
+
if (dispatching === undefined)
|
|
1240
|
+
throw new Error("Delegation was not reserved");
|
|
1241
|
+
return dispatching;
|
|
1242
|
+
}
|
|
1243
|
+
/** Binds the accepted revision to what can settle it: a control operation, or a work version. */
|
|
1244
|
+
#bindDispatch(filePath, parentSessionId, receipt, submitted) {
|
|
1245
|
+
// A settlement that arrived before this binding landed is still that delegation's answer, and
|
|
1246
|
+
// the manager already holds it.
|
|
1247
|
+
const recent = this.#recentSettlement.get(submitted.child.id);
|
|
1248
|
+
const settled = submitted.settled ??
|
|
1249
|
+
(submitted.controlId !== undefined && recent?.controlId === submitted.controlId
|
|
1250
|
+
? recent
|
|
1251
|
+
: undefined);
|
|
1252
|
+
const bound = updateReceipt(filePath, parentSessionId, receipt.delegationId, {
|
|
1253
|
+
from: ["dispatching"],
|
|
1254
|
+
status: settled === undefined ? "accepted" : settled.status,
|
|
1255
|
+
...(submitted.controlId !== undefined
|
|
1256
|
+
? { controlId: submitted.controlId }
|
|
1257
|
+
: submitted.workVersion === undefined
|
|
1258
|
+
? {}
|
|
1259
|
+
: { runBoundary: submitted.workVersion }),
|
|
1260
|
+
...(settled === undefined ? {} : { resultText: settled.message }),
|
|
1261
|
+
});
|
|
1262
|
+
if (bound !== undefined)
|
|
1263
|
+
return bound;
|
|
1264
|
+
// The turn settled before this binding landed, so the stored row is already past dispatching.
|
|
1265
|
+
const current = readStoredReceipt(filePath, parentSessionId, receipt.delegationId);
|
|
1266
|
+
if (current === undefined)
|
|
1267
|
+
throw new Error("Delegation acceptance was lost");
|
|
1268
|
+
return current;
|
|
1269
|
+
}
|
|
852
1270
|
async spawn(input) {
|
|
1271
|
+
return this.#spawnWith(input);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* The spawn path with one seam: a caller that must own the new identity (a delegation reserves
|
|
1275
|
+
* its revision) sees it here, after it is minted and before anything is persisted or launched.
|
|
1276
|
+
*/
|
|
1277
|
+
async #spawnWith(input, onConfig) {
|
|
853
1278
|
const cleanTask = stripHindsightContent(input.task).trim();
|
|
854
1279
|
if (cleanTask === "")
|
|
855
1280
|
return failure("spawn", "Task must not be empty");
|
|
856
1281
|
let childId;
|
|
857
1282
|
try {
|
|
858
1283
|
const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
|
|
1284
|
+
onConfig?.(config);
|
|
859
1285
|
const cleanTask = stripHindsightContent(input.task);
|
|
860
1286
|
const placement = this.#placement(input);
|
|
861
1287
|
if ("reason" in placement)
|
|
@@ -872,9 +1298,16 @@ export class SubagentManager {
|
|
|
872
1298
|
this.#notify();
|
|
873
1299
|
await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
|
|
874
1300
|
let path;
|
|
1301
|
+
// A fresh child's first turn is version 1, claimed before the prompt goes out so a
|
|
1302
|
+
// preceding `agent_start` cannot push the receipt onto a version that never settles.
|
|
1303
|
+
const claim = isCompactCommand(cleanTask)
|
|
1304
|
+
? undefined
|
|
1305
|
+
: this.#claimRunBoundary(record.subagentId, false);
|
|
875
1306
|
try {
|
|
876
|
-
|
|
1307
|
+
const response = await this.#request(record.subagentId, "prompt", { message: cleanTask });
|
|
1308
|
+
path = acknowledgedPath(response);
|
|
877
1309
|
this.#paths.set(record.subagentId, path);
|
|
1310
|
+
this.#claimDispatch(record.subagentId, response, claim);
|
|
878
1311
|
}
|
|
879
1312
|
catch (error) {
|
|
880
1313
|
let failureState = "error";
|
|
@@ -929,7 +1362,11 @@ export class SubagentManager {
|
|
|
929
1362
|
return failed;
|
|
930
1363
|
}
|
|
931
1364
|
}
|
|
932
|
-
send(id, rawMessage,
|
|
1365
|
+
send(id, rawMessage, signal) {
|
|
1366
|
+
return this.#sendWithClaim(id, rawMessage, signal).then((result) => isOperationError(result) ? result : result.child);
|
|
1367
|
+
}
|
|
1368
|
+
/** The same delivery, with the work version this input was bound to. */
|
|
1369
|
+
async #sendWithClaim(id, rawMessage, signal) {
|
|
933
1370
|
const message = stripHindsightContent(rawMessage);
|
|
934
1371
|
this.#bumpActivity(id);
|
|
935
1372
|
this.#clearSettledPanelClose(id);
|
|
@@ -971,6 +1408,9 @@ export class SubagentManager {
|
|
|
971
1408
|
try {
|
|
972
1409
|
await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
|
|
973
1410
|
isResumed = true;
|
|
1411
|
+
// A new process counts its turns from one again, so this parent adopts the boundary the
|
|
1412
|
+
// ledger already spent before it opens the next one.
|
|
1413
|
+
this.#restoreWorkVersion(id);
|
|
974
1414
|
}
|
|
975
1415
|
catch (error) {
|
|
976
1416
|
const reason = errorMessage(error);
|
|
@@ -1000,12 +1440,19 @@ export class SubagentManager {
|
|
|
1000
1440
|
}
|
|
1001
1441
|
}
|
|
1002
1442
|
const state = isResumed ? "running" : record.state;
|
|
1003
|
-
// A resume already starts a fresh run, so the
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
const operation = isResumed ? "prompt" : mode;
|
|
1443
|
+
// A resume already starts a fresh run, so the message is that run's prompt. Otherwise the
|
|
1444
|
+
// input steers: a running child takes it at its next model step, an idle one starts a run.
|
|
1445
|
+
const operation = isResumed ? "prompt" : "steer";
|
|
1007
1446
|
if (signal?.aborted)
|
|
1008
1447
|
return failure("send", "Send was cancelled", id, state);
|
|
1448
|
+
// Read before this dispatch marks the child running: only a child already inside a turn
|
|
1449
|
+
// shares that turn's boundary, a fresh dispatch always opens the next one. The boundary
|
|
1450
|
+
// is claimed before the request goes out, because the child may announce `agent_start`
|
|
1451
|
+
// before this dispatch's own acknowledgement arrives.
|
|
1452
|
+
// A resume starts a fresh run in a new process, so it never shares the boundary of the
|
|
1453
|
+
// turn the dead one left behind.
|
|
1454
|
+
const joining = !isResumed && this.#working.has(id) && (this.#workVersions.get(id) ?? 0) > 0;
|
|
1455
|
+
const claim = isCompactCommand(message) ? undefined : this.#claimRunBoundary(id, joining);
|
|
1009
1456
|
this.#clearSettledPanelClose(id);
|
|
1010
1457
|
const pending = await this.#update(id, (current) => {
|
|
1011
1458
|
const { interrupted: _interrupted, ...clean } = current;
|
|
@@ -1017,13 +1464,16 @@ export class SubagentManager {
|
|
|
1017
1464
|
};
|
|
1018
1465
|
});
|
|
1019
1466
|
try {
|
|
1020
|
-
const
|
|
1467
|
+
const response = await this.#request(id, operation, { message });
|
|
1468
|
+
const path = acknowledgedPath(response);
|
|
1021
1469
|
this.#paths.set(id, path);
|
|
1470
|
+
this.#claimDispatch(id, response, claim);
|
|
1022
1471
|
}
|
|
1023
1472
|
catch (error) {
|
|
1024
1473
|
return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
|
|
1025
1474
|
}
|
|
1026
|
-
|
|
1475
|
+
const child = publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
|
|
1476
|
+
return { child, action: "reuse", ...this.#claimedSubmission(id) };
|
|
1027
1477
|
});
|
|
1028
1478
|
}
|
|
1029
1479
|
async get(id) {
|
|
@@ -1033,6 +1483,62 @@ export class SubagentManager {
|
|
|
1033
1483
|
const live = this.#isLive(id);
|
|
1034
1484
|
return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
|
|
1035
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* One delegation of one agent, by revision: the newest by default. The result body is only read
|
|
1488
|
+
* when it is asked for, and then only within the shared output budget.
|
|
1489
|
+
*/
|
|
1490
|
+
async inspect(input) {
|
|
1491
|
+
const record = await this.#deps.registry.get(input.id);
|
|
1492
|
+
if (record === undefined)
|
|
1493
|
+
return failure("get", "Unknown child", input.id);
|
|
1494
|
+
const filePath = this.#deps.registry.path;
|
|
1495
|
+
const receipt = input.revision === undefined
|
|
1496
|
+
? latestReceiptForChild(filePath, this.#deps.parentSessionId, input.id)
|
|
1497
|
+
: readReceiptRevision(filePath, this.#deps.parentSessionId, input.id, input.revision);
|
|
1498
|
+
if (receipt === undefined) {
|
|
1499
|
+
return failure("get", input.revision === undefined
|
|
1500
|
+
? "This agent has no delegation"
|
|
1501
|
+
: `This agent has no delegation revision ${input.revision}`, input.id, record.state);
|
|
1502
|
+
}
|
|
1503
|
+
return agentView(receipt, publicChild(this.#withSnapshot(record), this.#isLive(input.id), this.#paths.get(input.id)), { includeOutput: input.includeOutput === true });
|
|
1504
|
+
}
|
|
1505
|
+
/** Every agent of this parent: runtime state now, latest delegation status, no result bodies. */
|
|
1506
|
+
async dashboard() {
|
|
1507
|
+
const records = await this.#deps.registry.list();
|
|
1508
|
+
const summaries = latestReceiptSummaries(this.#deps.registry.path, this.#deps.parentSessionId);
|
|
1509
|
+
const children = [];
|
|
1510
|
+
for (const record of records) {
|
|
1511
|
+
const summary = summaries.get(record.subagentId);
|
|
1512
|
+
if (summary === undefined)
|
|
1513
|
+
continue;
|
|
1514
|
+
const live = this.#withSnapshot(record);
|
|
1515
|
+
children.push({
|
|
1516
|
+
id: record.subagentId,
|
|
1517
|
+
agent: record.launchConfig.agent.name,
|
|
1518
|
+
runtimeState: live.state,
|
|
1519
|
+
revision: summary.revision,
|
|
1520
|
+
status: summary.status,
|
|
1521
|
+
hasOutput: summary.hasOutput,
|
|
1522
|
+
...(live.latestSummary === undefined ? {} : { summary: live.latestSummary }),
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
const count = (state) => children.filter((child) => child.runtimeState === state).length;
|
|
1526
|
+
return {
|
|
1527
|
+
total: children.length,
|
|
1528
|
+
running: count("running"),
|
|
1529
|
+
blocked: count("blocked"),
|
|
1530
|
+
completed: count("done"),
|
|
1531
|
+
failed: count("error"),
|
|
1532
|
+
children,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
/** Stops the runtime and reports the same projection every other read returns. */
|
|
1536
|
+
async stopAgent(id) {
|
|
1537
|
+
const stopped = await this.stop(id);
|
|
1538
|
+
if (isOperationError(stopped))
|
|
1539
|
+
return stopped;
|
|
1540
|
+
return this.inspect({ id });
|
|
1541
|
+
}
|
|
1036
1542
|
async list() {
|
|
1037
1543
|
const records = await this.#deps.registry.list();
|
|
1038
1544
|
return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
|
|
@@ -1109,6 +1615,9 @@ export class SubagentManager {
|
|
|
1109
1615
|
const finished = await this.#releaseRuntime(id, "finished");
|
|
1110
1616
|
if (finished !== undefined)
|
|
1111
1617
|
stopped = finished;
|
|
1618
|
+
// Nothing can settle these any more: a waiter that was waiting on this child must end
|
|
1619
|
+
// with the reason, not hang on a runtime that is gone.
|
|
1620
|
+
abandonReceipts(this.#deps.registry.path, this.#deps.parentSessionId, id, "Child was stopped before the delegation finished");
|
|
1112
1621
|
return publicChild(stopped, false);
|
|
1113
1622
|
}
|
|
1114
1623
|
catch (error) {
|
|
@@ -1180,7 +1689,26 @@ export class SubagentManager {
|
|
|
1180
1689
|
throw new BridgeError("stale_report", "This report does not belong to the current runtime");
|
|
1181
1690
|
}
|
|
1182
1691
|
const reason = payload.reason ?? "blocked";
|
|
1183
|
-
const
|
|
1692
|
+
const settles = reason === "success" || reason === "blocked";
|
|
1693
|
+
// The settlement is durable before delivery is coordinated, and a waiting tool call that
|
|
1694
|
+
// already owns this turn's answer suppresses the push, so it is delivered exactly once.
|
|
1695
|
+
const settled = settles
|
|
1696
|
+
? this.#recordSettlement({
|
|
1697
|
+
childId,
|
|
1698
|
+
status: reason === "blocked" ? "blocked" : "completed",
|
|
1699
|
+
message: payload.message,
|
|
1700
|
+
...(payload.controlId === undefined
|
|
1701
|
+
? { workVersion: this.#workVersions.get(childId) ?? 0 }
|
|
1702
|
+
: { controlId: payload.controlId }),
|
|
1703
|
+
})
|
|
1704
|
+
: [];
|
|
1705
|
+
if (settled === undefined) {
|
|
1706
|
+
// The child still holds the report, so it can retry instead of losing the settlement.
|
|
1707
|
+
throw new BridgeError("delivery_failed", "Settlement was not recorded");
|
|
1708
|
+
}
|
|
1709
|
+
const delivered = this.#claimedByWaiter(settled)
|
|
1710
|
+
? { delivered: true }
|
|
1711
|
+
: await this.contactParent(childId, reason, payload.message, record);
|
|
1184
1712
|
if ("reason" in delivered)
|
|
1185
1713
|
throw new BridgeError("delivery_failed", delivered.reason);
|
|
1186
1714
|
if (reason === "success" || reason === "blocked") {
|
|
@@ -1269,16 +1797,23 @@ export class SubagentManager {
|
|
|
1269
1797
|
const message = value.message === undefined || value.message === ""
|
|
1270
1798
|
? "Task is unfinished and waiting for user intent."
|
|
1271
1799
|
: value.message;
|
|
1272
|
-
|
|
1800
|
+
const settled = this.#recordSettlement({
|
|
1801
|
+
childId: id,
|
|
1802
|
+
status: "blocked",
|
|
1803
|
+
message,
|
|
1804
|
+
workVersion,
|
|
1805
|
+
});
|
|
1806
|
+
if (settled !== undefined && !this.#claimedByWaiter(settled)) {
|
|
1807
|
+
await this.contactParent(id, "user_interrupt", message, record);
|
|
1808
|
+
}
|
|
1273
1809
|
return;
|
|
1274
1810
|
}
|
|
1275
1811
|
if (value.kind === "left_session") {
|
|
1276
1812
|
this.#working.delete(id);
|
|
1277
1813
|
// The process now serves another session, so it is no longer this child's runtime. Its
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
const retired = record.runtime?.runtimeIdentity;
|
|
1814
|
+
// runtime evidence is dropped, so neither a reconnect nor a later adopt can mistake the new
|
|
1815
|
+
// session for this child; the panel belongs to that other session from now on, which is why
|
|
1816
|
+
// it is forgotten here and never closed.
|
|
1282
1817
|
this.#clearSettledPanelClose(id);
|
|
1283
1818
|
this.#projectors.delete(id);
|
|
1284
1819
|
this.#attachments.delete(id);
|
|
@@ -1287,8 +1822,12 @@ export class SubagentManager {
|
|
|
1287
1822
|
// relaunch mistake the old connection for the new runtime.
|
|
1288
1823
|
this.#deps.bridge.disconnect(id);
|
|
1289
1824
|
await commitLease(this.#deps.registry, id, { type: "left-session" });
|
|
1290
|
-
|
|
1291
|
-
|
|
1825
|
+
this.#recordSettlement({
|
|
1826
|
+
childId: id,
|
|
1827
|
+
status: "failed",
|
|
1828
|
+
message: "Child left the delegated session.",
|
|
1829
|
+
workVersion,
|
|
1830
|
+
});
|
|
1292
1831
|
return;
|
|
1293
1832
|
}
|
|
1294
1833
|
// A child's own quit is a lifecycle notice, not state: the disconnect that follows says
|
|
@@ -1386,9 +1925,6 @@ export class SubagentManager {
|
|
|
1386
1925
|
registry: this.#deps.registry,
|
|
1387
1926
|
hosts: directory,
|
|
1388
1927
|
processes: { alive: processAlive },
|
|
1389
|
-
forgetRuntime: (runtimeIdentity) => {
|
|
1390
|
-
this.#deps.tokens?.forget(runtimeIdentity);
|
|
1391
|
-
},
|
|
1392
1928
|
});
|
|
1393
1929
|
const result = await lease.ready();
|
|
1394
1930
|
if (!result.ok)
|