@te-river/opencode-team-mode 1.8.5 → 1.8.7
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 +10 -9
- package/README.zh-CN.md +11 -10
- 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 +20 -9
- 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 +101 -11
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +572 -30
- package/dist/tm/dispatch.js.map +1 -1
- package/dist/tm/ledger.d.ts +50 -7
- package/dist/tm/ledger.d.ts.map +1 -1
- package/dist/tm/ledger.js +161 -18
- package/dist/tm/ledger.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
|
@@ -112,6 +112,34 @@ export function sessionApiOf(client) {
|
|
|
112
112
|
return null;
|
|
113
113
|
return api;
|
|
114
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
|
+
}
|
|
115
143
|
/** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
|
|
116
144
|
* takes `background: true`, and because that card is rendered by the host's
|
|
117
145
|
* own `task` renderer it links to the live child session — which a plugin tool
|
|
@@ -314,6 +342,177 @@ export function hostEnvelopeBody(messages, sessionID) {
|
|
|
314
342
|
}
|
|
315
343
|
return null;
|
|
316
344
|
}
|
|
345
|
+
const HISTORY_ACK_RE = /The subagent is working in the background \(sessionID: (ses_[A-Za-z0-9]+)\)/gi;
|
|
346
|
+
const SESSION_ID_RE = /^ses_[A-Za-z0-9]+$/;
|
|
347
|
+
const SHELL_ID_RE = /^sh_[A-Za-z0-9]+$/;
|
|
348
|
+
/**
|
|
349
|
+
* The text bodies a transcript carries, for the HISTORY scanners only.
|
|
350
|
+
*
|
|
351
|
+
* Deliberately wider than `transcriptTexts` above, which keeps the body-collection policy
|
|
352
|
+
* (`type:"text"`): a background dispatch's ACK reaches the parent as a TOOL RESULT part, and
|
|
353
|
+
* the v2 seam hands tool parts through untouched (`normaliseContextMessages` moves
|
|
354
|
+
* `content[]` to `parts[]` and keeps their kind). Reading only `type:"text"` would make
|
|
355
|
+
* this whole recovery path structurally dead — the exact shape of the task-offload bug,
|
|
356
|
+
* where a matcher that never fired was mistaken for "nothing to match". `textsOf` in
|
|
357
|
+
* `src/host/v2-subagent.ts` reads `{text}` off every part for the same reason.
|
|
358
|
+
*/
|
|
359
|
+
function historyTexts(messages) {
|
|
360
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
361
|
+
const out = [];
|
|
362
|
+
for (const entry of list) {
|
|
363
|
+
const e = entry;
|
|
364
|
+
if (typeof e?.text === "string" && e.text.trim())
|
|
365
|
+
out.push(e.text);
|
|
366
|
+
if (!Array.isArray(e?.parts))
|
|
367
|
+
continue;
|
|
368
|
+
const joined = e.parts
|
|
369
|
+
.map((p) => {
|
|
370
|
+
const t = p?.text;
|
|
371
|
+
return typeof t === "string" ? t : "";
|
|
372
|
+
})
|
|
373
|
+
.filter((s) => s.trim())
|
|
374
|
+
.join("\n");
|
|
375
|
+
if (joined.trim())
|
|
376
|
+
out.push(joined);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
/** End index of the tag that starts at `from`, honouring quotes: a shell `command` may
|
|
381
|
+
* contain `>` (a redirect), and cutting the tag there would lose the `state` after it. */
|
|
382
|
+
function historyTagEnd(text, from) {
|
|
383
|
+
let quote = "";
|
|
384
|
+
for (let i = from; i < text.length; i++) {
|
|
385
|
+
const ch = text[i];
|
|
386
|
+
if (quote) {
|
|
387
|
+
if (ch === "\\")
|
|
388
|
+
i++;
|
|
389
|
+
else if (ch === quote)
|
|
390
|
+
quote = "";
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === '"' || ch === "'")
|
|
394
|
+
quote = ch;
|
|
395
|
+
else if (ch === ">")
|
|
396
|
+
return i;
|
|
397
|
+
}
|
|
398
|
+
return -1;
|
|
399
|
+
}
|
|
400
|
+
function historyTagAttr(tag, name) {
|
|
401
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
402
|
+
return (m?.[1] ?? m?.[2] ?? "").trim();
|
|
403
|
+
}
|
|
404
|
+
/** A label for a candidate whose evidence carried none: the nearest line above the ACK (the
|
|
405
|
+
* dispatch description usually rides there), else the line below it, else `"host child"`. */
|
|
406
|
+
function historyLabelNear(text, index) {
|
|
407
|
+
const before = text
|
|
408
|
+
.slice(0, index)
|
|
409
|
+
.split(/\r?\n/)
|
|
410
|
+
.map((s) => s.trim())
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
const after = text
|
|
413
|
+
.slice(index)
|
|
414
|
+
.split(/\r?\n/)
|
|
415
|
+
.map((s) => s.trim())
|
|
416
|
+
.filter(Boolean);
|
|
417
|
+
const cand = before[before.length - 1] ?? after[1] ?? "";
|
|
418
|
+
return cand.replace(/^[-*·\s]+/, "").slice(0, 40) || "host child";
|
|
419
|
+
}
|
|
420
|
+
function historyStateOf(raw) {
|
|
421
|
+
const s = raw.toLowerCase();
|
|
422
|
+
if (s === "running" || s === "started" || s === "busy")
|
|
423
|
+
return "running";
|
|
424
|
+
if (s === "completed" || s === "succeeded" || s === "done")
|
|
425
|
+
return "completed";
|
|
426
|
+
return "error";
|
|
427
|
+
}
|
|
428
|
+
/** Sub-agent candidates named by a parent transcript, in transcript order (a later
|
|
429
|
+
* envelope overwrites the earlier ACK for the same id — that is what "last one wins"
|
|
430
|
+
* means for a child that was woken and re-ran). */
|
|
431
|
+
export function childrenFromParentHistory(messages) {
|
|
432
|
+
const found = new Map();
|
|
433
|
+
for (const text of historyTexts(messages)) {
|
|
434
|
+
for (const m of text.matchAll(HISTORY_ACK_RE)) {
|
|
435
|
+
const sid = m[1];
|
|
436
|
+
const prior = found.get(sid);
|
|
437
|
+
// An envelope already read for this id carries a stronger state; an ACK after it is a
|
|
438
|
+
// re-dispatch, so it wins — both cases reduce to "the later evidence wins".
|
|
439
|
+
found.set(sid, { sessionID: sid, state: "running", label: prior?.label ?? historyLabelNear(text, m.index ?? 0) });
|
|
440
|
+
}
|
|
441
|
+
let from = 0;
|
|
442
|
+
for (;;) {
|
|
443
|
+
const open = text.indexOf("<subagent", from);
|
|
444
|
+
if (open < 0)
|
|
445
|
+
break;
|
|
446
|
+
if (!/\s/.test(text[open + "<subagent".length] ?? "")) {
|
|
447
|
+
from = open + 9;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const gt = historyTagEnd(text, open);
|
|
451
|
+
if (gt < 0)
|
|
452
|
+
break;
|
|
453
|
+
const tag = text.slice(open, gt + 1);
|
|
454
|
+
const sid = historyTagAttr(tag, "sessionID");
|
|
455
|
+
const stateRaw = historyTagAttr(tag, "state");
|
|
456
|
+
const close = text.indexOf("</subagent>", gt + 1);
|
|
457
|
+
if (SESSION_ID_RE.test(sid) && stateRaw) {
|
|
458
|
+
const state = historyStateOf(stateRaw);
|
|
459
|
+
const body = close >= 0 && state === "completed" ? text.slice(gt + 1, close).trim() : "";
|
|
460
|
+
const described = historyTagAttr(tag, "description").slice(0, 40);
|
|
461
|
+
found.set(sid, {
|
|
462
|
+
sessionID: sid,
|
|
463
|
+
state,
|
|
464
|
+
...(body ? { body } : {}),
|
|
465
|
+
label: described || found.get(sid)?.label || "host child",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
from = close >= 0 ? close + "</subagent>".length : gt + 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return [...found.values()];
|
|
472
|
+
}
|
|
473
|
+
/** Background shells the host announced in a parent transcript, last state per id. */
|
|
474
|
+
export function backgroundShellsFromParentHistory(messages) {
|
|
475
|
+
const found = new Map();
|
|
476
|
+
for (const text of historyTexts(messages)) {
|
|
477
|
+
let from = 0;
|
|
478
|
+
for (;;) {
|
|
479
|
+
const open = text.indexOf("<shell", from);
|
|
480
|
+
if (open < 0)
|
|
481
|
+
break;
|
|
482
|
+
if (!/\s/.test(text[open + "<shell".length] ?? "")) {
|
|
483
|
+
from = open + 6;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const gt = historyTagEnd(text, open);
|
|
487
|
+
if (gt < 0)
|
|
488
|
+
break;
|
|
489
|
+
const tag = text.slice(open, gt + 1);
|
|
490
|
+
const id = historyTagAttr(tag, "id");
|
|
491
|
+
if (SHELL_ID_RE.test(id)) {
|
|
492
|
+
const state = (historyTagAttr(tag, "state") || "running").toLowerCase();
|
|
493
|
+
const command = historyTagAttr(tag, "command");
|
|
494
|
+
found.set(id, { id, state, ...(command ? { command: command.slice(0, 80) } : {}) });
|
|
495
|
+
}
|
|
496
|
+
from = gt + 1;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return [...found.values()];
|
|
500
|
+
}
|
|
501
|
+
/** The `后台 shell` section tm_join appends. A running shell is named as RUNNING and told
|
|
502
|
+
* not to be re-dispatched: the user can see those processes, and a lead that cannot see
|
|
503
|
+
* them dispatches the same work twice. Nothing is killed here — the host owns the shell. */
|
|
504
|
+
export function renderBackgroundShells(shells) {
|
|
505
|
+
if (!shells.length)
|
|
506
|
+
return null;
|
|
507
|
+
const verdict = (s) => s.state === "running" ? "运行中" : s.state === "completed" ? "已完成" : `失败(${s.state})`;
|
|
508
|
+
const parts = shells.map((s) => `${s.id} ${verdict(s)}${s.command ? ` · ${shorten(s.command, 60)}` : ""}`);
|
|
509
|
+
const running = shells.filter((s) => s.state === "running");
|
|
510
|
+
const lines = [`后台 shell:${shells.length} 个(${parts.join(" / ")})`];
|
|
511
|
+
if (running.length) {
|
|
512
|
+
lines.push(`⏳ ${running.map((s) => s.id).join("、")} 还在跑 —— 别重复派同类活(宿主的 shell 自己收输出,这里只报告,不 kill)。`);
|
|
513
|
+
}
|
|
514
|
+
return lines.join("\n");
|
|
515
|
+
}
|
|
317
516
|
/**
|
|
318
517
|
* Turn whatever the host puts in an error slot into ONE readable line.
|
|
319
518
|
*
|
|
@@ -365,10 +564,27 @@ export function describeHostError(err, max = 220) {
|
|
|
365
564
|
* that had already landed — the fix is to say it, not to withhold the text.
|
|
366
565
|
*/
|
|
367
566
|
export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
|
|
368
|
-
/** One child's status line — the lead reads these, so keep them terse.
|
|
567
|
+
/** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
|
|
568
|
+
* the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
|
|
569
|
+
* unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
|
|
570
|
+
* but a real settlement may print 完成. */
|
|
369
571
|
export function renderChildLine(c, elapsedMs) {
|
|
370
572
|
const secs = Math.round(elapsedMs / 1000);
|
|
371
|
-
const
|
|
573
|
+
const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
|
|
574
|
+
? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
|
|
575
|
+
: secs;
|
|
576
|
+
const tag = c.state === "running"
|
|
577
|
+
? `运行中 ${secs}s`
|
|
578
|
+
: c.state === "error"
|
|
579
|
+
? `失败:${shorten(c.error ?? "", 80)}`
|
|
580
|
+
: c.settleSource === "session-get"
|
|
581
|
+
? // The host's own record answered with a finished run: the only form allowed to
|
|
582
|
+
// print its outcome word, and it never says 推定.
|
|
583
|
+
`已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}s`
|
|
584
|
+
: c.settleSource === "parent-idle"
|
|
585
|
+
? // An inference. 完成 is exactly the word this may not borrow.
|
|
586
|
+
`推定已结算(未确认完成)· 派发后 ${secs}s`
|
|
587
|
+
: `已完成 ${secs}s`;
|
|
372
588
|
// #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
|
|
373
589
|
// needs to see that the child was never running, not a bare 已完成.
|
|
374
590
|
const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
|
|
@@ -378,6 +594,38 @@ export function renderChildLine(c, elapsedMs) {
|
|
|
378
594
|
? " ·(宿主 subagent 派发)"
|
|
379
595
|
: ""}`;
|
|
380
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* One line saying HOW the round's rows reached their state: `宿主确认` (the host's own record,
|
|
599
|
+
* event or envelope), `推定` (our inference) and `未结算` (still running). A lead skimming two
|
|
600
|
+
* rows that both read 已结算 otherwise cannot tell which one the host actually confirmed — and
|
|
601
|
+
* that difference is the whole point of asking `session.get` first.
|
|
602
|
+
*/
|
|
603
|
+
export function settleTallyLine(children) {
|
|
604
|
+
if (!children.length)
|
|
605
|
+
return "";
|
|
606
|
+
let confirmed = 0;
|
|
607
|
+
let presumed = 0;
|
|
608
|
+
let open = 0;
|
|
609
|
+
const outcomes = new Map();
|
|
610
|
+
for (const c of children) {
|
|
611
|
+
if (c.state === "running") {
|
|
612
|
+
open++;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (c.settleSource === "parent-idle") {
|
|
616
|
+
presumed++;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
confirmed++;
|
|
620
|
+
if (c.outcome)
|
|
621
|
+
outcomes.set(c.outcome, (outcomes.get(c.outcome) ?? 0) + 1);
|
|
622
|
+
}
|
|
623
|
+
const named = [...outcomes.entries()].map(([o, n]) => `${o} ${n}`);
|
|
624
|
+
const withoutOutcome = confirmed - [...outcomes.values()].reduce((a, b) => a + b, 0);
|
|
625
|
+
if (withoutOutcome > 0)
|
|
626
|
+
named.push(`宿主未给 outcome ${withoutOutcome}`);
|
|
627
|
+
return `结算:宿主确认 ${confirmed}${named.length ? `(${named.join(" · ")})` : ""} / 推定 ${presumed} / 未结算 ${open}`;
|
|
628
|
+
}
|
|
381
629
|
/** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
|
|
382
630
|
export function summarizeStates(children) {
|
|
383
631
|
let running = 0;
|
|
@@ -528,6 +776,51 @@ export function steerVerdictLine(outcome, opts) {
|
|
|
528
776
|
return `未插话${seam}:${shorten(opts?.note ?? "宿主没有回 inbox id", 120)}`;
|
|
529
777
|
}
|
|
530
778
|
}
|
|
779
|
+
/** What the lead reads after the delivery check. Every branch says out loud that delivery is
|
|
780
|
+
* not obedience — the composition must never promise what the child will do. */
|
|
781
|
+
export function steerDeliveryLine(v, inboxID, note) {
|
|
782
|
+
const inbox = inboxID ? `(inbox=${inboxID})` : "";
|
|
783
|
+
switch (v) {
|
|
784
|
+
case "delivered":
|
|
785
|
+
return `已送达${inbox}:该 inbox 项已经不在待投递清单里,宿主把它交给了子会话。投递不等于子代理照做 —— 它是否按这条改,仍要看下一次结算与正文。`;
|
|
786
|
+
case "queued":
|
|
787
|
+
return `已受理(未送达,宿主在步边界投递)${inbox}:该项仍在待投递清单里。投递≠执行,别把这一句当成子代理会照做。`;
|
|
788
|
+
case "unverified":
|
|
789
|
+
return `已受理${inbox}(未验证送达${note ? `:${shorten(note, 80)}` : ":宿主没有给出可核对的待投递清单"})。投递≠执行。`;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/** Re-read the child's inbox once after a successful steer and answer the only question that
|
|
793
|
+
* can be answered: is the item still pending? `unverified` covers every shape we cannot
|
|
794
|
+
* read — no seam, a throwing call, a payload with items we could not identify — because
|
|
795
|
+
* "absent" must not be inferred from "unreadable". */
|
|
796
|
+
export async function verifySteerDelivery(api, sessionID, inboxID) {
|
|
797
|
+
if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
|
|
798
|
+
return { verdict: "unverified", note: "这个宿主没有 session.inbox.list 的缝" };
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const un = unwrapClientResult(await api.inboxList({ sessionID }));
|
|
802
|
+
if (!un.ok)
|
|
803
|
+
return { verdict: "unverified", note: un.message };
|
|
804
|
+
const data = un.data;
|
|
805
|
+
const seamOut = String(data?.outcome ?? "");
|
|
806
|
+
if (seamOut === "threw" || seamOut === "no-seam") {
|
|
807
|
+
return {
|
|
808
|
+
verdict: "unverified",
|
|
809
|
+
note: seamOut === "no-seam" ? "宿主没给 session.inbox.list" : String(data?.message ?? "查询被宿主拒绝"),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
const listed = inboxItemLines(data?.items);
|
|
813
|
+
// An item we could not parse is not an item that is gone: `skipped` covers unknown id
|
|
814
|
+
// shapes AND the overflow past the list cap, and both make "absent" unprovable.
|
|
815
|
+
if (listed.skipped > 0)
|
|
816
|
+
return { verdict: "unverified", note: `清单里有 ${listed.skipped} 项没被列出(id 形状或条数上限)` };
|
|
817
|
+
const stillQueued = listed.lines.some((l) => l.split(" · ")[0] === `id=${inboxID}`);
|
|
818
|
+
return stillQueued ? { verdict: "queued" } : { verdict: "delivered" };
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
return { verdict: "unverified", note: describeHostError(err, 120) };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
531
824
|
export function steerOutcomeLabel(o) {
|
|
532
825
|
return o === "steered" ? "已受理" : o === "not-steered" ? "未插话" : "无插话缝";
|
|
533
826
|
}
|
|
@@ -688,6 +981,15 @@ export function buildDispatchTools(deps) {
|
|
|
688
981
|
// comparable with this one. Renaming would split the history in two.
|
|
689
982
|
const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
|
|
690
983
|
const children = new Map();
|
|
984
|
+
/** Settle checks still in flight. `observeEvent` is a synchronous hook — the host does not
|
|
985
|
+
* await it — while asking `session.get` is async, so the read is tracked here and DRAINED by
|
|
986
|
+
* tm_join before it reports. Without that, a join issued right after a parent-idle event
|
|
987
|
+
* could answer from a row we were in the middle of updating. */
|
|
988
|
+
const settling = new Set();
|
|
989
|
+
const trackSettle = (p) => {
|
|
990
|
+
settling.add(p);
|
|
991
|
+
void p.finally(() => settling.delete(p));
|
|
992
|
+
};
|
|
691
993
|
/** Per-parent memory of the LAST join's outcome. A wait is not parallelism
|
|
692
994
|
* — while tm_join is in flight the lead's turn is blocked exactly like a
|
|
693
995
|
* synchronous `task` call — so a SECOND wait that inherited a first wait
|
|
@@ -820,7 +1122,7 @@ export function buildDispatchTools(deps) {
|
|
|
820
1122
|
// occurs on a v1-shaped client that lacks `session.messages`, and a sentence
|
|
821
1123
|
// blaming "v2" there would be false in the opposite direction.
|
|
822
1124
|
if (typeof api?.children !== "function")
|
|
823
|
-
return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children
|
|
1125
|
+
return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(缺的只是「列举会话树」这一条缝,不是我整个 session API 不可用),我没有看过会话树" };
|
|
824
1126
|
if (!parent)
|
|
825
1127
|
return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
|
|
826
1128
|
let un;
|
|
@@ -860,6 +1162,118 @@ export function buildDispatchTools(deps) {
|
|
|
860
1162
|
}
|
|
861
1163
|
return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
|
|
862
1164
|
}
|
|
1165
|
+
/** Read the CALLING session's own transcript. On a host with no `session.children`
|
|
1166
|
+
* (2.1.4) this is the only recovery source left, and it is also where the host announces
|
|
1167
|
+
* its background shells. Never throws: a failure is a reported `why`, because
|
|
1168
|
+
* "we could not look" and "there was nothing" must not print the same. */
|
|
1169
|
+
async function readParentMessages(parent, directory) {
|
|
1170
|
+
if (!parent)
|
|
1171
|
+
return { ok: false, why: "调用方会话 id 缺失,读不到父会话历史" };
|
|
1172
|
+
if (typeof api?.messages !== "function") {
|
|
1173
|
+
return { ok: false, why: "这个宿主客户端没有给出读取会话正文的缝(v2 上是 ctx.session.context),父会话历史读不到" };
|
|
1174
|
+
}
|
|
1175
|
+
try {
|
|
1176
|
+
const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
1177
|
+
if (!un.ok)
|
|
1178
|
+
return { ok: false, why: `读取父会话历史失败:${shorten(un.message ?? "", 90)}` };
|
|
1179
|
+
return { ok: true, data: un.data, why: "" };
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
return {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
why: `读取父会话历史失败:${describeHostError(err?.message ?? err, 90)}`,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Adopt the children the PARENT TRANSCRIPT still names — the second recovery path, for a
|
|
1190
|
+
* host whose `session.children` does not exist. Measured live (2.1.4 + 1.8.6): a service
|
|
1191
|
+
* restart empties this in-process registry, `session.children` is gone, so tm_join
|
|
1192
|
+
* answered `没有待收集的派发` while the user could still see the sub-agents running — and
|
|
1193
|
+
* the lead re-dispatched work that was already in flight.
|
|
1194
|
+
*
|
|
1195
|
+
* The transcript is not trusted as ownership: every candidate is verified through the
|
|
1196
|
+
* host's own `session.get` (`parentID === the caller`) before a row is opened, because an
|
|
1197
|
+
* envelope names a session, it does not prove the session is OURS. A candidate the host
|
|
1198
|
+
* will not confirm is counted as skipped and never shown as a child.
|
|
1199
|
+
*
|
|
1200
|
+
* The row's state comes from the evidence: `state="completed"` envelope → settled
|
|
1201
|
+
* (`settleSource="injection"`, the host's own injection is the evidence);
|
|
1202
|
+
* `state="error"` → failed; an ACK or `state="running"` → running, and the host's own
|
|
1203
|
+
* `time.idle` is still asked for (it is the only source carrying the `outcome` word).
|
|
1204
|
+
*/
|
|
1205
|
+
async function adoptFromParentHistory(parent, parentMessages, directory, only) {
|
|
1206
|
+
const candidates = childrenFromParentHistory(parentMessages);
|
|
1207
|
+
let adopted = 0;
|
|
1208
|
+
let skipped = 0;
|
|
1209
|
+
for (const cand of candidates) {
|
|
1210
|
+
const sid = cand.sessionID;
|
|
1211
|
+
if (!sid || sid === parent || children.has(sid) || (only && !only.has(sid))) {
|
|
1212
|
+
skipped++;
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const gate = await parentageOf(sid, parent, directory);
|
|
1216
|
+
if (!gate.ok) {
|
|
1217
|
+
skipped++;
|
|
1218
|
+
log({ step_id: "join", event: "adopt_history_refused", child: sid, reason: "parentage" });
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
const hostAgent = gate.agent.trim().toLowerCase();
|
|
1222
|
+
// T3: this registry never carries a lead, whatever a stale transcript says.
|
|
1223
|
+
const agent = hostAgent && !sameAgent(hostAgent, leadAgent) ? hostAgent : "subagent";
|
|
1224
|
+
const startedAt = now();
|
|
1225
|
+
const rec = {
|
|
1226
|
+
sessionID: sid,
|
|
1227
|
+
agent,
|
|
1228
|
+
label: cand.label || "host child",
|
|
1229
|
+
parentSessionID: parent,
|
|
1230
|
+
startedAt,
|
|
1231
|
+
state: "running",
|
|
1232
|
+
via: "host-injection",
|
|
1233
|
+
};
|
|
1234
|
+
if (cand.state === "completed") {
|
|
1235
|
+
rec.state = "idle";
|
|
1236
|
+
rec.finishedAt = startedAt;
|
|
1237
|
+
rec.settleSource = "injection";
|
|
1238
|
+
}
|
|
1239
|
+
else if (cand.state === "error") {
|
|
1240
|
+
rec.state = "error";
|
|
1241
|
+
rec.finishedAt = startedAt;
|
|
1242
|
+
rec.error = "宿主注入信封报告 state=error(正文随注入消息送达)";
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
// Still running, as far as the transcript knows — but the host's own record is a
|
|
1246
|
+
// better witness and it also carries `outcome`, which no envelope does.
|
|
1247
|
+
const st = await readHostSessionState(api, sid);
|
|
1248
|
+
if (st.idleAt) {
|
|
1249
|
+
rec.state = "idle";
|
|
1250
|
+
rec.finishedAt = st.idleAt;
|
|
1251
|
+
rec.hostIdleAt = st.idleAt;
|
|
1252
|
+
if (st.createdAt !== undefined)
|
|
1253
|
+
rec.hostCreatedAt = st.createdAt;
|
|
1254
|
+
rec.settleSource = "session-get";
|
|
1255
|
+
if (st.outcome)
|
|
1256
|
+
rec.outcome = st.outcome;
|
|
1257
|
+
}
|
|
1258
|
+
else if (st.createdAt !== undefined) {
|
|
1259
|
+
rec.hostCreatedAt = st.createdAt;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
children.set(sid, rec);
|
|
1263
|
+
deps.onChildSession?.(sid, agent);
|
|
1264
|
+
adopted++;
|
|
1265
|
+
log({ step_id: "join", event: "adopt", child: sid, agent, state: cand.state, source: "parent-history" });
|
|
1266
|
+
}
|
|
1267
|
+
// One summary row per call: the counters are what makes "I looked and there was
|
|
1268
|
+
// nothing" distinguishable from "I never looked" in the trajectory.
|
|
1269
|
+
log({ step_id: "join", event: "adopt_history", candidates: candidates.length, adopted, skipped, source: "parent-history" });
|
|
1270
|
+
const why = !candidates.length
|
|
1271
|
+
? "父会话历史里没有子代理派发记录(既没有后台 ack,也没有 <subagent> 信封;历史若被压缩过,这条也不算数)"
|
|
1272
|
+
: adopted
|
|
1273
|
+
? ""
|
|
1274
|
+
: `父会话历史里 ${candidates.length} 个候选都没能认领(${skipped} 个已登记或宿主未确认归属)`;
|
|
1275
|
+
return { adopted, looked: true, candidates: candidates.length, skipped, why };
|
|
1276
|
+
}
|
|
863
1277
|
/** Claim ONE explicitly named child of the calling session — the host's own
|
|
864
1278
|
* `task` child included, which `adoptFromHost` must never pick up on its
|
|
865
1279
|
* own. The lock is the host's parentage (`parentID === the caller`), read
|
|
@@ -953,8 +1367,9 @@ export function buildDispatchTools(deps) {
|
|
|
953
1367
|
- 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.
|
|
954
1368
|
- { 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 }).
|
|
955
1369
|
- { 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.
|
|
956
|
-
- 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. 已受理
|
|
957
|
-
- 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
|
|
1370
|
+
- 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.
|
|
1371
|
+
- 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 on a host with no \`session.children\` the children your own transcript still names — the background ACK \`(sessionID: ses_…)\` and the \`<subagent sessionID="…" state="…">\` envelope — are claimed after the host confirms their parentage. One that answered while nobody was listening is reported 已完成, not lost.
|
|
1372
|
+
- Background SHELLS the host announced in this session are reported at the end of every reply (\`后台 shell:N 个\`), with the running ones named as still running. Nothing is killed by this tool: re-run a shell only if a completed one failed, and never dispatch the same work again while one is still running.
|
|
958
1373
|
- 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).`,
|
|
959
1374
|
args: {
|
|
960
1375
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
@@ -999,7 +1414,20 @@ export function buildDispatchTools(deps) {
|
|
|
999
1414
|
// wrote its governed result — so the token table read "0 调用 / 2 结果",
|
|
1000
1415
|
// which looks like a broken counter rather than a tool that ran.
|
|
1001
1416
|
store.appendTrajectory({ tool, step_id: "join", event: "call" });
|
|
1417
|
+
// A settle check may still be talking to the host (see `settling`). Drain it before
|
|
1418
|
+
// reading the registry so this reply never mixes a fresh table with a half-updated row.
|
|
1419
|
+
if (settling.size)
|
|
1420
|
+
await Promise.allSettled([...settling]);
|
|
1002
1421
|
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
1422
|
+
// Read the CALLER's own transcript ONCE per call: it is the floor recovery source
|
|
1423
|
+
// when the host has no `session.children`, and the only place a background shell is
|
|
1424
|
+
// ever announced. In-process on v2 (`ctx.session.context`), one hop on v1.
|
|
1425
|
+
const parentHistory = await readParentMessages(parent, directory);
|
|
1426
|
+
// Shells are REPORTED, never claimed: they are the host's processes, and this tool
|
|
1427
|
+
// only exists to keep the lead from re-dispatching what is already running.
|
|
1428
|
+
const shellSection = parentHistory.ok
|
|
1429
|
+
? renderBackgroundShells(backgroundShellsFromParentHistory(parentHistory.data))
|
|
1430
|
+
: null;
|
|
1003
1431
|
const idList = parseIdList(args.ids);
|
|
1004
1432
|
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
1005
1433
|
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
|
|
@@ -1018,6 +1446,24 @@ export function buildDispatchTools(deps) {
|
|
|
1018
1446
|
adoption = await adoptFromHost(parent, directory, idFilter);
|
|
1019
1447
|
if (adoption.adopted)
|
|
1020
1448
|
mine = owned();
|
|
1449
|
+
// Second recovery source, for the host that has no `session.children` at all
|
|
1450
|
+
// (measured 2.1.4): the parent transcript still names its children. Only asked
|
|
1451
|
+
// when the tree walk did not settle the question — never instead of it.
|
|
1452
|
+
if (mine.length < (idFilter ? idFilter.size : 1)) {
|
|
1453
|
+
const hist = parentHistory.ok
|
|
1454
|
+
? await adoptFromParentHistory(parent, parentHistory.data, directory, idFilter)
|
|
1455
|
+
: { adopted: 0, looked: false, candidates: 0, skipped: 0, why: parentHistory.why };
|
|
1456
|
+
if (hist.adopted)
|
|
1457
|
+
mine = owned();
|
|
1458
|
+
// The `why` has to keep the three states apart: a children API that looked, no
|
|
1459
|
+
// children API but the history claimed something, and neither source yielding a
|
|
1460
|
+
// child. Fusing them is the overstatement this path was built to stop.
|
|
1461
|
+
adoption = {
|
|
1462
|
+
adopted: adoption.adopted + hist.adopted,
|
|
1463
|
+
looked: adoption.looked,
|
|
1464
|
+
why: [adoption.why, hist.why].filter(Boolean).join(";"),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1021
1467
|
// Plan B: a NAMED id may be the host's own `task` child (no ·tm
|
|
1022
1468
|
// marker, so the tree walk above never claims it). Collecting it is
|
|
1023
1469
|
// how the lead reads back a result we replaced with a pointer — the
|
|
@@ -1140,6 +1586,20 @@ export function buildDispatchTools(deps) {
|
|
|
1140
1586
|
// and a verdict read off whatever came back.
|
|
1141
1587
|
note = "这个客户端不是 v2 的 ctx.session 桥(v1 的 session.prompt 走 {path,body},没有 delivery 字段),未调用";
|
|
1142
1588
|
}
|
|
1589
|
+
// C — the write only proves ADMISSION. Ask the same inbox once more for the one
|
|
1590
|
+
// checkable fact: is the item still pending? Nothing is claimed when the seam
|
|
1591
|
+
// cannot answer (see steerDeliveryLine's unverified branch).
|
|
1592
|
+
const verified = outcome === "steered" && inboxID ? await verifySteerDelivery(api, target, inboxID) : null;
|
|
1593
|
+
if (verified) {
|
|
1594
|
+
log({
|
|
1595
|
+
step_id: "join",
|
|
1596
|
+
event: "steer_delivery",
|
|
1597
|
+
child: target,
|
|
1598
|
+
inboxID,
|
|
1599
|
+
verdict: verified.verdict,
|
|
1600
|
+
...(verified.note ? { reason: shorten(verified.note, 120) } : {}),
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1143
1603
|
log({
|
|
1144
1604
|
step_id: "join",
|
|
1145
1605
|
event: "steer",
|
|
@@ -1150,6 +1610,7 @@ export function buildDispatchTools(deps) {
|
|
|
1150
1610
|
ok: outcome === "steered",
|
|
1151
1611
|
wake: wakeRequested,
|
|
1152
1612
|
...(inboxID ? { inboxID } : {}),
|
|
1613
|
+
...(verified ? { delivered: verified.verdict } : {}),
|
|
1153
1614
|
...(seam ? { seam } : {}),
|
|
1154
1615
|
// the LENGTH, never the text — R6 binds the audit trail as much as the reply
|
|
1155
1616
|
chars: steerText.length,
|
|
@@ -1160,7 +1621,10 @@ export function buildDispatchTools(deps) {
|
|
|
1160
1621
|
const wake = outcome === "steered" && wakeRequested && row && row.state !== "running"
|
|
1161
1622
|
? `\n已按 wake:true 唤醒:它当时不是运行中(${row.state === "error" ? "失败" : "已结算"}),宿主把这条插话当成新的执行输入——它会和新派发的 worker 并发,留意这一点。`
|
|
1162
1623
|
: "";
|
|
1163
|
-
|
|
1624
|
+
const deliveryLine = verified
|
|
1625
|
+
? `\n投递核对:${steerDeliveryLine(verified.verdict, inboxID, verified.note)}`
|
|
1626
|
+
: "";
|
|
1627
|
+
return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${deliveryLine}${wake}`);
|
|
1164
1628
|
}
|
|
1165
1629
|
if (wantsUnread) {
|
|
1166
1630
|
if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
|
|
@@ -1223,18 +1687,29 @@ export function buildDispatchTools(deps) {
|
|
|
1223
1687
|
const saw = adoption.looked;
|
|
1224
1688
|
const inner = idFilter
|
|
1225
1689
|
? (() => {
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1690
|
+
// TWO independent facts live here, and they used to be fused into one
|
|
1691
|
+
// sentence: what the session READER can do, and whether the CHILDREN
|
|
1692
|
+
// endpoint exists. On 2.1.4 `ctx.session.get({sessionID})` answers while
|
|
1693
|
+
// `session.children` does not, so the old text ("可辨认的 session API 缺失"
|
|
1694
|
+
// plus the last error of a shape already abandoned) told the lead the host
|
|
1695
|
+
// had no session API at all — and it went back to assuming its id was
|
|
1696
|
+
// fine and the host broken. State them separately, and name the shapes
|
|
1697
|
+
// only when EVERY shape failed: a shape that worked makes the abandoned
|
|
1698
|
+
// ones irrelevant, not evidence.
|
|
1231
1699
|
const sr = deps.sessionReaderReport?.();
|
|
1232
1700
|
const bridge = sr && sr.attempted > 0
|
|
1233
|
-
?
|
|
1701
|
+
? sr.usedShape
|
|
1702
|
+
? `;ctx.session.get 可用(形状 ${sr.usedShape}):宿主没有 session.children,按 id 读单条会话可以,列举会话树不行`
|
|
1703
|
+
: `;按 id 读子会话也失败:试过的 ctx.session 形状 ${sr.failedShapes.length ? sr.failedShapes.join("→") : "?"}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
|
|
1234
1704
|
: "";
|
|
1705
|
+
// A named id that neither our registry nor the host's by-id read could
|
|
1706
|
+
// resolve is a miss about THAT id — saying it means the host has no
|
|
1707
|
+
// session API is the claim this branch exists to stop making.
|
|
1708
|
+
const named = [...idFilter].slice(0, 3).map((s) => shorten(s, 40)).join("、");
|
|
1709
|
+
const miss = `ids 未匹配到本会话的子代理:${named} 在本机不存在或无权限`;
|
|
1235
1710
|
return saw
|
|
1236
|
-
?
|
|
1237
|
-
:
|
|
1711
|
+
? `(${miss};宿主会话树里也没有可认领的子会话${bridge})`
|
|
1712
|
+
: `(${miss};而且我没能查看宿主会话树:${adoption.why}${bridge})`;
|
|
1238
1713
|
})()
|
|
1239
1714
|
: saw
|
|
1240
1715
|
? ""
|
|
@@ -1245,7 +1720,11 @@ export function buildDispatchTools(deps) {
|
|
|
1245
1720
|
// the transcript, and the honest reason is that a synchronous host
|
|
1246
1721
|
// task never enters this registry at all.
|
|
1247
1722
|
`如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
|
|
1248
|
-
`只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。`
|
|
1723
|
+
`只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
|
|
1724
|
+
// A round with no collectable CHILD is still not an empty round when the host
|
|
1725
|
+
// has shell commands in flight: the user can see them and the lead cannot, so
|
|
1726
|
+
// silence here is what produced a duplicate dispatch.
|
|
1727
|
+
(shellSection ? `\n\n${shellSection}` : ""));
|
|
1249
1728
|
}
|
|
1250
1729
|
await settleAdopted(mine, directory);
|
|
1251
1730
|
const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
|
|
@@ -1362,8 +1841,10 @@ export function buildDispatchTools(deps) {
|
|
|
1362
1841
|
});
|
|
1363
1842
|
}
|
|
1364
1843
|
const sums = summarizeStates(mine);
|
|
1844
|
+
const settleLine = settleTallyLine(mine);
|
|
1365
1845
|
const header = [
|
|
1366
1846
|
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
|
|
1847
|
+
...(settleLine ? [settleLine] : []),
|
|
1367
1848
|
...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
|
|
1368
1849
|
];
|
|
1369
1850
|
// The `正文:` line belongs directly under the child rows it describes, but the
|
|
@@ -1525,7 +2006,7 @@ export function buildDispatchTools(deps) {
|
|
|
1525
2006
|
// when bodies were actually read (`includeText:false` has no body to judge).
|
|
1526
2007
|
if (bodyStatus.length)
|
|
1527
2008
|
header.splice(childRowsEnd, 0, ...bodyStatus);
|
|
1528
|
-
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
|
|
2009
|
+
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : []), ...(shellSection ? ["", shellSection] : [])].join("\n");
|
|
1529
2010
|
const stepId = deps.pipelines.nextStepId();
|
|
1530
2011
|
return toToolResult(await deps.pipelines.govern(stepId, tool, body, {
|
|
1531
2012
|
contentType: detectContentType(body),
|
|
@@ -1538,6 +2019,66 @@ export function buildDispatchTools(deps) {
|
|
|
1538
2019
|
}
|
|
1539
2020
|
},
|
|
1540
2021
|
};
|
|
2022
|
+
/** Settle the still-running HOST children of a session that just went idle — by ASKING the
|
|
2023
|
+
* host, not by assuming. One `session.get` per child, three outcomes kept apart:
|
|
2024
|
+
* - `time.idle` present → settled, `settleSource="session-get"`, host `outcome` kept;
|
|
2025
|
+
* - answered WITHOUT idle → the child is still running: the row is left alone and the
|
|
2026
|
+
* attempt is counted (`settle_unchecked`), because a live child reported as settled is
|
|
2027
|
+
* the exact overstatement this tool was already fixed for;
|
|
2028
|
+
* - no seam / a throwing call → the old `parent-idle` presumption, printed as one.
|
|
2029
|
+
* `idle_settled` and `idle_presumed` are separate trajectory rows on purpose: tm_stats can
|
|
2030
|
+
* then show how often the host actually confirmed a finish. */
|
|
2031
|
+
async function reconcileHostChildren(rows) {
|
|
2032
|
+
for (const child of rows) {
|
|
2033
|
+
if (child.state !== "running")
|
|
2034
|
+
continue;
|
|
2035
|
+
const st = await readHostSessionState(api, child.sessionID);
|
|
2036
|
+
if (st.idleAt) {
|
|
2037
|
+
child.state = "idle";
|
|
2038
|
+
child.finishedAt = st.idleAt;
|
|
2039
|
+
child.hostIdleAt = st.idleAt;
|
|
2040
|
+
if (st.createdAt !== undefined)
|
|
2041
|
+
child.hostCreatedAt = st.createdAt;
|
|
2042
|
+
child.settleSource = "session-get";
|
|
2043
|
+
if (st.outcome)
|
|
2044
|
+
child.outcome = st.outcome;
|
|
2045
|
+
log({
|
|
2046
|
+
step_id: "events",
|
|
2047
|
+
event: "idle_settled",
|
|
2048
|
+
source: "session-get",
|
|
2049
|
+
child: child.sessionID,
|
|
2050
|
+
agent: child.agent,
|
|
2051
|
+
...(st.outcome ? { outcome: st.outcome } : {}),
|
|
2052
|
+
ms: st.idleAt - child.startedAt,
|
|
2053
|
+
});
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
if (st.answered) {
|
|
2057
|
+
log({
|
|
2058
|
+
step_id: "events",
|
|
2059
|
+
event: "settle_unchecked",
|
|
2060
|
+
source: "session-get",
|
|
2061
|
+
reason: "still-running",
|
|
2062
|
+
child: child.sessionID,
|
|
2063
|
+
agent: child.agent,
|
|
2064
|
+
});
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
presumeParentIdle([child]);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
/** The FALLBACK: a still-running host child of a session that just went idle, on a host we
|
|
2071
|
+
* could not ask. Recorded as `parent-idle` and printed as a presumption — never 完成. */
|
|
2072
|
+
function presumeParentIdle(rows) {
|
|
2073
|
+
for (const child of rows) {
|
|
2074
|
+
if (child.state !== "running")
|
|
2075
|
+
continue;
|
|
2076
|
+
child.state = "idle";
|
|
2077
|
+
child.finishedAt = now();
|
|
2078
|
+
child.settleSource = "parent-idle";
|
|
2079
|
+
log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
1541
2082
|
const observeEvent = (event) => {
|
|
1542
2083
|
const rawType = String(event?.type ?? "");
|
|
1543
2084
|
if (!rawType.startsWith("session."))
|
|
@@ -1560,21 +2101,22 @@ export function buildDispatchTools(deps) {
|
|
|
1560
2101
|
return;
|
|
1561
2102
|
const rec = children.get(sid);
|
|
1562
2103
|
if (!rec) {
|
|
1563
|
-
// The parent's own idle is the only completion signal
|
|
1564
|
-
// child
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
2104
|
+
// The parent's own idle is the only completion signal the host gives us for a HOST
|
|
2105
|
+
// child — and it is not the strongest one available: `session.get({sessionID})` answers
|
|
2106
|
+
// the child's own `time.idle` (verified on 2.1.4), so we ASK first and only fall back to
|
|
2107
|
+
// the inference when the host cannot be asked. A row that stayed 运行中 forever would be
|
|
2108
|
+
// an overstatement in reverse; a row presumed settled when the host says it is still
|
|
2109
|
+
// executing is the overstatement this tool exists to refuse.
|
|
1568
2110
|
if (type === "session.idle") {
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
2111
|
+
const pending = [...children.values()].filter((child) => child.via === "host-injection" && child.state === "running" && child.parentSessionID === sid);
|
|
2112
|
+
if (pending.length) {
|
|
2113
|
+
// No seam at all → the fallback needs no round-trip, so it lands HERE and the
|
|
2114
|
+
// synchronous hook stays synchronously readable. A seam that EXISTS is asked, and
|
|
2115
|
+
// that read is tracked so tm_join drains it before reporting.
|
|
2116
|
+
if (!api || typeof api.get !== "function")
|
|
2117
|
+
presumeParentIdle(pending);
|
|
2118
|
+
else
|
|
2119
|
+
trackSettle(reconcileHostChildren(pending));
|
|
1578
2120
|
}
|
|
1579
2121
|
}
|
|
1580
2122
|
return;
|