@staix/agent-hub 0.12.15 → 0.12.16

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/src/hub/tasks.ts CHANGED
@@ -8,6 +8,7 @@ import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type R
8
8
  import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
9
9
  import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
10
10
  import { realPath } from "./project.ts";
11
+ import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
11
12
 
12
13
  export interface TasksDeps {
13
14
  board: Board;
@@ -68,6 +69,9 @@ export interface TasksDeps {
68
69
  recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
69
70
  /** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
70
71
  recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
72
+ sweep?: TaskSweepConfig;
73
+ /** Shutdown or coordinator work holds task sweeps too. */
74
+ sweepHeld?: () => boolean;
71
75
  }
72
76
 
73
77
  const ESCALATE_AFTER = 2;
@@ -80,7 +84,7 @@ const DEMOTE_AT = 1.5;
80
84
  const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
81
85
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
82
86
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
83
- const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
87
+ const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
84
88
 
85
89
  /** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
86
90
  export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
@@ -134,8 +138,11 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
134
138
  export class Tasks {
135
139
  /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
136
140
  readonly cohorts: Cohorts;
141
+ private readonly sweepConfig: TaskSweepConfig;
142
+ private sweeping = false;
137
143
 
138
144
  constructor(private readonly d: TasksDeps) {
145
+ this.sweepConfig = taskSweepConfig(d.sweep);
139
146
  this.cohorts = new Cohorts({
140
147
  silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
141
148
  idle: (peer) => this.d.idle?.(peer) ?? false,
@@ -157,6 +164,55 @@ export class Tasks {
157
164
 
158
165
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
159
166
 
167
+ /** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
168
+ async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
169
+ const recorded: { task: number; finding: SweepRecord }[] = [];
170
+ if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
171
+ this.sweeping = true;
172
+ try {
173
+ for (const task of this.d.board.list()) {
174
+ if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
175
+ const finding = nextSweep(task, this.sweepConfig, now);
176
+ if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
177
+ const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
178
+ if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
179
+ const title = this.publicTitle(task);
180
+ const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
181
+ // Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
182
+ // leaves the notice unpublished or uncertain; the durable-delivery journal owns any delivery retries.
183
+ this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
184
+ recorded.push({ task: task.id, finding });
185
+ if (finding.step === 1) {
186
+ this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
187
+ } else if (finding.step === 2) {
188
+ this.d.notify(message);
189
+ for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
190
+ if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
191
+ }
192
+ } else {
193
+ const reviewer = finding.kind === "review-pending";
194
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
195
+ const candidate = reviewer ? a.reviewer : a.owner;
196
+ this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
197
+ if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
198
+ await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", note: `idle sweep: ${finding.kind}; from ${responsible}` });
199
+ }
200
+ }
201
+ }
202
+ } finally { this.sweeping = false; }
203
+ return recorded;
204
+ }
205
+
206
+ private sweepAvailable(peer: PeerId): boolean {
207
+ const bus = this.d.bus;
208
+ return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()[peer] && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
209
+ }
210
+
211
+ private sweepNotice(task: Task, peer: PeerId, body: string): void {
212
+ if (peer === USER) this.d.notify(body);
213
+ else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
214
+ }
215
+
160
216
  configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
161
217
  if (!this.d.executionBudget) return undefined;
162
218
  if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
@@ -324,6 +324,11 @@ function sseResponse(response: Response, release: () => void, onModel?: (model:
324
324
 
325
325
  const copyRecord = (record: RelayRequestRecord): RelayRequestRecord => ({ ...record, ...(record.requestUsage ? { requestUsage: { ...record.requestUsage } } : {}), ...(record.toolSurface ? { toolSurface: { ...record.toolSurface, names: [...record.toolSurface.names] } } : {}) });
326
326
 
327
+ /** Pure advertised alias list, also used by no-start launch previews. */
328
+ export function relayModelIds(options: Pick<ModelRelayOptions, "enableHubAuto" | "mlx" | "mlxAlias" | "allowedDGXmodels">): string[] {
329
+ return [...new Set([...(options.enableHubAuto ? ["hub/auto"] : []), ...(options.mlx ? [options.mlxAlias ?? "mlx/fast"] : []), ...Object.keys(options.allowedDGXmodels)])];
330
+ }
331
+
327
332
  export async function startModelRelay(options: ModelRelayOptions): Promise<ModelRelay> {
328
333
  const host = options.host ?? "127.0.0.1";
329
334
  assertLoopback(host);
@@ -331,7 +336,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
331
336
  const mlxAlias = options.mlxAlias ?? "mlx/fast";
332
337
  const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
333
338
  const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
334
- const models = [...new Set([...(options.enableHubAuto ? ["hub/auto"] : []), ...(options.mlx ? [mlxAlias] : []), ...Object.keys(options.allowedDGXmodels)])];
339
+ const models = relayModelIds(options);
335
340
  const autoRoute = options.enableHubAuto ? new AutoRouteSelector(options, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
336
341
  let mlx: MlxHandle | undefined;
337
342
  let mlxStarting: Promise<MlxHandle> | undefined;
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import type { PiOptions, PiModelDescriptor, PiTuiLaunch } from "../adapters/pi.ts";
3
+
4
+ const modelFor = (backend: PiOptions["backend"], models: PiModelDescriptor[]): string => {
5
+ const ids = models.map((m) => m.id);
6
+ const find = (needle: string) => ids.find((id) => id === needle) ?? needle;
7
+ if (backend === "mlx") return find("mlx/fast");
8
+ if (backend === "dgx") return find("dgx/coding");
9
+ if (ids.includes("hub/auto")) return "hub/auto";
10
+ return find(ids.find((id) => id === "dgx/coding") ? "dgx/coding" : ids.find((id) => id === "mlx/fast") ?? "dgx/coding");
11
+ };
12
+
13
+ /** Pure native launch; bridge/relay setup and session validation remain with the adapter. */
14
+ export function buildPiLaunch(opts: Pick<PiOptions, "stateDir" | "cmd" | "mode" | "backend" | "model" | "relay" | "tools" | "preamble" | "maxSteps" | "sessionFile" | "sessionId">, inherited: NodeJS.ProcessEnv, extension: string, bridgePort: number | string, bridgeToken: string): PiTuiLaunch {
15
+ const env: NodeJS.ProcessEnv = { ...inherited, PI_CODING_AGENT_DIR: join(opts.stateDir, "pi"), AGENTHUB_PI_BRIDGE_URL: `http://127.0.0.1:${bridgePort}`, AGENTHUB_PI_BRIDGE_TOKEN: bridgeToken, AGENTHUB_PI_OWNER_TOKEN: bridgeToken, AGENTHUB_PI_RELAY_URL: opts.relay.url, AGENTHUB_PI_RELAY_TOKEN: opts.relay.token, AGENTHUB_PI_MODELS: JSON.stringify(opts.relay.models), AGENTHUB_PI_TOOLS: JSON.stringify(opts.tools), AGENTHUB_PI_MAX_STEPS: String(opts.maxSteps ?? 30) };
16
+ const args = [ ...(opts.mode === "headless" ? ["--mode", "rpc"] : []), "--provider", "agent-hub-local", "--model", opts.model ?? modelFor(opts.backend, opts.relay.models), "--models", opts.relay.models.map((model) => `agent-hub-local/${model.id}`).join(","), "--no-builtin-tools", "--no-skills", "--no-prompt-templates", "--no-extensions", "--extension", extension, ...(opts.preamble ? ["--append-system-prompt", opts.preamble] : []), "--session-dir", join(opts.stateDir, "pi-sessions")];
17
+ if (opts.sessionFile) args.push("--session", opts.sessionFile); else if (opts.sessionId) args.push("--session-id", opts.sessionId);
18
+ const command = opts.cmd ?? ["pi"];
19
+ return { cmd: command[0]!, args: [...command.slice(1), ...args], env };
20
+ }
package/src/ui/index.html CHANGED
@@ -205,6 +205,12 @@ function render(snapshot) {
205
205
  if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
206
206
  for (const [peer, budget] of Object.entries(snapshot.budget)) {
207
207
  const row = el('div', undefined, 'row'); row.append(el('div', peer, 'label'));
208
+ const context = budget.context;
209
+ if (context) {
210
+ const pct = context.used === null ? null : Math.round(context.used * 100);
211
+ row.append(el('p', 'Context: ' + (pct === null ? 'unknown' : pct + '% used') + ' (' + context.freshness + ')' + (context.source ? ' / ' + context.source : '') + (context.measuredAt !== null ? ' / measured ' + time(context.measuredAt) : ''), 'meta'));
212
+ if (pct !== null) { const bar = el('progress'); bar.max = 1; bar.value = context.used; bar.setAttribute('aria-label', peer + ' context usage'); row.append(bar); }
213
+ }
208
214
  if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
209
215
  if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
210
216
  for (const window of budget.windows) {