@hheei/pi-subagents 0.6.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 +14 -12
- package/dist/child-control.d.ts +0 -8
- package/dist/child-control.js +66 -3
- 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 +39 -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 +585 -48
- 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.d.ts +1 -0
- package/dist/state.js +29 -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;
|
|
@@ -570,13 +651,19 @@ export class SubagentManager {
|
|
|
570
651
|
else {
|
|
571
652
|
message = text ?? "";
|
|
572
653
|
}
|
|
654
|
+
const reason = isError ? "error" : isBlocked ? "blocked" : "success";
|
|
655
|
+
if (reason === "success" &&
|
|
656
|
+
facts?.contextTokens !== undefined &&
|
|
657
|
+
facts.contextTokens >= 100_000) {
|
|
658
|
+
const kTokens = `${Math.round(facts.contextTokens / 1000)}k`;
|
|
659
|
+
message = `${message}\n\n[Context: ~${kTokens} tokens. If retaining this subagent for future tasks, send "/compact" via agents_delegate to trim memory.]`;
|
|
660
|
+
}
|
|
573
661
|
const lastDelivered = this.#lastDeliveredTurn.get(id);
|
|
574
662
|
if (lastDelivered !== undefined &&
|
|
575
663
|
lastDelivered.workVersion === workVersion &&
|
|
576
664
|
lastDelivered.message === message) {
|
|
577
665
|
return;
|
|
578
666
|
}
|
|
579
|
-
const reason = isError ? "error" : isBlocked ? "blocked" : "success";
|
|
580
667
|
const projector = this.#projectors.get(id);
|
|
581
668
|
const updatedRecord = !failed
|
|
582
669
|
? await this.#update(id, (current) => {
|
|
@@ -595,8 +682,20 @@ export class SubagentManager {
|
|
|
595
682
|
projector.syncState(isError ? "error" : isBlocked ? "blocked" : "done");
|
|
596
683
|
}
|
|
597
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;
|
|
598
697
|
let deliveryOk = true;
|
|
599
|
-
if (this.#deps.channel !== undefined) {
|
|
698
|
+
if (!this.#claimedByWaiter(settled) && this.#deps.channel !== undefined) {
|
|
600
699
|
const contactResult = await this.contactParent(id, reason, message, updatedRecord);
|
|
601
700
|
if (isOperationError(contactResult)) {
|
|
602
701
|
deliveryOk = false;
|
|
@@ -608,8 +707,8 @@ export class SubagentManager {
|
|
|
608
707
|
});
|
|
609
708
|
}
|
|
610
709
|
/** Reads a child's session entries; used by transcript inspection fallbacks. */
|
|
611
|
-
async getEntries(id) {
|
|
612
|
-
return this.#entries(id);
|
|
710
|
+
async getEntries(id, range) {
|
|
711
|
+
return this.#entries(id, range?.cursor ?? 0, range?.limit ?? 20);
|
|
613
712
|
}
|
|
614
713
|
/** A child is live while its bridge connection is up, or while a process we started still runs. */
|
|
615
714
|
#isLive(id) {
|
|
@@ -692,18 +791,137 @@ export class SubagentManager {
|
|
|
692
791
|
return entries === undefined ? undefined : transcriptFacts(entries, expectedInput);
|
|
693
792
|
}
|
|
694
793
|
/** Reads a child's session entries over the bridge, falling back to disk when disconnected. */
|
|
695
|
-
async #entries(id) {
|
|
794
|
+
async #entries(id, cursor = 0, limit = 20) {
|
|
696
795
|
if (this.#deps.bridge.isConnected(id)) {
|
|
697
|
-
const response = await this.#request(id, "get_entries").catch(() => undefined);
|
|
698
|
-
if (isRecord(response) &&
|
|
699
|
-
|
|
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
|
+
};
|
|
700
805
|
}
|
|
701
806
|
}
|
|
702
|
-
|
|
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);
|
|
703
815
|
}
|
|
704
816
|
#request(id, operation, payload) {
|
|
705
817
|
return withDeadline(this.#deps.bridge.request(id, operation, payload), this.#deps.deadlineMs ?? 30_000);
|
|
706
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
|
+
}
|
|
707
925
|
#notify() {
|
|
708
926
|
for (const listener of this.#listeners) {
|
|
709
927
|
try {
|
|
@@ -720,6 +938,14 @@ export class SubagentManager {
|
|
|
720
938
|
* until a later send starts it from its recorded session.
|
|
721
939
|
*/
|
|
722
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
|
+
}
|
|
723
949
|
const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state === "running");
|
|
724
950
|
const outcomes = await Promise.all(records.map(async (record) => {
|
|
725
951
|
this.#working.add(record.subagentId);
|
|
@@ -760,9 +986,14 @@ export class SubagentManager {
|
|
|
760
986
|
return;
|
|
761
987
|
}
|
|
762
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");
|
|
763
993
|
}
|
|
764
994
|
/** Reads a connected child's real state and puts the record back in step with it. */
|
|
765
995
|
async #adopt(id) {
|
|
996
|
+
this.#restoreWorkVersion(id);
|
|
766
997
|
const workVersion = this.#workVersions.get(id) ?? 0;
|
|
767
998
|
const projector = this.#projectors.get(id);
|
|
768
999
|
const record = await this.#deps.registry.get(id);
|
|
@@ -843,13 +1074,214 @@ export class SubagentManager {
|
|
|
843
1074
|
}
|
|
844
1075
|
return { presentation: "panel" };
|
|
845
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
|
+
}
|
|
846
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) {
|
|
847
1278
|
const cleanTask = stripHindsightContent(input.task).trim();
|
|
848
1279
|
if (cleanTask === "")
|
|
849
1280
|
return failure("spawn", "Task must not be empty");
|
|
850
1281
|
let childId;
|
|
851
1282
|
try {
|
|
852
1283
|
const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
|
|
1284
|
+
onConfig?.(config);
|
|
853
1285
|
const cleanTask = stripHindsightContent(input.task);
|
|
854
1286
|
const placement = this.#placement(input);
|
|
855
1287
|
if ("reason" in placement)
|
|
@@ -866,9 +1298,16 @@ export class SubagentManager {
|
|
|
866
1298
|
this.#notify();
|
|
867
1299
|
await withDeadline(this.#start(record), this.#deps.deadlineMs ?? 60_000);
|
|
868
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);
|
|
869
1306
|
try {
|
|
870
|
-
|
|
1307
|
+
const response = await this.#request(record.subagentId, "prompt", { message: cleanTask });
|
|
1308
|
+
path = acknowledgedPath(response);
|
|
871
1309
|
this.#paths.set(record.subagentId, path);
|
|
1310
|
+
this.#claimDispatch(record.subagentId, response, claim);
|
|
872
1311
|
}
|
|
873
1312
|
catch (error) {
|
|
874
1313
|
let failureState = "error";
|
|
@@ -923,7 +1362,11 @@ export class SubagentManager {
|
|
|
923
1362
|
return failed;
|
|
924
1363
|
}
|
|
925
1364
|
}
|
|
926
|
-
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) {
|
|
927
1370
|
const message = stripHindsightContent(rawMessage);
|
|
928
1371
|
this.#bumpActivity(id);
|
|
929
1372
|
this.#clearSettledPanelClose(id);
|
|
@@ -965,6 +1408,9 @@ export class SubagentManager {
|
|
|
965
1408
|
try {
|
|
966
1409
|
await withDeadline(this.#start(resumedRecord), this.#deps.deadlineMs ?? 60_000);
|
|
967
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);
|
|
968
1414
|
}
|
|
969
1415
|
catch (error) {
|
|
970
1416
|
const reason = errorMessage(error);
|
|
@@ -994,12 +1440,19 @@ export class SubagentManager {
|
|
|
994
1440
|
}
|
|
995
1441
|
}
|
|
996
1442
|
const state = isResumed ? "running" : record.state;
|
|
997
|
-
// A resume already starts a fresh run, so the
|
|
998
|
-
//
|
|
999
|
-
|
|
1000
|
-
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";
|
|
1001
1446
|
if (signal?.aborted)
|
|
1002
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);
|
|
1003
1456
|
this.#clearSettledPanelClose(id);
|
|
1004
1457
|
const pending = await this.#update(id, (current) => {
|
|
1005
1458
|
const { interrupted: _interrupted, ...clean } = current;
|
|
@@ -1011,13 +1464,16 @@ export class SubagentManager {
|
|
|
1011
1464
|
};
|
|
1012
1465
|
});
|
|
1013
1466
|
try {
|
|
1014
|
-
const
|
|
1467
|
+
const response = await this.#request(id, operation, { message });
|
|
1468
|
+
const path = acknowledgedPath(response);
|
|
1015
1469
|
this.#paths.set(id, path);
|
|
1470
|
+
this.#claimDispatch(id, response, claim);
|
|
1016
1471
|
}
|
|
1017
1472
|
catch (error) {
|
|
1018
1473
|
return failure("send", errorMessage(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
|
|
1019
1474
|
}
|
|
1020
|
-
|
|
1475
|
+
const child = publicChild((await this.#deps.registry.get(id)) ?? pending, true, this.#paths.get(id));
|
|
1476
|
+
return { child, action: "reuse", ...this.#claimedSubmission(id) };
|
|
1021
1477
|
});
|
|
1022
1478
|
}
|
|
1023
1479
|
async get(id) {
|
|
@@ -1027,6 +1483,62 @@ export class SubagentManager {
|
|
|
1027
1483
|
const live = this.#isLive(id);
|
|
1028
1484
|
return publicChild(this.#withSnapshot(record), live, this.#paths.get(id));
|
|
1029
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
|
+
}
|
|
1030
1542
|
async list() {
|
|
1031
1543
|
const records = await this.#deps.registry.list();
|
|
1032
1544
|
return records.map((record) => publicChild(this.#withSnapshot(record), this.#isLive(record.subagentId), this.#paths.get(record.subagentId)));
|
|
@@ -1103,6 +1615,9 @@ export class SubagentManager {
|
|
|
1103
1615
|
const finished = await this.#releaseRuntime(id, "finished");
|
|
1104
1616
|
if (finished !== undefined)
|
|
1105
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");
|
|
1106
1621
|
return publicChild(stopped, false);
|
|
1107
1622
|
}
|
|
1108
1623
|
catch (error) {
|
|
@@ -1174,28 +1689,42 @@ export class SubagentManager {
|
|
|
1174
1689
|
throw new BridgeError("stale_report", "This report does not belong to the current runtime");
|
|
1175
1690
|
}
|
|
1176
1691
|
const reason = payload.reason ?? "blocked";
|
|
1177
|
-
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);
|
|
1178
1712
|
if ("reason" in delivered)
|
|
1179
1713
|
throw new BridgeError("delivery_failed", delivered.reason);
|
|
1180
|
-
if (reason === "success") {
|
|
1714
|
+
if (reason === "success" || reason === "blocked") {
|
|
1715
|
+
const terminalState = reason === "success" ? "done" : "blocked";
|
|
1181
1716
|
const projector = this.#projectors.get(childId);
|
|
1182
1717
|
if (projector !== undefined) {
|
|
1183
|
-
projector.syncState(
|
|
1718
|
+
projector.syncState(terminalState);
|
|
1184
1719
|
}
|
|
1185
|
-
await this.#update(childId, (current) =>
|
|
1186
|
-
...current
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
projector.syncState("blocked");
|
|
1194
|
-
}
|
|
1195
|
-
await this.#update(childId, (current) => ({
|
|
1196
|
-
...current,
|
|
1197
|
-
state: "blocked",
|
|
1198
|
-
}));
|
|
1720
|
+
await this.#update(childId, (current) => {
|
|
1721
|
+
const { unacknowledgedInput: _input, ...clean } = current;
|
|
1722
|
+
return {
|
|
1723
|
+
...clean,
|
|
1724
|
+
state: terminalState,
|
|
1725
|
+
};
|
|
1726
|
+
});
|
|
1727
|
+
this.#scheduleSettledPanelClose(childId);
|
|
1199
1728
|
}
|
|
1200
1729
|
else {
|
|
1201
1730
|
const projector = this.#projectors.get(childId);
|
|
@@ -1268,16 +1797,23 @@ export class SubagentManager {
|
|
|
1268
1797
|
const message = value.message === undefined || value.message === ""
|
|
1269
1798
|
? "Task is unfinished and waiting for user intent."
|
|
1270
1799
|
: value.message;
|
|
1271
|
-
|
|
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
|
+
}
|
|
1272
1809
|
return;
|
|
1273
1810
|
}
|
|
1274
1811
|
if (value.kind === "left_session") {
|
|
1275
1812
|
this.#working.delete(id);
|
|
1276
1813
|
// The process now serves another session, so it is no longer this child's runtime. Its
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
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.
|
|
1281
1817
|
this.#clearSettledPanelClose(id);
|
|
1282
1818
|
this.#projectors.delete(id);
|
|
1283
1819
|
this.#attachments.delete(id);
|
|
@@ -1286,8 +1822,12 @@ export class SubagentManager {
|
|
|
1286
1822
|
// relaunch mistake the old connection for the new runtime.
|
|
1287
1823
|
this.#deps.bridge.disconnect(id);
|
|
1288
1824
|
await commitLease(this.#deps.registry, id, { type: "left-session" });
|
|
1289
|
-
|
|
1290
|
-
|
|
1825
|
+
this.#recordSettlement({
|
|
1826
|
+
childId: id,
|
|
1827
|
+
status: "failed",
|
|
1828
|
+
message: "Child left the delegated session.",
|
|
1829
|
+
workVersion,
|
|
1830
|
+
});
|
|
1291
1831
|
return;
|
|
1292
1832
|
}
|
|
1293
1833
|
// A child's own quit is a lifecycle notice, not state: the disconnect that follows says
|
|
@@ -1385,9 +1925,6 @@ export class SubagentManager {
|
|
|
1385
1925
|
registry: this.#deps.registry,
|
|
1386
1926
|
hosts: directory,
|
|
1387
1927
|
processes: { alive: processAlive },
|
|
1388
|
-
forgetRuntime: (runtimeIdentity) => {
|
|
1389
|
-
this.#deps.tokens?.forget(runtimeIdentity);
|
|
1390
|
-
},
|
|
1391
1928
|
});
|
|
1392
1929
|
const result = await lease.ready();
|
|
1393
1930
|
if (!result.ok)
|