@staix/agent-hub 0.12.19 → 0.12.21
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 +38 -0
- package/README.md +7 -6
- package/docs/agent-notes/adapters.md +10 -2
- package/docs/agent-notes/bus.md +6 -2
- package/docs/agent-notes/daemon.md +10 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +6 -3
- package/docs/agent-notes/tests.md +3 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +6 -2
- package/docs/operations.md +655 -34
- package/docs/quickstart.md +12 -10
- package/docs/research.md +92 -0
- package/docs/security.md +59 -17
- package/docs/smoke.md +137 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +65 -26
- package/src/adapters/acp.ts +67 -6
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +70 -2
- package/src/adapters/local-worker.ts +8 -3
- package/src/adapters/pi.ts +58 -4
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +531 -110
- package/src/cli/console.ts +59 -26
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +130 -0
- package/src/cli/identity.ts +11 -5
- package/src/cli/launch.ts +98 -17
- package/src/cli/main.ts +325 -88
- package/src/cli/preview.ts +14 -3
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +10 -5
- package/src/cli/terminal-recovery.ts +196 -40
- package/src/cli/upgrade-runtime.ts +327 -60
- package/src/cli/upgrade.ts +442 -45
- package/src/hub/ask.ts +1 -1
- package/src/hub/bench.ts +211 -0
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +79 -8
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +54 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +9 -4
- package/src/hub/daemon.ts +401 -48
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +9 -2
- 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 +13 -12
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -0
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +68 -8
- package/src/hub/registry.ts +36 -22
- package/src/hub/report.ts +43 -1
- package/src/hub/research.ts +255 -0
- 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 +295 -115
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +2 -1
- package/src/local/tools.ts +14 -10
- 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/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +7 -1
- package/src/ui/index.html +84 -6
- package/src/ui/task-progress.ts +33 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/routing.toml +8 -0
package/src/hub/tasks.ts
CHANGED
|
@@ -4,10 +4,12 @@ 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";
|
|
12
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
11
13
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
12
14
|
|
|
13
15
|
export interface TasksDeps {
|
|
@@ -44,6 +46,10 @@ export interface TasksDeps {
|
|
|
44
46
|
roles?: Record<string, string[]>;
|
|
45
47
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
46
48
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
49
|
+
/** The PII screen (issue #198): a closed verdict from a model on this machine or on campus, `unknown` on any failure. */
|
|
50
|
+
piiScreen?: (text: string) => Promise<PiiVerdict>;
|
|
51
|
+
/** Each screen verdict, for the record: item, label, source and category, never the text. */
|
|
52
|
+
recordScreen?: (record: ScreenRecord) => void;
|
|
47
53
|
/** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
|
|
48
54
|
turnFree?: () => boolean;
|
|
49
55
|
/** Whether a peer's context path for facts is verified in its current session (issue #108). */
|
|
@@ -96,7 +102,7 @@ export const samePlace = (a: string, b: string) => {
|
|
|
96
102
|
};
|
|
97
103
|
|
|
98
104
|
/** 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"]);
|
|
105
|
+
export const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
|
|
100
106
|
/** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
|
|
101
107
|
const WITH_DONE = "with its done";
|
|
102
108
|
/** How many times a task changed hands: a cohort member's generation (issue #107). */
|
|
@@ -130,6 +136,10 @@ function cleanPlan(input: unknown): TaskPlan {
|
|
|
130
136
|
|
|
131
137
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
132
138
|
const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
|
|
139
|
+
/** What the PII screen reads of a task (#198): the text the patterns are checked against. */
|
|
140
|
+
const screenText = (t: { title: string; detail: string; plan?: TaskPlan }) => [t.title, t.detail, planText(t.plan)].join("\n");
|
|
141
|
+
/** Re-screens of one task whose verdict was unknown, per hub run (#198). */
|
|
142
|
+
const RESCREEN_MAX = 10;
|
|
133
143
|
|
|
134
144
|
/**
|
|
135
145
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
@@ -164,6 +174,42 @@ export class Tasks {
|
|
|
164
174
|
|
|
165
175
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
166
176
|
|
|
177
|
+
/** `signals.pii_screen = "local"` where the PII constraint applies (issue #198); on without a screener, every verdict is unknown. */
|
|
178
|
+
private screenOn = (): boolean => {
|
|
179
|
+
const r = this.d.routing();
|
|
180
|
+
return r.signals.pii_screen === "local" && r.constraints.pii === "local_only";
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** One bounded screen call. A screener that fails is no verdict, handled as PII (fail closed). */
|
|
184
|
+
private async screenCall(text: string): Promise<PiiVerdict> {
|
|
185
|
+
return (await this.d.piiScreen?.(text).catch(() => undefined)) ?? { label: "unknown", miss: "failed", ms: 0 };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private recordScreen(task: number | undefined, item: ScreenItem, v: PiiVerdict | undefined): void {
|
|
189
|
+
const record: ScreenRecord = !v
|
|
190
|
+
? { item, label: "pii", source: "regex" }
|
|
191
|
+
: { 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 };
|
|
192
|
+
try {
|
|
193
|
+
this.d.recordScreen?.({ ...(task === undefined ? {} : { task }), ...record });
|
|
194
|
+
} catch { /* telemetry only */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Whether the model screen withholds free text about an ordinary task (issue #198): it is on and its verdict is not
|
|
199
|
+
* `clear`. Text that matches a pattern needs no call (the #69 checks withhold it; only the record says so), and a PII
|
|
200
|
+
* task's text is private. Tool callers are models: anything but a string is not screened, so `off` stays as it was.
|
|
201
|
+
*/
|
|
202
|
+
private async withholds(item: ScreenItem, text: unknown, task?: Task): Promise<boolean> {
|
|
203
|
+
if (typeof text !== "string" || !text.trim() || !this.screenOn() || (task && this.isPii(task))) return false;
|
|
204
|
+
if (!this.nameable(text)) return (this.recordScreen(task?.id, item, undefined), false);
|
|
205
|
+
const v = await this.screenCall(text);
|
|
206
|
+
this.recordScreen(task?.id, item, v);
|
|
207
|
+
return v.label !== "clear";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Why free text is withheld: the pattern check first, as #69 words it. */
|
|
211
|
+
private whyWithheld = (text: string) => (this.nameable(text) ? "the PII screen did not clear it" : "it matches a PII pattern");
|
|
212
|
+
|
|
167
213
|
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
168
214
|
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
169
215
|
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
@@ -191,8 +237,9 @@ export class Tasks {
|
|
|
191
237
|
}
|
|
192
238
|
} else {
|
|
193
239
|
const reviewer = finding.kind === "review-pending";
|
|
194
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.
|
|
240
|
+
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
241
|
const candidate = reviewer ? a.reviewer : a.owner;
|
|
242
|
+
this.passedOver(task, a);
|
|
196
243
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
197
244
|
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
198
245
|
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 +252,7 @@ export class Tasks {
|
|
|
205
252
|
|
|
206
253
|
private sweepAvailable(peer: PeerId): boolean {
|
|
207
254
|
const bus = this.d.bus;
|
|
208
|
-
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()
|
|
255
|
+
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
256
|
}
|
|
210
257
|
|
|
211
258
|
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
@@ -223,14 +270,19 @@ export class Tasks {
|
|
|
223
270
|
/** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
|
|
224
271
|
admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
|
|
225
272
|
/** 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
|
-
|
|
273
|
+
admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] =>
|
|
274
|
+
this.d.executionBudget?.admitTasks(this.budgetTaskIds(envs), peer, unit, amount) ?? [];
|
|
275
|
+
|
|
276
|
+
/** Whether an execution budget meters this peer's work on these envelopes; admits nothing. */
|
|
277
|
+
budgetApplies = (envs: Envelope[], peer: PeerId): boolean => this.d.executionBudget?.applies(this.budgetTaskIds(envs), peer) ?? false;
|
|
278
|
+
|
|
279
|
+
private budgetTaskIds(envs: Envelope[]): number[] {
|
|
280
|
+
return [...new Set(envs.flatMap((e) => {
|
|
228
281
|
const value = e.refs?.task;
|
|
229
282
|
const id = typeof value === "string" ? Number(value) : NaN;
|
|
230
283
|
return Number.isSafeInteger(id) && id > 0 ? [id] : [];
|
|
231
284
|
}))];
|
|
232
|
-
|
|
233
|
-
};
|
|
285
|
+
}
|
|
234
286
|
|
|
235
287
|
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
236
288
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
@@ -239,10 +291,10 @@ export class Tasks {
|
|
|
239
291
|
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
240
292
|
const { title, detail, history, ...rest } = task;
|
|
241
293
|
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:
|
|
294
|
+
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
295
|
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
244
296
|
at: h.at, by: h.by, event: h.event,
|
|
245
|
-
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
|
|
297
|
+
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note", h.withheld) }),
|
|
246
298
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
247
299
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
248
300
|
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
@@ -257,7 +309,7 @@ export class Tasks {
|
|
|
257
309
|
const noReview = task.class !== "review" && !task.reviewer && OPEN.includes(task.state) && !this.waitsFor(task).length
|
|
258
310
|
? this.noReviewer(assign(task, this.states(), this.d.routing(), { candidates: task.owner ? [task.owner] : [], notReviewer: task.owner ?? undefined, ...this.weights(task.class) }))
|
|
259
311
|
: undefined;
|
|
260
|
-
const held = task.owner ? this.d.held?.()
|
|
312
|
+
const held = task.owner ? peerEntry(this.d.held?.(), task.owner) : undefined;
|
|
261
313
|
const holdText = held ? `${task.owner}'s queue is held (${held}); it receives the task once the hold is resolved` : undefined;
|
|
262
314
|
return [base, noReview, holdText].filter(Boolean).join("; ");
|
|
263
315
|
}
|
|
@@ -390,7 +442,7 @@ export class Tasks {
|
|
|
390
442
|
// delivered) or in which it claimed it: the routed peer this very task, the other owner the overlapped one while it
|
|
391
443
|
// is not started. Busy otherwise, it is at work on something else (#109, #115; a routing or cohort record is taken
|
|
392
444
|
// before the task is sent, so a busy candidate is not available then).
|
|
393
|
-
available: Object.fromEntries(peers.map((p) => [p, !failing
|
|
445
|
+
available: Object.fromEntries(peers.map((p) => [p, !peerEntry(failing, p) && (states[p] === "idle" || taking(p))])),
|
|
394
446
|
});
|
|
395
447
|
}
|
|
396
448
|
|
|
@@ -419,7 +471,7 @@ export class Tasks {
|
|
|
419
471
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
|
|
420
472
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
421
473
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
422
|
-
if (!title) throw new
|
|
474
|
+
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
423
475
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
424
476
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
425
477
|
let afterIds: unknown[] = [];
|
|
@@ -428,49 +480,98 @@ export class Tasks {
|
|
|
428
480
|
const deps: number[] = [];
|
|
429
481
|
for (const v of afterIds) {
|
|
430
482
|
const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
|
|
431
|
-
if (!Number.isInteger(id)) throw new
|
|
432
|
-
if (!this.d.board.get(id)) throw new
|
|
483
|
+
if (!Number.isInteger(id)) throw new PreEffectToolRefusal(`after: ${JSON.stringify(v)} is not a task id`);
|
|
484
|
+
if (!this.d.board.get(id)) throw new PreEffectToolRefusal(`after: no task #${id}`);
|
|
433
485
|
if (!deps.includes(id)) deps.push(id);
|
|
434
486
|
}
|
|
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
|
-
}
|
|
487
|
+
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
488
|
+
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
439
489
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
440
|
-
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new
|
|
490
|
+
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
|
|
441
491
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
442
492
|
const plan = cleanPlan(input.plan);
|
|
443
493
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
444
494
|
const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
|
|
445
495
|
// Urgent work is handed over even when its paused owner's window resets soon (issue #36).
|
|
446
496
|
if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
|
|
497
|
+
// The PII screen (issue #198) reads the same text before anything else does: until its verdict the task is on no
|
|
498
|
+
// board, in no envelope and before no other model. A pattern match is PII without a call; no verdict is PII too.
|
|
499
|
+
const screening = this.screenOn();
|
|
500
|
+
const screened = screening && !signals.includes("pii") ? await this.screenCall(screenText({ ...text, plan })) : undefined;
|
|
501
|
+
if (screened && screened.label !== "clear") signals.push("pii");
|
|
447
502
|
let cls = given as TaskClass | undefined;
|
|
448
503
|
let triaged = false;
|
|
504
|
+
let fenced = false; // a PII task off campus: triage is not asked
|
|
449
505
|
if (!cls && this.d.triage) {
|
|
450
506
|
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
451
507
|
const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
452
508
|
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
509
|
+
else fenced = true;
|
|
453
510
|
triaged = !!cls;
|
|
454
511
|
}
|
|
455
512
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
456
513
|
const defaulted = !cls && input.owner === by;
|
|
457
514
|
if (defaulted) cls = "implement";
|
|
458
|
-
if (!cls) throw new
|
|
515
|
+
if (!cls) throw new PreEffectToolRefusal(`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
516
|
const draft = { ...text, class: cls };
|
|
460
|
-
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
|
|
517
|
+
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
|
|
518
|
+
if (screening) task = this.screenedTask(task, screened);
|
|
461
519
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
462
520
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
463
521
|
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
464
522
|
// Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
|
|
465
523
|
const still = this.waitsFor(task);
|
|
466
524
|
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`);
|
|
525
|
+
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
526
|
return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
469
527
|
}
|
|
470
528
|
// Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
|
|
471
529
|
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
|
|
472
530
|
}
|
|
473
531
|
|
|
532
|
+
/** 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. */
|
|
533
|
+
private screenedTask(task: Task, v: PiiVerdict | undefined): Task {
|
|
534
|
+
this.recordScreen(task.id, "task", v);
|
|
535
|
+
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`);
|
|
536
|
+
const note = !v ? "pii: regex" : v.label === "clear" ? "clear" : v.label === "pii" ? `pii: screen, ${v.category}` : `pii: unknown, ${v.miss}`;
|
|
537
|
+
return this.d.board.update(task.id, HUB, "screened", {}, note);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** How often each task was screened again in this hub run. ponytail: in memory, so a restart allows another round. */
|
|
541
|
+
private readonly rescreens = new Map<number, number>();
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* A task whose screen gave no verdict waits as a PII task (#198). While it is proposed and no peer ever owned it,
|
|
545
|
+
* nothing private was sent anywhere, so the daemon's release timer asks again here: one task per call (the timer runs
|
|
546
|
+
* one at a time), the least tried first, at most RESCREEN_MAX times each in a hub run; text too long for the screen
|
|
547
|
+
* never clears. `clear` lifts the pii signal and routes the task as usual (a named owner is not remembered, a reserved
|
|
548
|
+
* one is offered first as always), `pii` settles it, another unknown changes only the record. A task an owner took
|
|
549
|
+
* (`local`), even one declined back since, keeps its PII path: its notes, summary and decline reason are not screened.
|
|
550
|
+
*/
|
|
551
|
+
async rescreen(): Promise<void> {
|
|
552
|
+
if (!this.screenOn()) return;
|
|
553
|
+
const waiting = (t: Task) => {
|
|
554
|
+
const last = [...t.history].reverse().find((h) => h.event === "screened")?.note ?? "";
|
|
555
|
+
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";
|
|
556
|
+
};
|
|
557
|
+
const tries = (t: Task) => this.rescreens.get(t.id) ?? 0;
|
|
558
|
+
const task = this.d.board.list("proposed").filter((t) => waiting(t) && tries(t) < RESCREEN_MAX).sort((a, b) => tries(a) - tries(b))[0];
|
|
559
|
+
if (!task) return;
|
|
560
|
+
this.rescreens.set(task.id, (this.rescreens.get(task.id) ?? 0) + 1);
|
|
561
|
+
const text = screenText(task);
|
|
562
|
+
const v = this.nameable(text) ? await this.screenCall(text) : undefined; // a pattern added since settles it
|
|
563
|
+
const now = this.d.board.get(task.id);
|
|
564
|
+
if (!now || !waiting(now)) return; // taken or moved meanwhile: its verdict stands
|
|
565
|
+
this.recordScreen(task.id, "task", v);
|
|
566
|
+
if (v?.label === "unknown") return;
|
|
567
|
+
if (v?.label !== "clear") return void this.d.board.update(task.id, HUB, "screened", {}, v ? `pii: screen, ${v.category}` : "pii: regex");
|
|
568
|
+
const lifted = this.d.board.update(task.id, HUB, "screened", { signals: now.signals.filter((s) => s !== "pii") }, "clear, screened again");
|
|
569
|
+
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
570
|
+
const still = this.waitsFor(lifted);
|
|
571
|
+
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
572
|
+
await this.assignOwner(lifted, HUB);
|
|
573
|
+
}
|
|
574
|
+
|
|
474
575
|
/**
|
|
475
576
|
* Open tasks of other owners whose paths overlap this one's. The later claimant settles it with the other owner, so
|
|
476
577
|
* only the new owner reads "settle it"; anyone else learns that the owner was told. PII tasks are left out on both
|
|
@@ -564,12 +665,12 @@ export class Tasks {
|
|
|
564
665
|
* Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
|
|
565
666
|
* that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
|
|
566
667
|
* 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.
|
|
668
|
+
* `ahub task show`. A PII task's messages are private already. `withheld`: the PII screen did not clear it (#198).
|
|
568
669
|
*/
|
|
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:
|
|
670
|
+
private screen(task: Task, text: string, what: string, to: PeerId | null | undefined, withheld = false): string {
|
|
671
|
+
if (!text || this.isPii(task) || to === USER || (!withheld && this.nameable(text))) return text;
|
|
672
|
+
this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (${this.whyWithheld(text)})`);
|
|
673
|
+
return `[${what} withheld: ${this.whyWithheld(text)}; ahub task show ${task.id}]`;
|
|
573
674
|
}
|
|
574
675
|
|
|
575
676
|
/** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
|
|
@@ -648,7 +749,7 @@ export class Tasks {
|
|
|
648
749
|
// Awaits run between tasks (briefs, memory): re-read, and stop for anything that changed meanwhile.
|
|
649
750
|
const task = this.d.board.get(id);
|
|
650
751
|
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.
|
|
752
|
+
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.excluded(task), peer], ...this.health() }).owner) continue;
|
|
652
753
|
try {
|
|
653
754
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
654
755
|
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
@@ -671,12 +772,15 @@ export class Tasks {
|
|
|
671
772
|
if (typeof target === "number") {
|
|
672
773
|
const task = this.d.board.get(target);
|
|
673
774
|
if (!task) throw new Error(`no task #${target}`);
|
|
674
|
-
const a = assign(task, this.states(), routing, { exclude: this.
|
|
775
|
+
const a = assign(task, this.states(), routing, { exclude: this.excluded(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
675
776
|
// 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 ?? [])];
|
|
777
|
+
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
778
|
}
|
|
678
779
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
679
|
-
|
|
780
|
+
const signals = detectSignals(draft, routing, this.d.cwd);
|
|
781
|
+
const trace = assign({ class: target.class, signals }, this.states(), routing, this.weights(target.class)).trace;
|
|
782
|
+
// A draft is not sent to the PII screen (issue #198): a proposal is, and a pii or unknown verdict adds the pii signal.
|
|
783
|
+
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
784
|
}
|
|
681
785
|
|
|
682
786
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
@@ -684,7 +788,22 @@ export class Tasks {
|
|
|
684
788
|
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
685
789
|
}
|
|
686
790
|
|
|
687
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Peers routing leaves out for good (#207): the owners a task was declined for (the console declines for one; older rows
|
|
793
|
+
* name only the decliner) and the owners it was escalated away from, so a reservation never hands it back to them.
|
|
794
|
+
*/
|
|
795
|
+
private excluded = (task: Task) => task.history.flatMap((h) => (h.event === "declined" ? [h.from ?? h.by] : h.event === "escalated" && h.from ? [h.from] : []));
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Says on the console and hub.log that routing passed over the reserved owner (#207); returns the history note's suffix.
|
|
799
|
+
* Only news is said: not when the reserved owner is the one the task moves away from, nor once it refused the task for
|
|
800
|
+
* good (its decline or escalation said that).
|
|
801
|
+
*/
|
|
802
|
+
private passedOver(task: Task, a: Assignment): string {
|
|
803
|
+
if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
|
|
804
|
+
this.d.notify(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
|
|
805
|
+
return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
|
|
806
|
+
}
|
|
688
807
|
|
|
689
808
|
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
690
809
|
private noReviewer(a: Assignment): string {
|
|
@@ -697,18 +816,20 @@ export class Tasks {
|
|
|
697
816
|
|
|
698
817
|
private announceRouting(task: Task, a: Assignment): void {
|
|
699
818
|
if (task.class !== "review" && !a.reviewer) this.d.notify(`task ${this.publicTitle(task)}: ${this.noReviewer(a)}`);
|
|
700
|
-
const hold = a.owner ? this.d.held?.()
|
|
819
|
+
const hold = a.owner ? peerEntry(this.d.held?.(), a.owner) : undefined;
|
|
701
820
|
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
821
|
}
|
|
703
822
|
|
|
704
|
-
|
|
823
|
+
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
824
|
+
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
825
|
const waits = this.waitsFor(task);
|
|
706
|
-
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.
|
|
826
|
+
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
827
|
|
|
708
828
|
if (waits.length) {
|
|
709
829
|
this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
710
830
|
return task;
|
|
711
831
|
}
|
|
832
|
+
const passed = this.passedOver(task, a);
|
|
712
833
|
if (!a.owner) {
|
|
713
834
|
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
835
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
@@ -719,7 +840,10 @@ export class Tasks {
|
|
|
719
840
|
return task;
|
|
720
841
|
}
|
|
721
842
|
const profile = this.d.splitProfile?.(a.owner);
|
|
722
|
-
|
|
843
|
+
// A person's explicit assign replaces an agent's reservation (#207). Only a refusal keeps the owner it moves away from
|
|
844
|
+
// out for good: a reviewer's rejections or an escalation by hand, never the hub's own move after a failed delivery.
|
|
845
|
+
const lasting = opts.event === "escalated" && task.owner && (opts.reason === "rejections" || opts.reason === "manual");
|
|
846
|
+
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
847
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
724
848
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
725
849
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -744,14 +868,16 @@ export class Tasks {
|
|
|
744
868
|
this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
745
869
|
return claimed;
|
|
746
870
|
}
|
|
747
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits));
|
|
871
|
+
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
748
872
|
return next;
|
|
749
873
|
}
|
|
750
874
|
|
|
751
|
-
|
|
875
|
+
/** `contextWithheld`: the PII screen did not clear a budget hand-off (#198); an escalation's reason is the hub's own words. */
|
|
876
|
+
private async sendTask(task: Task, a: Assignment, context?: string, overlap = "", contextWithheld = false): Promise<void> {
|
|
752
877
|
const pii = this.isPii(task);
|
|
753
878
|
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)}`);
|
|
879
|
+
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)}`);
|
|
880
|
+
const handoff = context && !pii ? this.screen(task, context, "handoff", a.owner, contextWithheld).slice(0, 3000) : "";
|
|
755
881
|
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
882
|
const plan = planText(task.plan);
|
|
757
883
|
const body = [
|
|
@@ -763,7 +889,7 @@ export class Tasks {
|
|
|
763
889
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
764
890
|
brief ?? "",
|
|
765
891
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
766
|
-
|
|
892
|
+
handoff ? `Handoff from the previous owner:\n${handoff}` : "",
|
|
767
893
|
`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
894
|
].filter(Boolean).join("\n\n");
|
|
769
895
|
// A hand-over the owner takes in a turn of its own (#109, #115): idle before, busy after and not queued, this envelope
|
|
@@ -777,58 +903,85 @@ export class Tasks {
|
|
|
777
903
|
}
|
|
778
904
|
|
|
779
905
|
/** Nobody, the console user included, works on a task before what it waits for is approved. */
|
|
780
|
-
private ready(task: Task): void {
|
|
906
|
+
private ready(task: Task, preEffect = false): void {
|
|
781
907
|
const waits = this.waitsFor(task);
|
|
782
|
-
if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
908
|
+
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
783
909
|
}
|
|
784
910
|
|
|
785
911
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
786
912
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
913
|
+
try {
|
|
914
|
+
for (const t of this.d.board.list("proposed")) {
|
|
915
|
+
if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
|
|
916
|
+
await this.offerReady(t, `#${approved.id} approved`);
|
|
917
|
+
}
|
|
918
|
+
} catch (e) {
|
|
919
|
+
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
790
920
|
}
|
|
791
921
|
}
|
|
792
922
|
|
|
793
|
-
|
|
923
|
+
/** Notifications may read the same unavailable board; they must not undo a saved approval's return. */
|
|
924
|
+
private releaseNotice(line: string): void {
|
|
925
|
+
try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
794
929
|
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
795
930
|
|
|
796
931
|
/**
|
|
797
932
|
* A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
|
|
798
933
|
* with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
|
|
799
|
-
* each such task is offered once
|
|
934
|
+
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
935
|
+
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
800
936
|
*/
|
|
801
937
|
async releaseReady(): Promise<void> {
|
|
802
938
|
for (const t of this.d.board.list("proposed")) {
|
|
803
939
|
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
|
-
|
|
940
|
+
if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready" && last !== "reserved") || this.waitsFor(t).length) continue;
|
|
941
|
+
// ponytail: a reserved owner (#207) that attaches after another peer that can take the task is passed over here like
|
|
942
|
+
// an offline one; hold reserved tasks for a grace period if restarts show that up.
|
|
943
|
+
if (!assign(t, this.states(), this.d.routing(), { exclude: this.excluded(t), ...this.health() }).owner) continue; // nobody attached can take it yet
|
|
806
944
|
await this.offerReady(t, "nothing left to wait for");
|
|
807
945
|
}
|
|
808
946
|
}
|
|
809
947
|
|
|
810
948
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
811
949
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
950
|
+
let offering = false;
|
|
951
|
+
try {
|
|
952
|
+
const t = this.d.board.get(stale.id);
|
|
953
|
+
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
954
|
+
this.offered.add(t.id);
|
|
955
|
+
offering = true;
|
|
956
|
+
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
957
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
958
|
+
await this.assignOwner(ready, HUB);
|
|
959
|
+
} catch (e) {
|
|
960
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
961
|
+
} finally {
|
|
962
|
+
if (offering) {
|
|
963
|
+
try {
|
|
964
|
+
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
965
|
+
} catch (e) {
|
|
966
|
+
this.offered.delete(stale.id);
|
|
967
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not verify assignment: ${e instanceof Error ? e.message : String(e)}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
818
971
|
}
|
|
819
972
|
|
|
820
|
-
private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
|
|
821
|
-
if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
973
|
+
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
974
|
+
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
822
975
|
}
|
|
823
976
|
|
|
824
|
-
private need(id: unknown, open = false): Task {
|
|
977
|
+
private need(id: unknown, open = false, preEffect = false): Task {
|
|
825
978
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
826
979
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
827
|
-
if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
980
|
+
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
828
981
|
const task = this.d.board.get(n);
|
|
829
|
-
if (!task) throw new Error(`no task #${n}`);
|
|
982
|
+
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
830
983
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
831
|
-
if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
984
|
+
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
832
985
|
return task;
|
|
833
986
|
}
|
|
834
987
|
|
|
@@ -837,15 +990,16 @@ export class Tasks {
|
|
|
837
990
|
* announced like one found at assignment (issue #31).
|
|
838
991
|
*/
|
|
839
992
|
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
840
|
-
const task = this.need(id);
|
|
841
|
-
this.mine(task, by, "owner");
|
|
842
|
-
this.ready(task);
|
|
993
|
+
const task = this.need(id, false, true);
|
|
994
|
+
this.mine(task, by, "owner", true);
|
|
995
|
+
this.ready(task, true);
|
|
996
|
+
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
843
997
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
844
998
|
const given = plan == null ? undefined : cleanPlan(plan);
|
|
845
999
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
846
1000
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
847
1001
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
|
|
848
|
-
throw new
|
|
1002
|
+
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
849
1003
|
}
|
|
850
1004
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
851
1005
|
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
|
|
@@ -904,9 +1058,10 @@ export class Tasks {
|
|
|
904
1058
|
};
|
|
905
1059
|
|
|
906
1060
|
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
907
|
-
const task = this.need(id, true);
|
|
908
|
-
this.mine(task, by, "owner");
|
|
909
|
-
|
|
1061
|
+
const task = this.need(id, true, true);
|
|
1062
|
+
this.mine(task, by, "owner", true);
|
|
1063
|
+
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1064
|
+
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
|
|
910
1065
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
911
1066
|
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
|
|
912
1067
|
}
|
|
@@ -922,14 +1077,20 @@ export class Tasks {
|
|
|
922
1077
|
isChecking = (id: number) => this.checking.has(id);
|
|
923
1078
|
|
|
924
1079
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1080
|
+
const valid = (): Task => {
|
|
1081
|
+
const t = this.need(id, false, true);
|
|
1082
|
+
this.mine(t, by, "owner", true);
|
|
1083
|
+
if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1084
|
+
this.ready(t, true);
|
|
1085
|
+
if (this.checking.has(t.id)) {
|
|
1086
|
+
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
1087
|
+
throw new PreEffectToolRefusal(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`);
|
|
1088
|
+
}
|
|
1089
|
+
return t;
|
|
1090
|
+
};
|
|
1091
|
+
// The PII screen reads the summary of a done that passes these checks (issue #198); they run again after it.
|
|
1092
|
+
const withheld = await this.withholds("summary", summary, valid());
|
|
1093
|
+
let task = valid();
|
|
933
1094
|
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
934
1095
|
const cohort = this.cohorts.of(task.id);
|
|
935
1096
|
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
@@ -960,15 +1121,15 @@ export class Tasks {
|
|
|
960
1121
|
}
|
|
961
1122
|
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
1123
|
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
963
|
-
if (!command) return this.complete(task, by, summary, refs);
|
|
1124
|
+
if (!command) return this.complete(task, by, summary, refs, "", withheld);
|
|
964
1125
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
965
1126
|
this.checking.set(task.id, task.owner);
|
|
966
|
-
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
|
|
1127
|
+
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
967
1128
|
this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
968
1129
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
969
1130
|
this.pendingChecks++;
|
|
970
1131
|
this.checkQueue = this.checkQueue
|
|
971
|
-
.then(() => this.finishChecked(pending.id, by, summary, command, seen))
|
|
1132
|
+
.then(() => this.finishChecked(pending.id, by, summary, command, seen, withheld))
|
|
972
1133
|
.catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
|
|
973
1134
|
.finally(() => this.pendingChecks--);
|
|
974
1135
|
return pending;
|
|
@@ -1005,7 +1166,7 @@ export class Tasks {
|
|
|
1005
1166
|
const theirs = this.places(u);
|
|
1006
1167
|
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
1007
1168
|
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)] : [];
|
|
1169
|
+
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note, at.withheld)] : [];
|
|
1009
1170
|
});
|
|
1010
1171
|
if (!open.length && !done.length) continue;
|
|
1011
1172
|
const owners = [...new Set(open.map((h) => h.task.owner!))];
|
|
@@ -1033,7 +1194,8 @@ export class Tasks {
|
|
|
1033
1194
|
const lines = others.map((t) => {
|
|
1034
1195
|
const files = this.places(t).paths.filter(this.nameable);
|
|
1035
1196
|
const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
|
|
1036
|
-
const
|
|
1197
|
+
const done = doneAt(t);
|
|
1198
|
+
const first = done?.withheld ? undefined : (done?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
1037
1199
|
return [
|
|
1038
1200
|
`- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
|
|
1039
1201
|
files.length ? ` changed files: ${files.join(", ")}` : "",
|
|
@@ -1049,7 +1211,7 @@ export class Tasks {
|
|
|
1049
1211
|
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
1212
|
}
|
|
1051
1213
|
|
|
1052
|
-
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
|
|
1214
|
+
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }, withheld: boolean): Promise<void> {
|
|
1053
1215
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
1054
1216
|
this.checking.delete(id);
|
|
1055
1217
|
const task = this.d.board.get(id);
|
|
@@ -1074,7 +1236,7 @@ export class Tasks {
|
|
|
1074
1236
|
}
|
|
1075
1237
|
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
1076
1238
|
// 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);
|
|
1239
|
+
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail, withheld);
|
|
1078
1240
|
return;
|
|
1079
1241
|
}
|
|
1080
1242
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
@@ -1085,12 +1247,12 @@ export class Tasks {
|
|
|
1085
1247
|
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
1248
|
}
|
|
1087
1249
|
|
|
1088
|
-
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
|
|
1250
|
+
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = "", withheld = false): Promise<Task> {
|
|
1089
1251
|
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);
|
|
1252
|
+
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
1253
|
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);
|
|
1254
|
+
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`, withheld);
|
|
1255
|
+
this.tellCompleted(next, summary, withheld);
|
|
1094
1256
|
if (!reviewer) {
|
|
1095
1257
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1096
1258
|
this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
@@ -1106,7 +1268,7 @@ export class Tasks {
|
|
|
1106
1268
|
* work is done (after its check, when one runs), and nobody else does (issue #31). A message of its own, not a
|
|
1107
1269
|
* ride-along line: an owner in the middle of those files needs it before its next task arrives.
|
|
1108
1270
|
*/
|
|
1109
|
-
private tellCompleted(task: Task, summary?: string): void {
|
|
1271
|
+
private tellCompleted(task: Task, summary?: string, withheld = false): void {
|
|
1110
1272
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
1111
1273
|
const cohort = this.cohorts.of(task.id);
|
|
1112
1274
|
const silent = this.silentFor(task.id);
|
|
@@ -1117,19 +1279,20 @@ export class Tasks {
|
|
|
1117
1279
|
cohort.held.add(task.id);
|
|
1118
1280
|
continue;
|
|
1119
1281
|
}
|
|
1120
|
-
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
|
|
1282
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld));
|
|
1121
1283
|
}
|
|
1122
1284
|
}
|
|
1123
1285
|
|
|
1124
1286
|
/**
|
|
1125
1287
|
* 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
|
|
1288
|
+
* included), and any item that matches a PII pattern is left out of what other owners get, the summary also when the
|
|
1289
|
+
* PII screen did not clear it (`withheld`, #198).
|
|
1127
1290
|
*/
|
|
1128
|
-
private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
|
|
1291
|
+
private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string, withheld = false): string {
|
|
1129
1292
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
1130
1293
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
1131
1294
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
1132
|
-
const line = first && this.nameable(first) ? first : undefined;
|
|
1295
|
+
const line = first && !withheld && this.nameable(first) ? first : undefined;
|
|
1133
1296
|
return [
|
|
1134
1297
|
`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
1298
|
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
@@ -1144,7 +1307,7 @@ export class Tasks {
|
|
|
1144
1307
|
const t = this.d.board.get(id);
|
|
1145
1308
|
if (!t || this.isPii(t)) return [];
|
|
1146
1309
|
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)];
|
|
1310
|
+
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
1311
|
});
|
|
1149
1312
|
return lines.length ? lines.join("\n") : undefined;
|
|
1150
1313
|
}
|
|
@@ -1163,21 +1326,28 @@ export class Tasks {
|
|
|
1163
1326
|
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
1164
1327
|
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
1165
1328
|
].join("\n");
|
|
1166
|
-
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
|
|
1329
|
+
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer, last.withheld) : "(no summary)";
|
|
1167
1330
|
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
1331
|
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
1332
|
}
|
|
1170
1333
|
|
|
1171
1334
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
1172
|
-
const
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1335
|
+
const valid = (): Task => {
|
|
1336
|
+
const t = this.need(id, false, true);
|
|
1337
|
+
this.mine(t, by, "reviewer", true);
|
|
1338
|
+
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
1339
|
+
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
1340
|
+
return t;
|
|
1341
|
+
};
|
|
1177
1342
|
const items = textList(unmet);
|
|
1178
1343
|
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
1344
|
+
// The PII screen reads the note of a review that passes these checks (issue #198); they run again after it.
|
|
1345
|
+
const withheld = await this.withholds("review note", note, valid());
|
|
1346
|
+
const task = valid();
|
|
1347
|
+
const pii = this.isPii(task);
|
|
1348
|
+
const flag = { withheld: withheld || undefined };
|
|
1179
1349
|
if (verdict === "approved") {
|
|
1180
|
-
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
|
|
1350
|
+
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note, flag);
|
|
1181
1351
|
if (next.owner) {
|
|
1182
1352
|
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
1183
1353
|
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
@@ -1186,31 +1356,31 @@ export class Tasks {
|
|
|
1186
1356
|
this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
|
|
1187
1357
|
}
|
|
1188
1358
|
}
|
|
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);
|
|
1359
|
+
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
1360
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii);
|
|
1191
1361
|
await this.releaseDependents(next);
|
|
1192
1362
|
return next;
|
|
1193
1363
|
}
|
|
1194
|
-
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
1364
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note, flag);
|
|
1195
1365
|
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
1196
1366
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
1197
1367
|
this.contradict(rejected);
|
|
1198
|
-
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}
|
|
1368
|
+
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
1199
1369
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1200
1370
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1201
1371
|
if (moved.owner !== rejected.owner) return moved;
|
|
1202
1372
|
// 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);
|
|
1373
|
+
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
1374
|
return moved;
|
|
1205
1375
|
}
|
|
1206
1376
|
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);
|
|
1377
|
+
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
1378
|
return reopened;
|
|
1209
1379
|
}
|
|
1210
1380
|
|
|
1211
1381
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
1212
|
-
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
|
|
1213
|
-
let task = this.need(id, true);
|
|
1382
|
+
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
1383
|
+
let task = this.need(id, true, preEffect);
|
|
1214
1384
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
1215
1385
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
1216
1386
|
const from = task.owner;
|
|
@@ -1229,8 +1399,13 @@ export class Tasks {
|
|
|
1229
1399
|
}
|
|
1230
1400
|
|
|
1231
1401
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1232
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
|
|
1233
|
-
|
|
1402
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
|
|
1403
|
+
const task = this.need(id, true, preEffect);
|
|
1404
|
+
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1405
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1406
|
+
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1407
|
+
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`);
|
|
1408
|
+
return next;
|
|
1234
1409
|
}
|
|
1235
1410
|
|
|
1236
1411
|
/**
|
|
@@ -1240,6 +1415,9 @@ export class Tasks {
|
|
|
1240
1415
|
async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
1241
1416
|
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
1242
1417
|
const routing = this.d.routing();
|
|
1418
|
+
// The hand-off is screened once (#198), before anything moves, when an ordinary task will carry it.
|
|
1419
|
+
const moves = (t: Task) => (!urgentOnly || t.signals.includes("urgent")) && t.owner === peer && OPEN.includes(t.state);
|
|
1420
|
+
const contextWithheld = context && this.d.board.list().some((t) => moves(t) && !this.isPii(t)) ? await this.withholds("handoff", context) : false;
|
|
1243
1421
|
for (const task of this.d.board.list()) {
|
|
1244
1422
|
// Waiting out a window that resets soon (issue #36): only urgent work moves.
|
|
1245
1423
|
if (urgentOnly && !task.signals.includes("urgent")) continue;
|
|
@@ -1247,7 +1425,7 @@ export class Tasks {
|
|
|
1247
1425
|
const pii = this.isPii(task);
|
|
1248
1426
|
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
1427
|
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 } : {}) });
|
|
1428
|
+
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
1429
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1252
1430
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1253
1431
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
@@ -1282,6 +1460,7 @@ export class Tasks {
|
|
|
1282
1460
|
const title = String(input.title ?? "").trim();
|
|
1283
1461
|
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
1462
|
if (!this.d.memory) return "memory is disabled; nothing saved";
|
|
1463
|
+
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
1464
|
const kind = (NOTE_KINDS as readonly string[]).includes(String(input.kind)) ? String(input.kind) : "finding";
|
|
1286
1465
|
const res = await this.d.memory.save({ text, ...(title ? { title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
|
|
1287
1466
|
if (!res) return "memory worker unavailable; nothing saved";
|
|
@@ -1292,10 +1471,11 @@ export class Tasks {
|
|
|
1292
1471
|
}
|
|
1293
1472
|
|
|
1294
1473
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
1295
|
-
private note(task: Task, by: PeerId, kind: string, text: string): void {
|
|
1474
|
+
private note(task: Task, by: PeerId, kind: string, text: string, withheld = false): void {
|
|
1296
1475
|
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
|
-
|
|
1476
|
+
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1477
|
+
// not clear (#198), is not sent (#69).
|
|
1478
|
+
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
1479
|
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
1480
|
}
|
|
1301
1481
|
|