@staix/agent-hub 0.8.0 → 0.9.0

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.
@@ -35,11 +35,11 @@ const tool = (name: string, description: string, properties: Record<string, unkn
35
35
  });
36
36
 
37
37
  export const TASK_TOOLS: HubTool[] = [
38
- tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
38
+ tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" }, after: { type: "array", items: { type: "integer" }, description: "ids of tasks that must be approved first; until then this one waits, offered to nobody, and cannot be claimed" }, urgent: { type: "boolean", description: "hand it over even when its owner is paused for quota only briefly" } }, ["title"]),
39
39
  tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
40
40
  tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
41
41
  tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
42
- tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
42
+ tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
43
43
  tool("hub_review", "Give your verdict on a task you were asked to review. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str }, ["id", "verdict"]),
44
44
  tool("hub_checkpoint", "Answer a checkpoint request from the hub (your quota window is nearly used up): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str }, ["summary"]),
45
45
  tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter.", { text: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id }, ["text"]),
@@ -49,7 +49,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
49
49
 
50
50
  /** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
51
51
  export const ROLE_TEXT: Record<string, string> = {
52
- planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs) instead of doing everything yourself.",
52
+ planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs, and after: [ids] for work that must wait for other tasks) instead of doing everything yourself.",
53
53
  implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
54
54
  verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
55
55
  reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
@@ -179,6 +179,15 @@ export async function startProject(project: Project, options: { unattended?: boo
179
179
  } finally { registry.close(); }
180
180
  }
181
181
 
182
+ /** Whether `instanceId` still holds the project's registry claim, counted as held while its process may be alive. */
183
+ function claimHeld(id: string, instanceId: string): boolean {
184
+ const registry = new Registry();
185
+ try {
186
+ const row = registry.get(id);
187
+ return row?.instanceId === instanceId && (row.pid === null || processAlive(row.pid) !== false);
188
+ } finally { registry.close(); }
189
+ }
190
+
182
191
  export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
183
192
  assertLifecycleAvailable();
184
193
  const inspection = await inspectProject(project);
@@ -194,8 +203,10 @@ export async function stopProject(project: Project, expectedInstance?: string):
194
203
  const deadline = Date.now() + 10_000;
195
204
  while (Date.now() < deadline) {
196
205
  const current = readControl(project.stateDir);
197
- if (!current) return; // daemon removes its manifest only after owned children stop
198
- if (current.instanceId !== instanceId) throw new Error("a new hub instance started; it was not stopped");
206
+ // The daemon removes its manifest only after owned children stop; its process releases the registry claim just
207
+ // after that. Until then `projects remove` refuses and `up` reads the project as starting, so wait for both.
208
+ if (!current && !claimHeld(project.id, instanceId)) return;
209
+ if (current && current.instanceId !== instanceId) throw new Error("a new hub instance started; it was not stopped");
199
210
  await Bun.sleep(100);
200
211
  }
201
212
  throw new Error("shutdown is still pending; state and ownership were retained");
@@ -0,0 +1,71 @@
1
+ import type { PeerId, Priority } from "./envelope.ts";
2
+
3
+ /** Per-sender limits on what agents send (issue #38). 0 turns a limit off; the console user and the hub are never limited. */
4
+ export interface LimitsConfig {
5
+ /** Messages a sender may send per minute, to anyone. */
6
+ sender_per_min: number;
7
+ /** Messages a sender may send per minute to one recipient (a broadcast counts as one recipient, `*`). */
8
+ pair_per_min: number;
9
+ /** `important` messages a sender may send per hour: each can interrupt a running turn. */
10
+ important_per_hour: number;
11
+ /** An identical message to the same recipients within this many seconds is dropped. */
12
+ repeat_window_s: number;
13
+ }
14
+ // Off here like approvals.notify, so tests and a hub without a config file are unlimited; the template turns it on.
15
+ export const DEFAULT_LIMITS: LimitsConfig = { sender_per_min: 0, pair_per_min: 0, important_per_hour: 0, repeat_window_s: 0 };
16
+ /** What a project config gets unless it says otherwise. */
17
+ export const PROJECT_LIMITS: LimitsConfig = { sender_per_min: 12, pair_per_min: 6, important_per_hour: 6, repeat_window_s: 120 };
18
+
19
+ interface Bucket {
20
+ tokens: number;
21
+ at: number;
22
+ }
23
+
24
+ /**
25
+ * Token buckets per sender, per (sender, recipient) and for `important`, plus repeat suppression. A refusal says why
26
+ * and when to retry, and costs no token: the sender learns at once instead of a queue filling up downstream.
27
+ */
28
+ export class Limiter {
29
+ private readonly buckets = new Map<string, Bucket>();
30
+ private readonly recent = new Map<string, number>();
31
+
32
+ constructor(
33
+ private readonly cfg: LimitsConfig,
34
+ private readonly now: () => number = Date.now,
35
+ ) {}
36
+
37
+ /**
38
+ * undefined when the message may go; otherwise the reason, for the sender. `parent` is the id of what the message
39
+ * answers: "Yes." to two different questions is two messages, not a repeat.
40
+ */
41
+ admit(from: PeerId, to: PeerId[] | undefined, priority: Priority, body: string, parent = ""): string | undefined {
42
+ const now = this.now();
43
+ const audience = to?.length ? [...new Set(to)].sort() : ["*"];
44
+ const key = `${from}\0${audience.join(",")}\0${parent}\0${body.trim().replace(/\s+/g, " ")}`;
45
+ if (this.cfg.repeat_window_s > 0) {
46
+ const last = this.recent.get(key);
47
+ if (last !== undefined && now - last < this.cfg.repeat_window_s * 1000) {
48
+ return `the same message went to ${to?.length ? audience.join(", ") : "everyone"} ${Math.round((now - last) / 1000)} s ago`;
49
+ }
50
+ this.recent.set(key, now);
51
+ if (this.recent.size > 1000) for (const [k, at] of this.recent) if (now - at >= this.cfg.repeat_window_s * 1000) this.recent.delete(k);
52
+ }
53
+ const wanted: [string, number, number][] = []; // key, capacity, refill per ms
54
+ if (this.cfg.sender_per_min > 0) wanted.push([`s\0${from}`, this.cfg.sender_per_min, this.cfg.sender_per_min / 60_000]);
55
+ if (this.cfg.pair_per_min > 0) for (const r of audience) wanted.push([`p\0${from}\0${r}`, this.cfg.pair_per_min, this.cfg.pair_per_min / 60_000]);
56
+ if (priority === "important" && this.cfg.important_per_hour > 0) wanted.push([`i\0${from}`, this.cfg.important_per_hour, this.cfg.important_per_hour / 3_600_000]);
57
+ const filled = wanted.map(([key, cap, rate]) => {
58
+ const b = this.buckets.get(key) ?? { tokens: cap, at: now };
59
+ return { key, rate, b: { tokens: Math.min(cap, b.tokens + (now - b.at) * rate), at: now } };
60
+ });
61
+ const short = filled.filter((f) => f.b.tokens < 1);
62
+ if (short.length) {
63
+ const wait = Math.max(...short.map((f) => Math.ceil(Math.round((1 - f.b.tokens) / f.rate) / 1000))); // whole ms first: 1/(1/x) is not always x
64
+ if (this.cfg.repeat_window_s > 0) this.recent.delete(key); // refused, so not sent
65
+ const what = short.some((f) => f.key.startsWith("i\0")) ? "important messages" : "messages";
66
+ return `rate limited: too many ${what} from ${from}; retry after ${wait} s${what === "important messages" ? ", or send it without [IMPORTANT]" : ""}`;
67
+ }
68
+ for (const f of filled) this.buckets.set(f.key, { tokens: f.b.tokens - 1, at: now });
69
+ return undefined;
70
+ }
71
+ }
package/src/hub/peers.ts CHANGED
@@ -18,7 +18,7 @@ export interface PeerAdapter {
18
18
  start(): Promise<void>;
19
19
  stop(): Promise<void>;
20
20
  /** Set by the bus. The peer said something worth sharing. */
21
- onMessage?: (body: string, opts?: EnvelopeOpts) => void;
21
+ onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
22
22
  /** Set by the bus. */
23
23
  onState?: (state: PeerState) => void;
24
24
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
@@ -33,7 +33,8 @@ export const DEFAULT_WATCHDOG_MS = 300_000;
33
33
 
34
34
  /** State holder with a per-turn inactivity watchdog: a busy peer that goes silent is forced back to idle. */
35
35
  export abstract class BasePeer implements PeerAdapter {
36
- onMessage?: (body: string, opts?: EnvelopeOpts) => void;
36
+ /** Returns a reason (a string) when the hub refused the message (issue #38). */
37
+ onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
37
38
  onState?: (state: PeerState) => void;
38
39
  onFailed?: (envs: Envelope[]) => void;
39
40
  onDelivery?: (receipt: DeliveryReceipt) => void;
package/src/hub/report.ts CHANGED
@@ -6,13 +6,15 @@ export interface Report {
6
6
  peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
7
7
  messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
8
8
  overlaps: { warnings: number; pairs: number };
9
+ /** Files one agent changed after another owner's open task had changed them (issue #32). */
10
+ conflicts: number;
9
11
  tasks: Record<string, number>;
10
12
  quota: { readings: number; hard: number };
11
13
  }
12
14
 
13
15
  /** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
14
16
  export function summarize(events: StampedEvent[]): Report {
15
- const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, tasks: {}, quota: { readings: 0, hard: 0 } };
17
+ const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
16
18
  const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
17
19
  const pairs = new Set<string>();
18
20
  const taskMessages = new Map<string, number>();
@@ -43,6 +45,9 @@ export function summarize(events: StampedEvent[]): Report {
43
45
  r.overlaps.warnings++;
44
46
  for (const o of e.others) pairs.add([e.task, o.task].sort((a, b) => a - b).join("-"));
45
47
  break;
48
+ case "conflict":
49
+ r.conflicts++;
50
+ break;
46
51
  case "quota":
47
52
  r.quota.readings++;
48
53
  if (e.hard) r.quota.hard++;
@@ -60,7 +65,7 @@ export function formatReport(r: Report): string[] {
60
65
  for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens || "-"} tokens`);
61
66
  const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
62
67
  lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
63
- lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}`);
68
+ lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
64
69
  const tasks = Object.entries(r.tasks).sort().map(([k, n]) => `${k} ${n}`).join(", ");
65
70
  lines.push(`task events: ${tasks || "none"}`);
66
71
  lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
@@ -89,7 +89,7 @@ export function currentRouting(cwd: string, log: (line: string) => void = () =>
89
89
  }
90
90
  }
91
91
 
92
- export type Signal = "pii" | "long_context";
92
+ export type Signal = "pii" | "long_context" | "urgent";
93
93
 
94
94
  /** What the policy can see in a task. Context length is estimated from the referenced files, 3 characters per token as elsewhere. */
95
95
  export function detectSignals(task: Pick<Task, "title" | "detail" | "refs">, routing: Routing, cwd: string): Signal[] {
@@ -125,10 +125,22 @@ export function assign(
125
125
  task: Pick<Task, "class" | "signals">,
126
126
  states: Record<PeerId, PeerState>,
127
127
  routing: Routing,
128
- opts: { exclude?: PeerId[]; candidates?: PeerId[]; notReviewer?: PeerId } = {},
128
+ opts: {
129
+ exclude?: PeerId[];
130
+ candidates?: PeerId[];
131
+ notReviewer?: PeerId;
132
+ waitsFor?: number[];
133
+ /** Quota per peer from its fresh readings, and the clock they are read against (issue #36). */
134
+ quota?: Record<PeerId, { headroom: number; resetsAt?: number }>;
135
+ now?: number;
136
+ /** Peers demoted for this task's class, with their recent failure weight (issue #36). */
137
+ demoted?: Record<PeerId, number>;
138
+ } = {},
129
139
  ): Assignment {
130
140
  const policy = routing.classes[task.class];
131
141
  const trace: string[] = [`class ${task.class}${policy ? "" : " (no [classes] entry: only an explicit owner can take it)"}`, `signals: ${task.signals.join(", ") || "none"}`];
142
+ // Readiness is an input like peer states (issue #34): a task that waits for others goes to nobody yet.
143
+ if (opts.waitsFor?.length) return { trace: [...trace, `blocked: waits for ${opts.waitsFor.map((id) => `#${id}`).join(", ")} (not approved)`] };
132
144
  const pii = task.signals.includes("pii") && routing.constraints.pii === "local_only";
133
145
 
134
146
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
@@ -148,6 +160,29 @@ export function assign(
148
160
  return undefined;
149
161
  };
150
162
 
163
+ /** Quota that resets soonest gets used first: headroom per hour left in the window, at least 15 min (a task assigned that close to a reset mostly runs after it). */
164
+ const drain = (p: PeerId): number | undefined => {
165
+ const q = opts.quota?.[p];
166
+ return q?.resetsAt === undefined ? undefined : q.headroom / Math.max((q.resetsAt - (opts.now ?? 0)) / 3_600_000, 0.25);
167
+ };
168
+ /** Peers with readings swap places among themselves by drain rate; peers without (local, pi) keep theirs. */
169
+ const byDrain = (list: PeerId[]): PeerId[] => {
170
+ const slots = list.flatMap((p, i) => (drain(p) === undefined ? [] : [i]));
171
+ if (slots.length < 2) return list;
172
+ const sorted = slots.map((i) => list[i]!).sort((a, b) => drain(b)! - drain(a)!);
173
+ const out = [...list];
174
+ slots.forEach((i, k) => (out[i] = sorted[k]!));
175
+ const left = (p: PeerId) => `${p} ${Math.round(opts.quota![p]!.headroom * 100)}% left, resets in ${Math.max(0, Math.round((opts.quota![p]!.resetsAt! - (opts.now ?? 0)) / 60_000))} min`;
176
+ if (sorted.join() !== slots.map((i) => list[i]).join()) trace.push(` quota first: ${sorted.map(left).join("; ")}`);
177
+ return out;
178
+ };
179
+ /** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
180
+ const rank = (ok: PeerId[], role: "owner" | "reviewer"): PeerId[] => {
181
+ const down = role === "owner" ? ok.filter((p) => opts.demoted?.[p]) : [];
182
+ if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
183
+ return [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
184
+ };
185
+
151
186
  const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
152
187
  const ok: PeerId[] = [];
153
188
  for (const peer of list) {
@@ -155,9 +190,11 @@ export function assign(
155
190
  trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
156
191
  if (!why) ok.push(peer);
157
192
  }
158
- const localTier = ok.filter((p) => p === LOCAL || p === PI);
193
+ const ranked = rank(ok, role);
194
+ // Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
195
+ const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted?.[p]));
159
196
  if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
160
- return ok.find((p) => states[p] === "idle") ?? ok[0];
197
+ return ranked.find((p) => states[p] === "idle") ?? ranked[0];
161
198
  };
162
199
 
163
200
  // Never the task's current owner by default: a decline or an escalation has to reach the next peer in the list.
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { spawnSync } from "node:child_process";
3
- import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { childEnv } from "./child-process.ts";
7
+ import type { Touch } from "./conflicts.ts";
7
8
 
8
9
  /**
9
10
  * Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
@@ -50,7 +51,15 @@ export function snapshot(repo: Repo): string | undefined {
50
51
  const tmp = mkdtempSync(join(tmpdir(), "ahub-snap-"));
51
52
  try {
52
53
  const env = { GIT_INDEX_FILE: join(tmp, "index") };
53
- if (existsSync(join(repo.dir, "index"))) copyFileSync(join(repo.dir, "index"), env.GIT_INDEX_FILE);
54
+ const index = join(repo.dir, "index");
55
+ if (existsSync(index)) {
56
+ // git trusts an entry's stat only when the entry is older than the index file, and compares in whole seconds. A
57
+ // copy written now makes a same-size edit in the second the index was written look clean: keep the original's
58
+ // time. Taken before the copy, so an index replaced in between gives the copy an earlier time (safe), not later.
59
+ const { atime, mtime } = statSync(index);
60
+ copyFileSync(index, env.GIT_INDEX_FILE);
61
+ utimesSync(env.GIT_INDEX_FILE, atime, mtime);
62
+ }
54
63
  if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
55
64
  // The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
56
65
  // only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
@@ -141,6 +150,9 @@ export class Turns {
141
150
  // Opened by a starting hub: a turn still open was cut short when the last run stopped. It has no end snapshot, and
142
151
  // it may have run until now, so its window ends now (other turns that overlapped it stay unknowable).
143
152
  this.db.query("UPDATE turns SET ended = ? WHERE ended IS NULL").run(Date.now());
153
+ // ponytail: rows of finished tasks stay (a few per file per task); prune by task state if hub.db ever grows.
154
+ this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
155
+ this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
144
156
  }
145
157
  begin(id: string, peer: string, startTree: string | undefined): void {
146
158
  this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
@@ -181,6 +193,21 @@ export class Turns {
181
193
  return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...rows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
182
194
  }
183
195
 
196
+ /** Files a peer's turn changed while it owned `task` in progress (issue #32). */
197
+ touch(task: number, peer: string, paths: string[]): void {
198
+ const at = Date.now();
199
+ const q = this.db.query("INSERT OR REPLACE INTO touches (task, peer, path, at) VALUES (?, ?, ?, ?)");
200
+ this.db.transaction(() => { for (const p of paths) q.run(task, peer, p, at); })();
201
+ }
202
+ touchesFor(tasks: number[]): Touch[] {
203
+ if (!tasks.length) return [];
204
+ return this.db.query(`SELECT * FROM touches WHERE task IN (${tasks.map(() => "?").join(",")})`).all(...tasks) as Touch[];
205
+ }
206
+ /** Other peers with a turn that was open at some point since `since`: their changes may be in this turn's diff. */
207
+ busySince(peer: string, since: number): string[] {
208
+ return (this.db.query("SELECT DISTINCT peer FROM turns WHERE peer != ? AND (ended IS NULL OR ended >= ?) ORDER BY peer").all(peer, since) as { peer: string }[]).map((r) => r.peer);
209
+ }
210
+
184
211
  /** The latest turn of a peer: a conversation can only be reverted from its latest turn. */
185
212
  latest(peer: string): TurnRecord | undefined {
186
213
  return this.list(peer, 1)[0];
package/src/hub/tasks.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Briefs } from "../memory/brief.ts";
2
2
  import type { MemoryClient } from "../memory/client.ts";
3
- import { CLASSES, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
3
+ import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
4
4
  import type { Bus } from "./bus.ts";
5
5
  import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
6
6
  import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
@@ -25,17 +25,24 @@ export interface TasksDeps {
25
25
  tell?: (peer: PeerId, line: string) => void;
26
26
  /** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
27
27
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
+ /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
+ quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
28
30
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
29
31
  triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
30
32
  }
31
33
 
32
34
  const ESCALATE_AFTER = 2;
35
+ // Demotion (issue #36): a failure counts half after a day. A peer is demoted for a class once its decayed failures
36
+ // reach 1.5 (two within about half a day, three within two days) and outweigh its decayed successes there.
37
+ // ponytail: fixed constants; make them config when someone needs to tune them.
38
+ const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
39
+ const DEMOTE_AT = 1.5;
33
40
  const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
34
41
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
35
42
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
36
43
 
37
44
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
38
- const samePlace = (a: string, b: string) => {
45
+ export const samePlace = (a: string, b: string) => {
39
46
  const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
40
47
  const [x, y] = [norm(a), norm(b)];
41
48
  return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
@@ -99,16 +106,57 @@ export class Tasks {
99
106
  return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
100
107
  }
101
108
 
102
- async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId }): Promise<Task> {
109
+ /** Dependencies of a task that are not approved yet: while there are any, it is offered to nobody (issue #34). */
110
+ waitsFor = (task: Pick<Task, "deps">): number[] => (task.deps ?? []).filter((id) => this.d.board.get(id)?.state !== "approved");
111
+
112
+ /** Decayed failure weights of the peers demoted for a class: failures count half after a day (issue #36). */
113
+ demoted(cls: TaskClass, now = Date.now()): Record<PeerId, number> {
114
+ const sums = new Map<PeerId, { bad: number; good: number }>();
115
+ for (const o of this.d.board.outcomes(cls, now - OUTCOMES_KEPT_MS)) {
116
+ const w = 0.5 ** ((now - o.at) / DEMOTION_HALF_LIFE_MS);
117
+ const s = sums.get(o.peer) ?? { bad: 0, good: 0 };
118
+ if (o.ok) s.good += w;
119
+ else s.bad += w;
120
+ sums.set(o.peer, s);
121
+ }
122
+ return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
123
+ }
124
+
125
+ /** What assignment weighs besides states and policy: quota and demotion. */
126
+ private weights(cls: TaskClass) {
127
+ const now = Date.now();
128
+ const quota = this.d.quota?.();
129
+ return { now, demoted: this.demoted(cls, now), ...(quota ? { quota } : {}) };
130
+ }
131
+
132
+ async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
103
133
  // Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
104
134
  const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
105
135
  if (!title) throw new Error("title is required");
136
+ // Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
137
+ // Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
138
+ let afterIds: unknown[] = [];
139
+ if (Array.isArray(input.after)) afterIds = input.after;
140
+ else if (input.after != null) afterIds = [input.after];
141
+ const deps: number[] = [];
142
+ for (const v of afterIds) {
143
+ const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
144
+ if (!Number.isInteger(id)) throw new Error(`after: ${JSON.stringify(v)} is not a task id`);
145
+ if (!this.d.board.get(id)) throw new Error(`after: no task #${id}`);
146
+ if (!deps.includes(id)) deps.push(id);
147
+ }
148
+ const waits = this.waitsFor({ deps });
149
+ if (waits.length && input.owner) {
150
+ 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"}`);
151
+ }
106
152
  const given = input.class === undefined || input.class === "" ? undefined : input.class;
107
153
  if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
108
154
  const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
109
155
  const plan = cleanPlan(input.plan);
110
156
  // The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
111
157
  const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
158
+ // Urgent work is handed over even when its paused owner's window resets soon (issue #36).
159
+ if (input.urgent === true && !signals.includes("urgent")) signals.push("urgent");
112
160
  let cls = given as TaskClass | undefined;
113
161
  let triaged = false;
114
162
  if (!cls && this.d.triage) {
@@ -122,10 +170,16 @@ export class Tasks {
122
170
  if (defaulted) cls = "implement";
123
171
  if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
124
172
  const draft = { ...text, class: cls };
125
- let task = this.d.board.propose(by, { ...draft, plan, signals });
173
+ let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), signals });
126
174
  if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
127
175
  if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
128
176
  this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
177
+ // Re-read: a dependency approved while triage was awaited looked for its dependents before this row existed.
178
+ const still = this.waitsFor(task);
179
+ if (still.length) {
180
+ this.d.notify(`task ${this.publicTitle(task)} waits for ${still.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
181
+ return this.d.board.update(task.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
182
+ }
129
183
  // Naming yourself is a claim: the work is already yours, so no offer comes back to you (paper: Agensh CLAIM, #68).
130
184
  return this.assignOwner(task, by, input.owner ? { candidates: [input.owner], claim: input.owner === by } : {});
131
185
  }
@@ -146,7 +200,7 @@ export class Tasks {
146
200
  private releasing: PeerId | undefined;
147
201
 
148
202
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
149
- private nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
203
+ nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
150
204
 
151
205
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
152
206
  private where(hit: { paths: string[]; symbols: string[] }): string {
@@ -227,16 +281,21 @@ export class Tasks {
227
281
  if (typeof target === "number") {
228
282
  const task = this.d.board.get(target);
229
283
  if (!task) throw new Error(`no task #${target}`);
230
- return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task) }).trace];
284
+ return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).trace];
231
285
  }
232
286
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
233
- return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing).trace;
287
+ return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
234
288
  }
235
289
 
236
290
  private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
237
291
 
238
292
  private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
239
- const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}) });
293
+ const waits = this.waitsFor(task);
294
+ const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
295
+ if (waits.length) {
296
+ this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
297
+ return task;
298
+ }
240
299
  if (!a.owner) {
241
300
  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>`);
242
301
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
@@ -278,6 +337,46 @@ export class Tasks {
278
337
  this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
279
338
  }
280
339
 
340
+ /** Nobody, the console user included, works on a task before what it waits for is approved. */
341
+ private ready(task: Task): void {
342
+ const waits = this.waitsFor(task);
343
+ if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
344
+ }
345
+
346
+ /** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
347
+ private async releaseDependents(approved: Task): Promise<void> {
348
+ for (const t of this.d.board.list("proposed")) {
349
+ if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
350
+ await this.offerReady(t, `#${approved.id} approved`);
351
+ }
352
+ }
353
+
354
+ private readonly offered = new Set<number>(); // ready tasks offered in this hub run
355
+
356
+ /**
357
+ * A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
358
+ * with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
359
+ * each such task is offered once per hub run, once an attached peer can take it (peers attach one by one).
360
+ */
361
+ async releaseReady(): Promise<void> {
362
+ for (const t of this.d.board.list("proposed")) {
363
+ const last = t.history.at(-1)?.event;
364
+ if (!t.deps?.length || t.owner || this.offered.has(t.id) || (last !== "blocked" && last !== "ready") || this.waitsFor(t).length) continue;
365
+ if (!assign(t, this.states(), this.d.routing(), { exclude: this.declined(t) }).owner) continue; // nobody attached can take it yet
366
+ await this.offerReady(t, "nothing left to wait for");
367
+ }
368
+ }
369
+
370
+ /** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
371
+ private async offerReady(stale: Task, why: string): Promise<void> {
372
+ const t = this.d.board.get(stale.id);
373
+ if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
374
+ this.offered.add(t.id);
375
+ const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
376
+ this.d.notify(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
377
+ await this.assignOwner(ready, HUB).catch((e: Error) => this.d.notify(`task ${this.publicTitle(ready)}: could not be assigned: ${e.message}`));
378
+ }
379
+
281
380
  private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
282
381
  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`);
283
382
  }
@@ -297,6 +396,7 @@ export class Tasks {
297
396
  accept(by: PeerId, id: unknown, plan?: unknown): Task {
298
397
  const task = this.need(id);
299
398
  this.mine(task, by, "owner");
399
+ this.ready(task);
300
400
  // Models send null or {} for an optional field they leave empty: neither replaces a plan.
301
401
  const given = plan == null ? undefined : cleanPlan(plan);
302
402
  const cleaned = given && Object.keys(given).length ? given : undefined;
@@ -327,6 +427,9 @@ export class Tasks {
327
427
  /** Tasks whose check is queued or running, and the owner it was started for. */
