@te-river/opencode-team-mode 1.8.4 → 1.8.6
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/README.md +9 -8
- package/README.zh-CN.md +10 -9
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +14 -5
- package/dist/agents.js.map +1 -1
- package/dist/host/v2-types.d.ts +0 -1
- package/dist/host/v2-types.d.ts.map +1 -1
- package/dist/host/v2.d.ts +9 -5
- package/dist/host/v2.d.ts.map +1 -1
- package/dist/host/v2.js +84 -72
- package/dist/host/v2.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/prompts/lead.d.ts +1 -1
- package/dist/prompts/lead.d.ts.map +1 -1
- package/dist/prompts/lead.js +7 -0
- package/dist/prompts/lead.js.map +1 -1
- package/dist/prompts/shared.d.ts +1 -1
- package/dist/prompts/shared.d.ts.map +1 -1
- package/dist/prompts/shared.js +10 -0
- package/dist/prompts/shared.js.map +1 -1
- package/dist/tm/config-files.d.ts +30 -6
- package/dist/tm/config-files.d.ts.map +1 -1
- package/dist/tm/config-files.js +47 -10
- package/dist/tm/config-files.js.map +1 -1
- package/dist/tm/config-layers.d.ts +20 -9
- package/dist/tm/config-layers.d.ts.map +1 -1
- package/dist/tm/config-layers.js +19 -8
- package/dist/tm/config-layers.js.map +1 -1
- package/dist/tm/config-template.js +2 -2
- package/dist/tm/config-template.js.map +1 -1
- package/dist/tm/dispatch.d.ts +90 -11
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +490 -52
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/pipelines.d.ts.map +1 -1
- package/dist/tm/pipelines.js +6 -3
- package/dist/tm/pipelines.js.map +1 -1
- package/dist/tm/result.d.ts +13 -1
- package/dist/tm/result.d.ts.map +1 -1
- package/dist/tm/result.js +14 -2
- package/dist/tm/result.js.map +1 -1
- package/docs/installation-v2.md +28 -24
- package/package.json +1 -1
package/dist/tm/dispatch.js
CHANGED
|
@@ -40,6 +40,22 @@
|
|
|
40
40
|
* already calls. What the host does NOT give is tool-level cancellation — the
|
|
41
41
|
* only thing stoppable is the whole active execution (`interrupt`) — so nothing
|
|
42
42
|
* here pretends that granularity exists.
|
|
43
|
+
*
|
|
44
|
+
* Waking (2026-10-23, measured in another project): steering a session that has
|
|
45
|
+
* already settled is not an interjection, it is a RESURRECTION — the host's own
|
|
46
|
+
* sentence is "Steering wakes session execution", so the line starts a second run
|
|
47
|
+
* that overlaps whatever the lead dispatched meanwhile (two processes on one
|
|
48
|
+
* device, in the incident that produced this). A steer therefore refuses unless
|
|
49
|
+
* the registry says `running`, and the explicit `wake:true` opt-in is the only way
|
|
50
|
+
* to deliver into a settled/unknown child. The same incident showed the mirror
|
|
51
|
+
* bug on the stop path: a settled row made `cancel:true` answer "没有运行中的
|
|
52
|
+
* 子代理可停" WITHOUT asking the host, while the woken child was in fact running.
|
|
53
|
+
* An explicitly named id is always asked of the host now.
|
|
54
|
+
*
|
|
55
|
+
* The body fallback has one more source: a settled host sub-agent's report also
|
|
56
|
+
* lives in the PARENT transcript as `<subagent sessionID="…">…</subagent>`; when
|
|
57
|
+
* the child session's context yields no assistant text, that envelope is read
|
|
58
|
+
* (exact id match, last one wins) instead of reporting `child_body_missing`.
|
|
43
59
|
*/
|
|
44
60
|
import { sameAgent } from "../identity.js";
|
|
45
61
|
import { tmError, toToolResult } from "./result.js";
|
|
@@ -96,6 +112,34 @@ export function sessionApiOf(client) {
|
|
|
96
112
|
return null;
|
|
97
113
|
return api;
|
|
98
114
|
}
|
|
115
|
+
export async function readHostSessionState(api, sessionID) {
|
|
116
|
+
const id = String(sessionID ?? "").trim();
|
|
117
|
+
if (!api || typeof api.get !== "function" || !id)
|
|
118
|
+
return { answered: false };
|
|
119
|
+
try {
|
|
120
|
+
// The flat `{sessionID}` spelling is the measured one; the v2 bridge also retries the
|
|
121
|
+
// older `{path:{id}}` shape, so either call convention still lands.
|
|
122
|
+
const un = unwrapClientResult(await api.get({ sessionID: id }));
|
|
123
|
+
if (!un.ok)
|
|
124
|
+
return { answered: false };
|
|
125
|
+
const d = un.data;
|
|
126
|
+
if (!d || typeof d !== "object")
|
|
127
|
+
return { answered: false };
|
|
128
|
+
const createdAt = typeof d.time?.created === "number" && d.time.created > 0 ? d.time.created : undefined;
|
|
129
|
+
const idleAt = typeof d.time?.idle === "number" && d.time.idle > 0 ? d.time.idle : undefined;
|
|
130
|
+
const outcome = typeof d.outcome === "string" && d.outcome.trim() ? d.outcome.trim() : undefined;
|
|
131
|
+
return {
|
|
132
|
+
answered: true,
|
|
133
|
+
...(createdAt ? { createdAt } : {}),
|
|
134
|
+
...(idleAt ? { idleAt } : {}),
|
|
135
|
+
...(outcome ? { outcome } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// A throwing seam is "we could not ask" — never "it settled".
|
|
140
|
+
return { answered: false };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
99
143
|
/** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
|
|
100
144
|
* takes `background: true`, and because that card is rendered by the host's
|
|
101
145
|
* own `task` renderer it links to the live child session — which a plugin tool
|
|
@@ -225,6 +269,79 @@ export function lastAssistantMessage(messages) {
|
|
|
225
269
|
export function lastAssistantText(messages) {
|
|
226
270
|
return lastAssistantMessage(messages).text;
|
|
227
271
|
}
|
|
272
|
+
/** Every non-empty text body a transcript carries, in message order.
|
|
273
|
+
* Deliberately NOT filtered by role: the host's completion envelope arrives as an
|
|
274
|
+
* ordinary (user-role) message, and dropping it here would make the fallback below
|
|
275
|
+
* structurally dead. */
|
|
276
|
+
function transcriptTexts(messages) {
|
|
277
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const entry of list) {
|
|
280
|
+
const e = entry;
|
|
281
|
+
if (Array.isArray(e?.parts)) {
|
|
282
|
+
const t = e.parts
|
|
283
|
+
.filter((p) => {
|
|
284
|
+
const pp = p;
|
|
285
|
+
return pp && pp.type === "text" && typeof pp.text === "string";
|
|
286
|
+
})
|
|
287
|
+
.map((p) => String(p.text))
|
|
288
|
+
.join("\n");
|
|
289
|
+
if (t.trim())
|
|
290
|
+
out.push(t);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (e?.type === "text" && typeof e.text === "string" && e.text.trim())
|
|
294
|
+
out.push(e.text);
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
/** The body of the LAST `<subagent sessionID="<sid>" …>…</subagent>` envelope in one
|
|
299
|
+
* text, or null. The id must match EXACTLY — an envelope naming another child is
|
|
300
|
+
* never adopted, because the lead naming a session must not receive someone else's
|
|
301
|
+
* report (the same rule `parentageOf` applies to steering). */
|
|
302
|
+
function lastEnvelopeFor(text, sessionID) {
|
|
303
|
+
let from = 0;
|
|
304
|
+
let found = null;
|
|
305
|
+
for (;;) {
|
|
306
|
+
const open = text.indexOf("<subagent", from);
|
|
307
|
+
if (open < 0)
|
|
308
|
+
break;
|
|
309
|
+
const gt = text.indexOf(">", open);
|
|
310
|
+
if (gt < 0)
|
|
311
|
+
break;
|
|
312
|
+
const tag = text.slice(open, gt + 1);
|
|
313
|
+
const close = text.indexOf("</subagent>", gt + 1);
|
|
314
|
+
if (close < 0)
|
|
315
|
+
break;
|
|
316
|
+
const id = /\bsessionID\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag);
|
|
317
|
+
if (id && (id[1] ?? id[2] ?? "").trim() === sessionID) {
|
|
318
|
+
const body = text.slice(gt + 1, close).trim();
|
|
319
|
+
if (body)
|
|
320
|
+
found = body;
|
|
321
|
+
}
|
|
322
|
+
from = close + "</subagent>".length;
|
|
323
|
+
}
|
|
324
|
+
return found;
|
|
325
|
+
}
|
|
326
|
+
/** The body the host injected into the PARENT transcript for this child, if any.
|
|
327
|
+
* The v2 plugin can read a child's own context, but a host sub-agent's report also
|
|
328
|
+
* arrives in the parent as a completion envelope — and on a host whose child context
|
|
329
|
+
* answers with no assistant text (the measured `child_body_missing`), that envelope
|
|
330
|
+
* is the only readable copy. Null when nothing matches, so the caller keeps its
|
|
331
|
+
* exact `child_body_missing` wording rather than inventing a third outcome. */
|
|
332
|
+
export function hostEnvelopeBody(messages, sessionID) {
|
|
333
|
+
const sid = String(sessionID ?? "").trim();
|
|
334
|
+
if (!sid)
|
|
335
|
+
return null;
|
|
336
|
+
const texts = transcriptTexts(messages);
|
|
337
|
+
// newest-first: a child re-run after a wake may have been injected more than once
|
|
338
|
+
for (let i = texts.length - 1; i >= 0; i--) {
|
|
339
|
+
const hit = lastEnvelopeFor(texts[i], sid);
|
|
340
|
+
if (hit)
|
|
341
|
+
return hit;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
228
345
|
/**
|
|
229
346
|
* Turn whatever the host puts in an error slot into ONE readable line.
|
|
230
347
|
*
|
|
@@ -276,10 +393,27 @@ export function describeHostError(err, max = 220) {
|
|
|
276
393
|
* that had already landed — the fix is to say it, not to withhold the text.
|
|
277
394
|
*/
|
|
278
395
|
export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
|
|
279
|
-
/** One child's status line — the lead reads these, so keep them terse.
|
|
396
|
+
/** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
|
|
397
|
+
* the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
|
|
398
|
+
* unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
|
|
399
|
+
* but a real settlement may print 完成. */
|
|
280
400
|
export function renderChildLine(c, elapsedMs) {
|
|
281
401
|
const secs = Math.round(elapsedMs / 1000);
|
|
282
|
-
const
|
|
402
|
+
const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
|
|
403
|
+
? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
|
|
404
|
+
: secs;
|
|
405
|
+
const tag = c.state === "running"
|
|
406
|
+
? `运行中 ${secs}s`
|
|
407
|
+
: c.state === "error"
|
|
408
|
+
? `失败:${shorten(c.error ?? "", 80)}`
|
|
409
|
+
: c.settleSource === "session-get"
|
|
410
|
+
? // The host's own record answered with a finished run: the only form allowed to
|
|
411
|
+
// print its outcome word, and it never says 推定.
|
|
412
|
+
`已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}s`
|
|
413
|
+
: c.settleSource === "parent-idle"
|
|
414
|
+
? // An inference. 完成 is exactly the word this may not borrow.
|
|
415
|
+
`推定已结算(未确认完成)· 派发后 ${secs}s`
|
|
416
|
+
: `已完成 ${secs}s`;
|
|
283
417
|
// #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
|
|
284
418
|
// needs to see that the child was never running, not a bare 已完成.
|
|
285
419
|
const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
|
|
@@ -289,6 +423,38 @@ export function renderChildLine(c, elapsedMs) {
|
|
|
289
423
|
? " ·(宿主 subagent 派发)"
|
|
290
424
|
: ""}`;
|
|
291
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* One line saying HOW the round's rows reached their state: `宿主确认` (the host's own record,
|
|
428
|
+
* event or envelope), `推定` (our inference) and `未结算` (still running). A lead skimming two
|
|
429
|
+
* rows that both read 已结算 otherwise cannot tell which one the host actually confirmed — and
|
|
430
|
+
* that difference is the whole point of asking `session.get` first.
|
|
431
|
+
*/
|
|
432
|
+
export function settleTallyLine(children) {
|
|
433
|
+
if (!children.length)
|
|
434
|
+
return "";
|
|
435
|
+
let confirmed = 0;
|
|
436
|
+
let presumed = 0;
|
|
437
|
+
let open = 0;
|
|
438
|
+
const outcomes = new Map();
|
|
439
|
+
for (const c of children) {
|
|
440
|
+
if (c.state === "running") {
|
|
441
|
+
open++;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (c.settleSource === "parent-idle") {
|
|
445
|
+
presumed++;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
confirmed++;
|
|
449
|
+
if (c.outcome)
|
|
450
|
+
outcomes.set(c.outcome, (outcomes.get(c.outcome) ?? 0) + 1);
|
|
451
|
+
}
|
|
452
|
+
const named = [...outcomes.entries()].map(([o, n]) => `${o} ${n}`);
|
|
453
|
+
const withoutOutcome = confirmed - [...outcomes.values()].reduce((a, b) => a + b, 0);
|
|
454
|
+
if (withoutOutcome > 0)
|
|
455
|
+
named.push(`宿主未给 outcome ${withoutOutcome}`);
|
|
456
|
+
return `结算:宿主确认 ${confirmed}${named.length ? `(${named.join(" · ")})` : ""} / 推定 ${presumed} / 未结算 ${open}`;
|
|
457
|
+
}
|
|
292
458
|
/** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
|
|
293
459
|
export function summarizeStates(children) {
|
|
294
460
|
let running = 0;
|
|
@@ -439,6 +605,51 @@ export function steerVerdictLine(outcome, opts) {
|
|
|
439
605
|
return `未插话${seam}:${shorten(opts?.note ?? "宿主没有回 inbox id", 120)}`;
|
|
440
606
|
}
|
|
441
607
|
}
|
|
608
|
+
/** What the lead reads after the delivery check. Every branch says out loud that delivery is
|
|
609
|
+
* not obedience — the composition must never promise what the child will do. */
|
|
610
|
+
export function steerDeliveryLine(v, inboxID, note) {
|
|
611
|
+
const inbox = inboxID ? `(inbox=${inboxID})` : "";
|
|
612
|
+
switch (v) {
|
|
613
|
+
case "delivered":
|
|
614
|
+
return `已送达${inbox}:该 inbox 项已经不在待投递清单里,宿主把它交给了子会话。投递不等于子代理照做 —— 它是否按这条改,仍要看下一次结算与正文。`;
|
|
615
|
+
case "queued":
|
|
616
|
+
return `已受理(未送达,宿主在步边界投递)${inbox}:该项仍在待投递清单里。投递≠执行,别把这一句当成子代理会照做。`;
|
|
617
|
+
case "unverified":
|
|
618
|
+
return `已受理${inbox}(未验证送达${note ? `:${shorten(note, 80)}` : ":宿主没有给出可核对的待投递清单"})。投递≠执行。`;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/** Re-read the child's inbox once after a successful steer and answer the only question that
|
|
622
|
+
* can be answered: is the item still pending? `unverified` covers every shape we cannot
|
|
623
|
+
* read — no seam, a throwing call, a payload with items we could not identify — because
|
|
624
|
+
* "absent" must not be inferred from "unreadable". */
|
|
625
|
+
export async function verifySteerDelivery(api, sessionID, inboxID) {
|
|
626
|
+
if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
|
|
627
|
+
return { verdict: "unverified", note: "这个宿主没有 session.inbox.list 的缝" };
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const un = unwrapClientResult(await api.inboxList({ sessionID }));
|
|
631
|
+
if (!un.ok)
|
|
632
|
+
return { verdict: "unverified", note: un.message };
|
|
633
|
+
const data = un.data;
|
|
634
|
+
const seamOut = String(data?.outcome ?? "");
|
|
635
|
+
if (seamOut === "threw" || seamOut === "no-seam") {
|
|
636
|
+
return {
|
|
637
|
+
verdict: "unverified",
|
|
638
|
+
note: seamOut === "no-seam" ? "宿主没给 session.inbox.list" : String(data?.message ?? "查询被宿主拒绝"),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const listed = inboxItemLines(data?.items);
|
|
642
|
+
// An item we could not parse is not an item that is gone: `skipped` covers unknown id
|
|
643
|
+
// shapes AND the overflow past the list cap, and both make "absent" unprovable.
|
|
644
|
+
if (listed.skipped > 0)
|
|
645
|
+
return { verdict: "unverified", note: `清单里有 ${listed.skipped} 项没被列出(id 形状或条数上限)` };
|
|
646
|
+
const stillQueued = listed.lines.some((l) => l.split(" · ")[0] === `id=${inboxID}`);
|
|
647
|
+
return stillQueued ? { verdict: "queued" } : { verdict: "delivered" };
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
return { verdict: "unverified", note: describeHostError(err, 120) };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
442
653
|
export function steerOutcomeLabel(o) {
|
|
443
654
|
return o === "steered" ? "已受理" : o === "not-steered" ? "未插话" : "无插话缝";
|
|
444
655
|
}
|
|
@@ -599,6 +810,15 @@ export function buildDispatchTools(deps) {
|
|
|
599
810
|
// comparable with this one. Renaming would split the history in two.
|
|
600
811
|
const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
|
|
601
812
|
const children = new Map();
|
|
813
|
+
/** Settle checks still in flight. `observeEvent` is a synchronous hook — the host does not
|
|
814
|
+
* await it — while asking `session.get` is async, so the read is tracked here and DRAINED by
|
|
815
|
+
* tm_join before it reports. Without that, a join issued right after a parent-idle event
|
|
816
|
+
* could answer from a row we were in the middle of updating. */
|
|
817
|
+
const settling = new Set();
|
|
818
|
+
const trackSettle = (p) => {
|
|
819
|
+
settling.add(p);
|
|
820
|
+
void p.finally(() => settling.delete(p));
|
|
821
|
+
};
|
|
602
822
|
/** Per-parent memory of the LAST join's outcome. A wait is not parallelism
|
|
603
823
|
* — while tm_join is in flight the lead's turn is blocked exactly like a
|
|
604
824
|
* synchronous `task` call — so a SECOND wait that inherited a first wait
|
|
@@ -641,7 +861,26 @@ export function buildDispatchTools(deps) {
|
|
|
641
861
|
}
|
|
642
862
|
}
|
|
643
863
|
}
|
|
644
|
-
|
|
864
|
+
/** Read the completion envelope the host injected into the PARENT transcript for
|
|
865
|
+
* this child. Absent seam / failed call / no matching envelope all return null —
|
|
866
|
+
* the fallback may not turn "I could not look" into a body. */
|
|
867
|
+
async function envelopeFromParent(parent, sid, directory) {
|
|
868
|
+
if (!parent || !sid || parent === sid)
|
|
869
|
+
return null;
|
|
870
|
+
if (typeof api?.messages !== "function")
|
|
871
|
+
return null;
|
|
872
|
+
try {
|
|
873
|
+
const raw = await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) });
|
|
874
|
+
const un = unwrapClientResult(raw);
|
|
875
|
+
if (!un.ok)
|
|
876
|
+
return null;
|
|
877
|
+
return hostEnvelopeBody(un.data, sid);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function fetchReply(sid, directory, parentSessionID) {
|
|
645
884
|
return (async () => {
|
|
646
885
|
if (typeof api?.messages !== "function") {
|
|
647
886
|
return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)", bodyComplete: false };
|
|
@@ -668,9 +907,19 @@ export function buildDispatchTools(deps) {
|
|
|
668
907
|
bodyComplete: typeof got.completedAt === "number",
|
|
669
908
|
...(typeof got.completedAt === "number" ? { bodyCompletedAt: got.completedAt } : {}),
|
|
670
909
|
};
|
|
671
|
-
return got.text
|
|
910
|
+
return got.text.trim()
|
|
672
911
|
? { ...got, via, ...(shape ? { shape } : {}), ...completeness }
|
|
673
|
-
:
|
|
912
|
+
: await (async () => {
|
|
913
|
+
// C: the child's own context answered with no assistant text. Before
|
|
914
|
+
// reporting `child_body_missing`, look for the host's completion envelope
|
|
915
|
+
// in the parent — for a host sub-agent that IS the delivered report, and a
|
|
916
|
+
// lead that cannot read it has to re-dispatch work that already landed.
|
|
917
|
+
const envelope = await envelopeFromParent(parentSessionID ?? "", sid, directory);
|
|
918
|
+
if (envelope) {
|
|
919
|
+
return { text: envelope, via: "host-envelope", shape: "parent-envelope", bodyComplete: true };
|
|
920
|
+
}
|
|
921
|
+
return { text: "", error: got.error, via, ...(shape ? { shape } : {}), ...completeness };
|
|
922
|
+
})();
|
|
674
923
|
}
|
|
675
924
|
catch (err) {
|
|
676
925
|
return { text: "", error: describeHostError(err?.message ?? err, 140), bodyComplete: false };
|
|
@@ -702,7 +951,7 @@ export function buildDispatchTools(deps) {
|
|
|
702
951
|
// occurs on a v1-shaped client that lacks `session.messages`, and a sentence
|
|
703
952
|
// blaming "v2" there would be false in the opposite direction.
|
|
704
953
|
if (typeof api?.children !== "function")
|
|
705
|
-
return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children
|
|
954
|
+
return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(缺的只是「列举会话树」这一条缝,不是我整个 session API 不可用),我没有看过会话树" };
|
|
706
955
|
if (!parent)
|
|
707
956
|
return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
|
|
708
957
|
let un;
|
|
@@ -834,10 +1083,10 @@ export function buildDispatchTools(deps) {
|
|
|
834
1083
|
description: `Collect the sub-agent work attached to THIS session — the read-back side of delegation. You reach for it in two situations: the host's background \`task\` finished and TeamMode replaced its injected reply with a preview (pull the whole thing back with \`{ ids: ["<child session>"] }\`), or a dispatched child from before this build is still open and needs collecting or cancelling.
|
|
835
1084
|
- No args: status snapshot of every open child of THIS session — running / idle(done) / error, with elapsed seconds. Cheap and non-blocking: use it to decide whether to keep working or start merging.
|
|
836
1085
|
- { waitMs: 30000 }: bounded wait (capped at ${waitLabel(maxWaitMs)}) until every child settles. A wait BLOCKS YOU — your turn is parked in this tool call, so it is the synchronous \`task\` experience with none of its visibility. Wait once, briefly, and only when the very next step needs the answer; a second consecutive wait after nothing settled is cut to 10s and answered with what to do instead. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
|
|
837
|
-
- { ids: [...] }: restrict to those child sessions — a host \`task\` child is collectable when you NAME it (its parentage is verified against the host's own session tree, never assumed); { cancel: true }: abort still-running ones.
|
|
838
|
-
- Steer a child while it runs — the same call, no separate tool: { steer: "先停手,验收改成 X", ids: ["<child>"] } admits the line into that child's inbox with delivery "steer" (or "queue") and the host wakes execution at the step boundary. 已受理
|
|
1086
|
+
- { ids: [...] }: restrict to those child sessions — a host \`task\` child is collectable when you NAME it (its parentage is verified against the host's own session tree, never assumed); { cancel: true }: abort still-running ones. With explicit ids the host is ALWAYS asked, even for a row this process believes settled — a settled-looking row can have been woken and be running for real.
|
|
1087
|
+
- Steer a child while it runs — the same call, no separate tool: { steer: "先停手,验收改成 X", ids: ["<child>"] } admits the line into that child's inbox with delivery "steer" (or "queue") and the host wakes execution at the step boundary. 已受理 means the host took the line into that inbox — NOT that the child read or obeyed it. After a successful steer the tool re-reads that inbox ONCE: an item gone from the pending list is reported 已送达, still pending is 已受理(未送达,宿主在步边界投递), and an inbox it cannot read is 已受理(未验证送达)— delivery is never inferred from the write alone, and none of the three means the child followed it. Steer REFUSES a child whose registered state is not running (idle / error / unknown), because the host's doc sentence "Steering wakes session execution" means the line starts a second run that overlaps new dispatches — pass { wake: true } to do that deliberately. { unread: true, ids: ["<child>"] } lists that child's undelivered inbox items (ids + shape only, never the body); { unsend: {"sessionID":"ses_…","inboxID":"msg_…"} } withdraws one that has not been delivered — an already-delivered item is the documented no-op and is reported as one. All three ask the host for parentage FIRST and refuse a session that is not yours; a host without the seam answers no-seam instead of pretending.
|
|
839
1088
|
- A plugin/host restart does NOT orphan a child: those the host still lists under your session are re-adopted automatically (their rows are marked 接管), and one that answered while nobody was listening is reported 已完成, not lost.
|
|
840
|
-
- Replies come back through the offload pipeline: a long sub-agent report arrives as a handle + ≤80-token preview (page it with tm_fetch), so a batch of parallel work does not multiply your context. STATUS: blocked/failed children are surfaced first, always.`,
|
|
1089
|
+
- Replies come back through the offload pipeline: a long sub-agent report arrives as a handle + ≤80-token preview (page it with tm_fetch), so a batch of parallel work does not multiply your context. STATUS: blocked/failed children are surfaced first, always. When a child's own session yields no assistant text, the host's injected \`<subagent sessionID="…">…</subagent>\` envelope in THIS session is read as the body and labelled 正文来源=宿主注入信封 (an id must match exactly).`,
|
|
841
1090
|
args: {
|
|
842
1091
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
843
1092
|
waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
|
|
@@ -857,6 +1106,9 @@ export function buildDispatchTools(deps) {
|
|
|
857
1106
|
delivery: {
|
|
858
1107
|
descriptor: "delivery: steer|queue (optional, default steer — applies to steer only. Any other value is refused rather than silently defaulted.)",
|
|
859
1108
|
},
|
|
1109
|
+
wake: {
|
|
1110
|
+
descriptor: "wake: boolean (optional, default false — steer only. A steer into a settled/idle child is REFUSED unless this is true, because the host wakes execution and the child then overlaps new dispatches. Accepts true/false or the strings \"true\"/\"false\".)",
|
|
1111
|
+
},
|
|
860
1112
|
unread: {
|
|
861
1113
|
descriptor: "unread: true (optional — list the undelivered inbox items of the child named by ids: ids and shape only, never the body)",
|
|
862
1114
|
},
|
|
@@ -878,6 +1130,10 @@ export function buildDispatchTools(deps) {
|
|
|
878
1130
|
// wrote its governed result — so the token table read "0 调用 / 2 结果",
|
|
879
1131
|
// which looks like a broken counter rather than a tool that ran.
|
|
880
1132
|
store.appendTrajectory({ tool, step_id: "join", event: "call" });
|
|
1133
|
+
// A settle check may still be talking to the host (see `settling`). Drain it before
|
|
1134
|
+
// reading the registry so this reply never mixes a fresh table with a half-updated row.
|
|
1135
|
+
if (settling.size)
|
|
1136
|
+
await Promise.allSettled([...settling]);
|
|
881
1137
|
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
882
1138
|
const idList = parseIdList(args.ids);
|
|
883
1139
|
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
@@ -963,6 +1219,45 @@ export function buildDispatchTools(deps) {
|
|
|
963
1219
|
? renderChildLine(row, (row.finishedAt ?? now()) - row.startedAt)
|
|
964
1220
|
: `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
|
|
965
1221
|
if (steerText) {
|
|
1222
|
+
// A steer into a session that is not running is a WAKE, not an interjection:
|
|
1223
|
+
// the host's own doc says "Steering wakes session execution", so it starts a
|
|
1224
|
+
// second run that overlaps whatever the lead dispatched meanwhile. Measured
|
|
1225
|
+
// cost: a settled child was woken this way and ran concurrently with a fresh
|
|
1226
|
+
// worker, two processes on one device. Default = refuse; `wake:true` is the
|
|
1227
|
+
// explicit opt-in, and the value is coerced the way the rest of this tool's
|
|
1228
|
+
// boolean args are (`cancel`/`unread` above): boolean or "true"/"false".
|
|
1229
|
+
const wakeRaw = args.wake;
|
|
1230
|
+
let wakeRequested = false;
|
|
1231
|
+
if (wakeRaw !== undefined && wakeRaw !== null) {
|
|
1232
|
+
if (wakeRaw === true || wakeRaw === false)
|
|
1233
|
+
wakeRequested = wakeRaw;
|
|
1234
|
+
else if (typeof wakeRaw === "string" && /^(true|false)$/i.test(wakeRaw.trim())) {
|
|
1235
|
+
wakeRequested = /^true$/i.test(wakeRaw.trim());
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
return toToolResult(tmError(tool, "args", `wake 只接受 true/false(布尔或 "true"/"false" 字符串),收到 ${shorten(String(wakeRaw), 24)},未发送。`));
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// The registry can be stale in the dangerous direction too (an event we
|
|
1242
|
+
// missed would leave a settled child looking running); ask the host before
|
|
1243
|
+
// trusting "running" when it offers a status map.
|
|
1244
|
+
if (row && row.state === "running" && typeof api?.status === "function") {
|
|
1245
|
+
await refreshFromStatus(directory);
|
|
1246
|
+
}
|
|
1247
|
+
const rowState = row?.state;
|
|
1248
|
+
if (rowState !== "running" && !wakeRequested) {
|
|
1249
|
+
log({
|
|
1250
|
+
step_id: "join",
|
|
1251
|
+
event: "steer_refused",
|
|
1252
|
+
child: target,
|
|
1253
|
+
agent: gate.agent,
|
|
1254
|
+
reason: "not-running",
|
|
1255
|
+
state: rowState ?? "unregistered",
|
|
1256
|
+
// the LENGTH, never the text — R6 binds the audit trail as much as the reply
|
|
1257
|
+
chars: steerText.length,
|
|
1258
|
+
});
|
|
1259
|
+
return toToolResult(`没有发送:该子代理已结算(或状态未知);插话会唤醒它并与新派发并发——要停止用 cancel:true,要复活请显式 wake:true。\n目标:${rowLine}`);
|
|
1260
|
+
}
|
|
966
1261
|
let outcome = "no-seam";
|
|
967
1262
|
let inboxID;
|
|
968
1263
|
let note;
|
|
@@ -980,6 +1275,20 @@ export function buildDispatchTools(deps) {
|
|
|
980
1275
|
// and a verdict read off whatever came back.
|
|
981
1276
|
note = "这个客户端不是 v2 的 ctx.session 桥(v1 的 session.prompt 走 {path,body},没有 delivery 字段),未调用";
|
|
982
1277
|
}
|
|
1278
|
+
// C — the write only proves ADMISSION. Ask the same inbox once more for the one
|
|
1279
|
+
// checkable fact: is the item still pending? Nothing is claimed when the seam
|
|
1280
|
+
// cannot answer (see steerDeliveryLine's unverified branch).
|
|
1281
|
+
const verified = outcome === "steered" && inboxID ? await verifySteerDelivery(api, target, inboxID) : null;
|
|
1282
|
+
if (verified) {
|
|
1283
|
+
log({
|
|
1284
|
+
step_id: "join",
|
|
1285
|
+
event: "steer_delivery",
|
|
1286
|
+
child: target,
|
|
1287
|
+
inboxID,
|
|
1288
|
+
verdict: verified.verdict,
|
|
1289
|
+
...(verified.note ? { reason: shorten(verified.note, 120) } : {}),
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
983
1292
|
log({
|
|
984
1293
|
step_id: "join",
|
|
985
1294
|
event: "steer",
|
|
@@ -988,7 +1297,9 @@ export function buildDispatchTools(deps) {
|
|
|
988
1297
|
delivery,
|
|
989
1298
|
outcome,
|
|
990
1299
|
ok: outcome === "steered",
|
|
1300
|
+
wake: wakeRequested,
|
|
991
1301
|
...(inboxID ? { inboxID } : {}),
|
|
1302
|
+
...(verified ? { delivered: verified.verdict } : {}),
|
|
992
1303
|
...(seam ? { seam } : {}),
|
|
993
1304
|
// the LENGTH, never the text — R6 binds the audit trail as much as the reply
|
|
994
1305
|
chars: steerText.length,
|
|
@@ -996,10 +1307,13 @@ export function buildDispatchTools(deps) {
|
|
|
996
1307
|
const tally = {};
|
|
997
1308
|
tally[outcome] = 1;
|
|
998
1309
|
log({ step_id: "join", event: "steer_summary", count: 1, outcomes: steerOutcomeParts(tally).join(" / ") });
|
|
999
|
-
const wake = row && row.state !== "running"
|
|
1000
|
-
? `\n
|
|
1310
|
+
const wake = outcome === "steered" && wakeRequested && row && row.state !== "running"
|
|
1311
|
+
? `\n已按 wake:true 唤醒:它当时不是运行中(${row.state === "error" ? "失败" : "已结算"}),宿主把这条插话当成新的执行输入——它会和新派发的 worker 并发,留意这一点。`
|
|
1312
|
+
: "";
|
|
1313
|
+
const deliveryLine = verified
|
|
1314
|
+
? `\n投递核对:${steerDeliveryLine(verified.verdict, inboxID, verified.note)}`
|
|
1001
1315
|
: "";
|
|
1002
|
-
return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${wake}`);
|
|
1316
|
+
return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${deliveryLine}${wake}`);
|
|
1003
1317
|
}
|
|
1004
1318
|
if (wantsUnread) {
|
|
1005
1319
|
if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
|
|
@@ -1062,18 +1376,29 @@ export function buildDispatchTools(deps) {
|
|
|
1062
1376
|
const saw = adoption.looked;
|
|
1063
1377
|
const inner = idFilter
|
|
1064
1378
|
? (() => {
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1379
|
+
// TWO independent facts live here, and they used to be fused into one
|
|
1380
|
+
// sentence: what the session READER can do, and whether the CHILDREN
|
|
1381
|
+
// endpoint exists. On 2.1.4 `ctx.session.get({sessionID})` answers while
|
|
1382
|
+
// `session.children` does not, so the old text ("可辨认的 session API 缺失"
|
|
1383
|
+
// plus the last error of a shape already abandoned) told the lead the host
|
|
1384
|
+
// had no session API at all — and it went back to assuming its id was
|
|
1385
|
+
// fine and the host broken. State them separately, and name the shapes
|
|
1386
|
+
// only when EVERY shape failed: a shape that worked makes the abandoned
|
|
1387
|
+
// ones irrelevant, not evidence.
|
|
1070
1388
|
const sr = deps.sessionReaderReport?.();
|
|
1071
1389
|
const bridge = sr && sr.attempted > 0
|
|
1072
|
-
?
|
|
1390
|
+
? sr.usedShape
|
|
1391
|
+
? `;ctx.session.get 可用(形状 ${sr.usedShape}):宿主没有 session.children,按 id 读单条会话可以,列举会话树不行`
|
|
1392
|
+
: `;按 id 读子会话也失败:试过的 ctx.session 形状 ${sr.failedShapes.length ? sr.failedShapes.join("→") : "?"}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
|
|
1073
1393
|
: "";
|
|
1394
|
+
// A named id that neither our registry nor the host's by-id read could
|
|
1395
|
+
// resolve is a miss about THAT id — saying it means the host has no
|
|
1396
|
+
// session API is the claim this branch exists to stop making.
|
|
1397
|
+
const named = [...idFilter].slice(0, 3).map((s) => shorten(s, 40)).join("、");
|
|
1398
|
+
const miss = `ids 未匹配到本会话的子代理:${named} 在本机不存在或无权限`;
|
|
1074
1399
|
return saw
|
|
1075
|
-
?
|
|
1076
|
-
:
|
|
1400
|
+
? `(${miss};宿主会话树里也没有可认领的子会话${bridge})`
|
|
1401
|
+
: `(${miss};而且我没能查看宿主会话树:${adoption.why}${bridge})`;
|
|
1077
1402
|
})()
|
|
1078
1403
|
: saw
|
|
1079
1404
|
? ""
|
|
@@ -1119,47 +1444,92 @@ export function buildDispatchTools(deps) {
|
|
|
1119
1444
|
let cancelTally = null;
|
|
1120
1445
|
if (args.cancel === true || args.cancel === "true") {
|
|
1121
1446
|
cancelTally = {};
|
|
1122
|
-
|
|
1447
|
+
// B: an EXPLICITLY named id is always asked of the host. The registry is this
|
|
1448
|
+
// process's memory, not the host's truth — the live failure was a row settled
|
|
1449
|
+
// HERE while the host had just been made to run it by a steer, and the old loop
|
|
1450
|
+
// over `stillRunning` answered "没有运行中的子代理可停" without ever calling the
|
|
1451
|
+
// host. With no ids the shipped behaviour stays ("only those we still believe
|
|
1452
|
+
// are running"), because "cancel everything" must not interrogate every settled
|
|
1453
|
+
// child of the session.
|
|
1454
|
+
const cancelTargets = idFilter ? mine.filter((r) => idFilter.has(r.sessionID)) : stillRunning;
|
|
1455
|
+
for (const r of cancelTargets) {
|
|
1456
|
+
const wasRunning = r.state === "running";
|
|
1123
1457
|
let outcome;
|
|
1124
1458
|
let note;
|
|
1125
1459
|
if (typeof api?.abort === "function") {
|
|
1126
|
-
// v1's path, byte-exact and FIRST: the personality is
|
|
1127
|
-
// test pins "aborted on request".
|
|
1460
|
+
// v1's path, byte-exact and FIRST for a RUNNING row: the personality is
|
|
1461
|
+
// frozen and a shipped test pins "aborted on request".
|
|
1128
1462
|
const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1463
|
+
if (wasRunning) {
|
|
1464
|
+
outcome = un.ok ? "stopped" : "threw";
|
|
1465
|
+
r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
|
|
1466
|
+
r.state = "error";
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
// v1's abort returns no `interrupted` boolean, so an already-settled row
|
|
1470
|
+
// cannot honestly be called stopped OR failed from this answer.
|
|
1471
|
+
outcome = un.ok ? "unknown" : "threw";
|
|
1472
|
+
note = un.ok ? "v1 的 abort 不返回 interrupted 布尔" : un.message;
|
|
1473
|
+
r.cancelNote = cancelVerdictLine(outcome, note);
|
|
1474
|
+
}
|
|
1132
1475
|
}
|
|
1133
1476
|
else if (typeof api?.interrupt === "function") {
|
|
1134
1477
|
const got = cancelOutcomeOf(await api.interrupt({ path: { id: r.sessionID } }));
|
|
1135
1478
|
outcome = got.outcome;
|
|
1136
1479
|
note = got.note;
|
|
1137
|
-
// `idle` is the host saying there was no active execution: the registry row
|
|
1138
|
-
// was stale, not a failed stop. Anything else closes as undelivered.
|
|
1139
|
-
r.state = outcome === "idle" ? "idle" : "error";
|
|
1140
1480
|
r.cancelNote = cancelVerdictLine(outcome, note);
|
|
1141
|
-
if (
|
|
1481
|
+
if (wasRunning) {
|
|
1482
|
+
// `idle` is the host saying there was no active execution: the registry row
|
|
1483
|
+
// was stale, not a failed stop. Anything else closes as undelivered.
|
|
1484
|
+
r.state = outcome === "idle" ? "idle" : "error";
|
|
1485
|
+
if (r.state === "error")
|
|
1486
|
+
r.error = r.cancelNote;
|
|
1487
|
+
}
|
|
1488
|
+
else if (outcome === "stopped") {
|
|
1489
|
+
// A row we believed settled was in fact executing (a woken session is the
|
|
1490
|
+
// measured case): the host just stopped it, and the row must say so.
|
|
1491
|
+
r.state = "error";
|
|
1142
1492
|
r.error = r.cancelNote;
|
|
1493
|
+
}
|
|
1494
|
+
// idle / unknown / no-seam / threw on an already-settled row leave its settle
|
|
1495
|
+
// verdict alone — the stop attempt is extra evidence, not a re-settlement.
|
|
1143
1496
|
}
|
|
1144
1497
|
else {
|
|
1145
1498
|
outcome = "no-seam";
|
|
1146
|
-
|
|
1147
|
-
|
|
1499
|
+
if (wasRunning) {
|
|
1500
|
+
r.error = "宿主无 abort 接口,未取消";
|
|
1501
|
+
r.state = "error";
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
r.cancelNote = cancelVerdictLine("no-seam");
|
|
1505
|
+
}
|
|
1148
1506
|
}
|
|
1149
|
-
r.finishedAt
|
|
1507
|
+
if (wasRunning || r.finishedAt === undefined)
|
|
1508
|
+
r.finishedAt = now();
|
|
1150
1509
|
cancelTally[outcome] = (cancelTally[outcome] ?? 0) + 1;
|
|
1151
|
-
log({
|
|
1510
|
+
log({
|
|
1511
|
+
step_id: "join",
|
|
1512
|
+
event: "cancel",
|
|
1513
|
+
child: r.sessionID,
|
|
1514
|
+
agent: r.agent,
|
|
1515
|
+
outcome,
|
|
1516
|
+
explicit: Boolean(idFilter),
|
|
1517
|
+
was_running: wasRunning,
|
|
1518
|
+
ok: outcome === "stopped",
|
|
1519
|
+
});
|
|
1152
1520
|
}
|
|
1153
1521
|
log({
|
|
1154
1522
|
step_id: "join",
|
|
1155
1523
|
event: "cancel_summary",
|
|
1156
|
-
count:
|
|
1524
|
+
count: cancelTargets.length,
|
|
1157
1525
|
outcomes: cancelOutcomeParts(cancelTally).join(" / "),
|
|
1158
1526
|
});
|
|
1159
1527
|
}
|
|
1160
1528
|
const sums = summarizeStates(mine);
|
|
1529
|
+
const settleLine = settleTallyLine(mine);
|
|
1161
1530
|
const header = [
|
|
1162
1531
|
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
|
|
1532
|
+
...(settleLine ? [settleLine] : []),
|
|
1163
1533
|
...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
|
|
1164
1534
|
];
|
|
1165
1535
|
// The `正文:` line belongs directly under the child rows it describes, but the
|
|
@@ -1168,7 +1538,11 @@ export function buildDispatchTools(deps) {
|
|
|
1168
1538
|
const childRowsEnd = header.length;
|
|
1169
1539
|
if (cancelTally) {
|
|
1170
1540
|
const parts = cancelOutcomeParts(cancelTally);
|
|
1171
|
-
header.push(`取消尝试:${parts.join(" · ") ||
|
|
1541
|
+
header.push(`取消尝试:${parts.join(" · ") ||
|
|
1542
|
+
// Never leave "没有运行中的子代理可停" as the ONLY answer: with no ids the
|
|
1543
|
+
// default restricted the attempt to rows we believed running, and the lead
|
|
1544
|
+
// must know that a settled-looking row was therefore not asked about.
|
|
1545
|
+
"没有运行中的子代理可停(未显式给 ids 时只对登记为运行中的子代理发起取消;若你怀疑某个已结算的子会话其实在跑,用 ids 点名——我会先去问宿主)"} —— ` +
|
|
1172
1546
|
`只有「已由宿主中断」是宿主确认过的停止;「空闲未中断」说明它当时没在活动执行里` +
|
|
1173
1547
|
`(登记行过时了,不是失败);「未确认」说明中断调用成功但宿主没回 interrupted 布尔。` +
|
|
1174
1548
|
`这三种结论不要混成一句"已取消"转述给用户。`);
|
|
@@ -1267,7 +1641,7 @@ export function buildDispatchTools(deps) {
|
|
|
1267
1641
|
const bodyStatus = [];
|
|
1268
1642
|
if (wantText) {
|
|
1269
1643
|
for (const r of collectible) {
|
|
1270
|
-
const reply = await fetchReply(r.sessionID, directory);
|
|
1644
|
+
const reply = await fetchReply(r.sessionID, directory, parent);
|
|
1271
1645
|
const text = reply.text;
|
|
1272
1646
|
r.bodyComplete = reply.bodyComplete === true;
|
|
1273
1647
|
r.bodyCompletedAt = reply.bodyCompletedAt;
|
|
@@ -1279,7 +1653,10 @@ export function buildDispatchTools(deps) {
|
|
|
1279
1653
|
// fragment, which would be the same overstatement facing the other way.
|
|
1280
1654
|
const half = hasBody && r.bodyComplete === false && r.settleSource === "parent-idle";
|
|
1281
1655
|
bodyStatus.push(`正文:${!hasBody ? "缺失" : r.bodyComplete ? "完整" : half ? "半截(推定)" : "未带完成时间"} · ${r.agent} "${r.label}"`);
|
|
1282
|
-
|
|
1656
|
+
// C labels the fallback in the lead's words: the body above came from the
|
|
1657
|
+
// host's own completion injection, not from the child's session context.
|
|
1658
|
+
const viaLabel = reply.via === "host-envelope" ? "宿主注入信封" : reply.via;
|
|
1659
|
+
const src = text && viaLabel ? ` ·正文来源=${viaLabel}` : "";
|
|
1283
1660
|
// Which seam answered, per child — an id, a seam name and the SHAPE it saw, never
|
|
1284
1661
|
// a body (R6 binds a diagnostic too). This is the line that makes "正文来源=…"
|
|
1285
1662
|
// checkable later, and the only place the three outcomes stay distinguishable:
|
|
@@ -1327,6 +1704,66 @@ export function buildDispatchTools(deps) {
|
|
|
1327
1704
|
}
|
|
1328
1705
|
},
|
|
1329
1706
|
};
|
|
1707
|
+
/** Settle the still-running HOST children of a session that just went idle — by ASKING the
|
|
1708
|
+
* host, not by assuming. One `session.get` per child, three outcomes kept apart:
|
|
1709
|
+
* - `time.idle` present → settled, `settleSource="session-get"`, host `outcome` kept;
|
|
1710
|
+
* - answered WITHOUT idle → the child is still running: the row is left alone and the
|
|
1711
|
+
* attempt is counted (`settle_unchecked`), because a live child reported as settled is
|
|
1712
|
+
* the exact overstatement this tool was already fixed for;
|
|
1713
|
+
* - no seam / a throwing call → the old `parent-idle` presumption, printed as one.
|
|
1714
|
+
* `idle_settled` and `idle_presumed` are separate trajectory rows on purpose: tm_stats can
|
|
1715
|
+
* then show how often the host actually confirmed a finish. */
|
|
1716
|
+
async function reconcileHostChildren(rows) {
|
|
1717
|
+
for (const child of rows) {
|
|
1718
|
+
if (child.state !== "running")
|
|
1719
|
+
continue;
|
|
1720
|
+
const st = await readHostSessionState(api, child.sessionID);
|
|
1721
|
+
if (st.idleAt) {
|
|
1722
|
+
child.state = "idle";
|
|
1723
|
+
child.finishedAt = st.idleAt;
|
|
1724
|
+
child.hostIdleAt = st.idleAt;
|
|
1725
|
+
if (st.createdAt !== undefined)
|
|
1726
|
+
child.hostCreatedAt = st.createdAt;
|
|
1727
|
+
child.settleSource = "session-get";
|
|
1728
|
+
if (st.outcome)
|
|
1729
|
+
child.outcome = st.outcome;
|
|
1730
|
+
log({
|
|
1731
|
+
step_id: "events",
|
|
1732
|
+
event: "idle_settled",
|
|
1733
|
+
source: "session-get",
|
|
1734
|
+
child: child.sessionID,
|
|
1735
|
+
agent: child.agent,
|
|
1736
|
+
...(st.outcome ? { outcome: st.outcome } : {}),
|
|
1737
|
+
ms: st.idleAt - child.startedAt,
|
|
1738
|
+
});
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
if (st.answered) {
|
|
1742
|
+
log({
|
|
1743
|
+
step_id: "events",
|
|
1744
|
+
event: "settle_unchecked",
|
|
1745
|
+
source: "session-get",
|
|
1746
|
+
reason: "still-running",
|
|
1747
|
+
child: child.sessionID,
|
|
1748
|
+
agent: child.agent,
|
|
1749
|
+
});
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
presumeParentIdle([child]);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
/** The FALLBACK: a still-running host child of a session that just went idle, on a host we
|
|
1756
|
+
* could not ask. Recorded as `parent-idle` and printed as a presumption — never 完成. */
|
|
1757
|
+
function presumeParentIdle(rows) {
|
|
1758
|
+
for (const child of rows) {
|
|
1759
|
+
if (child.state !== "running")
|
|
1760
|
+
continue;
|
|
1761
|
+
child.state = "idle";
|
|
1762
|
+
child.finishedAt = now();
|
|
1763
|
+
child.settleSource = "parent-idle";
|
|
1764
|
+
log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1330
1767
|
const observeEvent = (event) => {
|
|
1331
1768
|
const rawType = String(event?.type ?? "");
|
|
1332
1769
|
if (!rawType.startsWith("session."))
|
|
@@ -1349,21 +1786,22 @@ export function buildDispatchTools(deps) {
|
|
|
1349
1786
|
return;
|
|
1350
1787
|
const rec = children.get(sid);
|
|
1351
1788
|
if (!rec) {
|
|
1352
|
-
// The parent's own idle is the only completion signal
|
|
1353
|
-
// child
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1789
|
+
// The parent's own idle is the only completion signal the host gives us for a HOST
|
|
1790
|
+
// child — and it is not the strongest one available: `session.get({sessionID})` answers
|
|
1791
|
+
// the child's own `time.idle` (verified on 2.1.4), so we ASK first and only fall back to
|
|
1792
|
+
// the inference when the host cannot be asked. A row that stayed 运行中 forever would be
|
|
1793
|
+
// an overstatement in reverse; a row presumed settled when the host says it is still
|
|
1794
|
+
// executing is the overstatement this tool exists to refuse.
|
|
1357
1795
|
if (type === "session.idle") {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1796
|
+
const pending = [...children.values()].filter((child) => child.via === "host-injection" && child.state === "running" && child.parentSessionID === sid);
|
|
1797
|
+
if (pending.length) {
|
|
1798
|
+
// No seam at all → the fallback needs no round-trip, so it lands HERE and the
|
|
1799
|
+
// synchronous hook stays synchronously readable. A seam that EXISTS is asked, and
|
|
1800
|
+
// that read is tracked so tm_join drains it before reporting.
|
|
1801
|
+
if (!api || typeof api.get !== "function")
|
|
1802
|
+
presumeParentIdle(pending);
|
|
1803
|
+
else
|
|
1804
|
+
trackSettle(reconcileHostChildren(pending));
|
|
1367
1805
|
}
|
|
1368
1806
|
}
|
|
1369
1807
|
return;
|