@staix/agent-hub 0.8.1 → 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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/docs/events.md +2 -1
- package/docs/operations.md +108 -8
- package/docs/quickstart.md +1 -1
- package/docs/security.md +1 -0
- package/docs/smoke.md +18 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +95 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4 -4
- package/src/adapters/local-worker.ts +2 -1
- package/src/cli/main.ts +43 -8
- package/src/cli/upgrade.ts +1 -1
- package/src/hub/board.ts +24 -7
- package/src/hub/budget.ts +57 -6
- package/src/hub/bus.ts +24 -6
- package/src/hub/conflicts.ts +63 -0
- package/src/hub/daemon.ts +116 -23
- package/src/hub/events.ts +1 -0
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/limits.ts +71 -0
- package/src/hub/peers.ts +3 -2
- package/src/hub/report.ts +7 -2
- package/src/hub/routing.ts +41 -4
- package/src/hub/snapshots.ts +29 -2
- package/src/hub/tasks.ts +131 -12
- package/templates/claude-hooks.json +10 -0
- package/templates/config.json +3 -2
package/src/hub/hub-tools.ts
CHANGED
|
@@ -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.",
|
|
@@ -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) =>
|
|
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
|
-
|
|
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)`);
|
package/src/hub/routing.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
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
|
|
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.
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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" });
|
package/templates/config.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"local": ["implementer", "verifier"],
|
|
15
15
|
"pi": ["implementer", "verifier"]
|
|
16
16
|
},
|
|
17
|
-
"budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0 },
|
|
17
|
+
"budget": { "gate": 0.9, "stale_min": 30, "poll_min": 10, "checkpoint_timeout_s": 90, "kimi_tokens_5h": 0, "wait_max_min": 30 },
|
|
18
18
|
"inference": { "enabled": true, "digest_min_items": 5, "digest_min_chars": 8000, "triage": true },
|
|
19
19
|
"omniroute": {
|
|
20
20
|
"urls": [],
|
|
@@ -27,5 +27,6 @@
|
|
|
27
27
|
"approvals": { "timeout_s": 120 },
|
|
28
28
|
"tasks": { "release_after_min": 30 },
|
|
29
29
|
"checks": { "timeout_s": 600 },
|
|
30
|
-
"snapshots": { "enabled": true, "keep": 20 }
|
|
30
|
+
"snapshots": { "enabled": true, "keep": 20 },
|
|
31
|
+
"limits": { "sender_per_min": 12, "pair_per_min": 6, "important_per_hour": 6, "repeat_window_s": 120 }
|
|
31
32
|
}
|