328
428
  private readonly checking = new Map<number, PeerId | null>();
329
429
  private checkQueue: Promise<void> = Promise.resolve();
430
+ private pendingChecks = 0;
431
+ /** Checks queued or running, until their result is on the board: a recovery commit waits for them. */
432
+ checksPending = () => this.pendingChecks;
330
433
 
331
434
  /** Whether a completion check is still running for this task. */
332
435
  isChecking = (id: number) => this.checking.has(id);
@@ -335,6 +438,7 @@ export class Tasks {
335
438
  let task = this.need(id);
336
439
  this.mine(task, by, "owner");
337
440
  if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
441
+ this.ready(task);
338
442
  if (this.checking.has(task.id)) {
339
443
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
340
444
  throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
@@ -347,9 +451,11 @@ export class Tasks {
347
451
  const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary);
348
452
  this.d.notify(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
349
453
  const seen = { events: pending.history.length, owner: pending.owner };
454
+ this.pendingChecks++;
350
455
  this.checkQueue = this.checkQueue
351
456
  .then(() => this.finishChecked(pending.id, by, summary, command, seen))
352
- .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`));
457
+ .catch((e: Error) => this.d.notify(`task #${pending.id}: its check result could not be recorded: ${e.message}`))
458
+ .finally(() => this.pendingChecks--);
353
459
  return pending;
354
460
  }
355
461
 
@@ -375,6 +481,7 @@ export class Tasks {
375
481
  return;
376
482
  }
377
483
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
484
+ if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
378
485
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
379
486
  this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
380
487
  }
@@ -384,7 +491,11 @@ export class Tasks {
384
491
  const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
385
492
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
386
493
  this.tellCompleted(next, summary);
387
- if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
494
+ if (!reviewer) {
495
+ if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
496
+ this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
497
+ await this.releaseDependents(next);
498
+ }
388
499
  else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
389
500
  else this.sendReview(next, reviewer);
390
501
  return next;
@@ -432,11 +543,14 @@ export class Tasks {
432
543
  const pii = this.isPii(task);
433
544
  if (verdict === "approved") {
434
545
  const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
546
+ if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true);
435
547
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
436
548
  this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
549
+ await this.releaseDependents(next);
437
550
  return next;
438
551
  }
439
552
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
553
+ if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
440
554
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
441
555
  if (rejected.rejections >= ESCALATE_AFTER) {
442
556
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
@@ -456,6 +570,9 @@ export class Tasks {
456
570
  const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
457
571
  if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
458
572
  const from = task.owner;
573
+ // The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
574
+ // Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
575
+ if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
459
576
  const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
460
577
  if (next.owner && next.owner !== from) {
461
578
  this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
@@ -474,10 +591,12 @@ export class Tasks {
474
591
  * Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
475
592
  * the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
476
593
  */
477
- async reassignForPause(peer: PeerId, context: string | undefined): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
594
+ async reassignForPause(peer: PeerId, context: string | undefined, urgentOnly = false): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
478
595
  const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
479
596
  const routing = this.d.routing();
480
597
  for (const task of this.d.board.list()) {
598
+ // Waiting out a window that resets soon (issue #36): only urgent work moves.
599
+ if (urgentOnly && !task.signals.includes("urgent")) continue;
481
600
  if (task.owner === peer && OPEN.includes(task.state)) {
482
601
  const pii = this.isPii(task);
483
602
  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);
@@ -486,7 +605,7 @@ export class Tasks {
486
605
  moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
487
606
  } else if (task.reviewer === peer && task.state !== "approved") {
488
607
  // No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
489
- const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
608
+ const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
490
609
  const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
491
610
  const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, `budget pause of ${peer}`);
492
611
  moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });