@staix/agent-hub 0.12.3 → 0.12.5
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 +15 -0
- package/docs/cooperbench.md +35 -2
- package/docs/events.md +9 -0
- package/docs/operations.md +173 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -1
- package/docs/verification/2026-10-02-0.12.4.md +82 -0
- package/docs/verification/2026-10-03-0.12.5.md +46 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5 -4
- package/src/adapters/claude-channel.ts +2 -1
- package/src/adapters/codex-appserver.ts +51 -6
- package/src/cli/facts-hook.ts +40 -0
- package/src/cli/launch.ts +27 -3
- package/src/cli/main.ts +31 -2
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +4 -2
- package/src/hub/bus.ts +75 -3
- package/src/hub/child-process.ts +223 -2
- package/src/hub/cohorts.ts +308 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +389 -15
- package/src/hub/events.ts +9 -0
- package/src/hub/facts.ts +853 -0
- package/src/hub/report.ts +3 -0
- package/src/hub/routing.ts +94 -0
- package/src/hub/tasks.ts +412 -20
- package/templates/AGENTS.block.md +1 -1
package/src/hub/report.ts
CHANGED
|
@@ -102,6 +102,9 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
102
102
|
case "undeliverable":
|
|
103
103
|
r.messages[e.type]++;
|
|
104
104
|
break;
|
|
105
|
+
case "stale": // published, then dropped before delivery (issue #106)
|
|
106
|
+
r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
|
|
107
|
+
break;
|
|
105
108
|
case "turn_end":
|
|
106
109
|
peer(e.peer).turns++;
|
|
107
110
|
peer(e.peer).busyMinutes += e.ms / 60_000;
|
package/src/hub/routing.ts
CHANGED
|
@@ -248,3 +248,97 @@ export function assign(
|
|
|
248
248
|
if (owner === PI) trace.push(`pi decision: backend ${piBackend ?? "dgx"}${task.signals.includes("long_context") ? `, context limit ${piBackend === "mlx" ? routing.pi.mlx_max_context_tokens : routing.pi.dgx_max_context_tokens}` : ""}`);
|
|
249
249
|
return { ...(owner ? { owner } : {}), ...(pii ? { reviewer: "user" } : reviewer ? { reviewer } : {}), ...(route ? { route } : {}), fixedModel, ...(piBackend ? { piBackend } : {}), trace };
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One recorded task of a peer in a class, as stage proxies (issue #109): `orient` from being handed the task to its
|
|
254
|
+
* accept (queueing included), `work` from the accept to its done. Unknown stages stay undefined, never zero.
|
|
255
|
+
*/
|
|
256
|
+
export interface SplitObservation {
|
|
257
|
+
outcome: "approved" | "failed";
|
|
258
|
+
orient?: number;
|
|
259
|
+
work?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Everything the shadow split prediction reads; `Tasks` builds it once and `route explain` shows the same. */
|
|
263
|
+
export interface SplitInput {
|
|
264
|
+
/** The pair: the peer routing would pick for the task, and the owner of the open task it overlaps. */
|
|
265
|
+
peers: [PeerId, PeerId];
|
|
266
|
+
/**
|
|
267
|
+
* Each peer's observations in this class, across hub runs of the project, from the records tagged with the profile
|
|
268
|
+
* the peer has now (hub version, agent version, hook profile): only those describe the peer being predicted.
|
|
269
|
+
*/
|
|
270
|
+
observations: Record<PeerId, SplitObservation[]>;
|
|
271
|
+
/** Each peer's profile now; undefined while its agent's version is unknown, and then no record matches it. */
|
|
272
|
+
profiles: Record<PeerId, string | undefined>;
|
|
273
|
+
/** Work units of the routed task and of the one it overlaps; undefined is unknown. */
|
|
274
|
+
units: [number | undefined, number | undefined];
|
|
275
|
+
/** Other open work each peer has: a busy owner does not start from orientation plus two whole units. */
|
|
276
|
+
backlog: Record<PeerId, number>;
|
|
277
|
+
available: Record<PeerId, boolean>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface SplitPrediction {
|
|
281
|
+
verdict: "split" | "single" | "unknown";
|
|
282
|
+
trace: string[];
|
|
283
|
+
/** The peer that would finish both units soonest on its own. */
|
|
284
|
+
single?: PeerId;
|
|
285
|
+
/** Predicted seconds: both peers one unit each, in parallel (the later of the two), and the best peer alone. */
|
|
286
|
+
splitS?: number;
|
|
287
|
+
singleS?: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Observations a peer needs, and the share of failures a history may hold, before a prediction is made. */
|
|
291
|
+
export const SPLIT_MIN = 5;
|
|
292
|
+
const SPLIT_FAILURE_SHARE = 0.3;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Shadow split prediction (issue #109): never changes assignment. For two similar units of overlapping work, a split
|
|
296
|
+
* (each peer one unit, in parallel) finishes when the later of `o + u` does; the best single peer takes `o + 2u`. With
|
|
297
|
+
* a faster and a slower peer that is `o_s + u_s < o_f + 2u_f`. It is a model assumption (equal units, measured
|
|
298
|
+
* orientation, free coordination), not a bound, so anything that breaks it makes the prediction unknown: unequal or
|
|
299
|
+
* unknown units, a busy or unavailable peer, too few or failure-heavy records, or work times too spread out to call
|
|
300
|
+
* comparable. A difference under a tenth of the single time is inconclusive.
|
|
301
|
+
*/
|
|
302
|
+
export function predictSplit(input: SplitInput): SplitPrediction {
|
|
303
|
+
const trace: string[] = ["shadow split prediction (it never changes assignment):"];
|
|
304
|
+
const unknown = (why: string): SplitPrediction => ({ verdict: "unknown", trace: [...trace, ` unknown: ${why}`] });
|
|
305
|
+
const [a, b] = input.peers;
|
|
306
|
+
if (a === b) return unknown("the routed task and the one it overlaps have the same owner");
|
|
307
|
+
const [ua, ub] = input.units;
|
|
308
|
+
if (ua === undefined || ub === undefined || ua !== ub) return unknown(`work units ${ua ?? "?"} and ${ub ?? "?"}: the rule needs two equal, known units`);
|
|
309
|
+
for (const p of [a, b]) {
|
|
310
|
+
if (!input.profiles[p]) return unknown(`${p}'s version is unknown: no record can be matched to it`);
|
|
311
|
+
trace.push(` ${p}: ${input.profiles[p]}`);
|
|
312
|
+
}
|
|
313
|
+
for (const p of [a, b]) {
|
|
314
|
+
if (!input.available[p]) return unknown(`${p} is not available`);
|
|
315
|
+
if ((input.backlog[p] ?? 0) > 0) return unknown(`${p} has ${input.backlog[p]} other open task(s): it would not start from orientation plus two units`);
|
|
316
|
+
}
|
|
317
|
+
const median = (xs: number[]) => {
|
|
318
|
+
const v = [...xs].sort((x, y) => x - y);
|
|
319
|
+
return v.length % 2 ? v[(v.length - 1) / 2]! : (v[v.length / 2 - 1]! + v[v.length / 2]!) / 2;
|
|
320
|
+
};
|
|
321
|
+
const stats: Record<PeerId, { o: number; u: number }> = {};
|
|
322
|
+
for (const p of [a, b]) {
|
|
323
|
+
const all = input.observations[p] ?? [];
|
|
324
|
+
const failed = all.filter((o) => o.outcome !== "approved").length;
|
|
325
|
+
const ok = all.filter((o): o is Required<SplitObservation> => o.outcome === "approved" && o.orient !== undefined && o.work !== undefined);
|
|
326
|
+
if (ok.length < SPLIT_MIN) return unknown(`${p} has ${ok.length} measured task(s) with this profile; ${SPLIT_MIN} are needed`);
|
|
327
|
+
if (failed / all.length > SPLIT_FAILURE_SHARE) return unknown(`${failed} of ${all.length} of ${p}'s recorded tasks failed: its successes alone would understate its time`);
|
|
328
|
+
const works = ok.map((o) => o.work).sort((x, y) => x - y);
|
|
329
|
+
const u = median(works);
|
|
330
|
+
const iqr = works[Math.floor((works.length * 3) / 4)]! - works[Math.floor(works.length / 4)]!;
|
|
331
|
+
if (u <= 0 || iqr / u > 1) return unknown(`${p}'s work times spread too widely (IQR ${Math.round(iqr / 1000)} s against a median of ${Math.round(u / 1000)} s) to call its tasks comparable units`);
|
|
332
|
+
stats[p] = { o: median(ok.map((o) => o.orient)), u };
|
|
333
|
+
trace.push(` ${p}: orientation ${Math.round(stats[p]!.o / 1000)} s, one unit ${Math.round(u / 1000)} s (median of ${ok.length})`);
|
|
334
|
+
}
|
|
335
|
+
const single = stats[a]!.o + 2 * stats[a]!.u <= stats[b]!.o + 2 * stats[b]!.u ? a : b;
|
|
336
|
+
const s = (ms: number) => Math.round(ms / 1000);
|
|
337
|
+
const splitS = s(Math.max(stats[a]!.o + stats[a]!.u, stats[b]!.o + stats[b]!.u));
|
|
338
|
+
const singleS = s(stats[single]!.o + 2 * stats[single]!.u);
|
|
339
|
+
trace.push(` split: ${splitS} s (${a} ${s(stats[a]!.o + stats[a]!.u)} s, ${b} ${s(stats[b]!.o + stats[b]!.u)} s for one unit each); ${single} alone: ${singleS} s for two`);
|
|
340
|
+
if (Math.abs(splitS - singleS) < singleS / 10) return { ...unknown("the difference is under a tenth of the single time: inconclusive"), single, splitS, singleS };
|
|
341
|
+
const verdict = splitS < singleS ? "split" : "single";
|
|
342
|
+
trace.push(verdict === "split" ? " a split would finish sooner" : ` ${single} alone would finish sooner`);
|
|
343
|
+
return { verdict, trace, single, splitS, singleS };
|
|
344
|
+
}
|