@te-river/opencode-team-mode 1.8.6 → 1.8.8
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/tm/config-layers.js +1 -1
- package/dist/tm/config-layers.js.map +1 -1
- package/dist/tm/dispatch.d.ts +58 -1
- package/dist/tm/dispatch.d.ts.map +1 -1
- package/dist/tm/dispatch.js +440 -16
- 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/package.json +1 -1
package/dist/tm/dispatch.js
CHANGED
|
@@ -342,6 +342,177 @@ export function hostEnvelopeBody(messages, sessionID) {
|
|
|
342
342
|
}
|
|
343
343
|
return null;
|
|
344
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
|
+
}
|
|
345
516
|
/**
|
|
346
517
|
* Turn whatever the host puts in an error slot into ONE readable line.
|
|
347
518
|
*
|
|
@@ -393,31 +564,49 @@ export function describeHostError(err, max = 220) {
|
|
|
393
564
|
* that had already landed — the fix is to say it, not to withhold the text.
|
|
394
565
|
*/
|
|
395
566
|
export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
|
|
567
|
+
/** Wall-clock elapsed for a row, or `null` when NOBODY measured a start. `startedAt` is only
|
|
568
|
+
* set when it is a real measurement (the host's own `time.created` for an adopted history row,
|
|
569
|
+
* our dispatch moment otherwise); the host's run window (`hostCreatedAt` → `hostIdleAt`) is the
|
|
570
|
+
* fallback. A history row is discovered long after its run began, so our discovery moment is
|
|
571
|
+
* not a start — printing `0s` from it is the fake duration this split exists to remove. */
|
|
572
|
+
export function elapsedOf(c, at = Date.now()) {
|
|
573
|
+
if (!c.startedAtUnknown)
|
|
574
|
+
return (c.finishedAt ?? at) - c.startedAt;
|
|
575
|
+
if (typeof c.hostCreatedAt === "number")
|
|
576
|
+
return (c.hostIdleAt ?? c.finishedAt ?? at) - c.hostCreatedAt;
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
396
579
|
/** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
|
|
397
580
|
* the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
|
|
398
581
|
* unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
|
|
399
582
|
* but a real settlement may print 完成. */
|
|
400
583
|
export function renderChildLine(c, elapsedMs) {
|
|
401
|
-
const secs = Math.round(elapsedMs / 1000);
|
|
584
|
+
const secs = elapsedMs === null ? null : Math.round(elapsedMs / 1000);
|
|
585
|
+
/** Seconds, or the truth when nobody measured a start — never `0s`. */
|
|
586
|
+
const dur = (n) => (n === null ? "时长未知" : `${n}s`);
|
|
402
587
|
const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
|
|
403
588
|
? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
|
|
404
589
|
: secs;
|
|
405
590
|
const tag = c.state === "running"
|
|
406
|
-
?
|
|
591
|
+
? secs === null
|
|
592
|
+
? "运行中(时长未知)"
|
|
593
|
+
: `运行中 ${secs}s`
|
|
407
594
|
: c.state === "error"
|
|
408
595
|
? `失败:${shorten(c.error ?? "", 80)}`
|
|
409
596
|
: c.settleSource === "session-get"
|
|
410
597
|
? // The host's own record answered with a finished run: the only form allowed to
|
|
411
598
|
// print its outcome word, and it never says 推定.
|
|
412
|
-
`已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}
|
|
599
|
+
`已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${dur(hostRunSecs)}`
|
|
413
600
|
: c.settleSource === "parent-idle"
|
|
414
601
|
? // An inference. 完成 is exactly the word this may not borrow.
|
|
415
|
-
`推定已结算(未确认完成)· 派发后 ${secs}
|
|
416
|
-
: `已完成 ${secs}
|
|
602
|
+
`推定已结算(未确认完成)· 派发后 ${dur(secs)}`
|
|
603
|
+
: `已完成 ${dur(secs)}`;
|
|
417
604
|
// #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
|
|
418
605
|
// needs to see that the child was never running, not a bare 已完成.
|
|
419
606
|
const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
|
|
420
|
-
|
|
607
|
+
// A state read off the transcript envelope is not the host's verdict, and the row says so.
|
|
608
|
+
const inferred = c.historyInferred ? " ·(按历史信封推断)" : "";
|
|
609
|
+
return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${inferred}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
|
|
421
610
|
? " ·(宿主子代理:随父会话空闲推定已结算;宿主未给该子会话 idle 事件)"
|
|
422
611
|
: c.via === "host-injection"
|
|
423
612
|
? " ·(宿主 subagent 派发)"
|
|
@@ -991,6 +1180,171 @@ export function buildDispatchTools(deps) {
|
|
|
991
1180
|
}
|
|
992
1181
|
return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
|
|
993
1182
|
}
|
|
1183
|
+
/** Read the CALLING session's own transcript. On a host with no `session.children`
|
|
1184
|
+
* (2.1.4) this is the only recovery source left, and it is also where the host announces
|
|
1185
|
+
* its background shells. Never throws: a failure is a reported `why`, because
|
|
1186
|
+
* "we could not look" and "there was nothing" must not print the same. */
|
|
1187
|
+
async function readParentMessages(parent, directory) {
|
|
1188
|
+
if (!parent)
|
|
1189
|
+
return { ok: false, why: "调用方会话 id 缺失,读不到父会话历史" };
|
|
1190
|
+
if (typeof api?.messages !== "function") {
|
|
1191
|
+
return { ok: false, why: "这个宿主客户端没有给出读取会话正文的缝(v2 上是 ctx.session.context),父会话历史读不到" };
|
|
1192
|
+
}
|
|
1193
|
+
try {
|
|
1194
|
+
const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
|
|
1195
|
+
if (!un.ok)
|
|
1196
|
+
return { ok: false, why: `读取父会话历史失败:${shorten(un.message ?? "", 90)}` };
|
|
1197
|
+
return { ok: true, data: un.data, why: "" };
|
|
1198
|
+
}
|
|
1199
|
+
catch (err) {
|
|
1200
|
+
return {
|
|
1201
|
+
ok: false,
|
|
1202
|
+
why: `读取父会话历史失败:${describeHostError(err?.message ?? err, 90)}`,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Adopt the children the PARENT TRANSCRIPT still names — the second recovery path, for a
|
|
1208
|
+
* host whose `session.children` does not exist. Measured live (2.1.4 + 1.8.6): a service
|
|
1209
|
+
* restart empties this in-process registry, `session.children` is gone, so tm_join
|
|
1210
|
+
* answered `没有待收集的派发` while the user could still see the sub-agents running — and
|
|
1211
|
+
* the lead re-dispatched work that was already in flight.
|
|
1212
|
+
*
|
|
1213
|
+
* The transcript is not trusted as ownership: every candidate is verified through the
|
|
1214
|
+
* host's own `session.get` (`parentID === the caller`) before a row is opened, because an
|
|
1215
|
+
* envelope names a session, it does not prove the session is OURS. A candidate the host
|
|
1216
|
+
* will not confirm is counted as skipped and never shown as a child.
|
|
1217
|
+
*
|
|
1218
|
+
* The row's state comes from the HOST, for every candidate: `time.idle` → settled with the
|
|
1219
|
+
* host's own `outcome` word; answered WITHOUT `time.idle` → RUNNING, whatever the transcript's
|
|
1220
|
+
* envelope says (1.8.7 took three `state="error"` envelopes as conclusions and answered
|
|
1221
|
+
* `0 运行中 / 3 失败` while the host's own UI still showed two of those children running).
|
|
1222
|
+
* The envelope's state is the fallback ONLY when the per-session read cannot answer, and the
|
|
1223
|
+
* row then prints `按历史信封推断` instead of borrowing the host's verdict.
|
|
1224
|
+
*
|
|
1225
|
+
* A candidate that is already FINISHED does not enter the live table: it becomes one count
|
|
1226
|
+
* (`另有 N 条已结算的历史子代理`) and is returned, body and all, when named with `ids`.
|
|
1227
|
+
*/
|
|
1228
|
+
async function adoptFromParentHistory(parent, parentMessages, directory, only) {
|
|
1229
|
+
const candidates = childrenFromParentHistory(parentMessages);
|
|
1230
|
+
let adopted = 0;
|
|
1231
|
+
let skipped = 0;
|
|
1232
|
+
let settled = 0;
|
|
1233
|
+
let running = 0;
|
|
1234
|
+
// The split is counted per candidate, so the trajectory says how many of the leftovers were
|
|
1235
|
+
// still work and how many were already history. An already-registered row is tallied from
|
|
1236
|
+
// its own record — it is still part of what this transcript names.
|
|
1237
|
+
const tally = (r) => {
|
|
1238
|
+
if (r.state === "running")
|
|
1239
|
+
running++;
|
|
1240
|
+
else
|
|
1241
|
+
settled++;
|
|
1242
|
+
};
|
|
1243
|
+
for (const cand of candidates) {
|
|
1244
|
+
const sid = cand.sessionID;
|
|
1245
|
+
if (!sid || sid === parent) {
|
|
1246
|
+
skipped++;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const known = children.get(sid);
|
|
1250
|
+
if (known) {
|
|
1251
|
+
skipped++;
|
|
1252
|
+
if (!only || only.has(sid))
|
|
1253
|
+
tally(known);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (only && !only.has(sid)) {
|
|
1257
|
+
skipped++;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
const gate = await parentageOf(sid, parent, directory);
|
|
1261
|
+
if (!gate.ok) {
|
|
1262
|
+
skipped++;
|
|
1263
|
+
log({ step_id: "join", event: "adopt_history_refused", child: sid, reason: "parentage" });
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const hostAgent = gate.agent.trim().toLowerCase();
|
|
1267
|
+
// T3: this registry never carries a lead, whatever a stale transcript says.
|
|
1268
|
+
const agent = hostAgent && !sameAgent(hostAgent, leadAgent) ? hostAgent : "subagent";
|
|
1269
|
+
const rec = {
|
|
1270
|
+
sessionID: sid,
|
|
1271
|
+
agent,
|
|
1272
|
+
label: cand.label || "host child",
|
|
1273
|
+
parentSessionID: parent,
|
|
1274
|
+
// Our discovery moment — never the run's start. Replaced below by the host's own
|
|
1275
|
+
// `time.created` when it answers with one; until then the row reports 时长未知.
|
|
1276
|
+
startedAt: now(),
|
|
1277
|
+
startedAtUnknown: true,
|
|
1278
|
+
state: "running",
|
|
1279
|
+
via: "host-injection",
|
|
1280
|
+
};
|
|
1281
|
+
// ASK THE HOST FIRST, for every candidate. The transcript proves a dispatch existed; it
|
|
1282
|
+
// does not decide whether the run is over — the host's `time.idle` does. A 1.8.7 live
|
|
1283
|
+
// session read three `state="error"` envelopes as verdicts and reported 0 运行中 / 3 失败
|
|
1284
|
+
// while the host's UI still showed two children running.
|
|
1285
|
+
const st = await readHostSessionState(api, sid);
|
|
1286
|
+
if (st.createdAt !== undefined) {
|
|
1287
|
+
// The host's own start: the only clock a duration may be derived from.
|
|
1288
|
+
rec.hostCreatedAt = st.createdAt;
|
|
1289
|
+
rec.startedAt = st.createdAt;
|
|
1290
|
+
rec.startedAtUnknown = false;
|
|
1291
|
+
}
|
|
1292
|
+
if (st.idleAt) {
|
|
1293
|
+
rec.state = "idle";
|
|
1294
|
+
rec.finishedAt = st.idleAt;
|
|
1295
|
+
rec.hostIdleAt = st.idleAt;
|
|
1296
|
+
rec.settleSource = "session-get";
|
|
1297
|
+
if (st.outcome)
|
|
1298
|
+
rec.outcome = st.outcome;
|
|
1299
|
+
}
|
|
1300
|
+
else if (st.answered) {
|
|
1301
|
+
// Answered, and no finished run on record → still running. No `time.created` means no
|
|
1302
|
+
// duration; the row says so rather than counting from our discovery moment.
|
|
1303
|
+
}
|
|
1304
|
+
else if (cand.state === "completed" || cand.state === "error") {
|
|
1305
|
+
// The per-session read could not answer: the envelope is the only evidence left, and
|
|
1306
|
+
// the row is marked so the lead can see whose word this is.
|
|
1307
|
+
rec.historyInferred = true;
|
|
1308
|
+
rec.settleSource = "injection";
|
|
1309
|
+
if (cand.state === "completed") {
|
|
1310
|
+
rec.state = "idle";
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
rec.state = "error";
|
|
1314
|
+
rec.error = "宿主注入信封报告 state=error(正文随注入消息送达)";
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// A candidate that is already finished leaves the LIVE table (it cannot be running) and
|
|
1318
|
+
// becomes a count. `owned` still returns it when the lead names it with `ids`.
|
|
1319
|
+
if (rec.state !== "running")
|
|
1320
|
+
rec.historySettled = true;
|
|
1321
|
+
tally(rec);
|
|
1322
|
+
children.set(sid, rec);
|
|
1323
|
+
deps.onChildSession?.(sid, agent);
|
|
1324
|
+
adopted++;
|
|
1325
|
+
log({ step_id: "join", event: "adopt", child: sid, agent, state: rec.state, source: "parent-history" });
|
|
1326
|
+
}
|
|
1327
|
+
// One summary row per call: the counters are what makes "I looked and there was
|
|
1328
|
+
// nothing" distinguishable from "I never looked" in the trajectory — and `settled` /
|
|
1329
|
+
// `running` are what makes "the transcript named them" distinguishable from "they are
|
|
1330
|
+
// live work".
|
|
1331
|
+
log({
|
|
1332
|
+
step_id: "join",
|
|
1333
|
+
event: "adopt_history",
|
|
1334
|
+
candidates: candidates.length,
|
|
1335
|
+
adopted,
|
|
1336
|
+
skipped,
|
|
1337
|
+
settled,
|
|
1338
|
+
running,
|
|
1339
|
+
source: "parent-history",
|
|
1340
|
+
});
|
|
1341
|
+
const why = !candidates.length
|
|
1342
|
+
? "父会话历史里没有子代理派发记录(既没有后台 ack,也没有 <subagent> 信封;历史若被压缩过,这条也不算数)"
|
|
1343
|
+
: adopted
|
|
1344
|
+
? ""
|
|
1345
|
+
: `父会话历史里 ${candidates.length} 个候选都没能认领(${skipped} 个已登记或宿主未确认归属)`;
|
|
1346
|
+
return { adopted, looked: true, candidates: candidates.length, skipped, settled, running, why };
|
|
1347
|
+
}
|
|
994
1348
|
/** Claim ONE explicitly named child of the calling session — the host's own
|
|
995
1349
|
* `task` child included, which `adoptFromHost` must never pick up on its
|
|
996
1350
|
* own. The lock is the host's parentage (`parentID === the caller`), read
|
|
@@ -1085,7 +1439,8 @@ export function buildDispatchTools(deps) {
|
|
|
1085
1439
|
- { 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 }).
|
|
1086
1440
|
- { 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
1441
|
- 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.
|
|
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
|
|
1442
|
+
- 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. Their STATE is the host's \`session.get\`: no \`time.idle\` means still RUNNING, whatever an old envelope in the transcript says. One that answered while nobody was listening is not lost — it stops being a table row (only children that can still be running are listed) and is counted as \`另有 N 条已结算的历史子代理\`; name it with \`ids\` to get that row and its body back.
|
|
1443
|
+
- 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.
|
|
1089
1444
|
- 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).`,
|
|
1090
1445
|
args: {
|
|
1091
1446
|
ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
|
|
@@ -1135,9 +1490,43 @@ export function buildDispatchTools(deps) {
|
|
|
1135
1490
|
if (settling.size)
|
|
1136
1491
|
await Promise.allSettled([...settling]);
|
|
1137
1492
|
const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
|
|
1493
|
+
// Read the CALLER's own transcript ONCE per call: it is the floor recovery source
|
|
1494
|
+
// when the host has no `session.children`, and the only place a background shell is
|
|
1495
|
+
// ever announced. In-process on v2 (`ctx.session.context`), one hop on v1.
|
|
1496
|
+
const parentHistory = await readParentMessages(parent, directory);
|
|
1497
|
+
// Shells are REPORTED, never claimed: they are the host's processes, and this tool
|
|
1498
|
+
// only exists to keep the lead from re-dispatching what is already running.
|
|
1499
|
+
const shellSection = parentHistory.ok
|
|
1500
|
+
? renderBackgroundShells(backgroundShellsFromParentHistory(parentHistory.data))
|
|
1501
|
+
: null;
|
|
1138
1502
|
const idList = parseIdList(args.ids);
|
|
1139
1503
|
const idFilter = idList && idList.length ? new Set(idList) : null;
|
|
1140
|
-
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) &&
|
|
1504
|
+
const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) &&
|
|
1505
|
+
(!idFilter || idFilter.has(r.sessionID)) &&
|
|
1506
|
+
// A settled HISTORY row is a count, not a table row — the table answers "what can
|
|
1507
|
+
// still be running". Naming it with `ids` is the documented way to re-read it,
|
|
1508
|
+
// so an explicit filter always includes it.
|
|
1509
|
+
(!r.historySettled || Boolean(idFilter)));
|
|
1510
|
+
/** Settled history rows this session knows about, minus the ones the lead NAMED (those
|
|
1511
|
+
* are rendered as rows instead). Reported as ONE line, never as N rows: 26 finished
|
|
1512
|
+
* history children used to bury the single live one. */
|
|
1513
|
+
const historySettledCount = () => {
|
|
1514
|
+
let n = 0;
|
|
1515
|
+
for (const r of children.values()) {
|
|
1516
|
+
if (!r.historySettled)
|
|
1517
|
+
continue;
|
|
1518
|
+
if (parent && r.parentSessionID !== parent)
|
|
1519
|
+
continue;
|
|
1520
|
+
if (idFilter?.has(r.sessionID))
|
|
1521
|
+
continue;
|
|
1522
|
+
n++;
|
|
1523
|
+
}
|
|
1524
|
+
return n;
|
|
1525
|
+
};
|
|
1526
|
+
const historyLine = () => {
|
|
1527
|
+
const n = historySettledCount();
|
|
1528
|
+
return n ? `另有 ${n} 条已结算的历史子代理(正文可用 ids 取回)。` : "";
|
|
1529
|
+
};
|
|
1141
1530
|
let mine = owned();
|
|
1142
1531
|
// Whether we got to LOOK at the host's tree, and what it said — the empty
|
|
1143
1532
|
// answer must not claim "there is nothing to adopt" on a host we cannot
|
|
@@ -1153,6 +1542,24 @@ export function buildDispatchTools(deps) {
|
|
|
1153
1542
|
adoption = await adoptFromHost(parent, directory, idFilter);
|
|
1154
1543
|
if (adoption.adopted)
|
|
1155
1544
|
mine = owned();
|
|
1545
|
+
// Second recovery source, for the host that has no `session.children` at all
|
|
1546
|
+
// (measured 2.1.4): the parent transcript still names its children. Only asked
|
|
1547
|
+
// when the tree walk did not settle the question — never instead of it.
|
|
1548
|
+
if (mine.length < (idFilter ? idFilter.size : 1)) {
|
|
1549
|
+
const hist = parentHistory.ok
|
|
1550
|
+
? await adoptFromParentHistory(parent, parentHistory.data, directory, idFilter)
|
|
1551
|
+
: { adopted: 0, looked: false, candidates: 0, skipped: 0, settled: 0, running: 0, why: parentHistory.why };
|
|
1552
|
+
if (hist.adopted)
|
|
1553
|
+
mine = owned();
|
|
1554
|
+
// The `why` has to keep the three states apart: a children API that looked, no
|
|
1555
|
+
// children API but the history claimed something, and neither source yielding a
|
|
1556
|
+
// child. Fusing them is the overstatement this path was built to stop.
|
|
1557
|
+
adoption = {
|
|
1558
|
+
adopted: adoption.adopted + hist.adopted,
|
|
1559
|
+
looked: adoption.looked,
|
|
1560
|
+
why: [adoption.why, hist.why].filter(Boolean).join(";"),
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1156
1563
|
// Plan B: a NAMED id may be the host's own `task` child (no ·tm
|
|
1157
1564
|
// marker, so the tree walk above never claims it). Collecting it is
|
|
1158
1565
|
// how the lead reads back a result we replaced with a pointer — the
|
|
@@ -1216,7 +1623,7 @@ export function buildDispatchTools(deps) {
|
|
|
1216
1623
|
}
|
|
1217
1624
|
const row = children.get(target);
|
|
1218
1625
|
const rowLine = row
|
|
1219
|
-
? renderChildLine(row, (row
|
|
1626
|
+
? renderChildLine(row, elapsedOf(row, now()))
|
|
1220
1627
|
: `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
|
|
1221
1628
|
if (steerText) {
|
|
1222
1629
|
// A steer into a session that is not running is a WAKE, not an interjection:
|
|
@@ -1409,7 +1816,15 @@ export function buildDispatchTools(deps) {
|
|
|
1409
1816
|
// the transcript, and the honest reason is that a synchronous host
|
|
1410
1817
|
// task never enters this registry at all.
|
|
1411
1818
|
`如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
|
|
1412
|
-
`只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。`
|
|
1819
|
+
`只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
|
|
1820
|
+
// A round whose leftovers are ALL settled is still not empty: they are history,
|
|
1821
|
+
// and the lead must know they exist (and how to read them) without the whole
|
|
1822
|
+
// table being dumped again.
|
|
1823
|
+
(historyLine() ? `\n\n${historyLine()}` : "") +
|
|
1824
|
+
// A round with no collectable CHILD is still not an empty round when the host
|
|
1825
|
+
// has shell commands in flight: the user can see them and the lead cannot, so
|
|
1826
|
+
// silence here is what produced a duplicate dispatch.
|
|
1827
|
+
(shellSection ? `\n\n${shellSection}` : ""));
|
|
1413
1828
|
}
|
|
1414
1829
|
await settleAdopted(mine, directory);
|
|
1415
1830
|
const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
|
|
@@ -1530,7 +1945,10 @@ export function buildDispatchTools(deps) {
|
|
|
1530
1945
|
const header = [
|
|
1531
1946
|
`派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
|
|
1532
1947
|
...(settleLine ? [settleLine] : []),
|
|
1533
|
-
|
|
1948
|
+
// Just under the summary: the settled HISTORY rows, as one count. Their bodies are
|
|
1949
|
+
// still reachable with `ids`, which is exactly what the line promises.
|
|
1950
|
+
...(historyLine() ? [historyLine()] : []),
|
|
1951
|
+
...mine.map((r) => renderChildLine(r, elapsedOf(r, now()))),
|
|
1534
1952
|
];
|
|
1535
1953
|
// The `正文:` line belongs directly under the child rows it describes, but the
|
|
1536
1954
|
// body's completeness is only knowable once the bodies have been read — remember
|
|
@@ -1691,7 +2109,7 @@ export function buildDispatchTools(deps) {
|
|
|
1691
2109
|
// when bodies were actually read (`includeText:false` has no body to judge).
|
|
1692
2110
|
if (bodyStatus.length)
|
|
1693
2111
|
header.splice(childRowsEnd, 0, ...bodyStatus);
|
|
1694
|
-
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
|
|
2112
|
+
const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : []), ...(shellSection ? ["", shellSection] : [])].join("\n");
|
|
1695
2113
|
const stepId = deps.pipelines.nextStepId();
|
|
1696
2114
|
return toToolResult(await deps.pipelines.govern(stepId, tool, body, {
|
|
1697
2115
|
contentType: detectContentType(body),
|
|
@@ -1734,7 +2152,10 @@ export function buildDispatchTools(deps) {
|
|
|
1734
2152
|
child: child.sessionID,
|
|
1735
2153
|
agent: child.agent,
|
|
1736
2154
|
...(st.outcome ? { outcome: st.outcome } : {}),
|
|
1737
|
-
|
|
2155
|
+
...(() => {
|
|
2156
|
+
const ms = elapsedOf(child, st.idleAt);
|
|
2157
|
+
return ms === null ? {} : { ms };
|
|
2158
|
+
})(),
|
|
1738
2159
|
});
|
|
1739
2160
|
continue;
|
|
1740
2161
|
}
|
|
@@ -1761,7 +2182,8 @@ export function buildDispatchTools(deps) {
|
|
|
1761
2182
|
child.state = "idle";
|
|
1762
2183
|
child.finishedAt = now();
|
|
1763
2184
|
child.settleSource = "parent-idle";
|
|
1764
|
-
|
|
2185
|
+
const ms = elapsedOf(child);
|
|
2186
|
+
log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ...(ms === null ? {} : { ms }) });
|
|
1765
2187
|
}
|
|
1766
2188
|
}
|
|
1767
2189
|
const observeEvent = (event) => {
|
|
@@ -1811,7 +2233,8 @@ export function buildDispatchTools(deps) {
|
|
|
1811
2233
|
rec.state = "idle";
|
|
1812
2234
|
rec.finishedAt = now();
|
|
1813
2235
|
rec.settleSource = "event";
|
|
1814
|
-
|
|
2236
|
+
const ms = elapsedOf(rec);
|
|
2237
|
+
log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ...(ms === null ? {} : { ms }) });
|
|
1815
2238
|
}
|
|
1816
2239
|
return;
|
|
1817
2240
|
}
|
|
@@ -1870,7 +2293,8 @@ export function buildDispatchTools(deps) {
|
|
|
1870
2293
|
rec.state = "idle";
|
|
1871
2294
|
}
|
|
1872
2295
|
rec.settleSource = "injection";
|
|
1873
|
-
|
|
2296
|
+
const ms = elapsedOf(rec);
|
|
2297
|
+
log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ...(ms === null ? {} : { ms }) });
|
|
1874
2298
|
return true;
|
|
1875
2299
|
}
|
|
1876
2300
|
return {
|