@staix/agent-hub 0.12.19 → 0.12.20
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 +16 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/events.md +4 -1
- package/docs/operations.md +413 -17
- package/docs/quickstart.md +1 -1
- package/docs/security.md +17 -10
- package/docs/smoke.md +58 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +399 -3
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +63 -25
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +3 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/console-state.ts +325 -102
- package/src/cli/console.ts +44 -23
- package/src/cli/help.ts +120 -0
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +2 -0
- package/src/cli/main.ts +171 -75
- package/src/cli/preview.ts +1 -0
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +9 -5
- package/src/cli/terminal-recovery.ts +140 -36
- package/src/cli/upgrade-runtime.ts +274 -56
- package/src/cli/upgrade.ts +366 -41
- package/src/hub/ask.ts +1 -1
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +60 -4
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +38 -6
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +56 -9
- package/src/hub/events.ts +7 -1
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +7 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +51 -3
- package/src/hub/report.ts +43 -1
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +236 -83
- package/src/local/deny.ts +2 -1
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { MemoryClient } from "../memory/client.ts";
|
|
|
4
4
|
import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, TASK_MOVE_REASONS, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
5
5
|
import type { Bus } from "./bus.ts";
|
|
6
6
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
7
|
-
import
|
|
7
|
+
import type { PiiVerdict, ScreenItem, ScreenRecord } from "./inference.ts";
|
|
8
|
+
import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
8
9
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
9
10
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
10
11
|
import { realPath } from "./project.ts";
|
|
@@ -44,6 +45,10 @@ export interface TasksDeps {
|
|
|
44
45
|
roles?: Record<string, string[]>;
|
|
45
46
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
46
47
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
48
|
+
/** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
|
|
49
|
+
piiScreen?: (text: string) => Promise<PiiVerdict>;
|
|
50
|
+
/** Each screen verdict, for the record: item, label, source and category, never the text. */
|
|
51
|
+
recordScreen?: (record: ScreenRecord) => void;
|
|
47
52
|
/** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
|
|
48
53
|
turnFree?: () => boolean;
|
|
49
54
|
/** Whether a peer's context path for facts is verified in its current session (issue #108). */
|
|
@@ -96,7 +101,7 @@ export const samePlace = (a: string, b: string) => {
|
|
|
96
101
|
};
|
|
97
102
|
|
|
98
103
|
/** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
|
|
99
|
-
const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
|
|
104
|
+
export const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
|
|
100
105
|
/** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
|
|
101
106
|
const WITH_DONE = "with its done";
|
|
102
107
|
/** How many times a task changed hands: a cohort member's generation (issue #107). */
|
|
@@ -130,6 +135,10 @@ function cleanPlan(input: unknown): TaskPlan {
|
|
|
130
135
|
|
|
131
136
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
132
137
|
const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
|
|
138
|
+
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
139
|
+
const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
|
|
140
|
+
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
141
|
+
const RESCREEN_MAX = 10;
|
|
133
142
|
|
|
134
143
|
/**
|
|
135
144
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -164,6 +173,42 @@ export class Tasks {
|
|
|
164
173
|
|
|
165
174
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
166
175
|
|
|
176
|
+
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
177
|
+
private screenOn = (): boolean => {
|
|
178
|
+
const r = this.d.routing();
|
|
179
|
+
return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
183
|
+
private async screenCall(text: string): Promise<PiiVerdict> {
|
|
184
|
+
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
|
|
188
|
+
const record: ScreenRecord = !v
|
|
189
|
+
? { item, label: "pii", source: "regex" }
|
|
190
|
+
: { item, label: v.label === "clear" ? "clear" : "pii", source: v.label === "unknown" ? "unknown" : "screen", ...(v.category ? { category: v.category } : {}), ...(v.miss ? { miss: v.miss } : {}), ms: v.ms };
|
|
191
|
+
try {
|
|
192
|
+
this.d.recordScreen?.({ ...(task === undefined ? {} : { task }), ...record });
|
|
193
|
+
} catch { /* telemetry only */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whether the model screen withholds free text about an ordinary task (issue #198): it is on and its verdict is not
|
|
198
|
+
* `clear`. Text that matches a pattern needs no call (the #69 checks withhold it; only the record says so), and a PII
|
|
199
|
+
* task's text is private. Tool callers are models: anything but a string is not screened, so `off` stays as it was.
|
|
200
|
+
*/
|
|
201
|
+
private async withholds(item: ScreenItem, text: unknown, task?: Task): Promise<boolean> {
|
|
202
|
+
if (typeof text !== "string" || !text.trim() || !this.screenOn() || (task && this.isPii(task))) return false;
|
|
203
|
+
if (!this.nameable(text)) return (this.recordScreen(task?.id, item, undefined), false);
|
|
204
|
+
const v = await this.screenCall(text);
|
|
205
|
+
this.recordScreen(task?.id, item, v);
|
|
206
|
+
return v.label !== "clear";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Why free text is withheld: the pattern check first, as #69 words it. */
|
|
210
|
+
private whyWithheld = (text: string) => (this.nameable(text) ? "the PII screen did not clear it" : "it matches a PII pattern");
|
|
211
|
+
|
|
167
212
|
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
168
213
|
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
169
214
|
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
@@ -191,8 +236,9 @@ export class Tasks {
|
|
|
191
236
|
}
|
|
192
237
|
} else {
|
|
193
238
|
const reviewer = finding.kind === "review-pending";
|
|
194
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.
|
|
239
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
195
240
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
241
|
+
this.passedOver(task, a);
|
|
196
242
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
197
243
|
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
198
244
|
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
@@ -205,7 +251,7 @@ export class Tasks {
|
|
|
205
251
|
|
|
206
252
|
private sweepAvailable(peer: PeerId): boolean {
|
|
207
253
|
const bus = this.d.bus;
|
|
208
|
-
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()
|
|
254
|
+
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !peerEntry(this.d.held?.(), peer) && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
209
255
|
}
|
|
210
256
|
|
|
211
257
|
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
@@ -223,14 +269,19 @@ export class Tasks {
|
|
|
223
269
|
/** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
|
|
224
270
|
admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
|
|
225
271
|
/** A digest may include several tasks. Charge each matching task budget and each run budget once, atomically. */
|
|
226
|
-
admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] =>
|
|
227
|
-
|
|
272
|
+
admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] =>
|
|
273
|
+
this.d.executionBudget?.admitTasks(this.budgetTaskIds(envs), peer, unit, amount) ?? [];
|
|
274
|
+
|
|
275
|
+
/** Whether an execution budget meters this peer's work on these envelopes; admits nothing. */
|
|
276
|
+
budgetApplies = (envs: Envelope[], peer: PeerId): boolean => this.d.executionBudget?.applies(this.budgetTaskIds(envs), peer) ?? false;
|
|
277
|
+
|
|
278
|
+
private budgetTaskIds(envs: Envelope[]): number[] {
|
|
279
|
+
return [...new Set(envs.flatMap((e) => {
|
|
228
280
|
const value = e.refs?.task;
|
|
229
281
|
const id = typeof value === "string" ? Number(value) : NaN;
|
|
230
282
|
return Number.isSafeInteger(id) && id > 0 ? [id] : [];
|
|
231
283
|
}))];
|
|
232
|
-
|
|
233
|
-
};
|
|
284
|
+
}
|
|
234
285
|
|
|
235
286
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
236
287
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
@@ -239,10 +290,10 @@ export class Tasks {
|
|
|
239
290
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
240
291
|
const { title, detail, history, ...rest } = task;
|
|
241
292
|
if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
|
|
242
|
-
const screenHistoryText = (text: string, field: string): string => this.nameable(text) ? text : `[history ${field} withheld:
|
|
293
|
+
const screenHistoryText = (text: string, field: string, withheld = false): string => !withheld && this.nameable(text) ? text : `[history ${field} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
243
294
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
244
295
|
at: h.at, by: h.by, event: h.event,
|
|
245
|
-
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
|
|
296
|
+
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
246
297
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
247
298
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
248
299
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
@@ -257,7 +308,7 @@ export class Tasks {
|
|
|
257
308
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
258
309
|
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
|
|
259
310
|
: undefined;
|
|
260
|
-
const held = task.owner ? this.d.held?.()
|
|
311
|
+
const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
|
|
261
312
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
262
313
|
return [base, noReview, holdText].filter(Boolean).join("; ");
|
|
263
314
|
}
|
|
@@ -390,7 +441,7 @@ export class Tasks {
|
|
|
390
441
|
// delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
|
|
391
442
|
// is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
|
|
392
443
|
// before the task is sent, so a busy candidate is not available then).
|
|
393
|
-
available: Object.fromEntries(peers.map((p) => [p, !failing
|
|
444
|
+
available: Object.fromEntries(peers.map((p) => [p, !peerEntry(failing, p) && (states[p] === "idle" || taking(p))])),
|
|
394
445
|
});
|
|
395
446
|
}
|
|
396
447
|
|
|
@@ -432,10 +483,8 @@ export class Tasks {
|
|
|
432
483
|
if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
|
|
433
484
|
if (!deps.includes(id)) deps.push(id);
|
|
434
485
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
throw new Error(`this task would wait for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet: ${input.owner === by ? "claim it once they are" : "propose it without an owner; routing offers it when they are approved"}`);
|
|
438
|
-
}
|
|
486
|
+
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
487
|
+
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
439
488
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
440
489
|
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
|
|
441
490
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
@@ -444,33 +493,84 @@ export class Tasks {
|
|
|
444
493
|
const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
|
|
445
494
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
446
495
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
496
|
+
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
497
|
+
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
498
|
+
const screening = this.screenOn();
|
|
499
|
+
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
|
|
500
|
+
if (screened && screened.label !== "clear") signals.push("pii");
|
|
447
501
|
let cls = given as TaskClass | undefined;
|
|
448
502
|
let triaged = false;
|
|
503
|
+
let fenced = false; // a PII task off campus: triage is not asked
|
|
449
504
|
if (!cls && this.d.triage) {
|
|
450
505
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
451
506
|
const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
452
507
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
508
|
+
else fenced = true;
|
|
453
509
|
triaged = !!cls;
|
|
454
510
|
}
|
|
455
511
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
456
512
|
const defaulted = !cls && input.owner === by;
|
|
457
513
|
if (defaulted) cls = "implement";
|
|
458
|
-
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
|
|
514
|
+
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
|
|
459
515
|
const draft = { ...text, class: cls };
|
|
460
|
-
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
|
|
516
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
|
|
517
|
+
if (screening) task = this.screenedTask(task, screened);
|
|
461
518
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
462
519
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
463
520
|
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
464
521
|
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
465
522
|
const still = this.waitsFor(task);
|
|
466
523
|
if (still.length) {
|
|
467
|
-
this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
524
|
+
this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered ${task.reserved ? `to ${task.reserved} first ` : ""}once they are approved`);
|
|
468
525
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
469
526
|
}
|
|
470
527
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
471
528
|
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
|
|
472
529
|
}
|
|
473
530
|
|
|
531
|
+
/** Where a screened task's `pii` signal came from, on its history and in events (issue #198); no verdict is a console line by id. */
|
|
532
|
+
private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
|
|
533
|
+
this.recordScreen(task.id, "task", v);
|
|
534
|
+
if (v?.label === "unknown") this.d.notify(`task #${task.id}: the PII screen gave no verdict (${v.miss}); it is handled as a PII task`);
|
|
535
|
+
const note = !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`;
|
|
536
|
+
return this.d.board.update(task.id, HUB, "screened", {}, note);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** How often each task was screened again in this hub run. ponytail: in memory, so a restart allows another round. */
|
|
540
|
+
private readonly rescreens = new Map<number, number>();
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* A task whose screen gave no verdict waits as a PII task (#198). While it is proposed and no peer ever owned it,
|
|
544
|
+
* nothing private was sent anywhere, so the daemon's release timer asks again here: one task per call (the timer runs
|
|
545
|
+
* one at a time), the least tried first, at most RESCREEN_MAX times each in a hub run; text too long for the screen
|
|
546
|
+
* never clears. `clear` lifts the pii signal and routes the task as usual (a named owner is not remembered, a reserved
|
|
547
|
+
* one is offered first as always), `pii` settles it, another unknown changes only the record. A task an owner took
|
|
548
|
+
* (`local`), even one declined back since, keeps its PII path: its notes, summary and decline reason are not screened.
|
|
549
|
+
*/
|
|
550
|
+
async rescreen(): Promise<void> {
|
|
551
|
+
if (!this.screenOn()) return;
|
|
552
|
+
const waiting = (t: Task) => {
|
|
553
|
+
const last = [...t.history].reverse().find((h) => h.event === "screened")?.note ?? "";
|
|
554
|
+
return t.state === "proposed" && !t.owner && !t.history.some((h) => h.owner) && t.signals.includes("pii") && last.startsWith("pii: unknown") && last !== "pii: unknown, too long";
|
|
555
|
+
};
|
|
556
|
+
const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
|
|
557
|
+
const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < RESCREEN_MAX).sort((a, b) => tries(a) - tries(b))[0];
|
|
558
|
+
if (!task) return;
|
|
559
|
+
this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
|
|
560
|
+
const text = screenText(task);
|
|
561
|
+
const v = this.nameable(text) ? await this.screenCall(text) : undefined; // a pattern added since settles it
|
|
562
|
+
const now = this.d.board.get(task.id);
|
|
563
|
+
if (!now || !waiting(now)) return; // taken or moved meanwhile: its verdict stands
|
|
564
|
+
this.recordScreen(task.id, "task", v);
|
|
565
|
+
if (v?.label === "unknown") return;
|
|
566
|
+
if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
|
|
567
|
+
const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
|
|
568
|
+
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
569
|
+
const still = this.waitsFor(lifted);
|
|
570
|
+
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
571
|
+
await this.assignOwner(lifted, HUB);
|
|
572
|
+
}
|
|
573
|
+
|
|
474
574
|
/**
|
|
475
575
|
* Open tasks of other owners whose paths overlap this one's. The later claimant settles it with the other owner, so
|
|
476
576
|
* only the new owner reads "settle it"; anyone else learns that the owner was told. PII tasks are left out on both
|
|
@@ -564,12 +664,12 @@ export class Tasks {
|
|
|
564
664
|
* Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
|
|
565
665
|
* that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
|
|
566
666
|
* ordinary task's turn like any other (gateway off campus allowed, memory capture on). The board keeps the text for
|
|
567
|
-
* `ahub task show`. A PII task's messages are private already.
|
|
667
|
+
* `ahub task show`. A PII task's messages are private already. `withheld`: the PII screen did not clear it (#198).
|
|
568
668
|
*/
|
|
569
|
-
private screen(task: Task, text: string, what: string, to: PeerId | null | undefined): string {
|
|
570
|
-
if (!text || this.isPii(task) || to === USER || this.nameable(text)) return text;
|
|
571
|
-
this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (
|
|
572
|
-
return `[${what} withheld:
|
|
669
|
+
private screen(task: Task, text: string, what: string, to: PeerId | null | undefined, withheld = false): string {
|
|
670
|
+
if (!text || this.isPii(task) || to === USER || (!withheld && this.nameable(text))) return text;
|
|
671
|
+
this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (${this.whyWithheld(text)})`);
|
|
672
|
+
return `[${what} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
573
673
|
}
|
|
574
674
|
|
|
575
675
|
/** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
|
|
@@ -648,7 +748,7 @@ export class Tasks {
|
|
|
648
748
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
649
749
|
const task = this.d.board.get(id);
|
|
650
750
|
if (!task || task.owner !== peer || !OPEN.includes(task.state) || this.d.bus.stateOf(peer) !== "offline") continue;
|
|
651
|
-
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.
|
|
751
|
+
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
|
|
652
752
|
try {
|
|
653
753
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
654
754
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
@@ -671,12 +771,15 @@ export class Tasks {
|
|
|
671
771
|
if (typeof target === "number") {
|
|
672
772
|
const task = this.d.board.get(target);
|
|
673
773
|
if (!task) throw new Error(`no task #${target}`);
|
|
674
|
-
const a = assign(task, this.states(), routing, { exclude: this.
|
|
774
|
+
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
675
775
|
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
676
|
-
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
776
|
+
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"}${task.reserved ? `, reserved for ${task.reserved}` : ""})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
677
777
|
}
|
|
678
778
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
679
|
-
|
|
779
|
+
const signals = detectSignals(draft, routing, this.d.cwd);
|
|
780
|
+
const trace = assign({ class: target.class, signals }, this.states(), routing, this.weights(target.class)).trace;
|
|
781
|
+
// A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
|
|
782
|
+
return this.screenOn() && !signals.includes("pii") ? [...trace, "pii_screen is on: at proposal a pii or unknown verdict adds signal pii (local only)"] : trace;
|
|
680
783
|
}
|
|
681
784
|
|
|
682
785
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
@@ -684,7 +787,22 @@ export class Tasks {
|
|
|
684
787
|
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
685
788
|
}
|
|
686
789
|
|
|
687
|
-
|
|
790
|
+
/**
|
|
791
|
+
* Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
|
|
792
|
+
* name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
|
|
793
|
+
*/
|
|
794
|
+
private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
|
|
798
|
+
* Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
|
|
799
|
+
* good (its decline or escalation said that).
|
|
800
|
+
*/
|
|
801
|
+
private passedOver(task: Task, a: Assignment): string {
|
|
802
|
+
if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
|
|
803
|
+
this.d.notify(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
|
|
804
|
+
return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
|
|
805
|
+
}
|
|
688
806
|
|
|
689
807
|
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
690
808
|
private noReviewer(a: Assignment): string {
|
|
@@ -697,18 +815,20 @@ export class Tasks {
|
|
|
697
815
|
|
|
698
816
|
private announceRouting(task: Task, a: Assignment): void {
|
|
699
817
|
if (task.class !== "review" && !a.reviewer) this.d.notify(`task ${this.publicTitle(task)}: ${this.noReviewer(a)}`);
|
|
700
|
-
const hold = a.owner ? this.d.held?.()
|
|
818
|
+
const hold = a.owner ? peerEntry(this.d.held?.(), a.owner) : undefined;
|
|
701
819
|
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
702
820
|
}
|
|
703
821
|
|
|
704
|
-
|
|
822
|
+
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
823
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean } = {}): Promise<Task> {
|
|
705
824
|
const waits = this.waitsFor(task);
|
|
706
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.
|
|
825
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
|
|
707
826
|
|
|
708
827
|
if (waits.length) {
|
|
709
828
|
this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
710
829
|
return task;
|
|
711
830
|
}
|
|
831
|
+
const passed = this.passedOver(task, a);
|
|
712
832
|
if (!a.owner) {
|
|
713
833
|
this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
714
834
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
@@ -719,7 +839,10 @@ export class Tasks {
|
|
|
719
839
|
return task;
|
|
720
840
|
}
|
|
721
841
|
const profile = this.d.splitProfile?.(a.owner);
|
|
722
|
-
|
|
842
|
+
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
843
|
+
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
844
|
+
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
845
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}), ...(opts.override && task.reserved ? { reserved: null } : {}) }, `${opts.note ?? `to ${a.owner}`}${passed}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}), ...(lasting ? { from: task.owner! } : {}) });
|
|
723
846
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
724
847
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
725
848
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -744,14 +867,16 @@ export class Tasks {
|
|
|
744
867
|
this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
745
868
|
return claimed;
|
|
746
869
|
}
|
|
747
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits));
|
|
870
|
+
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
748
871
|
return next;
|
|
749
872
|
}
|
|
750
873
|
|
|
751
|
-
|
|
874
|
+
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
875
|
+
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
752
876
|
const pii = this.isPii(task);
|
|
753
877
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
754
|
-
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
|
|
878
|
+
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner, h.withheld)}`);
|
|
879
|
+
const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
|
|
755
880
|
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : task.class === "review" ? "no reviewer" : this.noReviewer(a)].filter(Boolean).join("; ");
|
|
756
881
|
const plan = planText(task.plan);
|
|
757
882
|
const body = [
|
|
@@ -763,7 +888,7 @@ export class Tasks {
|
|
|
763
888
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
764
889
|
brief ?? "",
|
|
765
890
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
766
|
-
|
|
891
|
+
handoff ? `Handoff from the previous owner:\n${handoff}` : "",
|
|
767
892
|
`Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
768
893
|
].filter(Boolean).join("\n\n");
|
|
769
894
|
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
@@ -801,8 +926,10 @@ export class Tasks {
|
|
|
801
926
|
async releaseReady(): Promise<void> {
|
|
802
927
|
for (const t of this.d.board.list("proposed")) {
|
|
803
928
|
const last = t.history.at(-1)?.event;
|
|
804
|
-
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
|
|
805
|
-
|
|
929
|
+
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
|
|
930
|
+
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
931
|
+
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
932
|
+
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
806
933
|
await this.offerReady(t, "nothing left to wait for");
|
|
807
934
|
}
|
|
808
935
|
}
|
|
@@ -906,7 +1033,8 @@ export class Tasks {
|
|
|
906
1033
|
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
907
1034
|
const task = this.need(id, true);
|
|
908
1035
|
this.mine(task, by, "owner");
|
|
909
|
-
|
|
1036
|
+
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1037
|
+
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
|
|
910
1038
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
911
1039
|
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
|
|
912
1040
|
}
|
|
@@ -922,14 +1050,20 @@ export class Tasks {
|
|
|
922
1050
|
isChecking = (id: number) => this.checking.has(id);
|
|
923
1051
|
|
|
924
1052
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1053
|
+
const valid = (): Task => {
|
|
1054
|
+
const t = this.need(id);
|
|
1055
|
+
this.mine(t, by, "owner");
|
|
1056
|
+
if (t.state === "in_review" || t.state === "approved") throw new Error(`task #${t.id} is already ${t.state}`);
|
|
1057
|
+
this.ready(t);
|
|
1058
|
+
if (this.checking.has(t.id)) {
|
|
1059
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
1060
|
+
throw new Error(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
1061
|
+
}
|
|
1062
|
+
return t;
|
|
1063
|
+
};
|
|
1064
|
+
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
1065
|
+
const withheld = await this.withholds("summary", summary, valid());
|
|
1066
|
+
let task = valid();
|
|
933
1067
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
934
1068
|
const cohort = this.cohorts.of(task.id);
|
|
935
1069
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -960,15 +1094,15 @@ export class Tasks {
|
|
|
960
1094
|
}
|
|
961
1095
|
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
962
1096
|
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
963
|
-
if (!command) return this.complete(task, by, summary, refs);
|
|
1097
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
964
1098
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
965
1099
|
this.checking.set(task.id, task.owner);
|
|
966
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
|
|
1100
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
967
1101
|
this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
968
1102
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
969
1103
|
this.pendingChecks++;
|
|
970
1104
|
this.checkQueue = this.checkQueue
|
|
971
|
-
.then(() => this.finishChecked(pending.id, by, summary, command, seen))
|
|
1105
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
972
1106
|
.catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
|
|
973
1107
|
.finally(() => this.pendingChecks--);
|
|
974
1108
|
return pending;
|
|
@@ -1005,7 +1139,7 @@ export class Tasks {
|
|
|
1005
1139
|
const theirs = this.places(u);
|
|
1006
1140
|
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1007
1141
|
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
1008
|
-
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note)] : [];
|
|
1142
|
+
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
1009
1143
|
});
|
|
1010
1144
|
if (!open.length && !done.length) continue;
|
|
1011
1145
|
const owners = [...new Set(open.map((h) => h.task.owner!))];
|
|
@@ -1033,7 +1167,8 @@ export class Tasks {
|
|
|
1033
1167
|
const lines = others.map((t) => {
|
|
1034
1168
|
const files = this.places(t).paths.filter(this.nameable);
|
|
1035
1169
|
const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
|
|
1036
|
-
const
|
|
1170
|
+
const done = doneAt(t);
|
|
1171
|
+
const first = done?.withheld ? undefined : (done?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
1037
1172
|
return [
|
|
1038
1173
|
`- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
|
|
1039
1174
|
files.length ? ` changed files: ${files.join(", ")}` : "",
|
|
@@ -1049,7 +1184,7 @@ export class Tasks {
|
|
|
1049
1184
|
return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
|
|
1050
1185
|
}
|
|
1051
1186
|
|
|
1052
|
-
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
|
|
1187
|
+
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
1053
1188
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
1054
1189
|
this.checking.delete(id);
|
|
1055
1190
|
const task = this.d.board.get(id);
|
|
@@ -1074,7 +1209,7 @@ export class Tasks {
|
|
|
1074
1209
|
}
|
|
1075
1210
|
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
1076
1211
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
1077
|
-
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
|
|
1212
|
+
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
1078
1213
|
return;
|
|
1079
1214
|
}
|
|
1080
1215
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
@@ -1085,12 +1220,12 @@ export class Tasks {
|
|
|
1085
1220
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
1086
1221
|
}
|
|
1087
1222
|
|
|
1088
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
|
|
1223
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
|
|
1089
1224
|
const reviewer = task.reviewer;
|
|
1090
|
-
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
|
|
1225
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary, { withheld: withheld || undefined });
|
|
1091
1226
|
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
1092
|
-
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}
|
|
1093
|
-
this.tellCompleted(next, summary);
|
|
1227
|
+
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
1228
|
+
this.tellCompleted(next, summary, withheld);
|
|
1094
1229
|
if (!reviewer) {
|
|
1095
1230
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1096
1231
|
this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
@@ -1106,7 +1241,7 @@ export class Tasks {
|
|
|
1106
1241
|
* work is done (after its check, when one runs), and nobody else does (issue #31). A message of its own, not a
|
|
1107
1242
|
* ride-along line: an owner in the middle of those files needs it before its next task arrives.
|
|
1108
1243
|
*/
|
|
1109
|
-
private tellCompleted(task: Task, summary?: string): void {
|
|
1244
|
+
private tellCompleted(task: Task, summary?: string, withheld = false): void {
|
|
1110
1245
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
1111
1246
|
const cohort = this.cohorts.of(task.id);
|
|
1112
1247
|
const silent = this.silentFor(task.id);
|
|
@@ -1117,19 +1252,20 @@ export class Tasks {
|
|
|
1117
1252
|
cohort.held.add(task.id);
|
|
1118
1253
|
continue;
|
|
1119
1254
|
}
|
|
1120
|
-
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
|
|
1255
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
|
|
1121
1256
|
}
|
|
1122
1257
|
}
|
|
1123
1258
|
|
|
1124
1259
|
/**
|
|
1125
1260
|
* The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
|
|
1126
|
-
* included), and any item that matches a PII pattern is left out of what other owners get
|
|
1261
|
+
* included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
|
|
1262
|
+
* PII screen did not clear it (`withheld`, #198).
|
|
1127
1263
|
*/
|
|
1128
|
-
private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
|
|
1264
|
+
private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
|
|
1129
1265
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
1130
1266
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
1131
1267
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
1132
|
-
const line = first && this.nameable(first) ? first : undefined;
|
|
1268
|
+
const line = first && !withheld && this.nameable(first) ? first : undefined;
|
|
1133
1269
|
return [
|
|
1134
1270
|
`Task #${task.id} (owner ${task.owner}) is done and touches your open #${hit.task.id} on ${this.where(hit)}. Check your work against it before you go on.`,
|
|
1135
1271
|
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
@@ -1144,7 +1280,7 @@ export class Tasks {
|
|
|
1144
1280
|
const t = this.d.board.get(id);
|
|
1145
1281
|
if (!t || this.isPii(t)) return [];
|
|
1146
1282
|
const done = [...t.history].reverse().find((h) => h.event === "done");
|
|
1147
|
-
return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note)];
|
|
1283
|
+
return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note, done?.withheld)];
|
|
1148
1284
|
});
|
|
1149
1285
|
return lines.length ? lines.join("\n") : undefined;
|
|
1150
1286
|
}
|
|
@@ -1163,21 +1299,28 @@ export class Tasks {
|
|
|
1163
1299
|
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
1164
1300
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
1165
1301
|
].join("\n");
|
|
1166
|
-
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
|
|
1302
|
+
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
1167
1303
|
const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
1168
1304
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
1169
1305
|
}
|
|
1170
1306
|
|
|
1171
1307
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1308
|
+
const valid = (): Task => {
|
|
1309
|
+
const t = this.need(id);
|
|
1310
|
+
this.mine(t, by, "reviewer");
|
|
1311
|
+
if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
|
|
1312
|
+
if (t.state !== "in_review") throw new Error(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
1313
|
+
return t;
|
|
1314
|
+
};
|
|
1177
1315
|
const items = textList(unmet);
|
|
1178
1316
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
1317
|
+
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
1318
|
+
const withheld = await this.withholds("review note", note, valid());
|
|
1319
|
+
const task = valid();
|
|
1320
|
+
const pii = this.isPii(task);
|
|
1321
|
+
const flag = { withheld: withheld || undefined };
|
|
1179
1322
|
if (verdict === "approved") {
|
|
1180
|
-
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
|
|
1323
|
+
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
|
|
1181
1324
|
if (next.owner) {
|
|
1182
1325
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
1183
1326
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -1186,25 +1329,25 @@ export class Tasks {
|
|
|
1186
1329
|
this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
|
|
1187
1330
|
}
|
|
1188
1331
|
}
|
|
1189
|
-
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}
|
|
1190
|
-
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner)}` : ""}`, pii);
|
|
1332
|
+
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
1333
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
|
|
1191
1334
|
await this.releaseDependents(next);
|
|
1192
1335
|
return next;
|
|
1193
1336
|
}
|
|
1194
|
-
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
1337
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
1195
1338
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
1196
1339
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
1197
1340
|
this.contradict(rejected);
|
|
1198
|
-
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}
|
|
1341
|
+
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
1199
1342
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1200
1343
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1201
1344
|
if (moved.owner !== rejected.owner) return moved;
|
|
1202
1345
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1203
|
-
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
1346
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
1204
1347
|
return moved;
|
|
1205
1348
|
}
|
|
1206
1349
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
1207
|
-
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner)}` : ""} Fix it and call hub_task_done again.`, pii);
|
|
1350
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii);
|
|
1208
1351
|
return reopened;
|
|
1209
1352
|
}
|
|
1210
1353
|
|
|
@@ -1230,7 +1373,12 @@ export class Tasks {
|
|
|
1230
1373
|
|
|
1231
1374
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1232
1375
|
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
|
|
1233
|
-
|
|
1376
|
+
const task = this.need(id, true);
|
|
1377
|
+
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1378
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1379
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1380
|
+
this.d.notify(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
1381
|
+
return next;
|
|
1234
1382
|
}
|
|
1235
1383
|
|
|
1236
1384
|
/**
|
|
@@ -1240,6 +1388,9 @@ export class Tasks {
|
|
|
1240
1388
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
1241
1389
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
1242
1390
|
const routing = this.d.routing();
|
|
1391
|
+
// The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
|
|
1392
|
+
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
1393
|
+
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
1243
1394
|
for (const task of this.d.board.list()) {
|
|
1244
1395
|
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
1245
1396
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
@@ -1247,7 +1398,7 @@ export class Tasks {
|
|
|
1247
1398
|
const pii = this.isPii(task);
|
|
1248
1399
|
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
1249
1400
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
1250
|
-
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
|
|
1401
|
+
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
|
|
1251
1402
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1252
1403
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1253
1404
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
@@ -1282,6 +1433,7 @@ export class Tasks {
|
|
|
1282
1433
|
const title = String(input.title ?? "").trim();
|
|
1283
1434
|
if (this.isPii({ signals: detectSignals({ title, detail: text, refs: {} }, this.d.routing(), this.d.cwd) })) throw new Error("this note matches a PII pattern and is not saved: claude-mem processes what it stores with a cloud model");
|
|
1284
1435
|
if (!this.d.memory) return "memory is disabled; nothing saved";
|
|
1436
|
+
if (await this.withholds("note", `${title}\n${text}`, task)) throw new Error("the PII screen did not clear this note, so it is not saved: claude-mem processes what it stores with a cloud model");
|
|
1285
1437
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
|
|
1286
1438
|
const res = await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
|
|
1287
1439
|
if (!res) return "memory worker unavailable; nothing saved";
|
|
@@ -1292,10 +1444,11 @@ export class Tasks {
|
|
|
1292
1444
|
}
|
|
1293
1445
|
|
|
1294
1446
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
1295
|
-
private note(task: Task, by: PeerId, kind: string, text: string): void {
|
|
1447
|
+
private note(task: Task, by: PeerId, kind: string, text: string, withheld = false): void {
|
|
1296
1448
|
if (this.isPii(task) || !this.d.memory) return;
|
|
1297
|
-
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern
|
|
1298
|
-
|
|
1449
|
+
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1450
|
+
// not clear (#198), is not sent (#69).
|
|
1451
|
+
if (withheld || !this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1299
1452
|
void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
1300
1453
|
}
|
|
1301
1454
|
|