pi-durable-subagents 1.0.16 → 1.0.18

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.
@@ -7,12 +7,13 @@
7
7
  // session-corrupt{call,line}: a malformed native session line was skipped (once per line, E4).
8
8
  // fence-failed{exec,error} is documented in sweep.ts. The orchestrator ledger owns hold/release{pool,slot,exec}
9
9
  // and mem{available,admitted,exec} (every admission; a repeated refusal at most every 30 s per call). All transitions are serialized before publication.
10
- import { mkdir, open, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
10
+ import { mkdir, open, readdir, readFile, realpath, rm, unlink, writeFile } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
- import { join } from "node:path";
12
+ import { delimiter, join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { CT, JT, attentionEntries, isEntry } from "../../types.js";
15
- import { callDir, callInbox, callSession, outboxRoot } from "../../paths.js";
15
+ import { binDir, callDir, callInbox, callSession, journalPath, outboxRoot } from "../../paths.js";
16
+ import { readJournalSnapshot } from "../../kernel/journal.js";
16
17
  import { Outbox } from "../../kernel/mailbox.js";
17
18
  import { contentHash, forwardRid } from "../../kernel/ids.js";
18
19
  import { capacity, seal } from "../../kernel/guards.js";
@@ -25,10 +26,11 @@ import { hibernation, openQuestion } from "./hibernate.js";
25
26
  import { emptyLedger, foldLedger, holdings as holdingsOf, settingsOf } from "../ledger.js";
26
27
  import { evidence, fatalProviderError, quotaExhausted, refusedByProvider, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
27
28
  import { activeTotal } from "./time.js";
29
+ import { entriesOf, trackedState, usageIds } from "./indexes.js";
28
30
  import { observeExecution } from "./observe.js";
29
31
  import { availableMemory } from "./memory.js";
30
32
  import { reached, sessionUsage, totalUsage } from "./usage.js";
31
- import { recordFenceFailure, resolveFenceAttention, serialContainment, skipLostCandidate, sweepExecutions } from "./sweep.js";
33
+ import { indexSweep, recordFenceFailure, resolveFenceAttention, serialContainment, skipLostCandidate, sweepExecutions } from "./sweep.js";
32
34
  import { gateRetired } from "./effects/gate.js";
33
35
  import { WorktreeIndex, worktreeCalls, worktreeLabel, worktreePair, worktreeRoots } from "./worktree.js";
34
36
  const MEM_RECORD_MS = 30000;
@@ -44,7 +46,7 @@ const extension = fileURLToPath(new URL(`../../agent/extension.${import.meta.url
44
46
  const entriesFor = (journal, call) => journal.entries().filter(e => e.call === call);
45
47
  const current = (journal, call) => entriesFor(journal, call).findLast(e => e.type === JT.exec)?.exec;
46
48
  const sealed = (journal, call) => entriesFor(journal, call).find(e => e.type === JT.sealed)?.result;
47
- const has = (journal, type, exec) => journal.entries().some(e => e.type === type && e.exec === exec);
49
+ const has = (journal, type, exec) => entriesOf(journal, type, exec).length > 0;
48
50
  /** The rid of the model request a follow-up naming a model makes (P12): derived, so withdrawing or replacing the
49
51
  * follow-up withdraws its model request too. */
50
52
  export const modelRid = (rid) => contentHash([rid, "model"]);
@@ -147,17 +149,19 @@ export default function createExecutor(ledgers, options = {}) {
147
149
  wake();
148
150
  }
149
151
  function tracked(journal, exec) {
150
- return journal.entries().filter(e => e.type === "tracked" && e.exec === exec).map(e => ({ pid: Number(e.pid), ppid: 0, start: String(e.start) }));
152
+ return trackedState(journal, exec).rows;
151
153
  }
154
+ /** Persist every newly seen identity, tagged or not: a process that later clears its tag and leaves the tree must
155
+ * still be fenced after an orchestrator restart. */
152
156
  async function track(journal, exec, found) {
153
157
  const known = tracked(journal, exec);
154
- found ??= (await containment.scan(new Map([[exec, known]]))).get(exec) ?? [];
158
+ found ??= (await containment.scan(new Map([[exec, known]]), { maxAgeMs: config.k?.trackerMs ?? 1000 })).get(exec) ?? [];
155
159
  await serial(async () => {
156
- const ids = new Set(tracked(journal, exec).map(p => `${p.pid}:${p.start}`));
160
+ const { ids } = trackedState(journal, exec);
157
161
  for (const p of found)
158
162
  if (p.start && !ids.has(`${p.pid}:${p.start}`)) {
159
163
  await journal.append("tracked", { exec, pid: p.pid, start: p.start });
160
- ids.add(`${p.pid}:${p.start}`);
164
+ trackedState(journal, exec);
161
165
  }
162
166
  });
163
167
  return found;
@@ -339,18 +343,15 @@ export default function createExecutor(ledgers, options = {}) {
339
343
  }
340
344
  async function recordUsage(t, values) {
341
345
  await serial(async () => {
342
- const recorded = new Set(t.journal.entries().filter(e => e.type === "usage" && e.call === t.callId).map(e => e.id));
343
346
  for (const u of values)
344
- if (!recorded.has(u.id)) {
347
+ if (!usageIds(t.journal, t.callId).has(u.id))
345
348
  await t.journal.append("usage", { call: t.callId, ...u });
346
- recorded.add(u.id);
347
- }
348
349
  await workflowReached(t);
349
350
  });
350
351
  }
351
352
  async function workflowReached(t) {
352
- const hit = reached(totalUsage(t.journal.entries()), t.workflowBudget), id = `budget:${t.wid}`;
353
- if (hit && !t.journal.entries().some(e => isEntry(e, JT.attention) && e.item.id === id))
353
+ const hit = reached(totalUsage(entriesOf(t.journal, "usage")), t.workflowBudget), id = `budget:${t.wid}`;
354
+ if (hit && !entriesOf(t.journal, JT.attention).some(e => isEntry(e, JT.attention) && e.item.id === id))
354
355
  await t.journal.append(JT.attention, { item: { id, rev: 1, kind: "budget", text: "Workflow budget reached", wid: t.wid } });
355
356
  return hit;
356
357
  }
@@ -436,7 +437,9 @@ export default function createExecutor(ledgers, options = {}) {
436
437
  const timer = setInterval(() => { checking = checking.then(check); }, config.k?.trackerMs ?? 1000);
437
438
  try {
438
439
  await check();
439
- result = await effects.beforeSeal(a.ticket, exec, result, { signal: a.controller.signal });
440
+ await launchGate(a, exec, "gate");
441
+ if (!interrupted(a))
442
+ result = await effects.beforeSeal(a.ticket, exec, result, { signal: a.controller.signal });
440
443
  await check();
441
444
  }
442
445
  catch (error) {
@@ -445,6 +448,7 @@ export default function createExecutor(ledgers, options = {}) {
445
448
  finally {
446
449
  clearInterval(timer);
447
450
  await checking;
451
+ a.live = undefined;
448
452
  }
449
453
  }
450
454
  const value = await serial(async () => {
@@ -481,9 +485,114 @@ export default function createExecutor(ledgers, options = {}) {
481
485
  }
482
486
  const stopped = (a) => a.stopped || entriesFor(a.ticket.journal, a.ticket.callId).some(e => e.type === "stop-intent");
483
487
  const interrupted = (a) => closed || a.suspended || a.retired || stopped(a);
488
+ // Restart (RestartBody): while one is decided no execution passes this gate, so the executions it finds live are all
489
+ // there are; one that passes is live until its fence (or the end of its gates). Synchronous from the check to the mark.
490
+ let paused = false;
491
+ const unpaused = new Set();
492
+ async function launchGate(a, exec, phase) {
493
+ while (paused && !interrupted(a)) {
494
+ await new Promise(resolve => {
495
+ const timer = setTimeout(done, config.k?.trackerMs ?? 1000);
496
+ function done() { clearTimeout(timer); unpaused.delete(done); resolve(); }
497
+ unpaused.add(done);
498
+ a.wake = done;
499
+ });
500
+ }
501
+ if (!interrupted(a))
502
+ a.live = { exec, since: Date.now(), phase };
503
+ }
504
+ // Writer lock: one call that writes in a worktree runs there at a time. Ownership is durable (writer-hold/-release in
505
+ // the orchestrator ledger) and lasts from the first admission to the call's seal or retirement, across executions,
506
+ // hibernation and orchestrator restarts. Waiters are admitted in the order they first waited.
507
+ const writerWaits = new Map();
508
+ /** A waiter recovered from the journal keeps its place this long for its call to come back after a restart. */
509
+ const RECOVERED_WAIT_MS = 60_000;
510
+ async function writerRoot(t, cwd) {
511
+ if (t.spec.isolation === "worktree")
512
+ return;
513
+ const tools = t.spec.tools ?? t.agent.tools;
514
+ if (!(t.spec.writer ?? (!tools || tools.some(n => n === "edit" || n === "write"))))
515
+ return;
516
+ return await roots(cwd, "x") ?? await realpath(cwd).catch(() => cwd);
517
+ }
518
+ /** Whether every execution of `call` was fenced: until then its processes may still write. */
519
+ function writerFenced(entries, call) {
520
+ const fenced = new Set(entries.filter(e => e.type === JT.fenced).map(e => String(e.exec)));
521
+ return entries.every(e => e.type !== JT.exec || callOf(String(e.exec)) !== call || fenced.has(String(e.exec)));
522
+ }
523
+ /** An owner whose call ended (sealed, retired, revised away, its workflow done or pruned) and whose executions were
524
+ * all fenced no longer holds the lock. */
525
+ function writerEnded(call) {
526
+ if (active.has(call))
527
+ return false;
528
+ const { wid } = address(call), rev = Number(/^.*@(\d+)\//.exec(call)?.[1]);
529
+ const journal = journals.get(wid), entries = journal && !journal.closed ? journal.entries() : readJournalSnapshot(journalPath(home, wid));
530
+ if (!entries.length)
531
+ return true;
532
+ if (!writerFenced(entries, call))
533
+ return false;
534
+ const revised = entries.findLastIndex(e => e.type === "revised");
535
+ if (revised >= 0 && Number(entries[revised].revision) > rev)
536
+ return true;
537
+ return entries.some((e, i) => (e.type === JT.sealed || e.type === "retired") && e.call === call || i > revised && e.type === JT.done);
538
+ }
539
+ /** Serial sections only: whether `a` may write in `root` now; records the wait (durably, for status) when not. */
540
+ async function writerAdmit(a, root) {
541
+ if ((settings().writerLock ?? "queue") === "off")
542
+ return true;
543
+ const t = a.ticket, call = t.callId;
544
+ let owner = folded().writers.get(root)?.call;
545
+ if (owner && owner !== call && writerEnded(owner)) {
546
+ await orch.append("writer-release", { root, call: owner, ended: true });
547
+ owner = undefined;
548
+ }
549
+ if (owner === call)
550
+ return true;
551
+ const mine = writerWaits.get(call) ?? { root, since: t.journal.entries().find(e => e.type === "writer-wait" && e.call === call)?.ts ?? Date.now() };
552
+ writerWaits.set(call, mine);
553
+ delete mine.recovered;
554
+ const waiting = (c, w) => active.has(c) ? !interrupted(active.get(c))
555
+ : w.recovered !== undefined && Date.now() - w.recovered < RECOVERED_WAIT_MS && !writerEnded(c);
556
+ const earlier = [...writerWaits].filter(([c, w]) => c !== call && w.root === root && (w.since < mine.since || w.since === mine.since && c < call) && waiting(c, w)).map(([c]) => c);
557
+ const holder = owner ?? earlier[0];
558
+ if (!holder) {
559
+ await orch.append("writer-hold", { root, call });
560
+ writerWaits.delete(call);
561
+ const waited = t.journal.entries().findLast(e => (e.type === "writer-wait" || e.type === "writer-acquired") && e.call === call);
562
+ if (waited?.type === "writer-wait") {
563
+ await t.journal.append("writer-acquired", { call, root });
564
+ const item = attentionEntries(t.journal.entries()).find(x => x.item.id === `writer:${call}`)?.item;
565
+ if (item && !t.journal.entries().some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
566
+ await t.journal.append(JT.attentionResolved, { id: item.id, rev: item.rev, resolution: "acquired" });
567
+ }
568
+ return true;
569
+ }
570
+ const last = t.journal.entries().findLast(e => (e.type === "writer-wait" || e.type === "writer-acquired") && e.call === call);
571
+ if (mine.holder !== holder && !(last?.type === "writer-wait" && last.holder === holder && last.root === root)) {
572
+ await t.journal.append("writer-wait", { call, root, holder, ...(owner ? {} : { queued: true }) });
573
+ }
574
+ mine.holder = holder;
575
+ const id = `writer:${call}`;
576
+ if (!t.journal.entries().some(e => isEntry(e, JT.attention) && e.item.id === id)) {
577
+ const h = address(holder);
578
+ const text = `${worktreeLabel(call)} waits for the writer lock of ${root}: ${h.wid}/${h.key} ${owner ? "holds it" : "waits for it first"}. ` +
579
+ `It starts when that call ends. To run it now: stop one of them, run it with writer:false (when it does not write there) or isolation:"worktree"`;
580
+ await t.journal.append(JT.attention, { item: { id, rev: 1, kind: "conflict", text, wid: t.wid, call } });
581
+ }
582
+ return false;
583
+ }
584
+ async function writerRelease(call) {
585
+ await serial(async () => {
586
+ writerWaits.delete(call);
587
+ for (const [root, owner] of folded().writers)
588
+ if (owner.call === call)
589
+ await orch.append("writer-release", { root, call });
590
+ });
591
+ wake();
592
+ }
484
593
  /** Wait for a slot. The candidates are decided again on every attempt, inside the serial section that also applies
485
594
  * config.json changes: a queued call follows a reloaded defaultModel, pool or limit, never a mix of two versions. */
486
- async function acquire(a, exec, decide) {
595
+ async function acquire(a, exec, decide, writer) {
487
596
  let continuation = false;
488
597
  for (;;) {
489
598
  let signal;
@@ -491,6 +600,8 @@ export default function createExecutor(ledgers, options = {}) {
491
600
  const chosen = await serial(async () => {
492
601
  if (interrupted(a) || await workflowReached(a.ticket))
493
602
  return;
603
+ if (writer && !await writerAdmit(a, writer))
604
+ return;
494
605
  const decision = await decide(), models = decision.candidates, pool = decision.pool;
495
606
  continuation = decision.continuation;
496
607
  for (const model of models) {
@@ -737,7 +848,7 @@ export default function createExecutor(ledgers, options = {}) {
737
848
  const t = a.ticket, journal = t.journal;
738
849
  const session = callSession(home, t.wid, t.key, t.gen), dir = callDir(home, t.wid, t.key, t.gen);
739
850
  const makeResult = (status, output = "", error) => buildCallResult({ key: t.key, gen: t.gen, status, output, ...(error ? { error } : {}) });
740
- let cwd = t.cwd;
851
+ let cwd = t.cwd, writer;
741
852
  if (sealed(journal, t.callId)) {
742
853
  const result = sealed(journal, t.callId);
743
854
  await effects.afterSeal(t, result);
@@ -840,7 +951,7 @@ export default function createExecutor(ledgers, options = {}) {
840
951
  }
841
952
  let decision;
842
953
  try {
843
- decision = await acquire(a, exec, () => launchModel(t, entries, previous));
954
+ decision = await acquire(a, exec, () => launchModel(t, entries, previous), writer ??= await writerRoot(t, cwd));
844
955
  }
845
956
  catch (error) {
846
957
  await fence(journal, exec, { park: a });
@@ -883,6 +994,7 @@ export default function createExecutor(ledgers, options = {}) {
883
994
  await sender.send(t.callId, "continue", { message: continueMessage(dangling) }, undefined, { rid: contentHash([exec, "dispatch"]) });
884
995
  else
885
996
  await sender.send(t.callId, "task", { message: t.opening?.message ?? t.spec.task }, undefined, { rid: contentHash([exec, "dispatch"]) });
997
+ await launchGate(a, exec, "child");
886
998
  if (interrupted(a)) {
887
999
  await fence(journal, exec, { park: a });
888
1000
  return finish(journal, t.callId, exec, makeResult("stopped"));
@@ -890,10 +1002,11 @@ export default function createExecutor(ledgers, options = {}) {
890
1002
  let child;
891
1003
  try {
892
1004
  child = await containment.spawn({ exec, command: "pi", args: [...buildPiArgs(t.agent, t.spec, { sessionPath: session, systemPromptPath: prompt, continuation: decision.continuation, controlTools: t.spec.schema === undefined ? ["ask"] : ["ask", "report"], ...(model.id ? { model } : {}) }), "-e", extension], cwd,
893
- env: { DSA_HOME: home, DSA_EXEC: exec, DSA_CALL: t.callId, DSA_INBOX: inbox(t.callId), DSA_JOURNAL: journal.path, ...(t.spec.schema !== undefined ? { DSA_SCHEMA: schema } : {}), ...(t.spec.budget ? { DSA_BUDGET: JSON.stringify(t.spec.budget) } : {}), ...(model.provider && model.id ? { DSA_MODEL: `${model.provider}/${model.id}` } : {}) } });
1005
+ env: { DSA_HOME: home, PATH: [binDir(home), process.env.PATH].filter(Boolean).join(delimiter), DSA_EXEC: exec, DSA_CALL: t.callId, DSA_INBOX: inbox(t.callId), DSA_JOURNAL: journal.path, ...(t.spec.schema !== undefined ? { DSA_SCHEMA: schema } : {}), ...(t.spec.budget ? { DSA_BUDGET: JSON.stringify(t.spec.budget) } : {}), ...(model.provider && model.id ? { DSA_MODEL: `${model.provider}/${model.id}` } : {}) } });
894
1006
  }
895
1007
  catch (error) {
896
1008
  await fence(journal, exec, { park: a });
1009
+ a.live = undefined;
897
1010
  return finish(journal, t.callId, exec, makeResult("failed", "", `Spawn failed: ${String(error)}`));
898
1011
  }
899
1012
  try {
@@ -921,6 +1034,7 @@ export default function createExecutor(ledgers, options = {}) {
921
1034
  }
922
1035
  finally {
923
1036
  await fence(journal, exec, { child, park: a });
1037
+ a.live = undefined;
924
1038
  }
925
1039
  }
926
1040
  }
@@ -954,8 +1068,14 @@ export default function createExecutor(ledgers, options = {}) {
954
1068
  try {
955
1069
  if (exec && await fence(ticket.journal, exec))
956
1070
  await release(exec);
1071
+ // The writer lock outlives executions and restarts; it ends with the call (or its retirement).
1072
+ // Not before every execution is fenced: a later sweep proves it, and the next writer's admission releases it.
1073
+ if ((sealed(ticket.journal, ticket.callId) || a.retired || ticket.journal.entries().some(e => e.type === "retired" && e.call === ticket.callId))
1074
+ && writerFenced(ticket.journal.entries(), ticket.callId))
1075
+ await writerRelease(ticket.callId);
957
1076
  }
958
1077
  finally {
1078
+ writerWaits.delete(ticket.callId);
959
1079
  active.delete(ticket.callId);
960
1080
  collected.delete(ticket.callId);
961
1081
  for (const e of inUse.keys())
@@ -1150,6 +1270,7 @@ export default function createExecutor(ledgers, options = {}) {
1150
1270
  },
1151
1271
  async recover(wid, journal) {
1152
1272
  journals.set(wid, journal);
1273
+ indexSweep(journal);
1153
1274
  await serial(() => resolveWorktrees());
1154
1275
  await effects.recover(journal);
1155
1276
  for (const e of journal.entries().filter(e => e.type === JT.exec)) {
@@ -1158,6 +1279,17 @@ export default function createExecutor(ledgers, options = {}) {
1158
1279
  if (await fence(journal, exec))
1159
1280
  await release(exec);
1160
1281
  }
1282
+ // Writers that waited before the restart keep their order while their calls come back.
1283
+ for (const e of journal.entries()) {
1284
+ const call = String(e.call);
1285
+ if (e.type === "writer-wait" && !writerWaits.has(call))
1286
+ writerWaits.set(call, { root: String(e.root), since: e.ts, recovered: Date.now() });
1287
+ else if (e.type === "writer-acquired" || e.type === JT.sealed || e.type === "retired") {
1288
+ const w = writerWaits.get(call);
1289
+ if (w?.recovered !== undefined)
1290
+ writerWaits.delete(call);
1291
+ }
1292
+ }
1161
1293
  const calls = new Set(journal.entries().filter(e => e.type === JT.exec).map(e => String(e.call)));
1162
1294
  for (const call of calls) {
1163
1295
  const session = await readCall(journal, call), values = sessionUsage(session, call);
@@ -1214,6 +1346,12 @@ export default function createExecutor(ledgers, options = {}) {
1214
1346
  }
1215
1347
  },
1216
1348
  busy: () => active.size > 0,
1349
+ quiesce() {
1350
+ paused = true;
1351
+ const live = [...active.values()].filter(a => a.live).map(a => ({ wid: a.ticket.wid, key: a.ticket.key, gen: a.ticket.gen, callId: a.ticket.callId, ...a.live }));
1352
+ return { live, resume: () => { paused = false; for (const fn of [...unpaused])
1353
+ fn(); } };
1354
+ },
1217
1355
  suspend(only) {
1218
1356
  if (only) { // scoped (one session's quit, one workflow): other workflows keep running and dispatching
1219
1357
  const targets = [...active.values()].filter(a => only(a.ticket.wid));
@@ -0,0 +1,63 @@
1
+ const indexes = new WeakMap();
2
+ export function entriesOf(journal, type, exec) {
3
+ let index = indexes.get(journal);
4
+ if (!index) {
5
+ index = { count: 0, types: new Map(), execs: new Map() };
6
+ indexes.set(journal, index);
7
+ }
8
+ const entries = journal.entries();
9
+ while (index.count < entries.length) {
10
+ const e = entries[index.count++], bucket = index.types.get(e.type) ?? [];
11
+ if (!index.types.has(e.type))
12
+ index.types.set(e.type, bucket);
13
+ bucket.push(e);
14
+ if (typeof e.exec === "string") {
15
+ const byExec = index.execs.get(e.type) ?? new Map();
16
+ if (!index.execs.has(e.type))
17
+ index.execs.set(e.type, byExec);
18
+ const owned = byExec.get(e.exec) ?? [];
19
+ if (!byExec.has(e.exec))
20
+ byExec.set(e.exec, owned);
21
+ owned.push(e);
22
+ }
23
+ }
24
+ return (exec === undefined ? index.types.get(type) : index.execs.get(type)?.get(exec)) ?? [];
25
+ }
26
+ const identities = new WeakMap();
27
+ export function trackedState(journal, exec) {
28
+ let byExec = identities.get(journal);
29
+ if (!byExec) {
30
+ byExec = new Map();
31
+ identities.set(journal, byExec);
32
+ }
33
+ let state = byExec.get(exec);
34
+ if (!state) {
35
+ state = { count: 0, rows: [], ids: new Set() };
36
+ byExec.set(exec, state);
37
+ }
38
+ const entries = entriesOf(journal, "tracked", exec);
39
+ while (state.count < entries.length) {
40
+ const e = entries[state.count++], pid = Number(e.pid), start = String(e.start), id = `${pid}:${start}`;
41
+ if (!state.ids.has(id)) {
42
+ state.ids.add(id);
43
+ state.rows.push({ pid, ppid: 0, start });
44
+ }
45
+ }
46
+ return state;
47
+ }
48
+ const usage = new WeakMap();
49
+ export function usageIds(journal, call) {
50
+ let state = usage.get(journal);
51
+ if (!state) {
52
+ state = { count: 0, calls: new Map() };
53
+ usage.set(journal, state);
54
+ }
55
+ const entries = entriesOf(journal, "usage");
56
+ while (state.count < entries.length) {
57
+ const e = entries[state.count++], key = String(e.call), ids = state.calls.get(key) ?? new Set();
58
+ if (!state.calls.has(key))
59
+ state.calls.set(key, ids);
60
+ ids.add(String(e.id));
61
+ }
62
+ return state.calls.get(call) ?? new Set();
63
+ }
@@ -9,8 +9,10 @@ import { JT, attentionEntries } from "../../types.js";
9
9
  import { callDir, callSession } from "../../paths.js";
10
10
  import { monotoneTime } from "../../kernel/guards.js";
11
11
  import { readSession } from "./session.js";
12
+ import { entriesOf } from "./indexes.js";
12
13
  import { ActiveTime, activeTotal } from "./time.js";
13
14
  import { observation, reached, sessionUsage, totalUsage } from "./usage.js";
15
+ export const sessionChanged = (name) => name === null || String(name) === "session.jsonl" || String(name) === "inbox";
14
16
  /** A tool call's command for a status line: bash's command, else its arguments, on one line and clipped. */
15
17
  export function toolCommand(args) {
16
18
  const a = args && typeof args === "object" ? args : {};
@@ -23,6 +25,8 @@ export async function observeExecution(d) {
23
25
  const { home, config, ticket: t, exec, child, serial } = d;
24
26
  const session = callSession(home, t.wid, t.key, t.gen), clock = new ActiveTime(), started = clock.last;
25
27
  let progress = started, providerError;
28
+ // Diagnostic metadata only: checkpoint the latest received stream update, never its content.
29
+ let lastStream;
26
30
  const tools = new Set();
27
31
  // The tool calls running now, for the stall text: a silent long command and a stuck call read differently.
28
32
  const running = new Map();
@@ -31,14 +35,14 @@ export async function observeExecution(d) {
31
35
  let signal;
32
36
  const boundary = new Promise(resolve => { signal = resolve; d.setWake(resolve); });
33
37
  let failure, pending = Promise.resolve(), ending = false;
34
- const has = (type) => t.journal.entries().some(e => e.type === type && e.exec === exec);
38
+ const has = (type) => entriesOf(t.journal, type, exec).length > 0;
35
39
  const enqueue = (fn) => { if (!ending)
36
40
  pending = pending.then(fn).catch(error => { failure = error; signal(); }); };
37
41
  const saveTime = () => serial(async () => {
38
- const previous = t.journal.entries().findLast(e => e.type === "time" && e.exec === exec);
42
+ const previous = entriesOf(t.journal, "time", exec).at(-1);
39
43
  if (!monotoneTime({ active: Number(previous?.active ?? 0) }, { active: clock.active }))
40
44
  throw new Error("Nonmonotone active time");
41
- await t.journal.append("time", { exec, active: clock.active });
45
+ await t.journal.append("time", { exec, active: clock.active, ...(lastStream ? { lastStream } : {}) });
42
46
  });
43
47
  const limits = async () => {
44
48
  if (t.spec.timeoutMs !== undefined && prior + clock.active >= t.spec.timeoutMs) {
@@ -46,18 +50,23 @@ export async function observeExecution(d) {
46
50
  await t.journal.append("timeout-intent", { exec, call: t.callId }); });
47
51
  signal();
48
52
  }
49
- if (reached(totalUsage(t.journal.entries(), t.callId), t.spec.budget))
53
+ if (reached(totalUsage(entriesOf(t.journal, "usage"), t.callId), t.spec.budget))
50
54
  signal();
51
55
  };
56
+ const openAlert = (id) => {
57
+ const last = attentionEntries(entriesOf(t.journal, JT.attention), id).at(-1)?.item;
58
+ return last && !entriesOf(t.journal, JT.attentionResolved).some(e => e.id === id && e.rev === last.rev);
59
+ };
60
+ const progressDue = () => !clock.asking && !tools.size && performance.now() - progress >= (config.k?.progressMs ?? 600000);
52
61
  const stall = () => serial(async () => {
53
62
  const id = `stall:${t.callId}`;
54
- const items = attentionEntries(t.journal.entries(), id), last = items.at(-1)?.item;
55
- const open = last && !t.journal.entries().some(e => e.type === JT.attentionResolved && e.id === id && e.rev === last.rev);
63
+ const items = attentionEntries(entriesOf(t.journal, JT.attention), id), last = items.at(-1)?.item;
64
+ const open = last && !entriesOf(t.journal, JT.attentionResolved).some(e => e.id === id && e.rev === last.rev);
56
65
  const fresh = items.at(-1)?.exec === exec ? clock.last > Number(items.at(-1)?.horizon) : clock.last > started;
57
66
  if (open && fresh)
58
67
  await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "activity" });
59
- else if (!open && !clock.asking && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
60
- await t.journal.append(JT.attention, { exec, horizon: clock.last, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: `${t.wid}/${t.key}: no execution activity for ${Math.floor((performance.now() - clock.last) / 60000)}m` + runningText(), wid: t.wid, call: t.callId } });
68
+ else if (!open && !clock.asking && !progressDue() && !openAlert(`noprogress:${t.callId}`) && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
69
+ await t.journal.append(JT.attention, { exec, horizon: clock.last, ...(lastStream ? { lastStream } : {}), item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: `${t.wid}/${t.key}: no execution activity observed for ${Math.floor((performance.now() - clock.last) / 60000)}m` + runningText(), wid: t.wid, call: t.callId } });
61
70
  });
62
71
  /** "; running bash `make matrix` for 14m (no output or CPU use seen)": a silent long command, not a stuck model. */
63
72
  const runningText = () => {
@@ -68,15 +77,15 @@ export async function observeExecution(d) {
68
77
  };
69
78
  const noProgress = () => serial(async () => {
70
79
  const id = `noprogress:${t.callId}`;
71
- const items = attentionEntries(t.journal.entries(), id), last = items.at(-1)?.item;
72
- const open = last && !t.journal.entries().some(e => e.type === JT.attentionResolved && e.id === id && e.rev === last.rev);
80
+ const items = attentionEntries(entriesOf(t.journal, JT.attention), id), last = items.at(-1)?.item;
81
+ const open = last && !entriesOf(t.journal, JT.attentionResolved).some(e => e.id === id && e.rev === last.rev);
73
82
  const fresh = items.at(-1)?.exec === exec ? progress > Number(items.at(-1)?.horizon) : progress > started;
74
83
  if (open && fresh)
75
84
  await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "progress" });
76
- else if (!open && !clock.asking && !tools.size && performance.now() - progress >= (config.k?.progressMs ?? 600000)) {
77
- const text = `${t.wid}/${t.key}: running but no progress for ${Math.floor((performance.now() - progress) / 60000)}m (no output tokens or tool results)` +
85
+ else if (!open && progressDue() && !openAlert(`stall:${t.callId}`)) {
86
+ const text = `${t.wid}/${t.key}: no new output or tool progress received for ${Math.floor((performance.now() - progress) / 60000)}m; the model may still be processing` +
78
87
  (providerError ? `; last provider error: ${providerError.slice(0, 200)}` : "");
79
- await t.journal.append(JT.attention, { exec, horizon: progress, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text, wid: t.wid, call: t.callId } });
88
+ await t.journal.append(JT.attention, { exec, horizon: progress, ...(lastStream ? { lastStream } : {}), item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text, wid: t.wid, call: t.callId } });
80
89
  }
81
90
  });
82
91
  child.stdin.on("error", () => { });
@@ -105,6 +114,10 @@ export async function observeExecution(d) {
105
114
  // P18: Apply RPC boundaries at receipt, before any in-flight scan can resume.
106
115
  // Durable observations remain queued; clock transitions never wait on I/O.
107
116
  clock.event(event, performance.now());
117
+ if (event.type === "message_update") {
118
+ const type = event.assistantMessageEvent?.type;
119
+ lastStream = { type: typeof type === "string" && /^(thinking|text|toolcall)_(start|delta|end)$/.test(type) ? type : "message_update", receivedAt: Date.now() };
120
+ }
108
121
  const message = event.message;
109
122
  const error = event.type === "auto_retry_start" ? event.errorMessage : event.type === "auto_retry_end" ? event.finalError :
110
123
  event.type === "message_end" && message?.stopReason === "error" ? message.errorMessage : undefined;
@@ -122,6 +135,9 @@ export async function observeExecution(d) {
122
135
  if (event.type === "message_update" || ["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type)) ||
123
136
  event.type === "message_end" && message?.stopReason !== "error" && (message?.usage?.output ?? 0) > 0)
124
137
  progress = performance.now();
138
+ // Stream deltas already updated receipt-time clocks; periodic checks bound decision latency.
139
+ if (event.type === "message_update" || event.type === "tool_execution_update")
140
+ return;
125
141
  enqueue(async () => {
126
142
  const args = event.args;
127
143
  if (event.type === "tool_execution_start" && (event.toolName === "edit" || event.toolName === "write") && typeof args?.path === "string")
@@ -146,10 +162,13 @@ export async function observeExecution(d) {
146
162
  }
147
163
  });
148
164
  });
149
- let scanning = false, failingSince;
150
- const scan = () => {
151
- if (scanning)
165
+ let scanning = false, sessionDirty = false, failingSince;
166
+ const scan = (processes = true) => {
167
+ if (scanning) {
168
+ if (!processes)
169
+ sessionDirty = true;
152
170
  return;
171
+ }
153
172
  scanning = true;
154
173
  enqueue(async () => {
155
174
  try {
@@ -158,8 +177,10 @@ export async function observeExecution(d) {
158
177
  // a rerun). Only a scan that keeps failing for a minute ends the observation.
159
178
  // Measured in elapsed time: the scan period is fixed at start while k.trackerMs can be reloaded.
160
179
  try {
161
- clock.scan(await d.track());
162
- failingSince = undefined;
180
+ if (processes) {
181
+ clock.scan(await d.track());
182
+ failingSince = undefined;
183
+ }
163
184
  }
164
185
  catch (error) {
165
186
  failingSince ??= performance.now();
@@ -187,11 +208,20 @@ export async function observeExecution(d) {
187
208
  }
188
209
  finally {
189
210
  scanning = false;
211
+ if (sessionDirty) {
212
+ sessionDirty = false;
213
+ scan(false);
214
+ }
190
215
  }
191
216
  });
192
217
  };
193
- const timer = setInterval(scan, config.k?.trackerMs ?? 1000);
194
- const watcher = watch(callDir(home, t.wid, t.key, t.gen), scan);
218
+ const timer = setInterval(() => scan(), Math.min(config.k?.trackerMs ?? 1000, 1000));
219
+ let notification;
220
+ const watcher = watch(callDir(home, t.wid, t.key, t.gen), (_event, name) => {
221
+ if (!sessionChanged(name) || notification)
222
+ return;
223
+ notification = setTimeout(() => { notification = undefined; scan(false); }, 10);
224
+ });
195
225
  watcher.on("error", () => { });
196
226
  try {
197
227
  if (d.interrupted())
@@ -207,6 +237,7 @@ export async function observeExecution(d) {
207
237
  }
208
238
  finally {
209
239
  watcher.close();
240
+ clearTimeout(notification);
210
241
  clearInterval(timer);
211
242
  d.setWake(() => { });
212
243
  ending = true;
@@ -1,10 +1,12 @@
1
1
  import { JT, attentionEntries, isEntry } from "../../types.js";
2
2
  import { Containment as PlatformContainment } from "../../platform/containment.js";
3
3
  import { ProcessTable } from "../../platform/proctable.js";
4
- /** C2, F4: A containment whose process-table snapshots never overlap (ProcessTable.list is not reentrant). */
5
- export function serialContainment(table = new ProcessTable()) {
4
+ /** C2, F4: Real observers share snapshots; injected tables retain serialized access. */
5
+ export function serialContainment(table) {
6
+ if (!table || table instanceof ProcessTable)
7
+ return new PlatformContainment(table);
6
8
  let queue = Promise.resolve();
7
- return new PlatformContainment({ list(known) { const next = queue.then(() => table.list(known)); queue = next.catch(() => { }); return next; } });
9
+ return new PlatformContainment({ list(known, options) { const next = queue.then(() => table.list(known, options)); queue = next.catch(() => { }); return next; } });
8
10
  }
9
11
  /** K7: Three consecutive losses for a candidate within its pool suspend it for ten minutes. */
10
12
  export async function skipLostCandidate(journal, orch, exec) {
@@ -58,36 +60,51 @@ export async function fenceAttentionResolved(journal, id) {
58
60
  if (item && !journal.entries().some(e => e.type === JT.attentionResolved && e.id === `fence:${id}` && e.rev === item.rev))
59
61
  await journal.append(JT.attentionResolved, { id: `fence:${id}`, rev: item.rev, resolution: "fenced" });
60
62
  }
63
+ const sweeps = new WeakMap();
64
+ /** A1: Fold old history during recovery, so the first sweep does not allocate it on the timer path. */
65
+ export function indexSweep(journal) {
66
+ let state = sweeps.get(journal);
67
+ if (!state) {
68
+ state = { count: 0, fenced: new Set(), retired: new Set(), failed: new Set(), gated: new Set(), tracked: new Map(), execs: [], gates: [] };
69
+ sweeps.set(journal, state);
70
+ }
71
+ const { fenced, retired, failed, gated, tracked, execs, gates } = state, entries = journal.entries();
72
+ while (state.count < entries.length) {
73
+ const e = entries[state.count++];
74
+ if (e.type === JT.exec)
75
+ execs.push(e);
76
+ else if (e.type === "gate-intent")
77
+ gates.push(e);
78
+ else if (e.type === JT.fenced)
79
+ fenced.add(e.exec);
80
+ else if (e.type === "retired")
81
+ retired.add(e.call);
82
+ else if (e.type === "fence-failed")
83
+ failed.add(e.exec);
84
+ else if (e.type === "gate")
85
+ gated.add(e.id);
86
+ else if (e.type === "tracked" || e.type === "gate-tracked") {
87
+ const id = String(e.type === "tracked" ? e.exec : e.id), list = tracked.get(id) ?? [];
88
+ if (!tracked.has(id))
89
+ tracked.set(id, list);
90
+ list.push(e.type === "tracked" ? { pid: Number(e.pid), ppid: 0, start: String(e.start) } : e.process);
91
+ }
92
+ }
93
+ return state;
94
+ }
61
95
  /** P23, F1, F2: Every K1 re-fence retired identities and retry failed fences (executions and gates without an outcome)
62
96
  * with one shared scan; a failing identity is reported through `failed` and never stops the sweep of the others. */
63
97
  export async function sweepExecutions(journals, containment, hooks = {}) {
64
98
  const targets = [];
65
99
  for (const journal of journals) {
66
- const fenced = new Set(), retired = new Set(), failed = new Set(), gated = new Set(), tracked = new Map();
67
- const entries = journal.entries();
68
- for (const e of entries) {
69
- if (e.type === JT.fenced)
70
- fenced.add(e.exec);
71
- else if (e.type === "retired")
72
- retired.add(e.call);
73
- else if (e.type === "fence-failed")
74
- failed.add(e.exec);
75
- else if (e.type === "gate")
76
- gated.add(e.id);
77
- else if (e.type === "tracked")
78
- tracked.set(String(e.exec), [...tracked.get(String(e.exec)) ?? [], { pid: Number(e.pid), ppid: 0, start: String(e.start) }]);
79
- else if (e.type === "gate-tracked")
80
- tracked.set(String(e.id), [...tracked.get(String(e.id)) ?? [], e.process]);
81
- }
100
+ const { fenced, retired, failed, gated, tracked, execs, gates } = indexSweep(journal);
82
101
  // A gate whose recovery fence failed has no outcome yet; it is retried here until its processes are gone.
83
- for (const e of entries) {
102
+ for (const e of gates) {
84
103
  const id = String(e.id);
85
104
  if (e.type === "gate-intent" && !gated.has(id) && (failed.has(id) || hooks.failing?.(id)))
86
105
  targets.push({ journal, exec: id, call: String(e.call), fenced: false, failed: true, tracked: tracked.get(id) ?? [] });
87
106
  }
88
- for (const e of entries) {
89
- if (e.type !== JT.exec)
90
- continue;
107
+ for (const e of execs) {
91
108
  const exec = String(e.exec), isFailed = failed.has(exec) || !!hooks.failing?.(exec);
92
109
  if (fenced.has(exec) || retired.has(e.call) || isFailed)
93
110
  targets.push({ journal, exec, call: String(e.call), fenced: fenced.has(exec), failed: isFailed, tracked: tracked.get(exec) ?? [] });