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.
- package/CHANGELOG.md +45 -0
- package/README.md +85 -21
- package/dist/agent/main/snapshots.js +6 -2
- package/dist/agent/main/tool.js +8 -5
- package/dist/agent/main.js +18 -1
- package/dist/cli/control.js +4 -0
- package/dist/cli/hold.js +184 -0
- package/dist/cli/main.js +65 -9
- package/dist/cli/restart.js +71 -0
- package/dist/compat/spec.js +3 -1
- package/dist/kernel/mailbox.js +1 -1
- package/dist/orchestrator/config.js +4 -2
- package/dist/orchestrator/engine.js +26 -1
- package/dist/orchestrator/executor/index.js +158 -20
- package/dist/orchestrator/executor/indexes.js +63 -0
- package/dist/orchestrator/executor/observe.js +51 -20
- package/dist/orchestrator/executor/sweep.js +40 -23
- package/dist/orchestrator/executor/usage.js +22 -12
- package/dist/orchestrator/executor/worktree.js +1 -1
- package/dist/orchestrator/ledger.js +11 -2
- package/dist/orchestrator/main.js +21 -5
- package/dist/orchestrator/snapshot.js +24 -14
- package/dist/orchestrator/store.js +15 -3
- package/dist/paths.js +2 -0
- package/dist/platform/containment.js +13 -7
- package/dist/platform/lease.js +236 -0
- package/dist/platform/proctable.js +37 -10
- package/dist/ui/cards.js +35 -17
- package/dist/ui/index.js +5 -7
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
353
|
-
if (hit && !t.journal.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
55
|
-
const open = last && !t.journal.
|
|
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.
|
|
72
|
-
const open = last && !t.journal.
|
|
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 &&
|
|
77
|
-
const text = `${t.wid}/${t.key}:
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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:
|
|
5
|
-
export function serialContainment(table
|
|
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
|
|
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
|
|
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
|
|
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) ?? [] });
|