@staix/agent-hub 0.12.20 → 0.12.22
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 +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { StampedEvent } from "./events.ts";
|
|
5
|
+
import { hubHome } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #247: opt-in research records of how tasks went, built only from `events.jsonl` (ids, states, counts, tokens), so the
|
|
9
|
+
* live writer and `ahub research backfill` produce the same records. Never a title, detail, plan, note, path, message
|
|
10
|
+
* body or check output: the events carry none of those.
|
|
11
|
+
*/
|
|
12
|
+
export const RESEARCH_SCHEMA = "agent-hub.research/v1";
|
|
13
|
+
export const LABELS = ["ok", "regressed", "reverted", "incomplete", "wrong", "abandoned"] as const;
|
|
14
|
+
export type Label = (typeof LABELS)[number];
|
|
15
|
+
/** A person's later verdict that turns an approved task into a failure. */
|
|
16
|
+
const FAILED: ReadonlySet<string> = new Set(["regressed", "reverted", "incomplete", "wrong", "abandoned"]);
|
|
17
|
+
|
|
18
|
+
export interface PeerUsage { input: number | null; output: number | null; cacheRead: number | null; total: number | null }
|
|
19
|
+
export interface TaskRecord {
|
|
20
|
+
schema: typeof RESEARCH_SCHEMA; kind: "task"; project: string; task: number;
|
|
21
|
+
class: string | null; pii: boolean; outcome: "approved";
|
|
22
|
+
/** The proposal time also tells a task from an earlier one with the same id after `ahub reset --all`. */
|
|
23
|
+
createdAt: string | null; startedAt: string | null; approvedAt: string;
|
|
24
|
+
/** From the first `in_progress` to the approval; null when the start is not in the events. */
|
|
25
|
+
wallMs: number | null;
|
|
26
|
+
/** Sum of the attributed turns' durations, the turns still open at the approval included. */
|
|
27
|
+
activeMs: number;
|
|
28
|
+
/** Owners in the order they held the task. */
|
|
29
|
+
owners: string[]; reviewer: string | null;
|
|
30
|
+
/** Times the task went to review, was sent back, passed or failed its check, was reported done. */
|
|
31
|
+
reviewRounds: number; changesRequested: number; checkPassed: number; checkFailed: number; dones: number;
|
|
32
|
+
/** Owner changes after the first owner, and the same counted by the board's move reason. */
|
|
33
|
+
reassignments: number; reassignedBy: Record<string, number>;
|
|
34
|
+
/** Approved on its first review with no failed check. */
|
|
35
|
+
firstPass: boolean;
|
|
36
|
+
stuck: number; overlaps: number; conflicts: number;
|
|
37
|
+
tests: { pass: number; fail: number };
|
|
38
|
+
/** Attributed tokens per peer and per #200 attribution rule; unattributed tokens are in no record. */
|
|
39
|
+
tokens: { total: number; byPeer: Record<string, number>; byAttribution: Record<string, number> };
|
|
40
|
+
usage: Record<string, PeerUsage>;
|
|
41
|
+
/** Provider-reported total tokens per served (else requested) model. */
|
|
42
|
+
usageByModel: Record<string, number>;
|
|
43
|
+
turns: number; filesChanged: number;
|
|
44
|
+
/** Model routes and served models seen for the task's attributed requests. */
|
|
45
|
+
models: string[];
|
|
46
|
+
writer: { version: string; source: "live" | "backfill" };
|
|
47
|
+
}
|
|
48
|
+
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
49
|
+
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
50
|
+
export type ResearchRecord = TaskRecord | LabelRecord;
|
|
51
|
+
|
|
52
|
+
/** The store name for a project: a hash of its id, never its path. */
|
|
53
|
+
export const projectKey = (projectId: string): string => createHash("sha256").update(projectId).digest("hex").slice(0, 16);
|
|
54
|
+
export const researchDir = (home = hubHome()): string => join(home, "research");
|
|
55
|
+
export const researchFile = (projectId: string, home = hubHome()): string => join(researchDir(home), `${projectKey(projectId)}.jsonl`);
|
|
56
|
+
/** One task across every record about it: a task id is reused after `ahub reset --all`, its proposal time is not. */
|
|
57
|
+
export const recordKey = (r: { project: string; task: number; createdAt: string | null; approvedAt?: string }): string => `${r.project}#${r.task}@${r.createdAt ?? r.approvedAt ?? ""}`;
|
|
58
|
+
|
|
59
|
+
type Acc = Omit<TaskRecord, "schema" | "kind" | "project" | "approvedAt" | "wallMs" | "firstPass" | "outcome" | "writer" | "models"> & {
|
|
60
|
+
models: Set<string>; approvedAt: string | null; lastState: string | null;
|
|
61
|
+
};
|
|
62
|
+
/** Keyed by peer ids, model names and reasons, any of which may be `constructor` or `__proto__`: no prototype to inherit from. */
|
|
63
|
+
const keyed = <T>(): Record<string, T> => Object.create(null) as Record<string, T>;
|
|
64
|
+
const blank = (task: number): Acc => ({ task, class: null, pii: false, createdAt: null, startedAt: null, activeMs: 0, owners: [], reviewer: null,
|
|
65
|
+
reviewRounds: 0, changesRequested: 0, checkPassed: 0, checkFailed: 0, dones: 0, reassignments: 0, reassignedBy: keyed(), stuck: 0, overlaps: 0, conflicts: 0,
|
|
66
|
+
tests: { pass: 0, fail: 0 }, tokens: { total: 0, byPeer: keyed(), byAttribution: keyed() }, usage: keyed(), usageByModel: keyed(), turns: 0, filesChanged: 0, models: new Set(),
|
|
67
|
+
approvedAt: null, lastState: null });
|
|
68
|
+
const add = (current: number | null, value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? (current ?? 0) + value : current;
|
|
69
|
+
const bump = (counts: Record<string, number>, key: string, n = 1) => { counts[key] = (counts[key] ?? 0) + n; };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One record per approved task in `events`. Approval is final on the board, so everything attributed to the task
|
|
73
|
+
* counts, also after the approval: the turn that approved it ends later, and its usage arrives after that. The live
|
|
74
|
+
* writer therefore waits for those turns (daemon `recordResearch`); backfill reads to the end of the file.
|
|
75
|
+
*/
|
|
76
|
+
export function taskRecords(events: StampedEvent[], projectId: string, writer: TaskRecord["writer"]): TaskRecord[] {
|
|
77
|
+
const tasks = new Map<number, Acc>(), earlier: Acc[] = [];
|
|
78
|
+
const acc = (id: number) => { let a = tasks.get(id); if (!a) tasks.set(id, a = blank(id)); return a; };
|
|
79
|
+
const seenUsage = new Set<string>();
|
|
80
|
+
for (const e of events) {
|
|
81
|
+
if (e.type === "task") {
|
|
82
|
+
// Ids restart after `ahub reset --all` archives the events, but a file that kept both starts the task anew.
|
|
83
|
+
if (e.event === "proposed" && tasks.get(e.id)?.createdAt) { earlier.push(tasks.get(e.id)!); tasks.delete(e.id); }
|
|
84
|
+
const a = acc(e.id);
|
|
85
|
+
a.class = e.class; if (e.pii) a.pii = true;
|
|
86
|
+
if (e.owner && e.owner !== a.owners.at(-1)) {
|
|
87
|
+
if (a.owners.length) { a.reassignments++; bump(a.reassignedBy, e.reason ?? "none"); }
|
|
88
|
+
a.owners.push(e.owner);
|
|
89
|
+
}
|
|
90
|
+
if (e.reviewer) a.reviewer = e.reviewer;
|
|
91
|
+
if (e.event === "proposed") a.createdAt ??= e.at;
|
|
92
|
+
if (e.state === "in_progress") a.startedAt ??= e.at;
|
|
93
|
+
if (e.event === "done") a.dones++;
|
|
94
|
+
if (e.event === "check passed") a.checkPassed++;
|
|
95
|
+
if (e.event === "check failed") a.checkFailed++;
|
|
96
|
+
if (e.state === "in_review" && a.lastState !== "in_review") a.reviewRounds++;
|
|
97
|
+
if (e.state === "changes_requested" && a.lastState !== "changes_requested") a.changesRequested++;
|
|
98
|
+
if (e.state === "approved") a.approvedAt ??= e.at;
|
|
99
|
+
a.lastState = e.state;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const task = "task" in e && typeof e.task === "number" ? e.task : undefined;
|
|
103
|
+
if (task === undefined) continue;
|
|
104
|
+
const a = acc(task);
|
|
105
|
+
if ("pii" in e && e.pii === true) a.pii = true;
|
|
106
|
+
if (e.type === "tokens") { a.tokens.total += e.n; bump(a.tokens.byPeer, e.peer, e.n); bump(a.tokens.byAttribution, e.attribution ?? "unknown", e.n); }
|
|
107
|
+
else if (e.type === "turn_end") { a.turns++; a.activeMs += e.ms; a.filesChanged += e.files ?? 0; }
|
|
108
|
+
else if (e.type === "usage") {
|
|
109
|
+
const key = `${e.peer}\0${e.source}\0${e.id}`;
|
|
110
|
+
if (seenUsage.has(key)) continue;
|
|
111
|
+
seenUsage.add(key);
|
|
112
|
+
const u = a.usage[e.peer] ??= { input: null, output: null, cacheRead: null, total: null };
|
|
113
|
+
u.input = add(u.input, e.inputTokens); u.output = add(u.output, e.outputTokens); u.cacheRead = add(u.cacheRead, e.cacheReadTokens); u.total = add(u.total, e.totalTokens);
|
|
114
|
+
const model = e.servedModel ?? e.requestedModel;
|
|
115
|
+
if (model) a.models.add(String(model));
|
|
116
|
+
if (typeof e.totalTokens === "number" && Number.isFinite(e.totalTokens)) bump(a.usageByModel, String(model ?? "unknown"), e.totalTokens);
|
|
117
|
+
}
|
|
118
|
+
else if (e.type === "route") a.models.add(e.route);
|
|
119
|
+
else if (e.type === "route_outcome") { if (e.next?.tests === "pass") a.tests.pass++; if (e.next?.tests === "fail") a.tests.fail++; }
|
|
120
|
+
else if (e.type === "stuck") a.stuck++;
|
|
121
|
+
else if (e.type === "overlap") a.overlaps++;
|
|
122
|
+
else if (e.type === "conflict") a.conflicts++;
|
|
123
|
+
}
|
|
124
|
+
const out: TaskRecord[] = [];
|
|
125
|
+
for (const a of [...earlier, ...tasks.values()]) {
|
|
126
|
+
if (!a.approvedAt) continue;
|
|
127
|
+
const { approvedAt, lastState: _state, models, ...rest } = a;
|
|
128
|
+
const start = a.startedAt ? Date.parse(a.startedAt) : NaN, end = Date.parse(approvedAt);
|
|
129
|
+
out.push({ schema: RESEARCH_SCHEMA, kind: "task", project: projectKey(projectId), ...rest, outcome: "approved", approvedAt,
|
|
130
|
+
wallMs: Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : null,
|
|
131
|
+
firstPass: a.changesRequested === 0 && a.checkFailed === 0 && a.reviewRounds <= 1, models: [...models].sort(), writer });
|
|
132
|
+
}
|
|
133
|
+
return out.sort((x, y) => x.approvedAt.localeCompare(y.approvedAt) || x.task - y.task);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every record in one store file; a line that does not parse is skipped. */
|
|
137
|
+
export function readStore(file: string): ResearchRecord[] {
|
|
138
|
+
if (!existsSync(file)) return [];
|
|
139
|
+
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
140
|
+
if (!line.trim()) return [];
|
|
141
|
+
try { const r = JSON.parse(line); return r?.schema === RESEARCH_SCHEMA ? [r as ResearchRecord] : []; } catch { return []; }
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** This project's store, or every project's with `all`. */
|
|
145
|
+
export function readStores(projectId: string | undefined, home = hubHome()): ResearchRecord[] {
|
|
146
|
+
if (projectId) return readStore(researchFile(projectId, home));
|
|
147
|
+
const dir = researchDir(home);
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).sort().flatMap((name) => readStore(join(dir, name)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Append task records the store does not hold yet (same task, by `recordKey`), and labels; returns how many were written. */
|
|
153
|
+
export function appendRecords(projectId: string, records: ResearchRecord[], home = hubHome()): number {
|
|
154
|
+
if (!records.length) return 0;
|
|
155
|
+
const file = researchFile(projectId, home);
|
|
156
|
+
mkdirSync(researchDir(home), { recursive: true, mode: 0o700 });
|
|
157
|
+
chmodSync(researchDir(home), 0o700); // mkdir's mode applies only when it creates the directory
|
|
158
|
+
const held = new Set(readStore(file).flatMap((r) => r.kind === "task" ? [recordKey(r)] : []));
|
|
159
|
+
const added = records.filter((r) => r.kind === "label" || !held.has(recordKey(r)));
|
|
160
|
+
if (!added.length) return 0;
|
|
161
|
+
appendFileSync(file, added.map((r) => JSON.stringify(r)).join("\n") + "\n", { mode: 0o600 });
|
|
162
|
+
chmodSync(file, 0o600);
|
|
163
|
+
return added.length;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The record a person's label is for. `created` (a record's `createdAt`, from `ahub research export`) names one exactly.
|
|
168
|
+
* Otherwise the current board's task with that id, found by its proposal in `events`, so a label never lands on an
|
|
169
|
+
* earlier task with the same id; an id this hub's events do not have (a task from before `ahub reset --all`) is the
|
|
170
|
+
* store's only record with that id. Throws why there is none, or which ones to choose from.
|
|
171
|
+
*/
|
|
172
|
+
export function labelTarget(records: ResearchRecord[], events: StampedEvent[], task: number, created?: string): TaskRecord {
|
|
173
|
+
// A record without a proposal time (its proposal was not in the events) cannot be told apart, so it takes no label.
|
|
174
|
+
const mine = records.filter((r): r is TaskRecord => r.kind === "task" && r.task === task && r.createdAt !== null);
|
|
175
|
+
if (created !== undefined) {
|
|
176
|
+
return mine.find((r) => r.createdAt === created) ?? fail(`no research record for task #${task} proposed at ${created}${mine.length ? ` (it has: ${mine.map((r) => r.createdAt).join(", ")})` : ""}`);
|
|
177
|
+
}
|
|
178
|
+
const proposed = events.filter((e) => e.type === "task" && e.id === task && e.event === "proposed").at(-1);
|
|
179
|
+
if (proposed) {
|
|
180
|
+
return mine.find((r) => r.createdAt === proposed.at)
|
|
181
|
+
?? fail(`task #${task} has no research record yet: it is not approved, its record still waits for the turns that approved it, or it was approved while research was off (ahub research backfill builds that one)${mine.length ? `; an earlier task #${task} from before a reset is labelled with --created ${mine.map((r) => r.createdAt).join(" or ")}` : ""}`);
|
|
182
|
+
}
|
|
183
|
+
if (mine.length === 1) return mine[0]!;
|
|
184
|
+
if (!mine.length) throw new Error(records.some((r) => r.kind === "task" && r.task === task) ? `task #${task}'s research record has no proposal time (its proposal was not in the events), so it takes no label` : `no research record for task #${task}`);
|
|
185
|
+
throw new Error(`task #${task} is not on this hub's board and the store has ${mine.length} records with that id; name one with --created <time>: ${mine.map((r) => r.createdAt).join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
const fail = (message: string): never => { throw new Error(message); };
|
|
188
|
+
|
|
189
|
+
const pct = (n: number, d: number) => d ? n / d : null;
|
|
190
|
+
/** Linear interpolation between the closest ranks: the median of [100, 300] is 200. */
|
|
191
|
+
function quantile(values: number[], q: number): number | null {
|
|
192
|
+
if (!values.length) return null;
|
|
193
|
+
const sorted = [...values].sort((a, b) => a - b), at = q * (sorted.length - 1), low = Math.floor(at);
|
|
194
|
+
return sorted[low]! + (sorted[Math.min(low + 1, sorted.length - 1)]! - sorted[low]!) * (at - low);
|
|
195
|
+
}
|
|
196
|
+
export interface Measures {
|
|
197
|
+
tasks: number; succeeded: number; failedByLabel: number;
|
|
198
|
+
successRate: number | null; firstPassRate: number | null; reworkRate: number | null; checkFailureRate: number | null;
|
|
199
|
+
tokensPerTask: { median: number | null; p90: number | null }; wallMsPerTask: { median: number | null; p90: number | null };
|
|
200
|
+
}
|
|
201
|
+
function measures(records: TaskRecord[], labels: Map<string, Label>): Measures {
|
|
202
|
+
const failed = records.filter((r) => FAILED.has(labels.get(recordKey(r)) ?? "")).length;
|
|
203
|
+
return { tasks: records.length, succeeded: records.length - failed, failedByLabel: failed,
|
|
204
|
+
successRate: pct(records.length - failed, records.length), firstPassRate: pct(records.filter((r) => r.firstPass).length, records.length),
|
|
205
|
+
reworkRate: pct(records.reduce((n, r) => n + r.changesRequested, 0), records.length),
|
|
206
|
+
checkFailureRate: pct(records.reduce((n, r) => n + r.checkFailed, 0), records.reduce((n, r) => n + r.checkFailed + r.checkPassed, 0)),
|
|
207
|
+
tokensPerTask: { median: quantile(records.map((r) => r.tokens.total), 0.5), p90: quantile(records.map((r) => r.tokens.total), 0.9) },
|
|
208
|
+
wallMsPerTask: { median: quantile(records.flatMap((r) => r.wallMs ?? []), 0.5), p90: quantile(records.flatMap((r) => r.wallMs ?? []), 0.9) } };
|
|
209
|
+
}
|
|
210
|
+
export interface ResearchReport { overall: Measures; byClass: Record<string, Measures>; byOwner: Record<string, Measures>; byProject: Record<string, Measures>; byModel: Record<string, Measures> }
|
|
211
|
+
/**
|
|
212
|
+
* The derived measures, never stored as truth: each task counts once (its last record), and a person's latest label
|
|
213
|
+
* decides whether an approved task later failed. Records older than `since` (ms epoch, by approval) are left out.
|
|
214
|
+
*/
|
|
215
|
+
export function researchReport(records: ResearchRecord[], since = 0): ResearchReport {
|
|
216
|
+
const latest = new Map<string, TaskRecord>(), labels = new Map<string, Label>();
|
|
217
|
+
for (const r of records) {
|
|
218
|
+
const key = recordKey(r);
|
|
219
|
+
if (r.kind === "label") { labels.set(key, r.label); continue; }
|
|
220
|
+
if (Date.parse(r.approvedAt) >= since) latest.set(key, r);
|
|
221
|
+
}
|
|
222
|
+
const all = [...latest.values()];
|
|
223
|
+
const group = (keys: (r: TaskRecord) => string[]) => {
|
|
224
|
+
// A Map: a peer or model may be named `constructor`, which a plain object answers with an inherited value.
|
|
225
|
+
const out = new Map<string, TaskRecord[]>();
|
|
226
|
+
for (const r of all) for (const k of keys(r)) out.set(k, [...(out.get(k) ?? []), r]);
|
|
227
|
+
return Object.fromEntries([...out].sort(([a], [b]) => a.localeCompare(b)).map(([k, rs]) => [k, measures(rs, labels)]));
|
|
228
|
+
};
|
|
229
|
+
return { overall: measures(all, labels), byClass: group((r) => [r.class ?? "unknown"]), byOwner: group((r) => r.owners.length ? [...new Set(r.owners)] : ["none"]),
|
|
230
|
+
byProject: group((r) => [r.project]), byModel: group((r) => r.models.length ? r.models : ["unknown"]) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const fmtPct = (v: number | null) => v === null ? "-" : `${Math.round(v * 100)}%`;
|
|
234
|
+
const fmtNum = (v: number | null) => v === null ? "-" : String(Math.round(v));
|
|
235
|
+
export function formatResearch(r: ResearchReport): string[] {
|
|
236
|
+
const row = (name: string, m: Measures) => `${name.padEnd(24)} ${String(m.tasks).padStart(5)} ${fmtPct(m.successRate).padStart(7)} ${fmtPct(m.firstPassRate).padStart(10)} ${(m.reworkRate === null ? "-" : m.reworkRate.toFixed(2)).padStart(6)} ${fmtPct(m.checkFailureRate).padStart(11)} ${fmtNum(m.tokensPerTask.median).padStart(10)} ${fmtNum(m.tokensPerTask.p90).padStart(10)} ${(m.wallMsPerTask.median === null ? "-" : `${Math.round(m.wallMsPerTask.median / 60_000)}m`).padStart(8)}`;
|
|
237
|
+
const head = `${"".padEnd(24)} ${"tasks".padStart(5)} ${"success".padStart(7)} ${"first pass".padStart(10)} ${"rework".padStart(6)} ${"check fails".padStart(11)} ${"tokens p50".padStart(10)} ${"tokens p90".padStart(10)} ${"wall p50".padStart(8)}`;
|
|
238
|
+
const section = (title: string, groups: Record<string, Measures>) => Object.keys(groups).length ? [``, title, head, ...Object.entries(groups).map(([k, m]) => row(k, m))] : [];
|
|
239
|
+
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|
|
243
|
+
"reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
|
|
244
|
+
"testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
|
|
245
|
+
/** Task records as CSV with a header row, one row per record, each with the task's latest label; JSONL has every field. */
|
|
246
|
+
export function toCsv(records: ResearchRecord[]): string {
|
|
247
|
+
const labels = new Map<string, Label>();
|
|
248
|
+
for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
|
|
249
|
+
const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
250
|
+
const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
|
|
251
|
+
r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
|
|
252
|
+
r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
|
|
253
|
+
labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
|
|
254
|
+
return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
|
|
255
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { assign, detectSignals, LOCAL, peerEntry, PI, predictSplit, type Assignm
|
|
|
9
9
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
10
10
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
11
11
|
import { realPath } from "./project.ts";
|
|
12
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
12
13
|
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
13
14
|
|
|
14
15
|
export interface TasksDeps {
|
|
@@ -470,7 +471,7 @@ export class Tasks {
|
|
|
470
471
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
|
|
471
472
|
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
472
473
|
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
473
|
-
if (!title) throw new
|
|
474
|
+
if (!title) throw new PreEffectToolRefusal("title is required");
|
|
474
475
|
// Dependencies can only name tasks that exist, so the new task closes no cycle: nothing can depend on it yet.
|
|
475
476
|
// Callers are models: a missing or null `after` is no dependency, and an id is an integer or a digit string.
|
|
476
477
|
let afterIds: unknown[] = [];
|
|
@@ -479,14 +480,14 @@ export class Tasks {
|
|
|
479
480
|
const deps: number[] = [];
|
|
480
481
|
for (const v of afterIds) {
|
|
481
482
|
const id = typeof v === "number" || (typeof v === "string" && /^\s*\d+\s*$/.test(v)) ? Number(v) : NaN;
|
|
482
|
-
if (!Number.isInteger(id)) throw new
|
|
483
|
-
if (!this.d.board.get(id)) throw new
|
|
483
|
+
if (!Number.isInteger(id)) throw new PreEffectToolRefusal(`after: ${JSON.stringify(v)} is not a task id`);
|
|
484
|
+
if (!this.d.board.get(id)) throw new PreEffectToolRefusal(`after: no task #${id}`);
|
|
484
485
|
if (!deps.includes(id)) deps.push(id);
|
|
485
486
|
}
|
|
486
487
|
// An owner for work that waits is reserved (issue #207): routing offers it to that peer first once it is ready.
|
|
487
488
|
const reserved = this.waitsFor({ deps }).length ? input.owner : undefined;
|
|
488
489
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
489
|
-
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new
|
|
490
|
+
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new PreEffectToolRefusal(`class must be one of ${CLASSES.join(", ")}`);
|
|
490
491
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
491
492
|
const plan = cleanPlan(input.plan);
|
|
492
493
|
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
@@ -511,7 +512,7 @@ export class Tasks {
|
|
|
511
512
|
// A claim is work the caller will do itself: without a class and a model to name one, it is implementation (#6).
|
|
512
513
|
const defaulted = !cls && input.owner === by;
|
|
513
514
|
if (defaulted) cls = "implement";
|
|
514
|
-
if (!cls) throw new
|
|
515
|
+
if (!cls) throw new PreEffectToolRefusal(`class is required (one of ${CLASSES.join(", ")}); ${fenced ? "the task is handled as PII and the hub's model is not reached on campus, so it was not asked to name one" : "the hub could not name one for you"}`);
|
|
515
516
|
const draft = { ...text, class: cls };
|
|
516
517
|
let task = this.d.board.propose(by, { ...draft, plan, ...(deps.length ? { deps } : {}), ...(reserved ? { reserved } : {}), signals });
|
|
517
518
|
if (screening) task = this.screenedTask(task, screened);
|
|
@@ -902,26 +903,47 @@ export class Tasks {
|
|
|
902
903
|
}
|
|
903
904
|
|
|
904
905
|
/** Nobody, the console user included, works on a task before what it waits for is approved. */
|
|
905
|
-
private ready(task: Task): void {
|
|
906
|
+
private ready(task: Task, preEffect = false): void {
|
|
906
907
|
const waits = this.waitsFor(task);
|
|
907
|
-
if (waits.length) throw new Error(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
908
|
+
if (waits.length) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} waits for ${waits.map((id) => `#${id}`).join(", ")}, not approved yet`);
|
|
908
909
|
}
|
|
909
910
|
|
|
910
911
|
/** An approved task may be the last thing others waited for: those go through assignment now (issue #34). */
|
|
911
912
|
private async releaseDependents(approved: Task): Promise<void> {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
try {
|
|
914
|
+
for (const t of this.d.board.list("proposed")) {
|
|
915
|
+
if (!t.deps?.includes(approved.id) || t.owner || this.waitsFor(t).length) continue;
|
|
916
|
+
await this.offerReady(t, `#${approved.id} approved`);
|
|
917
|
+
}
|
|
918
|
+
} catch (e) {
|
|
919
|
+
this.releaseNotice(`task #${approved.id}: could not release dependents: ${e instanceof Error ? e.message : String(e)}`);
|
|
915
920
|
}
|
|
916
921
|
}
|
|
917
922
|
|
|
918
|
-
|
|
923
|
+
/**
|
|
924
|
+
* The one guard for a publish or notify after a board write (#231, #243): the write took effect, so a failure
|
|
925
|
+
* never throws back to the caller. A failed publish becomes a console notice naming what was lost; a failed notify
|
|
926
|
+
* is dropped (a second attempt would fail the same way). A lost publish is not retried here: the delivery
|
|
927
|
+
* journal's own recovery covers a storage error.
|
|
928
|
+
*/
|
|
929
|
+
private releaseNotice(line: string, run?: () => unknown): void {
|
|
930
|
+
if (!run) {
|
|
931
|
+
try { this.d.notify(line); } catch { /* best effort; the release timer retries ownerless work */ }
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
try { run(); } catch (e) {
|
|
935
|
+
try { this.d.notify(`${line}: ${e instanceof Error ? e.message : String(e)}`); } catch { /* best effort */ }
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
919
940
|
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
920
941
|
|
|
921
942
|
/**
|
|
922
943
|
* A stop between an approval and the assignment of its dependents (both are saved on their own) leaves them ownerless
|
|
923
944
|
* with nothing left to wait for, and no later approval to offer them. The daemon calls this on its release timer:
|
|
924
|
-
* each such task is offered once
|
|
945
|
+
* each such task is offered once an attached peer can take it (peers attach one by one). Only an assignment uses
|
|
946
|
+
* up its offer for this hub run; a failed or ownerless offer stays eligible, after approval and on the timer alike.
|
|
925
947
|
*/
|
|
926
948
|
async releaseReady(): Promise<void> {
|
|
927
949
|
for (const t of this.d.board.list("proposed")) {
|
|
@@ -936,26 +958,41 @@ export class Tasks {
|
|
|
936
958
|
|
|
937
959
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
938
960
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
961
|
+
let offering = false;
|
|
962
|
+
try {
|
|
963
|
+
const t = this.d.board.get(stale.id);
|
|
964
|
+
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
965
|
+
this.offered.add(t.id);
|
|
966
|
+
offering = true;
|
|
967
|
+
const ready = t.history.at(-1)?.event === "ready" ? t : this.d.board.update(t.id, HUB, "ready", {}, why);
|
|
968
|
+
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
969
|
+
await this.assignOwner(ready, HUB);
|
|
970
|
+
} catch (e) {
|
|
971
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
972
|
+
} finally {
|
|
973
|
+
if (offering) {
|
|
974
|
+
try {
|
|
975
|
+
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
976
|
+
} catch (e) {
|
|
977
|
+
this.offered.delete(stale.id);
|
|
978
|
+
this.releaseNotice(`task ${this.publicTitle(stale)}: could not verify assignment: ${e instanceof Error ? e.message : String(e)}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
945
982
|
}
|
|
946
983
|
|
|
947
|
-
private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
|
|
948
|
-
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`);
|
|
984
|
+
private mine(task: Task, by: PeerId, role: "owner" | "reviewer", preEffect = false): void {
|
|
985
|
+
if (by !== USER && task[role] !== by) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
949
986
|
}
|
|
950
987
|
|
|
951
|
-
private need(id: unknown, open = false): Task {
|
|
988
|
+
private need(id: unknown, open = false, preEffect = false): Task {
|
|
952
989
|
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
953
990
|
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
954
|
-
if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
991
|
+
if (!Number.isInteger(n)) throw new (preEffect ? PreEffectToolRefusal : Error)(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
955
992
|
const task = this.d.board.get(n);
|
|
956
|
-
if (!task) throw new Error(`no task #${n}`);
|
|
993
|
+
if (!task) throw new (preEffect ? PreEffectToolRefusal : Error)(`no task #${n}`);
|
|
957
994
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
958
|
-
if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
995
|
+
if (open && !OPEN.includes(task.state)) throw new (preEffect ? PreEffectToolRefusal : Error)(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
959
996
|
return task;
|
|
960
997
|
}
|
|
961
998
|
|
|
@@ -964,15 +1001,16 @@ export class Tasks {
|
|
|
964
1001
|
* announced like one found at assignment (issue #31).
|
|
965
1002
|
*/
|
|
966
1003
|
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
967
|
-
const task = this.need(id);
|
|
968
|
-
this.mine(task, by, "owner");
|
|
969
|
-
this.ready(task);
|
|
1004
|
+
const task = this.need(id, false, true);
|
|
1005
|
+
this.mine(task, by, "owner", true);
|
|
1006
|
+
this.ready(task, true);
|
|
1007
|
+
if (task.state !== "proposed" && task.state !== "changes_requested" && task.state !== "in_progress") throw new PreEffectToolRefusal(`task #${task.id} is ${task.state}: cannot move to in_progress`);
|
|
970
1008
|
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
971
1009
|
const given = plan == null ? undefined : cleanPlan(plan);
|
|
972
1010
|
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
973
1011
|
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
974
1012
|
if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
|
|
975
|
-
throw new
|
|
1013
|
+
throw new PreEffectToolRefusal("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
976
1014
|
}
|
|
977
1015
|
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
978
1016
|
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
|
|
@@ -1031,8 +1069,8 @@ export class Tasks {
|
|
|
1031
1069
|
};
|
|
1032
1070
|
|
|
1033
1071
|
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
1034
|
-
const task = this.need(id, true);
|
|
1035
|
-
this.mine(task, by, "owner");
|
|
1072
|
+
const task = this.need(id, true, true);
|
|
1073
|
+
this.mine(task, by, "owner", true);
|
|
1036
1074
|
// The owner it is declined for goes on the entry, also when the console declines for it: `excluded()` keeps it out (#207).
|
|
1037
1075
|
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason, task.owner ? { from: task.owner } : {});
|
|
1038
1076
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
@@ -1051,13 +1089,13 @@ export class Tasks {
|
|
|
1051
1089
|
|
|
1052
1090
|
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
1053
1091
|
const valid = (): Task => {
|
|
1054
|
-
const t = this.need(id);
|
|
1055
|
-
this.mine(t, by, "owner");
|
|
1056
|
-
if (t.state === "in_review" || t.state === "approved") throw new
|
|
1057
|
-
this.ready(t);
|
|
1092
|
+
const t = this.need(id, false, true);
|
|
1093
|
+
this.mine(t, by, "owner", true);
|
|
1094
|
+
if (t.state === "in_review" || t.state === "approved") throw new PreEffectToolRefusal(`task #${t.id} is already ${t.state}`);
|
|
1095
|
+
this.ready(t, true);
|
|
1058
1096
|
if (this.checking.has(t.id)) {
|
|
1059
1097
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
1060
|
-
throw new
|
|
1098
|
+
throw new PreEffectToolRefusal(this.checking.get(t.id) === t.owner ? `task #${t.id}: its check is still running; its result comes as a task message` : `task #${t.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
1061
1099
|
}
|
|
1062
1100
|
return t;
|
|
1063
1101
|
};
|
|
@@ -1098,7 +1136,7 @@ export class Tasks {
|
|
|
1098
1136
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
1099
1137
|
this.checking.set(task.id, task.owner);
|
|
1100
1138
|
const pending = this.d.board.update(task.id, by, "done (checking)", { refs: cleanRefs(refs) }, summary, { withheld: withheld || undefined });
|
|
1101
|
-
this.
|
|
1139
|
+
this.releaseNotice(`task ${this.publicTitle(pending)} done by ${by}; its check is queued or running: ${command}`);
|
|
1102
1140
|
const seen = { events: pending.history.length, owner: pending.owner };
|
|
1103
1141
|
this.pendingChecks++;
|
|
1104
1142
|
this.checkQueue = this.checkQueue
|
|
@@ -1228,11 +1266,11 @@ export class Tasks {
|
|
|
1228
1266
|
this.tellCompleted(next, summary, withheld);
|
|
1229
1267
|
if (!reviewer) {
|
|
1230
1268
|
if (next.owner) this.d.board.recordOutcome(next.owner, next.class, true); // approved without a review is a success too
|
|
1231
|
-
this.
|
|
1269
|
+
this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
1232
1270
|
await this.releaseDependents(next);
|
|
1233
1271
|
}
|
|
1234
|
-
else if (reviewer === USER) this.
|
|
1235
|
-
else this.sendReview(next, reviewer);
|
|
1272
|
+
else if (reviewer === USER) this.releaseNotice(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
1273
|
+
else this.releaseNotice(`task ${this.publicTitle(next)}: could not send the review request to ${reviewer}`, () => this.sendReview(next, reviewer));
|
|
1236
1274
|
return next;
|
|
1237
1275
|
}
|
|
1238
1276
|
|
|
@@ -1252,7 +1290,8 @@ export class Tasks {
|
|
|
1252
1290
|
cohort.held.add(task.id);
|
|
1253
1291
|
continue;
|
|
1254
1292
|
}
|
|
1255
|
-
this.
|
|
1293
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: could not publish the completed-change notice to ${hit.task.owner}`, () =>
|
|
1294
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary, withheld)));
|
|
1256
1295
|
}
|
|
1257
1296
|
}
|
|
1258
1297
|
|
|
@@ -1306,10 +1345,10 @@ export class Tasks {
|
|
|
1306
1345
|
|
|
1307
1346
|
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
1308
1347
|
const valid = (): Task => {
|
|
1309
|
-
const t = this.need(id);
|
|
1310
|
-
this.mine(t, by, "reviewer");
|
|
1311
|
-
if (verdict !== "approved" && verdict !== "changes_requested") throw new
|
|
1312
|
-
if (t.state !== "in_review") throw new
|
|
1348
|
+
const t = this.need(id, false, true);
|
|
1349
|
+
this.mine(t, by, "reviewer", true);
|
|
1350
|
+
if (verdict !== "approved" && verdict !== "changes_requested") throw new PreEffectToolRefusal('verdict must be "approved" or "changes_requested"');
|
|
1351
|
+
if (t.state !== "in_review") throw new PreEffectToolRefusal(`task #${t.id} is ${t.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
1313
1352
|
return t;
|
|
1314
1353
|
};
|
|
1315
1354
|
const items = textList(unmet);
|
|
@@ -1330,7 +1369,8 @@ export class Tasks {
|
|
|
1330
1369
|
}
|
|
1331
1370
|
}
|
|
1332
1371
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`, withheld);
|
|
1333
|
-
this.
|
|
1372
|
+
this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${next.owner ?? "its owner"} it was approved`, () =>
|
|
1373
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner, withheld)}` : ""}`, pii));
|
|
1334
1374
|
await this.releaseDependents(next);
|
|
1335
1375
|
return next;
|
|
1336
1376
|
}
|
|
@@ -1343,17 +1383,19 @@ export class Tasks {
|
|
|
1343
1383
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1344
1384
|
if (moved.owner !== rejected.owner) return moved;
|
|
1345
1385
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1346
|
-
this.
|
|
1386
|
+
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
1387
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner, withheld)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii));
|
|
1347
1388
|
return moved;
|
|
1348
1389
|
}
|
|
1349
1390
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
1350
|
-
this.
|
|
1391
|
+
this.releaseNotice(`task ${this.publicTitle(reopened)}: could not tell ${reopened.owner ?? "its owner"} changes were requested`, () =>
|
|
1392
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner, withheld)}` : ""} Fix it and call hub_task_done again.`, pii));
|
|
1351
1393
|
return reopened;
|
|
1352
1394
|
}
|
|
1353
1395
|
|
|
1354
1396
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
1355
|
-
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
|
|
1356
|
-
let task = this.need(id, true);
|
|
1397
|
+
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual", preEffect = false): Promise<Task> {
|
|
1398
|
+
let task = this.need(id, true, preEffect);
|
|
1357
1399
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
1358
1400
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
1359
1401
|
const from = task.owner;
|
|
@@ -1364,16 +1406,18 @@ export class Tasks {
|
|
|
1364
1406
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
1365
1407
|
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1366
1408
|
if (next.owner && next.owner !== from) {
|
|
1367
|
-
|
|
1409
|
+
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
1410
|
+
this.releaseNotice(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
1368
1411
|
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
1369
|
-
if (from) this.
|
|
1370
|
-
|
|
1412
|
+
if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
|
|
1413
|
+
this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
|
|
1414
|
+
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1371
1415
|
return this.d.board.get(next.id)!;
|
|
1372
1416
|
}
|
|
1373
1417
|
|
|
1374
1418
|
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1375
|
-
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
|
|
1376
|
-
const task = this.need(id, true);
|
|
1419
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER, preEffect = false): Promise<Task> {
|
|
1420
|
+
const task = this.need(id, true, preEffect);
|
|
1377
1421
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1378
1422
|
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1379
1423
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
@@ -1448,7 +1492,7 @@ export class Tasks {
|
|
|
1448
1492
|
if (this.isPii(task) || !this.d.memory) return;
|
|
1449
1493
|
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern, or that the PII screen did
|
|
1450
1494
|
// not clear (#198), is not sent (#69).
|
|
1451
|
-
if (withheld || !this.nameable(text)) return this.
|
|
1495
|
+
if (withheld || !this.nameable(text)) return this.releaseNotice(`task #${task.id}: a ${kind} note was not saved to shared memory (${this.whyWithheld(text)})`);
|
|
1452
1496
|
void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
1453
1497
|
}
|
|
1454
1498
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A hub guard proved this request was refused before any task or board effect (screen telemetry is allowed). Never wrap arbitrary failures in this type. */
|
|
2
|
+
export class PreEffectToolRefusal extends Error {
|
|
3
|
+
constructor(message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "PreEffectToolRefusal";
|
|
6
|
+
}
|
|
7
|
+
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskProgress } from "../ui/task-progress.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
|
|
@@ -20,7 +21,9 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
20
21
|
const now = options.now ?? Date.now;
|
|
21
22
|
const tickets = new Map<string, number>();
|
|
22
23
|
const sessions = new Map<string, number>();
|
|
23
|
-
|
|
24
|
+
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
|
+
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
|
|
24
27
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
25
28
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
26
29
|
const headers = {
|