@staix/agent-hub 0.12.2 → 0.12.3
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 +8 -0
- package/docs/cooperbench.md +76 -0
- package/docs/events.md +5 -3
- package/docs/smoke.md +16 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +37 -0
- package/docs/verification/2026-10-02-0.12.3.md +44 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +21 -7
- package/src/adapters/claude-channel.ts +17 -4
- package/src/adapters/local-worker.ts +109 -16
- package/src/adapters/pi.ts +106 -21
- package/src/cli/launch.ts +18 -1
- package/src/cli/main.ts +13 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +17 -3
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +77 -7
- package/src/hub/events.ts +3 -1
- package/src/hub/execution-budget.ts +138 -0
- package/src/hub/report.ts +85 -1
- package/src/hub/tasks.ts +22 -0
- package/src/hub/usage.ts +55 -0
- package/src/local/sandbox.ts +6 -2
- package/src/local/tools.ts +7 -2
- package/src/models/relay.ts +12 -4
- package/src/omniroute/client.ts +14 -3
- package/src/omniroute/usage.ts +38 -0
- package/src/pi/extension.ts +44 -8
|
@@ -6,7 +6,9 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
|
6
6
|
import { runTool, TOOL_SCHEMAS, touchedPaths, type ToolContext } from "../local/tools.ts";
|
|
7
7
|
import type { Capture } from "../memory/capture.ts";
|
|
8
8
|
import type { ChatMessage, ChatResult, OmniRoute } from "../omniroute/client.ts";
|
|
9
|
+
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
9
10
|
import type { Sidecar } from "../switchyard/sidecar.ts";
|
|
11
|
+
import type { ExecutionBudgetDecision } from "../hub/execution-budget.ts";
|
|
10
12
|
|
|
11
13
|
export interface LocalOptions {
|
|
12
14
|
cwd: string;
|
|
@@ -21,6 +23,10 @@ export interface LocalOptions {
|
|
|
21
23
|
capture?: Capture;
|
|
22
24
|
/** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
|
|
23
25
|
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
|
|
26
|
+
/** Successful provider responses only; usage may be absent when the gateway omits it. Never includes prompt data. */
|
|
27
|
+
onUsage?: (record: { id: string; at: string; usage?: ChatResult["usage"]; requestedModel: string; servedModel?: string; provider?: string }) => void;
|
|
28
|
+
/** Atomic task/run admission immediately before every model request or tool execution. */
|
|
29
|
+
admitBudget?: (envs: Envelope[], unit: "model_calls" | "tool_calls") => Promise<ExecutionBudgetDecision[]>;
|
|
24
30
|
/** Per-turn policy from the task the delivery carries: the class's route, and whether it is a PII task. */
|
|
25
31
|
turnPolicy?: (envs: Envelope[]) => { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined;
|
|
26
32
|
/** Role contract, appended to the system prompt. */
|
|
@@ -35,6 +41,7 @@ const HISTORY_CHARS = 100_000;
|
|
|
35
41
|
const TURN_CHARS = 120_000;
|
|
36
42
|
/** Tools whose effects outlive a failed turn: once one ran, the turn is never redelivered. */
|
|
37
43
|
const SIDE_EFFECTS = new Set(["write", "edit", "bash", "git", "hub_send"]);
|
|
44
|
+
class ExecutionBudgetStop extends Error { readonly budgetStop = true; }
|
|
38
45
|
const chars = (msgs: ChatMessage[]) => msgs.reduce((n, m) => n + (m.content?.length ?? 0) + JSON.stringify(m.tool_calls ?? "").length, 0);
|
|
39
46
|
|
|
40
47
|
const asFunction = (t: { name: string; description: string; inputSchema: unknown }) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.inputSchema } });
|
|
@@ -57,6 +64,8 @@ export class LocalPeer extends BasePeer {
|
|
|
57
64
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
58
65
|
private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
|
|
59
66
|
private abort: AbortController | undefined;
|
|
67
|
+
private budgetTimer?: ReturnType<typeof setTimeout>;
|
|
68
|
+
private budgetStopReason = "";
|
|
60
69
|
private activeDeliveryId: string | undefined;
|
|
61
70
|
private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
|
|
62
71
|
/** What served the last call, for `ahub status`. */
|
|
@@ -83,6 +92,7 @@ export class LocalPeer extends BasePeer {
|
|
|
83
92
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
|
|
84
93
|
this.activeDeliveryId = undefined;
|
|
85
94
|
this.turn++;
|
|
95
|
+
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
86
96
|
this.abort?.abort();
|
|
87
97
|
await this.opts.capture?.end();
|
|
88
98
|
this.setState("offline");
|
|
@@ -95,7 +105,10 @@ export class LocalPeer extends BasePeer {
|
|
|
95
105
|
throw new Error(`${this.id} is ${this.state}`);
|
|
96
106
|
}
|
|
97
107
|
const turn = ++this.turn;
|
|
108
|
+
this.budgetStopReason = "";
|
|
109
|
+
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
98
110
|
this.activeDeliveryId = deliveryId;
|
|
111
|
+
this.abort = new AbortController();
|
|
99
112
|
this.setState("busy");
|
|
100
113
|
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
101
114
|
// The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
|
|
@@ -115,7 +128,12 @@ export class LocalPeer extends BasePeer {
|
|
|
115
128
|
})
|
|
116
129
|
.catch((e: Error) => {
|
|
117
130
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
131
|
+
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
118
132
|
this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
|
|
133
|
+
if (e instanceof ExecutionBudgetStop) {
|
|
134
|
+
this.reportBudgetStop(e, msgs, progress, reply, !!policy?.pii, deliveryId);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
119
137
|
if (!progress.sideEffects) {
|
|
120
138
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: e.message });
|
|
121
139
|
else this.onFailed?.(envs); // legacy delivery: safe to redeliver
|
|
@@ -130,6 +148,7 @@ export class LocalPeer extends BasePeer {
|
|
|
130
148
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
|
|
131
149
|
})
|
|
132
150
|
.finally(() => {
|
|
151
|
+
if (turn === this.turn) { clearTimeout(this.budgetTimer); this.budgetTimer = undefined; }
|
|
133
152
|
if (turn === this.turn && this.state === "busy") this.setState("idle");
|
|
134
153
|
if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
|
|
135
154
|
});
|
|
@@ -139,6 +158,7 @@ export class LocalPeer extends BasePeer {
|
|
|
139
158
|
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
|
|
140
159
|
this.activeDeliveryId = undefined;
|
|
141
160
|
this.turn++;
|
|
161
|
+
clearTimeout(this.budgetTimer); this.budgetTimer = undefined;
|
|
142
162
|
this.abort?.abort();
|
|
143
163
|
super.onWatchdog();
|
|
144
164
|
}
|
|
@@ -152,6 +172,7 @@ export class LocalPeer extends BasePeer {
|
|
|
152
172
|
reply: EnvelopeOpts,
|
|
153
173
|
): Promise<string> {
|
|
154
174
|
const { maxSteps = 30 } = this.opts;
|
|
175
|
+
const turnSignal = this.abort!.signal;
|
|
155
176
|
// claude-mem's observer is a cloud model: nothing of a PII turn is captured.
|
|
156
177
|
const capture = policy?.pii ? undefined : this.opts.capture;
|
|
157
178
|
// Positive confirmation, and for the path this turn will really take: a sidecar generated against the off-campus URL
|
|
@@ -160,22 +181,11 @@ export class LocalPeer extends BasePeer {
|
|
|
160
181
|
if (policy?.pii && (viaOffCampus || !(await this.opts.omni.onCampus()))) {
|
|
161
182
|
return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
|
|
162
183
|
}
|
|
163
|
-
const ctx
|
|
164
|
-
cwd: this.opts.cwd,
|
|
165
|
-
deny: this.opts.tools.deny,
|
|
166
|
-
permit: this.opts.tools.permit,
|
|
167
|
-
sandboxProfile: this.sandboxProfile,
|
|
168
|
-
sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
|
|
169
|
-
send: (text, to) => {
|
|
170
|
-
const refused = this.onMessage?.(text, policy?.pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
|
|
171
|
-
if (typeof refused === "string") return `not sent: ${refused}`;
|
|
172
|
-
return policy?.pii ? "sent to the console user only (PII task)" : "sent";
|
|
173
|
-
},
|
|
174
|
-
};
|
|
184
|
+
const ctx = this.toolContext(envs, turnSignal, !!policy?.pii, reply);
|
|
175
185
|
let usedTools = false;
|
|
176
186
|
for (let step = 0; step < maxSteps; step++) {
|
|
177
187
|
this.elide(msgs);
|
|
178
|
-
const res = await this.call(msgs, policy);
|
|
188
|
+
const res = await this.call(msgs, policy, envs);
|
|
179
189
|
if (turn !== this.turn) return "";
|
|
180
190
|
this.touch();
|
|
181
191
|
msgs.push(res.message);
|
|
@@ -188,6 +198,10 @@ export class LocalPeer extends BasePeer {
|
|
|
188
198
|
// A tool has its own timeout (bash up to 600 s) and an approval can take 120 s: neither is the model going silent.
|
|
189
199
|
const alive = setInterval(() => this.state === "busy" && turn === this.turn && this.touch(), 30_000);
|
|
190
200
|
const name = call.function.name;
|
|
201
|
+
try {
|
|
202
|
+
await this.requireBudget(envs, "tool_calls");
|
|
203
|
+
if (turnSignal.aborted) throw new ExecutionBudgetStop(this.budgetStopReason || "turn cancelled before tool execution");
|
|
204
|
+
} catch (error) { clearInterval(alive); throw error; }
|
|
191
205
|
const running = TASK_TOOL_NAMES.has(name) && this.opts.taskTool ? this.opts.taskTool(name, safeParse(call.function.arguments), { pii: !!policy?.pii }).catch((e: Error) => `error: ${e.message}`) : runTool(name, call.function.arguments, ctx);
|
|
192
206
|
const output = await running.finally(() => clearInterval(alive));
|
|
193
207
|
if (turn !== this.turn) return "";
|
|
@@ -202,32 +216,111 @@ export class LocalPeer extends BasePeer {
|
|
|
202
216
|
return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
|
|
203
217
|
}
|
|
204
218
|
|
|
219
|
+
private toolContext(envs: Envelope[], turnSignal: AbortSignal, pii: boolean, reply: EnvelopeOpts): ToolContext {
|
|
220
|
+
return {
|
|
221
|
+
cwd: this.opts.cwd,
|
|
222
|
+
deny: this.opts.tools.deny,
|
|
223
|
+
permit: async (title) => {
|
|
224
|
+
if (turnSignal.aborted) return false;
|
|
225
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
226
|
+
const finish = (allowed: boolean) => { turnSignal.removeEventListener("abort", onAbort); resolve(allowed); };
|
|
227
|
+
const onAbort = () => finish(false);
|
|
228
|
+
turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
229
|
+
this.opts.tools.permit(title).then(finish, (error) => { turnSignal.removeEventListener("abort", onAbort); reject(error); });
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
sandboxProfile: this.sandboxProfile,
|
|
233
|
+
sandboxEnv: proxyEnv(this.opts.tools.bashNetwork ?? false),
|
|
234
|
+
signal: turnSignal,
|
|
235
|
+
send: (text, to) => {
|
|
236
|
+
const refused = this.onMessage?.(text, pii ? reply : { inReplyTo: replyParent(envs), to: to?.length ? to : replyAudience(envs) });
|
|
237
|
+
if (typeof refused === "string") return `not sent: ${refused}`;
|
|
238
|
+
return pii ? "sent to the console user only (PII task)" : "sent";
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, pii: boolean, deliveryId?: string): void {
|
|
244
|
+
this.completeMissingToolResults(msgs, `not run: ${e.message}`);
|
|
245
|
+
const detail = progress.sideEffects
|
|
246
|
+
? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
|
|
247
|
+
: `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
|
|
248
|
+
msgs.push({ role: "assistant", content: detail });
|
|
249
|
+
if (!pii) this.commit(msgs);
|
|
250
|
+
this.onMessage?.(detail, reply);
|
|
251
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async requireBudget(envs: Envelope[], unit: "model_calls" | "tool_calls"): Promise<void> {
|
|
255
|
+
if (!this.opts.admitBudget) return;
|
|
256
|
+
const decisions = await this.opts.admitBudget(envs, unit);
|
|
257
|
+
const denied = decisions.find((decision) => !decision.allowed);
|
|
258
|
+
if (denied) throw new ExecutionBudgetStop(`execution budget ${denied.reason ?? "exhausted"}: ${denied.scope} ${denied.unit} used ${denied.used}${denied.limit === null ? "" : ` of ${denied.limit}`}`);
|
|
259
|
+
const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
|
|
260
|
+
if (remaining !== undefined) {
|
|
261
|
+
clearTimeout(this.budgetTimer);
|
|
262
|
+
this.budgetTimer = setTimeout(() => {
|
|
263
|
+
this.budgetStopReason = "execution budget exhausted: elapsed_ms wall cap reached";
|
|
264
|
+
this.abort?.abort();
|
|
265
|
+
}, Math.max(0, remaining));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Preserve valid model history when admission stops in the middle of a parallel tool-call batch. */
|
|
270
|
+
private completeMissingToolResults(msgs: ChatMessage[], result: string): void {
|
|
271
|
+
const called = new Set<string>();
|
|
272
|
+
for (const msg of msgs) if (msg.role === "tool" && msg.tool_call_id) called.add(msg.tool_call_id);
|
|
273
|
+
const missing: string[] = [];
|
|
274
|
+
for (const msg of msgs) if (msg.role === "assistant") for (const call of msg.tool_calls ?? []) if (call.id && !called.has(call.id)) {
|
|
275
|
+
called.add(call.id); missing.push(call.id);
|
|
276
|
+
}
|
|
277
|
+
for (const tool_call_id of missing) msgs.push({ role: "tool", tool_call_id, content: result });
|
|
278
|
+
}
|
|
279
|
+
|
|
205
280
|
/** L2 when the sidecar is up, otherwise (or when a call through it fails) the fixed model on L3. */
|
|
206
|
-
private async call(turnMsgs: ChatMessage[], policy
|
|
281
|
+
private async call(turnMsgs: ChatMessage[], policy: { route?: string; fixedModel?: string } | undefined, envs: Envelope[]): Promise<ChatResult> {
|
|
207
282
|
const { omni, sidecar } = this.opts;
|
|
208
283
|
// A task turn asks for its class's route; a route needs the sidecar, which exists only when the worker was started with one.
|
|
209
284
|
const route = sidecar ? (policy?.route ?? this.opts.route) : undefined;
|
|
210
285
|
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
211
286
|
const tools = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? TASK_TOOLS.map(asFunction) : [])];
|
|
212
|
-
this.abort
|
|
213
|
-
const signal = this.abort.signal;
|
|
287
|
+
const signal = this.abort!.signal;
|
|
214
288
|
const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
|
|
215
289
|
const via = route ? await sidecar?.endpoint() : undefined;
|
|
216
290
|
if (via) {
|
|
291
|
+
await this.requireBudget(envs, "model_calls");
|
|
217
292
|
try {
|
|
218
293
|
const res = await omni.chat({ model: route!, messages, tools }, { via, sessionId: this.sessionId, signal });
|
|
219
294
|
this.lastServedBy = `switchyard ${route} -> ${res.selectedModel ?? "?"}`;
|
|
295
|
+
this.recordUsage(res, route!);
|
|
220
296
|
return res;
|
|
221
297
|
} catch (e) {
|
|
222
298
|
if (signal.aborted) throw e;
|
|
223
299
|
sidecar!.disable((e as Error).message);
|
|
224
300
|
}
|
|
225
301
|
}
|
|
302
|
+
await this.requireBudget(envs, "model_calls");
|
|
226
303
|
const res = await omni.chat({ model: fixedModel, messages, tools }, { signal });
|
|
227
304
|
this.lastServedBy = `omniroute ${fixedModel} (provider ${res.provider ?? "?"})`;
|
|
305
|
+
this.recordUsage(res, fixedModel);
|
|
228
306
|
return res;
|
|
229
307
|
}
|
|
230
308
|
|
|
309
|
+
private recordUsage(res: ChatResult, requestedModel: string): void {
|
|
310
|
+
try {
|
|
311
|
+
this.opts.onUsage?.({
|
|
312
|
+
id: randomUUID(),
|
|
313
|
+
at: new Date().toISOString(),
|
|
314
|
+
...(res.usage ? { usage: res.usage } : {}),
|
|
315
|
+
requestedModel: safeModelLabel(requestedModel) ?? "unknown",
|
|
316
|
+
...(safeModelLabel(res.servedModel ?? res.selectedModel) ? { servedModel: safeModelLabel(res.servedModel ?? res.selectedModel)! } : {}),
|
|
317
|
+
...(safeModelLabel(res.provider) ? { provider: safeModelLabel(res.provider)! } : {}),
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
// Usage persistence is optional and must never affect a provider call or turn.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
231
324
|
/** A finished turn joins the history; whole old turns (user message up to the next one) fall off the front, so tool calls keep their results. */
|
|
232
325
|
private commit(msgs: ChatMessage[]): void {
|
|
233
326
|
this.history.push(...msgs);
|
package/src/adapters/pi.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId }
|
|
|
6
6
|
import { BasePeer } from "../hub/peers.ts";
|
|
7
7
|
import { stopOwnedProcess } from "../hub/child-process.ts";
|
|
8
8
|
import { realPath } from "../hub/project.ts";
|
|
9
|
+
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
9
10
|
|
|
10
11
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
11
12
|
export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
|
|
@@ -13,9 +14,11 @@ export interface PiRelay { url: string; token: string; models: PiModelDescriptor
|
|
|
13
14
|
export interface PiOptions {
|
|
14
15
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
15
16
|
model?: string;
|
|
16
|
-
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string) => Promise<string>; tools: PiToolSchema[];
|
|
17
|
+
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
|
|
17
18
|
preamble?: string;
|
|
18
19
|
selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
|
|
20
|
+
/** Atomic task/run admission immediately before provider requests or tool execution. */
|
|
21
|
+
admitBudget?: (envs: Envelope[], unit: ExecutionUnit) => Promise<ExecutionBudgetDecision[]>;
|
|
19
22
|
/** Reported message usage as increments, before agent_settled releases the turn. */
|
|
20
23
|
onTokens?: (added: number) => void;
|
|
21
24
|
onTurnFailure?: (envs: Envelope[], reason: string) => Promise<void>;
|
|
@@ -51,6 +54,11 @@ export class PiPeer extends BasePeer {
|
|
|
51
54
|
private currentReply?: Envelope;
|
|
52
55
|
private sessionId = "";
|
|
53
56
|
private sessionFile = "";
|
|
57
|
+
private executionBudgetTimer?: ReturnType<typeof setTimeout>;
|
|
58
|
+
private executionAbort?: AbortController;
|
|
59
|
+
private readonly budgetStops = new Map<number, string>();
|
|
60
|
+
private readonly idleBashReservations = new Map<string, { generation: number; expiresAt: number; deadlineAt?: number }>();
|
|
61
|
+
private budgetGeneration = 0;
|
|
54
62
|
private emptyResumeVerified = false;
|
|
55
63
|
private verifiedEmptyResume?: VerifiedEmptyResume;
|
|
56
64
|
private activityObserved = false;
|
|
@@ -132,11 +140,87 @@ export class PiPeer extends BasePeer {
|
|
|
132
140
|
this.verifiedEmptyResume = undefined;
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/** Envelopes belonging to the currently executing Pi turn, for relay-side model-call admission. */
|
|
144
|
+
get budgetEnvelopes(): Envelope[] { return this.agentRunning && this.state === "busy" ? this.activeEnvs.slice() : []; }
|
|
145
|
+
|
|
146
|
+
/** Mark an authoritative relay admission stop against the current Pi turn, never a later turn. */
|
|
147
|
+
recordBudgetStop(decision: ExecutionBudgetDecision): { generation: number; reason: string } | undefined {
|
|
148
|
+
if (!this.agentRunning || this.state !== "busy") return undefined;
|
|
149
|
+
const generation = this.budgetGeneration;
|
|
150
|
+
const reason = `execution budget ${decision.reason ?? "exhausted"}: ${decision.scope} ${decision.unit} used ${decision.used}${decision.limit === null ? "" : ` of ${decision.limit}`}; ${decision.remaining === null ? "remaining unknown" : `${decision.remaining} remaining`}`;
|
|
151
|
+
this.budgetStops.set(generation, reason);
|
|
152
|
+
this.executionAbort?.abort();
|
|
153
|
+
for (const old of this.budgetStops.keys()) if (old < generation - 8) this.budgetStops.delete(old);
|
|
154
|
+
return { generation, reason };
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
async start(): Promise<void> {
|
|
136
158
|
if (this.starting) return this.starting;
|
|
137
159
|
this.starting = this.startImpl();
|
|
138
160
|
try { await this.starting; } finally { this.starting = undefined; }
|
|
139
161
|
}
|
|
162
|
+
private async handleToolRequest(body: any): Promise<Response> {
|
|
163
|
+
if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
|
|
164
|
+
let executionSignal = this.executionAbort?.signal;
|
|
165
|
+
let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
|
|
166
|
+
if (body.purpose === "idle_user_bash") {
|
|
167
|
+
const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
|
|
168
|
+
if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
|
|
169
|
+
if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
|
|
170
|
+
this.idleBashReservations.delete(String(body.reservation));
|
|
171
|
+
return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
|
|
172
|
+
}
|
|
173
|
+
this.idleBashReservations.delete(String(body.reservation));
|
|
174
|
+
const idleBashAbort = new AbortController();
|
|
175
|
+
executionSignal = idleBashAbort.signal;
|
|
176
|
+
if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
|
|
177
|
+
}
|
|
178
|
+
this.noteActivity();
|
|
179
|
+
this.activeTools++;
|
|
180
|
+
if (this.state === "idle") this.setState("busy");
|
|
181
|
+
if (this.state === "busy") this.touch();
|
|
182
|
+
try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId, executionSignal) }); }
|
|
183
|
+
catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
|
|
184
|
+
finally {
|
|
185
|
+
clearTimeout(idleBashTimer);
|
|
186
|
+
this.activeTools--;
|
|
187
|
+
if (this.state === "busy") {
|
|
188
|
+
if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
|
|
189
|
+
else this.touch();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async handleBudgetRequest(body: any): Promise<Response> {
|
|
195
|
+
if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
|
|
196
|
+
const idleUserBash = body.idleUserBash === true;
|
|
197
|
+
const current = Number.isSafeInteger(body.generation) && body.generation === this.budgetGeneration;
|
|
198
|
+
const idleOwner = this.ownerClaimed && this.ownerPid !== undefined && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
|
|
199
|
+
if (!current || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
|
|
200
|
+
// An interactive user shell has no task delivery. It may spend an eligible run budget only.
|
|
201
|
+
const envs = idleUserBash ? [] : this.budgetEnvelopes;
|
|
202
|
+
const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
|
|
203
|
+
const denied = decisions.find((decision) => !decision.allowed);
|
|
204
|
+
const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
|
|
205
|
+
let reservation: string | undefined;
|
|
206
|
+
if (idleUserBash && !denied) {
|
|
207
|
+
for (const [key, value] of this.idleBashReservations) if (value.expiresAt < Date.now()) this.idleBashReservations.delete(key);
|
|
208
|
+
reservation = randomUUID();
|
|
209
|
+
this.idleBashReservations.set(reservation, { generation: body.generation, expiresAt: Date.now() + 30_000, ...(remaining === undefined ? {} : { deadlineAt: Date.now() + remaining }) });
|
|
210
|
+
}
|
|
211
|
+
clearTimeout(this.executionBudgetTimer);
|
|
212
|
+
if (!denied && remaining !== undefined && this.state === "busy") {
|
|
213
|
+
const generation = this.budgetGeneration;
|
|
214
|
+
const controller = this.executionAbort;
|
|
215
|
+
this.executionBudgetTimer = setTimeout(() => {
|
|
216
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || controller !== this.executionAbort) return;
|
|
217
|
+
controller?.abort();
|
|
218
|
+
void this.sendTui({ type: "abort_budget", generation }).catch((error) => this.opts.log?.(`[${this.id}] elapsed budget stop could not reach Pi: ${(error as Error).message}`));
|
|
219
|
+
}, Math.max(0, remaining));
|
|
220
|
+
}
|
|
221
|
+
return Response.json({ decisions, ...(reservation ? { reservation } : {}) });
|
|
222
|
+
}
|
|
223
|
+
|
|
140
224
|
private async startImpl(): Promise<void> {
|
|
141
225
|
this.stopping = false;
|
|
142
226
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
@@ -168,22 +252,8 @@ export class PiPeer extends BasePeer {
|
|
|
168
252
|
this.tuiCommands.delete(String(body.id)); body.ok ? pending.resolve(body.result) : pending.reject(new Error(String(body.error ?? "Pi TUI command failed")));
|
|
169
253
|
return Response.json({ ok: true });
|
|
170
254
|
}
|
|
171
|
-
if (url.pathname === "/tool")
|
|
172
|
-
|
|
173
|
-
this.noteActivity();
|
|
174
|
-
this.activeTools++;
|
|
175
|
-
if (this.state === "idle") this.setState("busy");
|
|
176
|
-
if (this.state === "busy") this.touch();
|
|
177
|
-
try { return Response.json({ text: await this.opts.executeTool(String(body.name), body.args, String(body.toolCallId ?? ""), this.sessionId) }); }
|
|
178
|
-
catch (error) { return Response.json({ text: `error: ${(error as Error).message}` }, { status: 200 }); }
|
|
179
|
-
finally {
|
|
180
|
-
this.activeTools--;
|
|
181
|
-
if (this.state === "busy") {
|
|
182
|
-
if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
|
|
183
|
-
else this.touch();
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
255
|
+
if (url.pathname === "/tool") return this.handleToolRequest(body);
|
|
256
|
+
if (url.pathname === "/budget") return this.handleBudgetRequest(body);
|
|
187
257
|
return new Response("not found", { status: 404 });
|
|
188
258
|
} });
|
|
189
259
|
this.server = bridge;
|
|
@@ -340,7 +410,11 @@ export class PiPeer extends BasePeer {
|
|
|
340
410
|
this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
|
|
341
411
|
}
|
|
342
412
|
if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
|
|
343
|
-
if (event.type === "agent_start") {
|
|
413
|
+
if (event.type === "agent_start") {
|
|
414
|
+
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
415
|
+
if (generation <= this.budgetGeneration) return;
|
|
416
|
+
this.usageSeen.clear(); this.idleBashReservations.clear(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
417
|
+
}
|
|
344
418
|
if (event.type === "activity" && this.state === "busy") this.touch();
|
|
345
419
|
if (event.type === "tokens" && this.state === "busy" && typeof event.id === "string" && event.id.length <= 100 && Number.isSafeInteger(event.tokens) && event.tokens >= 0 && !this.usageSeen.has(event.id)) {
|
|
346
420
|
this.usageSeen.add(event.id);
|
|
@@ -348,24 +422,35 @@ export class PiPeer extends BasePeer {
|
|
|
348
422
|
if (event.tokens > 0) this.opts.onTokens?.(event.tokens);
|
|
349
423
|
}
|
|
350
424
|
if (event.type === "agent_end") {
|
|
425
|
+
if (Number.isSafeInteger(event.generation) && event.generation !== this.budgetGeneration) return;
|
|
426
|
+
clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
|
|
427
|
+
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
428
|
+
const budgetStop = this.budgetStops.get(generation);
|
|
429
|
+
if (budgetStop) this.executionAbort?.abort();
|
|
351
430
|
this.settledText = typeof event.text === "string" ? event.text : "";
|
|
352
|
-
this.settledCancelled = event.cancelled === true;
|
|
353
|
-
this.settledError = event.failed ? String(event.error ?? "Pi agent run failed") : "";
|
|
431
|
+
this.settledCancelled = !budgetStop && event.cancelled === true;
|
|
432
|
+
this.settledError = budgetStop ?? (event.failed ? String(event.error ?? "Pi agent run failed") : "");
|
|
354
433
|
}
|
|
355
434
|
if (event.type === "agent_settled") {
|
|
435
|
+
if (Number.isSafeInteger(event.generation) && event.generation !== this.budgetGeneration) return;
|
|
436
|
+
clearTimeout(this.executionBudgetTimer); this.executionBudgetTimer = undefined;
|
|
356
437
|
this.agentRunning = false;
|
|
357
438
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
358
|
-
const
|
|
439
|
+
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
440
|
+
const text = this.settledText.trim(); const error = this.budgetStops.get(generation) ?? this.settledError; const cancelled = !error && this.settledCancelled;
|
|
441
|
+
this.budgetStops.delete(generation);
|
|
359
442
|
this.settledText = ""; this.settledError = ""; this.settledCancelled = false;
|
|
360
443
|
// Answer the peers this turn was for, not every peer on the bus (issue #29). activeEnvs still holds the
|
|
361
444
|
// whole delivery here, steered additions included.
|
|
362
445
|
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
363
446
|
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
447
|
+
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
364
448
|
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
|
|
365
449
|
else if (text) this.onMessage?.(text, reply);
|
|
366
450
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
367
451
|
this.activeDeliveryIds.clear();
|
|
368
452
|
this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
|
|
453
|
+
this.executionAbort = undefined;
|
|
369
454
|
}
|
|
370
455
|
}
|
|
371
456
|
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
2
4
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
3
5
|
|
|
6
|
+
/** A scoped candidate MCP server can be selected without promoting the installed plugin. */
|
|
7
|
+
function claudeChannel(userArgs: string[]): string {
|
|
8
|
+
const bundle = resolve(import.meta.dir, "../../plugins/agent-hub/server.js");
|
|
9
|
+
for (let i = 0; i < userArgs.length; i++) {
|
|
10
|
+
const arg = userArgs[i]!;
|
|
11
|
+
const path = arg === "--mcp-config" ? userArgs[i + 1] : arg.startsWith("--mcp-config=") ? arg.slice("--mcp-config=".length) : undefined;
|
|
12
|
+
if (!path) continue;
|
|
13
|
+
try {
|
|
14
|
+
const server = JSON.parse(readFileSync(resolve(path), "utf8")).mcpServers?.["agent-hub"];
|
|
15
|
+
if (server?.command === "bun" && Array.isArray(server.args) && server.args.some((value: unknown) => typeof value === "string" && resolve(value) === bundle)) return "server:agent-hub";
|
|
16
|
+
} catch { /* The native CLI reports invalid user-supplied MCP configuration. */ }
|
|
17
|
+
}
|
|
18
|
+
return CLAUDE_CHANNEL;
|
|
19
|
+
}
|
|
20
|
+
|
|
4
21
|
const OWNED: Record<"claude" | "codex", string[]> = {
|
|
5
22
|
claude: ["--dangerously-load-development-channels", "--dangerously-skip-permissions"],
|
|
6
23
|
codex: ["--remote", "--yolo", "--dangerously-bypass-approvals-and-sandbox"],
|
|
@@ -53,7 +70,7 @@ export function buildLaunch(
|
|
|
53
70
|
const notes = [unattended ? UNATTENDED_WARNING : "", ctx.statusLine && own ? "note: you passed --settings, so the hub's status line tee is off and the budget coordinator cannot see Claude's quota (ahub budget set claude <0..1> still works)." : ""].filter(Boolean);
|
|
54
71
|
return {
|
|
55
72
|
cmd: "claude",
|
|
56
|
-
args: ["--dangerously-load-development-channels",
|
|
73
|
+
args: ["--dangerously-load-development-channels", claudeChannel(passthrough), ...(unattended ? ["--dangerously-skip-permissions"] : []), ...tee, ...passthrough],
|
|
57
74
|
...(notes.length ? { warning: notes.join("\n") } : {}),
|
|
58
75
|
};
|
|
59
76
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -67,6 +67,7 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
67
67
|
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
68
68
|
start the text with [STATUS] to let it batch or [FYI] for the record only
|
|
69
69
|
ahub pause|resume <peer> hold a peer's deliveries in its queue / release them
|
|
70
|
+
ahub budget execution configure <config.json> | status [id] | disable <id>
|
|
70
71
|
ahub budget quota windows per peer, and who is paused until when
|
|
71
72
|
ahub budget set <peer> <0..1> [--resets-in 30m] [--window 5h|week] feed a reading by hand (also: test the relay)
|
|
72
73
|
ahub budget resume <peer> override a budget pause; readings are ignored for that peer until the window resets
|
|
@@ -643,6 +644,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
643
644
|
|
|
644
645
|
budget: async () => {
|
|
645
646
|
const hub = await connect();
|
|
647
|
+
if (args[0] === "execution") {
|
|
648
|
+
const op = args[1] ?? "status";
|
|
649
|
+
let request: Record<string, unknown> = { t: "execution_budget", op, ...(args[2] ? { id: args[2] } : {}) };
|
|
650
|
+
if (op === "configure") {
|
|
651
|
+
if (!args[2]) { hub.close(); fail("usage: ahub budget execution configure <config.json>"); }
|
|
652
|
+
try { request = { t: "execution_budget", op, config: JSON.parse(readFileSync(args[2]!, "utf8")) }; }
|
|
653
|
+
catch { hub.close(); fail("cannot read execution budget JSON configuration"); }
|
|
654
|
+
}
|
|
655
|
+
const result = await hub.request(request); hub.close();
|
|
656
|
+
if (!result.ok) fail(result.error);
|
|
657
|
+
return console.log(JSON.stringify(result.budgets ?? result.budget ?? { disabled: result.disabled }, null, 2));
|
|
658
|
+
}
|
|
646
659
|
let set: Record<string, unknown> | undefined;
|
|
647
660
|
if (args[0] === "resume") {
|
|
648
661
|
if (!args[1]) fail("usage: ahub budget resume <peer>");
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
2
2
|
|
|
3
|
-
export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
3
|
+
export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
4
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
|
|
5
5
|
|
|
6
6
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
|
@@ -15,6 +15,7 @@ export function peerLine(id: string, p: PeerRow): string {
|
|
|
15
15
|
// Which queued messages the peer will not wait out the batch window for (issue #41).
|
|
16
16
|
(p.queuedImportant ? ` (${p.queuedImportant} important)` : "") +
|
|
17
17
|
(p.needsReview ? ` needs review ${p.needsReview}` : "") +
|
|
18
|
+
(p.liveAccepted?.length ? ` awaiting settlement ${p.liveAccepted.join(", ")} (adapter completion; Claude: reply or hub_delivery_done; task state is independent)` : "") +
|
|
18
19
|
(p.heldBy ? ` ${p.holdNote ?? `held by needs_review ${p.heldBy}; ahub queue resolve ${p.heldBy} --action completed|retry|discard --reason <text>`}` : "") +
|
|
19
20
|
(p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
|
|
20
21
|
(p.attached === false ? " disconnected" : "") +
|
|
@@ -114,7 +114,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
114
114
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
115
115
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
116
116
|
const blockers = [...source.blockers];
|
|
117
|
-
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10 or protocol-
|
|
117
|
+
if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11 or protocol-12 source is required");
|
|
118
118
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
119
119
|
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
120
120
|
for (const peer of source.peers) {
|
package/src/hub/bus.ts
CHANGED
|
@@ -340,12 +340,14 @@ export class Bus {
|
|
|
340
340
|
void this.drain(record.peer);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
queueSummary(peer: string): { needsReview: number; heldBy?: string; oldestQueuedAt?: number } {
|
|
343
|
+
queueSummary(peer: string): { needsReview: number; heldBy?: string; liveAccepted?: string[]; oldestQueuedAt?: number } {
|
|
344
344
|
const rows = this.storageError ? [] : this.queueList(peer);
|
|
345
345
|
const queued = this.queues.get(peer) ?? [];
|
|
346
346
|
const review = rows.filter((r) => r.state === "needs_review");
|
|
347
347
|
const needsReview = review.length;
|
|
348
|
-
const
|
|
348
|
+
const blocked = rows.find((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && !this.activeDeliveries.has(r.id)));
|
|
349
|
+
const liveAccepted = rows.filter((r) => r.state === "accepted" && this.activeDeliveries.get(r.id) === peer).map((r) => r.id);
|
|
350
|
+
const hold = { ...(blocked ? { heldBy: blocked.id } : {}), ...(liveAccepted.length ? { liveAccepted } : {}) };
|
|
349
351
|
const oldest = queued.reduce<number | undefined>((a, r) => a === undefined ? r.ts : Math.min(a, r.ts), undefined);
|
|
350
352
|
return oldest === undefined ? { needsReview, ...hold } : { needsReview, ...hold, oldestQueuedAt: oldest };
|
|
351
353
|
}
|
|
@@ -379,7 +381,8 @@ export class Bus {
|
|
|
379
381
|
|
|
380
382
|
private refreshRecoveryHold(peer: PeerId): void {
|
|
381
383
|
if (!this.journal) return;
|
|
382
|
-
|
|
384
|
+
// Live accepted notifications are awaiting explicit correlation, not crash recovery.
|
|
385
|
+
const blocked = this.journal.list(peer).some((r) => r.state === "needs_review" || (["dispatching", "accepted"].includes(r.state) && this.activeDeliveries.get(r.id) !== peer));
|
|
383
386
|
if (blocked) this.recoveryHeldPeers.add(peer); else this.recoveryHeldPeers.delete(peer);
|
|
384
387
|
}
|
|
385
388
|
|
|
@@ -399,6 +402,17 @@ export class Bus {
|
|
|
399
402
|
}
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
/** Explicit completion never resolves recovered, unrelated or already uncertain work. */
|
|
406
|
+
completeDelivery(peer: PeerId, deliveryId: string): boolean {
|
|
407
|
+
const row = this.journal?.get(deliveryId);
|
|
408
|
+
// The bridge separately fences socket/generation/handed IDs. Retrying a completed
|
|
409
|
+
// acknowledgement (or acknowledging after a correlated reply) changes nothing.
|
|
410
|
+
if (row?.peer === peer && row.state === "completed") return true;
|
|
411
|
+
if (this.activeDeliveries.get(deliveryId) !== peer || row?.state !== "accepted") return false;
|
|
412
|
+
this.deliveryReceipt(peer, { id: deliveryId, state: "completed", reason: "explicit correlated completion" });
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
402
416
|
queueIds(id: PeerId): string[] {
|
|
403
417
|
return (this.queues.get(id) ?? []).map((e) => e.id);
|
|
404
418
|
}
|
|
@@ -6,10 +6,10 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics. The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
9
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets. The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 12;
|
|
11
11
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
12
|
-
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, PROTOCOL] as const;
|
|
12
|
+
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, PROTOCOL] as const;
|
|
13
13
|
|
|
14
14
|
export interface Hello {
|
|
15
15
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|