pi-durable-subagents 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -0
- package/dist/agent/main.js +4 -4
- package/dist/cli/main.js +6 -3
- package/dist/orchestrator/config.js +147 -0
- package/dist/orchestrator/engine.js +7 -1
- package/dist/orchestrator/executor/index.js +10 -4
- package/dist/orchestrator/executor/observe.js +5 -3
- package/dist/orchestrator/main.js +20 -12
- package/dist/orchestrator/snapshot.js +88 -14
- package/dist/ui/index.js +19 -4
- package/dist/ui/tool.js +1 -1
- package/dist/ui/view.js +15 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.7
|
|
4
|
+
|
|
5
|
+
- Changes to `config.json` (`defaultModel`, `pools`, `providers`, `memory`,
|
|
6
|
+
`k`) apply without restarting the orchestrator: it checks the file about
|
|
7
|
+
once a second and applies a valid change between slot admissions, so a call
|
|
8
|
+
still waiting for a slot follows the new model, pool or limit. Slots already
|
|
9
|
+
held are kept when a limit drops. An invalid change is refused and the
|
|
10
|
+
settings in effect stay; `status` names the error until the file is valid
|
|
11
|
+
again.
|
|
12
|
+
- `status` marks an asker whose execution hibernated with `hibernated: true`
|
|
13
|
+
(CLI: "hibernated, no slot"): it holds no provider slot until it is
|
|
14
|
+
answered. Both `status` forms list provider slots in use against their
|
|
15
|
+
limits, and the config in effect.
|
|
16
|
+
|
|
17
|
+
## 1.0.6
|
|
18
|
+
|
|
19
|
+
- A follow-up on a finished workflow shows as running work: in the dock, the
|
|
20
|
+
summary line, the list (no longer dimmed or hidden) and `status`. Quitting pi
|
|
21
|
+
pauses it, and `resume` continues it; before, a resume refused it as finished.
|
|
22
|
+
- ↓ opens the subagent list only from pi's input editor. With `/model`'s
|
|
23
|
+
selector, a dialog or another overlay in focus, ↓ moves in that list again.
|
|
24
|
+
|
|
3
25
|
## 1.0.5
|
|
4
26
|
|
|
5
27
|
- `status` without a wid is brief: what runs, what asks (with the address to
|
package/README.md
CHANGED
|
@@ -273,6 +273,11 @@ State lives in `~/.pi/durable-subagents`; set `DSA_HOME` to move it.
|
|
|
273
273
|
never stopped for memory.
|
|
274
274
|
- **onQuit:** `"pause"` (default) pauses a session's running workflows when
|
|
275
275
|
you quit that pi; `"continue"` lets them run on in the background.
|
|
276
|
+
- **Changes apply without a restart:** the orchestrator re-reads the file
|
|
277
|
+
when it changes. A new slot limit, pool or default model applies to the next
|
|
278
|
+
slot acquisition; slots already held are kept when a limit drops. An
|
|
279
|
+
invalid change is not applied, and `status` reports it next to the settings
|
|
280
|
+
still in effect (`config: <hash> since …`) and the slots held per provider.
|
|
276
281
|
|
|
277
282
|
## Switching back
|
|
278
283
|
|
package/dist/agent/main.js
CHANGED
|
@@ -12,7 +12,7 @@ import { OsLock } from "../platform/lock.js";
|
|
|
12
12
|
import { dsaHome, orchInbox, orchLedger, orchLock, outboxRoot } from "../paths.js";
|
|
13
13
|
import { CT, JT } from "../types.js";
|
|
14
14
|
import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
|
|
15
|
-
import { pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
|
|
15
|
+
import { isLive, pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
|
|
16
16
|
import { parameters, request } from "./main/tool.js";
|
|
17
17
|
import { discoverAgents } from "../compat/agents.js";
|
|
18
18
|
let noteSink;
|
|
@@ -165,7 +165,7 @@ export function registerMain(pi, ui) {
|
|
|
165
165
|
pi.on("session_start", async (_event, context) => {
|
|
166
166
|
await start(context);
|
|
167
167
|
// Quit pause: say once, at the session's start, which of its workflows wait for a resume.
|
|
168
|
-
const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && w
|
|
168
|
+
const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && isLive(w) && w.paused);
|
|
169
169
|
if (paused.length)
|
|
170
170
|
pi.sendMessage({ customType: CT.note, display: true, content: `${paused.length} subagent workflow${paused.length > 1 ? "s were" : " was"} paused when pi quit (${paused.map(w => w.name ?? w.wid).join(", ")}). Ask to resume, or open /subagents (or ↓) and press r.` });
|
|
171
171
|
});
|
|
@@ -176,7 +176,7 @@ export function registerMain(pi, ui) {
|
|
|
176
176
|
pi.on("session_shutdown", async (event) => {
|
|
177
177
|
if (event.reason === "quit" && quitPolicy(home) === "pause" && outbox && !stopped) {
|
|
178
178
|
try {
|
|
179
|
-
const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && w
|
|
179
|
+
const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && isLive(w) && !w.paused);
|
|
180
180
|
if (running)
|
|
181
181
|
await serial(async () => { await outbox?.send("orch", "drain", { fence: true, origin: sender }); });
|
|
182
182
|
}
|
|
@@ -325,7 +325,7 @@ export function registerMain(pi, ui) {
|
|
|
325
325
|
"run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
|
|
326
326
|
"agents: list names, descriptions, default models and source for this cwd; use these names for run.",
|
|
327
327
|
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model switches at next provider request. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
328
|
-
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address) or failed, finished workflows one line each; wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
|
|
328
|
+
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address; hibernated:true holds no slot) or failed, finished workflows one line each, provider slots held/limit and the config in effect; wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
|
|
329
329
|
"Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
|
|
330
330
|
...(agents ? [`Available agents: ${agents}.`] : []),
|
|
331
331
|
"User sees a summary line above the editor; ↓ on an empty editor (or /subagents) opens the list, Enter watches live OR finished calls (finished transcripts remain on disk) and expands finished workflows. List keys: s steer (paste-capable input), x stop (confirm y), m model, a answer when asked, f follow-up on finished calls; action feedback appears in footer.",
|
package/dist/cli/main.js
CHANGED
|
@@ -64,14 +64,17 @@ export function renderStatus(wf) {
|
|
|
64
64
|
}
|
|
65
65
|
/** P25, T10: Render the compact status projection shared with the `subagents` tool. */
|
|
66
66
|
export function renderView(view) {
|
|
67
|
-
const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
|
|
68
|
-
...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
|
|
67
|
+
const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.followUps ? " (follow-up running)" : ""}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
|
|
68
|
+
...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
|
|
69
69
|
...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
|
|
70
70
|
if (view.paused)
|
|
71
71
|
lines.unshift(`${view.paused} (pi-durable-subagents resume)`);
|
|
72
72
|
if (view.olderFinished)
|
|
73
73
|
lines.push(`(+${view.olderFinished} older finished workflows; status <wid> shows one in detail)`);
|
|
74
|
-
|
|
74
|
+
const footer = [view.slots?.length ? `slots: ${view.slots.join(", ")}` : "", view.config ? `config: ${view.config}` : "", view.configRejected ? `config.json rejected: ${view.configRejected}` : ""].filter(Boolean);
|
|
75
|
+
if (!lines.length)
|
|
76
|
+
lines.push("No workflows");
|
|
77
|
+
return [...lines, ...footer].join("\n");
|
|
75
78
|
}
|
|
76
79
|
function snapshots(home, wid) {
|
|
77
80
|
if (!wid)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// $DSA_HOME/config.json for the orchestrator: read at start and again whenever the file changes, so a new slot limit,
|
|
2
|
+
// pool order or K-parameter needs no orchestrator restart (a restart at the wrong moment used to cost a dispatch).
|
|
3
|
+
// Orchestrator ledger entries: config{hash,config} — the settings in effect from then on (at start, or after a change);
|
|
4
|
+
// config-rejected{hash,error} — a changed file that was not applied; the settings before it stay in effect.
|
|
5
|
+
// A reload changes the shared config object in place: every later read sees it (the next slot acquisition, model
|
|
6
|
+
// resolution or check). Slots already held are kept when a limit drops; timers of running executions keep their period.
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { contentHash } from "../kernel/ids.js";
|
|
10
|
+
/** The keys the orchestrator reads; config.json also holds pi-side settings (ui, onQuit) that it ignores. */
|
|
11
|
+
const KEYS = ["defaultModel", "pools", "providers", "memory", "k"];
|
|
12
|
+
const K = ["lossBound", "checkpointMs", "stallMs", "progressMs", "switchTimeoutMs", "idleExitMs", "trackerMs", "hibernateMs", "spawnBudget"];
|
|
13
|
+
export const configPath = (home) => join(home, "config.json");
|
|
14
|
+
/** The orchestrator's part of a parsed config.json. */
|
|
15
|
+
export function orchestratorSettings(raw) {
|
|
16
|
+
const value = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
17
|
+
return Object.fromEntries(KEYS.filter(k => value[k] !== undefined).map(k => [k, value[k]]));
|
|
18
|
+
}
|
|
19
|
+
export const configHash = (config) => contentHash(orchestratorSettings(config)).slice(0, 12);
|
|
20
|
+
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
22
|
+
/** What is wrong with a parsed config.json for the orchestrator, or undefined. Unknown keys are allowed. */
|
|
23
|
+
export function configProblem(raw) {
|
|
24
|
+
if (!record(raw))
|
|
25
|
+
return "config.json must be a JSON object";
|
|
26
|
+
const { defaultModel, pools, providers, memory, k } = raw;
|
|
27
|
+
if (defaultModel !== undefined && typeof defaultModel !== "string")
|
|
28
|
+
return "defaultModel must be a string";
|
|
29
|
+
if (pools !== undefined) {
|
|
30
|
+
if (!record(pools))
|
|
31
|
+
return "pools must map names to model lists";
|
|
32
|
+
for (const [name, list] of Object.entries(pools))
|
|
33
|
+
if (!Array.isArray(list) || !list.length || !list.every(m => typeof m === "string" && m))
|
|
34
|
+
return `pools.${name} must be a nonempty list of "provider/id[:thinking]"`;
|
|
35
|
+
}
|
|
36
|
+
if (providers !== undefined) {
|
|
37
|
+
if (!record(providers))
|
|
38
|
+
return "providers must map provider names to { slots }";
|
|
39
|
+
for (const [name, p] of Object.entries(providers))
|
|
40
|
+
if (!record(p) || !Number.isSafeInteger(p.slots) || Number(p.slots) < 0)
|
|
41
|
+
return `providers.${name}.slots must be a nonnegative integer`;
|
|
42
|
+
}
|
|
43
|
+
if (memory !== undefined) {
|
|
44
|
+
if (!record(memory))
|
|
45
|
+
return "memory must be { reserveMb?, perChildMb? }";
|
|
46
|
+
for (const key of ["reserveMb", "perChildMb"])
|
|
47
|
+
if (memory[key] !== undefined && !count(memory[key]))
|
|
48
|
+
return `memory.${key} must be a nonnegative number`;
|
|
49
|
+
}
|
|
50
|
+
if (k !== undefined) {
|
|
51
|
+
if (!record(k))
|
|
52
|
+
return "k must map K-parameters to numbers";
|
|
53
|
+
for (const [key, v] of Object.entries(k)) {
|
|
54
|
+
if (!K.includes(key))
|
|
55
|
+
return `k.${key} is not a K-parameter (${K.join(", ")})`;
|
|
56
|
+
if (!count(v) || (key !== "lossBound" && key !== "spawnBudget" && v === 0))
|
|
57
|
+
return `k.${key} must be a positive number`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Parse config.json; a missing file is the empty config. A JSON error throws. */
|
|
62
|
+
export async function readConfig(path) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT")
|
|
68
|
+
return {};
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Record the settings in effect unless the ledger already ends on the same ones (a restart with an unchanged file).
|
|
73
|
+
* After a rejection they are recorded again: that confirms the file is back to settings in effect. */
|
|
74
|
+
export async function recordConfig(orch, config) {
|
|
75
|
+
const hash = configHash(config), last = orch.entries().findLast(e => e.type === "config" || e.type === "config-rejected");
|
|
76
|
+
if (last?.type !== "config" || last.hash !== hash)
|
|
77
|
+
await orch.append("config", { hash, config: orchestratorSettings(config) });
|
|
78
|
+
return hash;
|
|
79
|
+
}
|
|
80
|
+
/** Replace the contents of the shared config object; readers hold the object, not a copy. */
|
|
81
|
+
function applyInPlace(target, next) {
|
|
82
|
+
const t = target;
|
|
83
|
+
for (const key of KEYS)
|
|
84
|
+
delete t[key];
|
|
85
|
+
Object.assign(t, orchestratorSettings(next));
|
|
86
|
+
}
|
|
87
|
+
/** Identity of the file's current version (taken before a read, so a change during the read is seen next time). */
|
|
88
|
+
export async function configStamp(path) {
|
|
89
|
+
try {
|
|
90
|
+
const s = await stat(path);
|
|
91
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === "ENOENT")
|
|
95
|
+
return "missing";
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Watch config.json from the version `stamp` (read at start) and apply each valid change in place. */
|
|
100
|
+
/** `apply` runs the change where readers cannot observe half of it (the executor's admission section). */
|
|
101
|
+
export function watchConfig(options) {
|
|
102
|
+
const { path, config, orch } = options;
|
|
103
|
+
let stamp = options.stamp, running, rejected, stopped = false;
|
|
104
|
+
const once = async () => {
|
|
105
|
+
const now = await configStamp(path);
|
|
106
|
+
if (now === stamp || stopped)
|
|
107
|
+
return;
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = await readConfig(path);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// A half-written file reads as invalid JSON: retry on the next change of the file, report it once per version.
|
|
114
|
+
stamp = now;
|
|
115
|
+
const hash = `invalid:${now}`;
|
|
116
|
+
if (rejected !== hash) {
|
|
117
|
+
rejected = hash;
|
|
118
|
+
await orch.append("config-rejected", { error: `config.json is not valid JSON: ${error.message}` });
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
stamp = now;
|
|
123
|
+
// Validate the content first: `[]` or `null` project to no settings and must not pass as "unchanged".
|
|
124
|
+
const problem = configProblem(raw), next = orchestratorSettings(raw), hash = configHash(next);
|
|
125
|
+
if (problem) {
|
|
126
|
+
if (rejected !== hash) {
|
|
127
|
+
rejected = hash;
|
|
128
|
+
await orch.append("config-rejected", { hash, error: problem });
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
rejected = undefined;
|
|
133
|
+
let changed = false;
|
|
134
|
+
await (options.apply ?? (change => change()))(async () => {
|
|
135
|
+
changed = hash !== configHash(config);
|
|
136
|
+
if (changed)
|
|
137
|
+
applyInPlace(config, next);
|
|
138
|
+
await recordConfig(orch, config); // also confirms a return to the settings in effect after a rejection
|
|
139
|
+
});
|
|
140
|
+
if (changed)
|
|
141
|
+
options.onApplied?.();
|
|
142
|
+
};
|
|
143
|
+
const check = () => running ??= once().catch(error => console.error(`durable-subagents: config.json check failed: ${String(error)}`)).finally(() => { running = undefined; });
|
|
144
|
+
const timer = setInterval(check, options.intervalMs ?? 1000);
|
|
145
|
+
timer.unref();
|
|
146
|
+
return { stop: async () => { stopped = true; clearInterval(timer); await running; }, check };
|
|
147
|
+
}
|
|
@@ -97,6 +97,11 @@ export class Engine {
|
|
|
97
97
|
held(wid) {
|
|
98
98
|
return holdOf(this.ledgers.orch.entries(), wid, this.store.workflows.get(wid)?.origin) !== undefined;
|
|
99
99
|
}
|
|
100
|
+
/** Follow-ups (generations) of the current revision that have not ended: on a finished workflow they are its only live work. */
|
|
101
|
+
openFollowUps(wf) {
|
|
102
|
+
const log = revisionEntries(wf), ended = new Set(log.filter(e => e.type === JT.sealed || e.type === 'retired').map(e => String(e.call)));
|
|
103
|
+
return log.some(e => e.type === 'generation' && !ended.has(`${wf.wid}@${wf.revision}/${e.key}@${e.gen}`));
|
|
104
|
+
}
|
|
100
105
|
watcher;
|
|
101
106
|
poll;
|
|
102
107
|
constructor(ledgers, executor, options = {}) {
|
|
@@ -370,7 +375,8 @@ export class Engine {
|
|
|
370
375
|
const inScope = (wf) => wid ? wf.wid === wid : origin ? wf.origin === origin : true;
|
|
371
376
|
const took = (wf) => wf.journal.entries().some(e => e.type === 'resumed' && e.rid === req.rid);
|
|
372
377
|
const replayed = this.ledgers.orch.entries().some(e => e.type === 'undrain' && e.rid === req.rid);
|
|
373
|
-
|
|
378
|
+
// Held follow-ups of a finished workflow are released too; they run again through dispatchGeneration below.
|
|
379
|
+
const holding = [...this.store.workflows.values()].filter(wf => inScope(wf) && this.held(wf.wid) && (!this.terminal(wf) || this.openFollowUps(wf)));
|
|
374
380
|
const releasing = replayed || holding.length > 0;
|
|
375
381
|
const final = (wf) => { const done = this.terminal(wf); return done && done.status !== 'parked' ? String(done.status) : undefined; };
|
|
376
382
|
const parked = (wf) => this.terminal(wf)?.status === 'parked';
|
|
@@ -389,13 +389,18 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
389
389
|
}
|
|
390
390
|
const stopped = (a) => a.stopped || entriesFor(a.ticket.journal, a.ticket.callId).some(e => e.type === "stop-intent");
|
|
391
391
|
const interrupted = (a) => closed || a.suspended || a.retired || stopped(a);
|
|
392
|
-
|
|
392
|
+
/** Wait for a slot. The candidates are decided again on every attempt, inside the serial section that also applies
|
|
393
|
+
* config.json changes: a queued call follows a reloaded defaultModel, pool or limit, never a mix of two versions. */
|
|
394
|
+
async function acquire(a, exec, decide) {
|
|
395
|
+
let continuation = false;
|
|
393
396
|
for (;;) {
|
|
394
397
|
let signal;
|
|
395
398
|
const changed = new Promise(resolve => { signal = resolve; waiters.add(resolve); });
|
|
396
399
|
const chosen = await serial(async () => {
|
|
397
400
|
if (interrupted(a) || await workflowReached(a.ticket))
|
|
398
401
|
return;
|
|
402
|
+
const decision = await decide(), models = decision.candidates, pool = decision.pool;
|
|
403
|
+
continuation = decision.continuation;
|
|
399
404
|
for (const model of models) {
|
|
400
405
|
if (!continuation && pool && skipped(pool, model))
|
|
401
406
|
continue;
|
|
@@ -434,7 +439,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
434
439
|
});
|
|
435
440
|
if (chosen || interrupted(a) || reached(totalUsage(a.ticket.journal.entries()), a.ticket.workflowBudget)) {
|
|
436
441
|
waiters.delete(signal);
|
|
437
|
-
return chosen;
|
|
442
|
+
return { model: chosen, continuation };
|
|
438
443
|
}
|
|
439
444
|
const timer = setTimeout(signal, config.k?.trackerMs ?? 1000);
|
|
440
445
|
try {
|
|
@@ -599,13 +604,13 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
599
604
|
}
|
|
600
605
|
let decision;
|
|
601
606
|
try {
|
|
602
|
-
decision = await launchModel(t, entries, previous);
|
|
607
|
+
decision = await acquire(a, exec, () => launchModel(t, entries, previous));
|
|
603
608
|
}
|
|
604
609
|
catch (error) {
|
|
605
610
|
await fence(journal, exec, { park: a });
|
|
606
611
|
return finish(journal, t.callId, exec, makeResult("failed", "", String(error)));
|
|
607
612
|
}
|
|
608
|
-
const model =
|
|
613
|
+
const model = decision.model;
|
|
609
614
|
if (!model || interrupted(a)) {
|
|
610
615
|
await fence(journal, exec, { park: a });
|
|
611
616
|
return finish(journal, t.callId, exec, !interrupted(a) ? makeResult("failed", "", "workflow budget reached") : makeResult("stopped"));
|
|
@@ -687,6 +692,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
687
692
|
await (await outbox).send(envelope.to, envelope.kind, envelope.body, envelope.cond, { rid: String(e.rid2) });
|
|
688
693
|
}
|
|
689
694
|
return {
|
|
695
|
+
async reconfigure(apply) { await serial(apply); wake(); },
|
|
690
696
|
run(ticket) {
|
|
691
697
|
const existing = completed.get(ticket.callId);
|
|
692
698
|
if (existing)
|
|
@@ -123,7 +123,7 @@ export async function observeExecution(d) {
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
-
let scanning = false,
|
|
126
|
+
let scanning = false, failingSince;
|
|
127
127
|
const scan = () => {
|
|
128
128
|
if (scanning)
|
|
129
129
|
return;
|
|
@@ -133,12 +133,14 @@ export async function observeExecution(d) {
|
|
|
133
133
|
// One failed process-table scan is no evidence, not a reason to fence a live child: on macOS `ps` can miss its
|
|
134
134
|
// 2 s deadline right after the orchestrator was stopped or the machine slept (seen on CI as a spurious loss and
|
|
135
135
|
// a rerun). Only a scan that keeps failing for a minute ends the observation.
|
|
136
|
+
// Measured in elapsed time: the scan period is fixed at start while k.trackerMs can be reloaded.
|
|
136
137
|
try {
|
|
137
138
|
clock.scan(await d.track());
|
|
138
|
-
|
|
139
|
+
failingSince = undefined;
|
|
139
140
|
}
|
|
140
141
|
catch (error) {
|
|
141
|
-
|
|
142
|
+
failingSince ??= performance.now();
|
|
143
|
+
if (performance.now() - failingSince >= 60_000)
|
|
142
144
|
throw error;
|
|
143
145
|
}
|
|
144
146
|
const nextSize = (await fileStat(session).catch(() => ({ size: 0 }))).size;
|
|
@@ -6,7 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { mkdir
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { realpathSync } from 'node:fs';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
@@ -14,6 +14,7 @@ import { dsaHome, orchLedger, orchLock } from "../paths.js";
|
|
|
14
14
|
import { openJournal } from "../kernel/journal.js";
|
|
15
15
|
import { OsLock } from "../platform/lock.js";
|
|
16
16
|
import { Engine } from "./engine.js";
|
|
17
|
+
import { configPath, configProblem, configStamp, readConfig, recordConfig, watchConfig } from "./config.js";
|
|
17
18
|
/** P2, C4, K6: Acquire the single authority before opening ledgers, recover, and idle-exit. */
|
|
18
19
|
export async function main(options = {}) {
|
|
19
20
|
const home = options.home ?? dsaHome();
|
|
@@ -21,27 +22,34 @@ export async function main(options = {}) {
|
|
|
21
22
|
const lock = await new OsLock().tryAcquire(orchLock(home));
|
|
22
23
|
if (!lock)
|
|
23
24
|
return;
|
|
24
|
-
let engine, ledgers;
|
|
25
|
+
let engine, ledgers, watcher;
|
|
25
26
|
try {
|
|
26
|
-
let config = options.config;
|
|
27
|
+
let config = options.config, stamp;
|
|
27
28
|
if (!config) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
29
|
+
// The stamp is taken before the read: a change made while reading is applied by the first check.
|
|
30
|
+
stamp = await configStamp(configPath(home));
|
|
31
|
+
const raw = await readConfig(configPath(home));
|
|
32
|
+
const problem = configProblem(raw);
|
|
33
|
+
if (problem)
|
|
34
|
+
console.error(`durable-subagents: config.json: ${problem}`);
|
|
35
|
+
config = raw;
|
|
36
36
|
}
|
|
37
37
|
ledgers = { home, config, orch: await openJournal(orchLedger(home)) };
|
|
38
38
|
const factory = options.executor ?? (await import(__rewriteRelativeImportExtension(new URL(import.meta.url.endsWith('.ts') ? './executor/index.ts' : './executor/index.js', import.meta.url).href))).default;
|
|
39
|
-
|
|
39
|
+
const executor = factory(ledgers);
|
|
40
|
+
engine = new Engine(ledgers, executor, options);
|
|
41
|
+
if (stamp !== undefined) {
|
|
42
|
+
await recordConfig(ledgers.orch, config);
|
|
43
|
+
// A change applies between slot admissions, so one admission never mixes two versions of the limits.
|
|
44
|
+
watcher = watchConfig({ path: configPath(home), stamp, config, orch: ledgers.orch, intervalMs: Math.min(1000, config.k?.trackerMs ?? 1000),
|
|
45
|
+
apply: change => executor.reconfigure ? executor.reconfigure(change) : change() });
|
|
46
|
+
}
|
|
40
47
|
await engine.recover();
|
|
41
48
|
await engine.loop(options.signal);
|
|
42
49
|
}
|
|
43
50
|
finally {
|
|
44
51
|
try {
|
|
52
|
+
await watcher?.stop();
|
|
45
53
|
await engine?.close();
|
|
46
54
|
}
|
|
47
55
|
finally {
|
|
@@ -6,6 +6,14 @@ import { compileFanout } from "../compat/fanout.js";
|
|
|
6
6
|
import { readJournalSnapshot } from "../kernel/journal.js";
|
|
7
7
|
import { journalPath, orchLedger, pinnedDir, workflowDir } from "../paths.js";
|
|
8
8
|
import { JT } from "../types.js";
|
|
9
|
+
/** A workflow has live work: it runs, or follow-ups opened on it after it finished have not ended yet. */
|
|
10
|
+
export function isLive(wf) {
|
|
11
|
+
return wf.status === "running" || (wf.followUps ?? 0) > 0;
|
|
12
|
+
}
|
|
13
|
+
/** The calls of a workflow that are live work: every unfinished call of a running workflow, else open follow-ups. */
|
|
14
|
+
export function liveCalls(wf) {
|
|
15
|
+
return wf.calls.filter(c => c.phase !== "sealed" && (wf.status === "running" || c.afterEnd === true));
|
|
16
|
+
}
|
|
9
17
|
/** v12 §4: done/total of one workflow: `done` counts keys whose latest generation is sealed; `total` is the planned
|
|
10
18
|
* count when known, else keys proposed so far (`plus` marks the `n+` script case while the workflow runs). */
|
|
11
19
|
export function progressOf(wf) {
|
|
@@ -68,7 +76,9 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
68
76
|
const done = terminal?.type === JT.done ? terminal : undefined;
|
|
69
77
|
const calls = new Map();
|
|
70
78
|
const byExec = new Map();
|
|
79
|
+
const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
|
|
71
80
|
const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
|
|
81
|
+
const generations = new Set(), retired = new Set();
|
|
72
82
|
const attention = [];
|
|
73
83
|
// P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
|
|
74
84
|
const live = new Map(), sealedUsage = new Map(), seen = new Set();
|
|
@@ -81,6 +91,8 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
81
91
|
continue;
|
|
82
92
|
const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
|
|
83
93
|
const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
|
|
94
|
+
if (e.type === "generation")
|
|
95
|
+
generations.add(callId);
|
|
84
96
|
const result = e.type === "refused" ? refusedResult(key, e.reason) :
|
|
85
97
|
e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
|
|
86
98
|
const wanted = e.spec?.model;
|
|
@@ -90,6 +102,9 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
90
102
|
phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
|
|
91
103
|
...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
|
|
92
104
|
}
|
|
105
|
+
else if (e.type === "retired") {
|
|
106
|
+
retired.add(String(e.call));
|
|
107
|
+
}
|
|
93
108
|
else if (e.type === JT.exec) {
|
|
94
109
|
const call = calls.get(String(e.call));
|
|
95
110
|
if (!call)
|
|
@@ -101,12 +116,29 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
101
116
|
call.startedAt ??= e.ts;
|
|
102
117
|
call.lastActivity = e.ts;
|
|
103
118
|
byExec.set(call.exec, call);
|
|
119
|
+
delete call.hibernated;
|
|
120
|
+
hibernating.delete(call.callId);
|
|
121
|
+
}
|
|
122
|
+
else if (e.type === "hibernated") {
|
|
123
|
+
// The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
|
|
124
|
+
const call = calls.get(String(e.call));
|
|
125
|
+
if (call?.exec && call.exec === e.exec)
|
|
126
|
+
hibernating.set(call.callId, call.exec);
|
|
127
|
+
}
|
|
128
|
+
else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
|
|
129
|
+
const call = calls.get(String(e.call));
|
|
130
|
+
if (call) {
|
|
131
|
+
delete call.hibernated;
|
|
132
|
+
hibernating.delete(call.callId);
|
|
133
|
+
}
|
|
104
134
|
}
|
|
105
135
|
else if (e.type === JT.fenced) {
|
|
106
136
|
// A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
|
|
107
137
|
const call = byExec.get(String(e.exec));
|
|
108
138
|
if (call && call.phase === "running")
|
|
109
139
|
call.phase = "queued";
|
|
140
|
+
if (call && hibernating.get(call.callId) === e.exec)
|
|
141
|
+
call.hibernated = true;
|
|
110
142
|
}
|
|
111
143
|
else if (e.type === "selected") {
|
|
112
144
|
const call = byExec.get(String(e.exec)), m = e.model;
|
|
@@ -144,6 +176,8 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
144
176
|
call.phase = "sealed";
|
|
145
177
|
call.result = e.result;
|
|
146
178
|
call.endedAt = e.ts;
|
|
179
|
+
delete call.hibernated;
|
|
180
|
+
hibernating.delete(call.callId);
|
|
147
181
|
}
|
|
148
182
|
else if (e.type === JT.attention) {
|
|
149
183
|
const item = e.item;
|
|
@@ -175,6 +209,9 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
175
209
|
if (call && (call.phase === "running" || call.phase === "queued"))
|
|
176
210
|
call.phase = "asking"; // a hibernated asker is fenced
|
|
177
211
|
}
|
|
212
|
+
for (const c of calls.values())
|
|
213
|
+
if (c.hibernated && c.phase !== "asking")
|
|
214
|
+
delete c.hibernated;
|
|
178
215
|
const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
|
|
179
216
|
const list = [...calls.values()];
|
|
180
217
|
const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
|
|
@@ -197,6 +234,10 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
197
234
|
c.switching = switching;
|
|
198
235
|
}
|
|
199
236
|
}
|
|
237
|
+
const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
|
|
238
|
+
for (const c of after)
|
|
239
|
+
c.afterEnd = true;
|
|
240
|
+
const followUps = after.length;
|
|
200
241
|
const usage = zero();
|
|
201
242
|
for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
|
|
202
243
|
const u = usageOf(id);
|
|
@@ -210,6 +251,7 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
210
251
|
status: done ? done.status : "running",
|
|
211
252
|
...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
|
|
212
253
|
...(done ? { endedAt: done.ts } : {}),
|
|
254
|
+
...(followUps ? { followUps } : {}),
|
|
213
255
|
calls: list, counts, attention, usage,
|
|
214
256
|
};
|
|
215
257
|
}
|
|
@@ -262,7 +304,7 @@ export function heldWorkflows(home) {
|
|
|
262
304
|
/** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
|
|
263
305
|
export function allWorkflows(home) {
|
|
264
306
|
const { held } = heldWorkflows(home);
|
|
265
|
-
return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return wf
|
|
307
|
+
return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return isLive(wf) && held(wid) ? { ...wf, paused: true } : wf; });
|
|
266
308
|
}
|
|
267
309
|
/** P10/P11: Per-workflow script console log written by the orchestrator (bounded, human-readable). */
|
|
268
310
|
export function scriptLogPath(home, wid) { return join(workflowDir(home, wid), "script.log"); }
|
|
@@ -283,10 +325,10 @@ export function compactWorkflow(wf) {
|
|
|
283
325
|
const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
|
|
284
326
|
return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
|
|
285
327
|
...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
|
|
286
|
-
...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}) };
|
|
328
|
+
...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
|
|
287
329
|
}),
|
|
288
330
|
attention: wf.attention.map(a => ({ id: a.id, rev: a.rev, kind: a.kind, text: clip(a.text, 300), ...(a.call ? { call: a.call } : {}), ...(a.qid ? { qid: a.qid } : {}) })),
|
|
289
|
-
...(wf.paused ? { paused: true } : {}),
|
|
331
|
+
...(wf.paused ? { paused: true } : {}), ...(wf.followUps ? { followUps: wf.followUps } : {}),
|
|
290
332
|
};
|
|
291
333
|
}
|
|
292
334
|
function origins(home) {
|
|
@@ -306,7 +348,7 @@ function snapshots(home, origin) {
|
|
|
306
348
|
const { since, held } = heldWorkflows(home);
|
|
307
349
|
const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
|
|
308
350
|
const wf = workflowSnapshot(home, wid), withOrigin = wf.origin === undefined && origin !== undefined ? { ...wf, origin } : wf;
|
|
309
|
-
return withOrigin
|
|
351
|
+
return isLive(withOrigin) && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
|
|
310
352
|
}).sort((a, b) => own(b) - own(a));
|
|
311
353
|
return { all, ...(since !== undefined ? { since } : {}) };
|
|
312
354
|
}
|
|
@@ -315,17 +357,47 @@ export function statusView(home, options = {}) {
|
|
|
315
357
|
const keep = options.keep ?? 10;
|
|
316
358
|
const { all, since } = snapshots(home, options.origin);
|
|
317
359
|
let finished = 0;
|
|
318
|
-
const shown = all.filter(w => !
|
|
360
|
+
const shown = all.filter(w => !settled(w) || ++finished <= keep);
|
|
319
361
|
const hidden = all.length - shown.length;
|
|
320
362
|
const paused = all.filter(w => w.paused).length;
|
|
321
363
|
return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
|
|
322
|
-
...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {})
|
|
364
|
+
...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {}),
|
|
365
|
+
...slotsView(home) };
|
|
323
366
|
}
|
|
324
367
|
const FINAL = ["done", "failed", "stopped"];
|
|
368
|
+
/** Finished with nothing left running (a follow-up on a finished workflow is live work). */
|
|
369
|
+
const settled = (w) => FINAL.includes(w.status) && !w.followUps;
|
|
325
370
|
const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`;
|
|
326
371
|
const tokens = (u) => { const n = u ? u.input + u.output : 0; return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n); };
|
|
327
372
|
/** The latest generation of every key, in first-call order. */
|
|
328
373
|
const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
|
|
374
|
+
/** Provider slots from the orchestrator ledger: holders per provider (hold/release{pool,slot,exec}) and the limits of
|
|
375
|
+
* the settings in effect (the latest config{hash,config}); config-rejected after it is reported too. */
|
|
376
|
+
export function slotsView(home, now = Date.now()) {
|
|
377
|
+
const held = new Map();
|
|
378
|
+
let config, rejected;
|
|
379
|
+
for (const e of readJournalSnapshot(orchLedger(home))) {
|
|
380
|
+
if (e.type === "hold")
|
|
381
|
+
held.set(`${e.pool}:${e.slot}`, e);
|
|
382
|
+
else if (e.type === "release" && held.get(`${e.pool}:${e.slot}`)?.exec === e.exec)
|
|
383
|
+
held.delete(`${e.pool}:${e.slot}`);
|
|
384
|
+
else if (e.type === "config") {
|
|
385
|
+
config = e;
|
|
386
|
+
rejected = undefined;
|
|
387
|
+
}
|
|
388
|
+
else if (e.type === "config-rejected")
|
|
389
|
+
rejected = e;
|
|
390
|
+
}
|
|
391
|
+
const limits = (config?.config?.providers) ?? {};
|
|
392
|
+
const holders = new Map();
|
|
393
|
+
for (const e of held.values())
|
|
394
|
+
if (e.pool !== "memory")
|
|
395
|
+
holders.set(String(e.pool), (holders.get(String(e.pool)) ?? 0) + 1);
|
|
396
|
+
const names = [...new Set([...Object.keys(limits), ...holders.keys()])].sort();
|
|
397
|
+
const slots = names.map(p => { const n = holders.get(p) ?? 0, limit = limits[p]?.slots; return typeof limit === "number" ? `${p} ${n}/${limit}` : `${p} ${n} (no limit)`; });
|
|
398
|
+
return { ...(slots.length ? { slots } : {}), ...(config ? { config: `${String(config.hash)} since ${age(now - config.ts)} ago` } : {}),
|
|
399
|
+
...(rejected ? { configRejected: `${clip(String(rejected.error), 200)} (${age(now - rejected.ts)} ago); ${config ? String(config.hash) : "the start settings"} stay in effect` } : {}) };
|
|
400
|
+
}
|
|
329
401
|
/** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
|
|
330
402
|
* The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
|
|
331
403
|
export function statusBrief(home, options = {}) {
|
|
@@ -334,7 +406,7 @@ export function statusBrief(home, options = {}) {
|
|
|
334
406
|
const mine = (w) => !origin || w.origin === origin;
|
|
335
407
|
const line = (w) => {
|
|
336
408
|
const p = progressOf(w), notOk = latestCalls(w).filter(c => c.result && !c.result.ok).length;
|
|
337
|
-
return [w.wid, w.name, `${w.paused ? "paused" : w.status}`, `${p.done}/${p.total}${p.plus ? "+" : ""} done`, notOk ? `${notOk} not ok` : "",
|
|
409
|
+
return [w.wid, w.name, `${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status}`, `${p.done}/${p.total}${p.plus ? "+" : ""} done`, notOk ? `${notOk} not ok` : "",
|
|
338
410
|
w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
|
|
339
411
|
};
|
|
340
412
|
const brief = (w) => {
|
|
@@ -345,16 +417,18 @@ export function statusBrief(home, options = {}) {
|
|
|
345
417
|
...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
|
|
346
418
|
...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
|
|
347
419
|
...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
|
|
348
|
-
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {})
|
|
420
|
+
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
|
|
421
|
+
...(c.hibernated ? { hibernated: true } : {}) };
|
|
349
422
|
});
|
|
350
|
-
const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
|
|
423
|
+
const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
|
|
424
|
+
...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
|
|
351
425
|
const alerts = open.filter(a => a.kind !== "question").map(a => `${a.kind}${a.call ? ` ${w.wid}/${callKey(a.call)}` : ""}: ${clip(a.text, 200)}`);
|
|
352
|
-
return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}),
|
|
426
|
+
return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
|
|
353
427
|
progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
|
|
354
428
|
...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
|
|
355
429
|
};
|
|
356
|
-
const active = all.filter(w => !
|
|
357
|
-
const finished = all.filter(w =>
|
|
430
|
+
const active = all.filter(w => !settled(w));
|
|
431
|
+
const finished = all.filter(w => settled(w) && mine(w));
|
|
358
432
|
const others = active.filter(w => !mine(w));
|
|
359
433
|
const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
|
|
360
434
|
const paused = [heldMine ? `${heldMine} workflow${heldMine > 1 ? "s" : ""} of this session ${heldMine > 1 ? "are" : "is"} paused (stop-all, drain or a quit pi); resume continues ${heldMine > 1 ? "them" : "it"}` : "",
|
|
@@ -362,13 +436,13 @@ export function statusBrief(home, options = {}) {
|
|
|
362
436
|
return {
|
|
363
437
|
active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
|
|
364
438
|
finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
|
|
365
|
-
...(paused ? { paused } : {}),
|
|
439
|
+
...(paused ? { paused } : {}), ...slotsView(home, now),
|
|
366
440
|
hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
|
|
367
441
|
};
|
|
368
442
|
}
|
|
369
443
|
/** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
|
|
370
444
|
export function pausedElsewhere(home, origin) {
|
|
371
|
-
return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !
|
|
445
|
+
return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !settled(w)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
|
|
372
446
|
}
|
|
373
447
|
/** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
|
|
374
448
|
* A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
|
package/dist/ui/index.js
CHANGED
|
@@ -38,7 +38,19 @@ export function registerUi(pi, deps) {
|
|
|
38
38
|
const state = { folded: new Set(), done: new Map(), viewed: new Set(), finished: false };
|
|
39
39
|
let screen, opening = false, stopped = false, closeScreen;
|
|
40
40
|
let unsubscribe, timer;
|
|
41
|
-
let dockRows = () => [], dockAt, dockTui, lastDock = "";
|
|
41
|
+
let dockRows = () => [], dockAt = null, dockTui, lastDock = "";
|
|
42
|
+
// ↓ opens the list only from pi's own input editor. Another surface — /model's selector, a dialog, another
|
|
43
|
+
// extension's overlay — has the focus instead, and its ↓ belongs to it. pi's editor (and any editor built on
|
|
44
|
+
// CustomEditor) carries the app action map; the TUI comes from the dock widget, so without a dock the check
|
|
45
|
+
// falls back to the editor text alone.
|
|
46
|
+
let keysTui;
|
|
47
|
+
const editorFocused = () => {
|
|
48
|
+
const focus = keysTui?.getFocusedComponent;
|
|
49
|
+
if (typeof focus !== "function")
|
|
50
|
+
return true;
|
|
51
|
+
const focused = focus.call(keysTui);
|
|
52
|
+
return !!focused && typeof focused === "object" && focused.actionHandlers instanceof Map;
|
|
53
|
+
};
|
|
42
54
|
const right = (text, width) => { const t = truncateToWidth(text, width); return " ".repeat(Math.max(0, width - visibleWidth(t))) + t; };
|
|
43
55
|
const stop = () => {
|
|
44
56
|
stopped = true;
|
|
@@ -75,8 +87,11 @@ export function registerUi(pi, deps) {
|
|
|
75
87
|
if (at !== dockAt) {
|
|
76
88
|
dockAt = at;
|
|
77
89
|
dockTui = undefined;
|
|
78
|
-
|
|
90
|
+
// With the dock off, an empty widget below the editor still lends the TUI to the ↓ focus check; pi adds no
|
|
91
|
+
// spacer for it, so it takes no line.
|
|
92
|
+
ctx.ui.setWidget("durable-subagents", !at ? tui => { keysTui = tui; return { invalidate() { }, render: () => [] }; } : (tui, theme) => {
|
|
79
93
|
dockTui = tui;
|
|
94
|
+
keysTui = tui;
|
|
80
95
|
return {
|
|
81
96
|
invalidate() { },
|
|
82
97
|
render(width) {
|
|
@@ -88,7 +103,7 @@ export function registerUi(pi, deps) {
|
|
|
88
103
|
return rows.map((row, i) => ` ${i === rows.length - 1 ? theme.fg("dim", right(row, inner)) : row.startsWith("? ") ? theme.fg("warning", row) : theme.fg("muted", row)}`);
|
|
89
104
|
},
|
|
90
105
|
};
|
|
91
|
-
}
|
|
106
|
+
}, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
|
|
92
107
|
}
|
|
93
108
|
const shown = dockRows(200).join("\n");
|
|
94
109
|
if (shown !== lastDock) {
|
|
@@ -118,7 +133,7 @@ export function registerUi(pi, deps) {
|
|
|
118
133
|
};
|
|
119
134
|
openList = open;
|
|
120
135
|
unsubscribe = ctx.ui.onTerminalInput(key => {
|
|
121
|
-
if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !data.workflows.length)
|
|
136
|
+
if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !editorFocused() || !data.workflows.length)
|
|
122
137
|
return;
|
|
123
138
|
open();
|
|
124
139
|
return { consume: true };
|
package/dist/ui/tool.js
CHANGED
|
@@ -50,7 +50,7 @@ export function resultLines(details) {
|
|
|
50
50
|
if (Array.isArray(d.active)) {
|
|
51
51
|
const rows = d.active.map(w => {
|
|
52
52
|
const failed = w.calls.filter(c => c.status && c.status !== "ok").length, asks = w.asking?.length ?? 0, alerts = w.alerts?.length ?? 0;
|
|
53
|
-
return `${w.name ?? short(w.wid)} · ${w.paused ? "paused" : w.status} · ${w.progress} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}${alerts ? ` · ${alerts} alert${alerts > 1 ? "s" : ""}` : ""}`;
|
|
53
|
+
return `${w.name ?? short(w.wid)} · ${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status} · ${w.progress} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}${alerts ? ` · ${alerts} alert${alerts > 1 ? "s" : ""}` : ""}`;
|
|
54
54
|
});
|
|
55
55
|
const finished = Array.isArray(d.finished) ? d.finished.length + Number(d.olderFinished ?? 0) : 0;
|
|
56
56
|
return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["nothing running"]),
|
package/dist/ui/view.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { progressOf } from "../orchestrator/snapshot.js";
|
|
2
|
+
import { isLive, liveCalls, progressOf } from "../orchestrator/snapshot.js";
|
|
3
3
|
import { oneLine } from "./frame.js";
|
|
4
4
|
/** UI §2: Render compact wall-clock durations; never present these as charged active time. */
|
|
5
5
|
export function duration(ms) {
|
|
@@ -146,18 +146,18 @@ export const resultWord = (status) => WORDS[status] ?? "failed";
|
|
|
146
146
|
const WORD_ORDER = ["done", "stopped", "failed", "timeout", "budget", "unknown", "skipped", "parked"];
|
|
147
147
|
/** UI §1,4, v12 §4: One working sentence; done/total uses planned totals, `n+` while a script workflow keeps proposing. */
|
|
148
148
|
export function summary(workflows) {
|
|
149
|
-
const running = workflows.filter(
|
|
150
|
-
const asking = running.reduce((n, w) => n + w.
|
|
149
|
+
const running = workflows.filter(isLive), open = (w) => liveCalls(w).length;
|
|
150
|
+
const asking = running.reduce((n, w) => n + liveCalls(w).filter(c => w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
|
|
151
151
|
const paused = running.filter(w => w.paused).reduce((n, w) => n + open(w), 0); // held work is not working: it burns nothing
|
|
152
152
|
// Waiting for a slot is not working either: nothing is spent until it launches.
|
|
153
|
-
const queued = running.filter(w => !w.paused).reduce((n, w) => n + w.
|
|
153
|
+
const queued = running.filter(w => !w.paused).reduce((n, w) => n + liveCalls(w).filter(c => c.phase === "queued" && !w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
|
|
154
154
|
const progress = running.map(progressOf);
|
|
155
155
|
return { working: running.reduce((n, w) => n + open(w), 0) - asking - paused - queued, asking, queued, paused,
|
|
156
156
|
done: progress.reduce((n, p) => n + p.done, 0), total: progress.reduce((n, p) => n + p.total, 0), plus: progress.some(p => p.plus) };
|
|
157
157
|
}
|
|
158
158
|
/** UI §1, v12 §4: What needs you first, then what is running, then progress — e.g. "1 asking · 3 working · 12/40 done". */
|
|
159
159
|
export function summaryText(workflows) {
|
|
160
|
-
const s = summary(workflows), finished = workflows.filter(w => w
|
|
160
|
+
const s = summary(workflows), finished = workflows.filter(w => !isLive(w)).length;
|
|
161
161
|
if (!s.total)
|
|
162
162
|
return finished ? `${finished} finished` : "nothing running";
|
|
163
163
|
return [s.asking ? `${s.asking} asking` : "", s.working ? `${s.working} working` : "", s.queued ? `${s.queued} queued` : "", s.paused ? `${s.paused} paused` : "", `${s.done}/${s.total}${s.plus ? "+" : ""} done`].filter(Boolean).join(" · ");
|
|
@@ -169,7 +169,7 @@ export function mainLine(workflows) {
|
|
|
169
169
|
// The key that opens the list is spelled out: a bare arrow is easy to miss.
|
|
170
170
|
if (s.working || s.asking || s.queued || s.paused)
|
|
171
171
|
return `${summaryText(workflows)} · ↓ subagents`;
|
|
172
|
-
const w = workflows.filter(w => w
|
|
172
|
+
const w = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
|
|
173
173
|
if (!w)
|
|
174
174
|
return undefined;
|
|
175
175
|
const latest = [...new Map(w.calls.map(c => [c.key, c])).values()]; // the newest generation of each key (P37)
|
|
@@ -188,15 +188,16 @@ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
|
|
|
188
188
|
* then one summary line; afterwards only the completion sentence for ten minutes; otherwise nothing. Activity spins
|
|
189
189
|
* only while there is fresh evidence, so a quiet agent visibly stops moving. */
|
|
190
190
|
export function dockLines(workflows, facts, name, width, now = Date.now(), rows = 3) {
|
|
191
|
-
const live = workflows.filter(
|
|
191
|
+
const live = workflows.filter(isLive);
|
|
192
192
|
const active = live.flatMap(w => {
|
|
193
193
|
const latest = new Map();
|
|
194
194
|
for (const c of w.calls)
|
|
195
195
|
latest.set(c.key, c);
|
|
196
|
-
|
|
196
|
+
const open = new Set(liveCalls(w));
|
|
197
|
+
return [...latest.values()].filter(c => open.has(c)).map(c => ({ w, c, asking: w.attention.some(a => a.kind === "question" && a.call === c.callId) }));
|
|
197
198
|
}).sort((a, b) => Number(b.asking) - Number(a.asking));
|
|
198
199
|
if (!active.length) {
|
|
199
|
-
const ended = workflows.filter(w => w
|
|
200
|
+
const ended = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
|
|
200
201
|
const line = ended && now - (ended.endedAt ?? 0) < 10 * 60_000 ? mainLine(workflows) : undefined;
|
|
201
202
|
return line ? [truncateToWidth(line, Math.max(1, width))] : [];
|
|
202
203
|
}
|
|
@@ -217,7 +218,7 @@ export function dockLines(workflows, facts, name, width, now = Date.now(), rows
|
|
|
217
218
|
* keep unviewed failures visible, and page newest done rows eight at a time. */
|
|
218
219
|
/** v12 §5: Whether a workflow shows its agents: running ones unless folded; finished ones once opened (Enter, or on completion). */
|
|
219
220
|
export function isOpen(w, state) {
|
|
220
|
-
return !state.folded.has(w.wid) && (w
|
|
221
|
+
return !state.folded.has(w.wid) && (isLive(w) || (state.done.get(w.wid) ?? 0) > 0);
|
|
221
222
|
}
|
|
222
223
|
/** v12 §5: Enter on a workflow row opens or closes it. */
|
|
223
224
|
export function toggleOpen(w, state) {
|
|
@@ -225,7 +226,7 @@ export function toggleOpen(w, state) {
|
|
|
225
226
|
state.folded.add(w.wid);
|
|
226
227
|
else {
|
|
227
228
|
state.folded.delete(w.wid);
|
|
228
|
-
if (w
|
|
229
|
+
if (!isLive(w))
|
|
229
230
|
state.done.set(w.wid, Math.max(8, state.done.get(w.wid) ?? 0));
|
|
230
231
|
}
|
|
231
232
|
}
|
|
@@ -267,13 +268,13 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
|
|
|
267
268
|
for (const c of v.calls)
|
|
268
269
|
latest.set(c.key, c);
|
|
269
270
|
const w = latest.size === v.calls.length ? v : { ...v, calls: v.calls.filter(c => latest.get(c.key) === c) };
|
|
270
|
-
const dim = w
|
|
271
|
+
const dim = !isLive(w); // v12 §5: finished workflows are dimmed, not hidden; an open follow-up keeps one live
|
|
271
272
|
if (w.calls.length === 1) {
|
|
272
273
|
callRow(w, w.calls[0], " ", " ", dim);
|
|
273
274
|
continue;
|
|
274
275
|
}
|
|
275
276
|
// Stable order (UI §2): active rows keep proposal (snapshot) order; done rows by immutable end time.
|
|
276
|
-
const done = doneOrder(w.calls), active = w
|
|
277
|
+
const done = doneOrder(w.calls), active = liveCalls(w), folded = !isOpen(w, state);
|
|
277
278
|
const progress = progressOf(w); // v12 §4: done/planned, `n+` while a script workflow keeps proposing
|
|
278
279
|
rows.push({ id: w.wid, kind: "workflow", workflow: w, dim, text: `${folded ? "▸" : "▾"} ${w.name ?? w.wid} · ${progress.done}/${progress.total}${progress.plus ? "+" : ""} · ${duration((w.endedAt ?? now) - (w.startedAt ?? now))}` });
|
|
279
280
|
if (folded)
|
|
@@ -285,7 +286,7 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
|
|
|
285
286
|
active.forEach((c, i) => { const last = i === active.length - 1 && !done.length; callRow(w, c, last ? " └ " : " ├ ", last ? " " : " │ ", dim); });
|
|
286
287
|
if (!done.length)
|
|
287
288
|
continue;
|
|
288
|
-
const count = state.done.get(w.wid) ?? (w
|
|
289
|
+
const count = state.done.get(w.wid) ?? (isLive(w) && !active.length || unviewed(w) ? 8 : 0), shown = done.slice(0, count);
|
|
289
290
|
rows.push({ id: `${w.wid}:done`, kind: "done", workflow: w, dim, text: ` └ ${count ? "▾" : "▸"} ${progress.done} done` });
|
|
290
291
|
const more = count && done.length > count;
|
|
291
292
|
shown.forEach((c, i) => callRow(w, c, i === shown.length - 1 && !more ? " └ " : " ├ ", undefined, dim));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|