@staix/agent-hub 0.12.15 → 0.12.17
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 +17 -0
- package/README.md +18 -5
- package/docs/agent-notes/adapters.md +1 -0
- package/docs/agent-notes/budget.md +5 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +277 -11
- package/docs/quickstart.md +4 -4
- package/docs/security.md +26 -1
- package/docs/smoke.md +93 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +223 -0
- package/docs/verification/2026-10-09-agent-shell-t0.md +123 -0
- package/docs/verified.json +168 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +18 -6
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/claude-channel.ts +3 -2
- package/src/adapters/codex-appserver.ts +10 -2
- package/src/adapters/local-worker.ts +5 -5
- package/src/adapters/pi.ts +4 -14
- package/src/cli/console-state.ts +213 -0
- package/src/cli/console.ts +201 -0
- package/src/cli/facts-hook.ts +8 -1
- package/src/cli/identity-audit.ts +66 -0
- package/src/cli/identity.ts +54 -0
- package/src/cli/init.ts +40 -30
- package/src/cli/launch.ts +31 -1
- package/src/cli/main.ts +95 -39
- package/src/cli/preview.ts +80 -0
- package/src/cli/status-lines.ts +8 -2
- package/src/cli/statusline-tee.ts +14 -0
- package/src/cli/tail-render.ts +17 -0
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/board.ts +9 -2
- package/src/hub/bus.ts +82 -10
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +196 -0
- package/src/hub/context-window.ts +72 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +492 -77
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +7 -1
- package/src/hub/hub-tools.ts +14 -1
- package/src/hub/report.ts +53 -4
- package/src/hub/supervision.ts +152 -0
- package/src/hub/task-sweep.ts +59 -0
- package/src/hub/tasks.ts +86 -15
- package/src/hub/usage.ts +37 -2
- package/src/models/relay.ts +6 -1
- package/src/pi/launch.ts +21 -0
- package/src/ui/index.html +6 -0
package/src/hub/envelope.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface Envelope {
|
|
|
14
14
|
kind: Kind;
|
|
15
15
|
priority: Priority;
|
|
16
16
|
body: string; // agent conclusions only, never tool noise
|
|
17
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string };
|
|
17
|
+
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; supervision?: boolean; supervisionKey?: string };
|
|
18
18
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
19
19
|
private?: boolean;
|
|
20
20
|
ts: number;
|
package/src/hub/events.ts
CHANGED
|
@@ -24,7 +24,12 @@ export type HubEvent =
|
|
|
24
24
|
| { type: "state"; peer: string; state: string }
|
|
25
25
|
| { type: "turn_start"; peer: string; turn: string }
|
|
26
26
|
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
27
|
-
| { type: "native_turn_end"; peer: string }
|
|
27
|
+
| { type: "native_turn_end"; peer: string; id?: string }
|
|
28
|
+
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
29
|
+
| { type: "agent_cli"; peer: string; command: string; refused: boolean }
|
|
30
|
+
| { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
|
|
31
|
+
/** Completed native turns that received supervision, not feed enqueues or inferred costs. */
|
|
32
|
+
| { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
|
|
28
33
|
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
29
34
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
30
35
|
| { type: "tokens"; peer: string; n: number }
|
|
@@ -32,6 +37,7 @@ export type HubEvent =
|
|
|
32
37
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
33
38
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
34
39
|
| { type: "conflict"; peer: string; task?: number; other: number; owner: string; paths: string[]; concurrent: boolean; turns?: [string, string] }
|
|
40
|
+
| { type: "context_pressure"; peer: string; source: string; measuredAt: number; used: number; window: number | null }
|
|
35
41
|
| { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
|
|
36
42
|
|
|
37
43
|
export type StampedEvent = HubEvent & { v: number; at: string };
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -41,14 +41,27 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
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
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: map the changed signatures and call sites to the task's plan or detail, read the check result, and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
44
|
-
tool("hub_checkpoint", "Answer a checkpoint request from the hub (
|
|
44
|
+
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): 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, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["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"]),
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
49
49
|
|
|
50
|
+
/** Separately published: serving a schema grants no authority, only the daemon's explicit role does. */
|
|
51
|
+
export const CONDUCTOR_TOOLS: HubTool[] = [
|
|
52
|
+
tool("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
|
|
53
|
+
tool("hub_task_show", "Read a task's public view and history. PII tasks remain stubs. Only the conductor may use this tool.", { id }, ["id"]),
|
|
54
|
+
tool("hub_task_assign", "Move a task to another peer, as the conductor. Requires assign capability when the conductor has an explicit capabilities list.", { id, peer: str }, ["id", "peer"]),
|
|
55
|
+
tool("hub_task_escalate", "Escalate a task through the normal task flow, as the conductor. Requires assign capability when explicitly listed.", { id }, ["id"]),
|
|
56
|
+
tool("hub_peer_start", "Start local, kimi or headless pi. Claude, Codex and Pi TUI requests return a command for the person to run, without launching a terminal.", { peer: { type: "string", enum: ["local", "kimi", "pi", "claude", "codex"] }, mode: { type: "string", enum: ["headless", "tui"] } }, ["peer"]),
|
|
57
|
+
tool("hub_peer_hold", "Hold a peer's deliveries as the conductor. This hold is separate from the person's hold and budget pauses.", { peer: str }, ["peer"]),
|
|
58
|
+
tool("hub_peer_release", "Release only the conductor hold you placed. Never lifts a person's hold or a budget pause.", { peer: str }, ["peer"]),
|
|
59
|
+
];
|
|
60
|
+
export const CONDUCTOR_TOOL_NAMES = new Set(CONDUCTOR_TOOLS.map((t) => t.name));
|
|
61
|
+
|
|
50
62
|
/** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
|
|
51
63
|
export const ROLE_TEXT: Record<string, string> = {
|
|
64
|
+
conductor: "conductor: plan and split work into tasks with owners, watch the team with hub_status, move stalled work, and ensure every task is reviewed (review it yourself only if you also hold reviewer). Report results and open decisions to the person. Do not implement tasks you handed out. Never ask a peer to answer an approval; ask the person for human-only actions. You may release only holds you placed, never human holds or budget pauses.",
|
|
52
65
|
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
66
|
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
67
|
verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
|
package/src/hub/report.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { StampedEvent } from "./events.ts";
|
|
|
3
3
|
export interface Report {
|
|
4
4
|
from?: string;
|
|
5
5
|
to?: string;
|
|
6
|
-
peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
|
|
6
|
+
peers: Record<string, { turns: number | null; busyMinutes: number; tokens: number; turnSource?: "native-stop" | "logical-state" | "unknown" }>;
|
|
7
7
|
usage: { peers: Record<string, UsageCoverage>; totals: UsageTotals; unknownPeers: string[]; completeCoverage: boolean; estimatedUsd?: number; measuredSpendUsd?: number };
|
|
8
8
|
messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
|
|
9
9
|
overlaps: { warnings: number; pairs: number };
|
|
@@ -11,6 +11,8 @@ export interface Report {
|
|
|
11
11
|
conflicts: number;
|
|
12
12
|
tasks: Record<string, number>;
|
|
13
13
|
quota: { readings: number; hard: number };
|
|
14
|
+
conduct: Record<string, Record<string, number>>;
|
|
15
|
+
supervision: Record<string, { turns: number | null; tokens: number | null; measuredTokens: number | null; knownTokenTurns: number; unknownTokenTurns: number }>;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface UsageCoverage {
|
|
@@ -83,16 +85,39 @@ function formatUsage(usage: Report["usage"]): string[] {
|
|
|
83
85
|
|
|
84
86
|
/** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
|
|
85
87
|
export function summarize(events: StampedEvent[]): Report {
|
|
86
|
-
const r: Report = { peers: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
88
|
+
const r: Report = { peers: {}, conduct: {}, supervision: {}, usage: { peers: {}, totals: { inputTokens: 0, inputRecords: 0, outputTokens: 0, outputRecords: 0, cacheReadTokens: 0, cacheReadRecords: 0, cacheWriteTokens: 0, cacheWriteRecords: 0, totalTokens: 0, totalRecords: 0 }, unknownPeers: [], completeCoverage: true }, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, conflicts: 0, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
89
|
+
const supervisor = (id: string) => (r.supervision[id] ??= { turns: null, tokens: null, measuredTokens: null, knownTokenTurns: 0, unknownTokenTurns: 0 });
|
|
87
90
|
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
91
|
+
const claudeNativeStops = events.some(event => event.type === "native_turn_end" && event.peer === "claude" && !!event.id);
|
|
92
|
+
const seenNativeStops = new Set<string>();
|
|
88
93
|
const usagePeer = (id: string) => (r.usage.peers[id] ??= emptyUsage());
|
|
89
94
|
const pairs = new Set<string>();
|
|
90
95
|
const taskMessages = new Map<string, number>();
|
|
91
96
|
const seenUsage = new Set<string>();
|
|
97
|
+
const seenSupervision = new Set<string>();
|
|
92
98
|
for (const e of events) {
|
|
93
99
|
r.from ??= e.at;
|
|
94
100
|
r.to = e.at;
|
|
95
101
|
switch (e.type) {
|
|
102
|
+
case "conduct": {
|
|
103
|
+
const actions = r.conduct[e.peer] ??= {};
|
|
104
|
+
actions[e.action] = (actions[e.action] ?? 0) + 1;
|
|
105
|
+
supervisor(e.peer);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "supervision_turn": {
|
|
109
|
+
const key = `${e.peer}\0${e.turn}`;
|
|
110
|
+
if (seenSupervision.has(key)) break;
|
|
111
|
+
seenSupervision.add(key);
|
|
112
|
+
const s = supervisor(e.peer);
|
|
113
|
+
s.turns = (s.turns ?? 0) + 1;
|
|
114
|
+
if (typeof e.tokens === "number" && Number.isSafeInteger(e.tokens) && e.tokens >= 0) {
|
|
115
|
+
s.measuredTokens = (s.measuredTokens ?? 0) + e.tokens;
|
|
116
|
+
s.tokens = s.unknownTokenTurns ? null : s.measuredTokens;
|
|
117
|
+
s.knownTokenTurns++;
|
|
118
|
+
} else { s.unknownTokenTurns++; s.tokens = null; }
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
96
121
|
case "envelope":
|
|
97
122
|
r.messages.total++;
|
|
98
123
|
if (e.dropped) r.messages.dropped[e.dropped] = (r.messages.dropped[e.dropped] ?? 0) + 1;
|
|
@@ -106,9 +131,16 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
106
131
|
r.messages.dropped.stale = (r.messages.dropped.stale ?? 0) + 1;
|
|
107
132
|
break;
|
|
108
133
|
case "turn_end":
|
|
109
|
-
peer(e.peer).turns
|
|
134
|
+
if (e.peer !== "claude" || !claudeNativeStops) peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
|
|
110
135
|
peer(e.peer).busyMinutes += e.ms / 60_000;
|
|
111
136
|
break;
|
|
137
|
+
case "native_turn_end":
|
|
138
|
+
if (e.peer === "claude" && e.id) {
|
|
139
|
+
if (seenNativeStops.has(e.id)) break;
|
|
140
|
+
seenNativeStops.add(e.id);
|
|
141
|
+
peer(e.peer).turns = (peer(e.peer).turns ?? 0) + 1;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
112
144
|
case "state":
|
|
113
145
|
peer(e.peer);
|
|
114
146
|
break;
|
|
@@ -143,13 +175,30 @@ export function summarize(events: StampedEvent[]): Report {
|
|
|
143
175
|
// Pricing is intentionally absent: a token count is neither an estimated price nor provider-reported spend.
|
|
144
176
|
r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
|
|
145
177
|
for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
|
|
178
|
+
if (r.peers.claude) {
|
|
179
|
+
const logical = events.some(event => event.type === "turn_end" && event.peer === "claude");
|
|
180
|
+
r.peers.claude.turnSource = claudeNativeStops ? "native-stop" : logical ? "logical-state" : "unknown";
|
|
181
|
+
if (!claudeNativeStops && !logical) r.peers.claude.turns = null;
|
|
182
|
+
}
|
|
146
183
|
return r;
|
|
147
184
|
}
|
|
148
185
|
|
|
149
186
|
export function formatReport(r: Report): string[] {
|
|
150
187
|
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
151
|
-
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b)))
|
|
188
|
+
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
189
|
+
const turns = p.turns === null ? "turns unknown" : `${p.turns} turn${p.turns === 1 ? "" : "s"}${p.turnSource === "native-stop" ? " (native Stop)" : p.turnSource === "logical-state" ? " (logical state, native completion unobserved)" : ""}`;
|
|
190
|
+
lines.push(`peer ${id}: ${turns}, ${p.busyMinutes} busy minutes, ${p.tokens ? `${p.tokens} tokens` : id === "pi" ? "tokens not reported" : "- tokens"}`);
|
|
191
|
+
}
|
|
152
192
|
if (Object.keys(r.usage.peers).length) lines.push(...formatUsage(r.usage));
|
|
193
|
+
for (const [id, s] of Object.entries(r.supervision).sort(([a], [b]) => a.localeCompare(b))) {
|
|
194
|
+
const turns = s.turns === null ? "turns unknown" : `${s.turns} completed native turns`;
|
|
195
|
+
const tokens = s.tokens === null ? "tokens unknown" : `${s.tokens} tokens`;
|
|
196
|
+
const measured = s.measuredTokens === null ? "measured subset unknown" : `${s.measuredTokens} measured tokens (${s.knownTokenTurns} known turns)`;
|
|
197
|
+
lines.push(`supervision ${id}: ${turns}; ${tokens}; ${measured}; ${s.unknownTokenTurns} completed turns with unknown tokens; whole native turns containing supervision, including other work; estimated price unknown; measured spend unknown`);
|
|
198
|
+
}
|
|
199
|
+
for (const [id, actions] of Object.entries(r.conduct).sort(([a], [b]) => a.localeCompare(b))) {
|
|
200
|
+
lines.push(`conductor ${id}: ${Object.entries(actions).sort(([a], [b]) => a.localeCompare(b)).map(([action, n]) => `${action} ${n}`).join(", ")}`);
|
|
201
|
+
}
|
|
153
202
|
const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
|
|
154
203
|
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`);
|
|
155
204
|
lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}; edit conflicts: ${r.conflicts}`);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { HistoryEntry, Task, TaskMoveReason } from "./board.ts";
|
|
2
|
+
import { sanitize, type Kind, type Priority } from "./envelope.ts";
|
|
3
|
+
|
|
4
|
+
export type SupervisionScope = "own" | "all" | "off";
|
|
5
|
+
export type SupervisionMilestone = "assigned" | "accepted" | "declined" | "done" | "review" | "approved" | "moved" | "escalated" | "stuck" | "sweep";
|
|
6
|
+
/** Codes, never free-form notes or check output. */
|
|
7
|
+
export type SupervisionReason = TaskMoveReason | "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap";
|
|
8
|
+
export interface SupervisionFields {
|
|
9
|
+
by?: string;
|
|
10
|
+
owner?: string | null;
|
|
11
|
+
check?: "passed" | "failed" | "unknown";
|
|
12
|
+
verdict?: "approved" | "changes_requested";
|
|
13
|
+
reason?: SupervisionReason;
|
|
14
|
+
sweep?: "unaccepted-assignment" | "idle-owner" | "review-pending";
|
|
15
|
+
}
|
|
16
|
+
export interface SupervisionNotice {
|
|
17
|
+
peer: string;
|
|
18
|
+
/** Replace only queued entries with this key; accepted/in-flight deliveries stay authoritative. */
|
|
19
|
+
key: string;
|
|
20
|
+
body: string;
|
|
21
|
+
priority: Priority;
|
|
22
|
+
kind: Kind;
|
|
23
|
+
task?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface SupervisionDeps {
|
|
26
|
+
/** Current role holder; undefined immediately revokes the feed. */
|
|
27
|
+
conductor: () => string | undefined;
|
|
28
|
+
scope: () => SupervisionScope;
|
|
29
|
+
tasks: () => Task[];
|
|
30
|
+
publicTitle: (task: Task) => string;
|
|
31
|
+
isPrivate: (task: Task) => boolean;
|
|
32
|
+
/** false means not admitted, so latches must remain retryable. No separate batching timer. */
|
|
33
|
+
emit: (notice: SupervisionNotice) => boolean | void;
|
|
34
|
+
approvalAgeMs?: number;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
/** Durable round state belongs to the daemon's store, not the controller. */
|
|
37
|
+
readRound?: (peer: string) => string | undefined;
|
|
38
|
+
writeRound?: (peer: string, signature: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Deterministic milestone producer. The Bus alone owns batching, retry and native turns. */
|
|
42
|
+
export class SupervisionFeed {
|
|
43
|
+
private readonly approvals = new Set<string>();
|
|
44
|
+
private readonly holds = new Set<string>();
|
|
45
|
+
private readonly rounds = new Map<string, string>();
|
|
46
|
+
constructor(private readonly d: SupervisionDeps) {}
|
|
47
|
+
|
|
48
|
+
/** Pending notices may have been withdrawn on role/off change; live requests can notify again on regrant. */
|
|
49
|
+
resetPending(peer: string): void {
|
|
50
|
+
for (const key of this.approvals) if (key.startsWith(`${peer}:`)) this.approvals.delete(key);
|
|
51
|
+
for (const key of this.holds) if (key.startsWith(`${peer}:`)) this.holds.delete(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private recipient(): string | undefined {
|
|
55
|
+
return this.d.scope() === "off" ? undefined : this.d.conductor();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
includes(task: Task): boolean {
|
|
59
|
+
const peer = this.recipient();
|
|
60
|
+
return !!peer && (this.d.scope() === "all" || task.history.some((h) => h.by === peer && ["proposed", "assigned", "reassigned", "escalated"].includes(h.event)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private send(key: string, body: string, priority: Priority = "status", task?: number, kind: Kind = "task"): boolean {
|
|
64
|
+
const peer = this.recipient();
|
|
65
|
+
if (!peer) return false;
|
|
66
|
+
try {
|
|
67
|
+
return this.d.emit({ peer, key: `supervision:${peer}:${key}`, body: sanitize(body), priority, kind, ...(task === undefined ? {} : { task }) }) !== false;
|
|
68
|
+
} catch { return false; } // a feed failure must not invalidate a successful board write
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
milestone(id: number, milestone: SupervisionMilestone, fields: SupervisionFields = {}): boolean {
|
|
72
|
+
const task = this.d.tasks().find((t) => t.id === id);
|
|
73
|
+
if (!task || !this.includes(task)) return false;
|
|
74
|
+
if (this.d.isPrivate(task)) return this.send(`task:${id}:${milestone}`, `#${id} [pii]: ${milestone}; ahub task show ${id}`, "status", id);
|
|
75
|
+
const bits = [this.d.publicTitle(task), milestone];
|
|
76
|
+
if (fields.by) bits.push(`by ${fields.by}`);
|
|
77
|
+
if ("owner" in fields) bits.push(`owner ${fields.owner ?? "none"}`);
|
|
78
|
+
if (fields.check) bits.push(`check ${fields.check}`);
|
|
79
|
+
if (fields.verdict) bits.push(`verdict ${fields.verdict}`);
|
|
80
|
+
if (fields.reason) bits.push(`reason ${fields.reason}`);
|
|
81
|
+
if (fields.sweep) bits.push(`finding ${fields.sweep}`);
|
|
82
|
+
return this.send(`task:${id}:${milestone}`, bits.join("; "), "status", id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Only structured history metadata is read; notes may contain private content. */
|
|
86
|
+
taskChanged(task: Task, entry: HistoryEntry, metadata: SupervisionFields = {}): void {
|
|
87
|
+
const fields: SupervisionFields = { by: entry.by, owner: task.owner, ...(entry.reason ? { reason: entry.reason } : {}), ...metadata };
|
|
88
|
+
const event = entry.event;
|
|
89
|
+
if (event === "done" || event === "check failed") {
|
|
90
|
+
const latestCheck = task.history.findLast((h) => ["check passed", "check failed", "check interrupted", "done (checking)", "accepted", "reopened"].includes(h.event));
|
|
91
|
+
fields.check = event === "check failed" ? "failed" : metadata.check ?? (latestCheck?.event === "check passed" ? "passed" : "unknown");
|
|
92
|
+
this.milestone(task.id, "done", fields);
|
|
93
|
+
if (event === "done" && task.state === "approved") this.milestone(task.id, "approved", fields);
|
|
94
|
+
} else if (event === "changes_requested") this.milestone(task.id, "review", { ...fields, verdict: "changes_requested" });
|
|
95
|
+
else if (event === "approved") {
|
|
96
|
+
this.milestone(task.id, "review", { ...fields, verdict: "approved" });
|
|
97
|
+
this.milestone(task.id, "approved", fields);
|
|
98
|
+
} else if (event === "idle sweep" && entry.sweep) this.milestone(task.id, "sweep", { ...fields, sweep: entry.sweep.kind });
|
|
99
|
+
else if (event === "reassigned") this.milestone(task.id, "moved", fields);
|
|
100
|
+
else if (["assigned", "accepted", "declined", "escalated"].includes(event)) this.milestone(task.id, event as SupervisionMilestone, fields);
|
|
101
|
+
this.checkRound();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
peerOffline(peer: string): boolean {
|
|
105
|
+
if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
|
|
106
|
+
return this.send(`peer:${peer}:offline`, `${peer}: offline`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
budgetPaused(peer: string, resetsAt?: number): boolean {
|
|
110
|
+
if (this.d.scope() !== "all" && !this.d.tasks().some((t) => this.includes(t) && t.state !== "approved" && (t.owner === peer || t.reviewer === peer))) return false;
|
|
111
|
+
const resetDate = new Date(resetsAt ?? NaN);
|
|
112
|
+
const reset = Number.isFinite(resetDate.getTime()) ? resetDate.toISOString() : "unknown";
|
|
113
|
+
return this.send(`peer:${peer}:budget`, `${peer}: budget paused; reset ${reset}`, "status", undefined, "budget");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
approvalWaiting(request: { id: string; peer: string; tool: string; createdAt: number }): boolean {
|
|
117
|
+
const peer = this.recipient();
|
|
118
|
+
const age = (this.d.now?.() ?? Date.now()) - request.createdAt;
|
|
119
|
+
const key = `${peer}:${request.id}`;
|
|
120
|
+
if (!peer || !Number.isFinite(age) || age < (this.d.approvalAgeMs ?? 60_000) || this.approvals.has(key)) return false;
|
|
121
|
+
const sent = this.send(`approval:${request.id}`, `${request.peer}: approval waiting; tool ${request.tool}; age ${Math.floor(age / 1000)}s; ask the person to answer it in ahub console`, "important");
|
|
122
|
+
if (sent) this.approvals.add(key);
|
|
123
|
+
return sent;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
needsReview(peer: string, deliveryId: string): boolean {
|
|
127
|
+
const conductor = this.recipient();
|
|
128
|
+
const key = `${conductor}:${peer}:${deliveryId}`;
|
|
129
|
+
if (!conductor || this.holds.has(key)) return false;
|
|
130
|
+
const sent = this.send(`hold:${peer}:${deliveryId}`, `${peer}: needs_review hold; delivery ${deliveryId}; ask the person to resolve it with ahub queue resolve`, "important");
|
|
131
|
+
if (sent) this.holds.add(key);
|
|
132
|
+
return sent;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
checkRound(): boolean {
|
|
136
|
+
const peer = this.recipient();
|
|
137
|
+
if (!peer) return false;
|
|
138
|
+
const tasks = this.d.tasks().filter((t) => this.includes(t));
|
|
139
|
+
if (!tasks.length || tasks.some((t) => t.state !== "approved")) return false;
|
|
140
|
+
const signature = tasks.map((t) => t.id).sort((a, b) => a - b).join(",");
|
|
141
|
+
const previous = this.d.readRound?.(peer) ?? this.rounds.get(peer);
|
|
142
|
+
const completed = new Set(previous?.split(",") ?? []);
|
|
143
|
+
const newlyJoined = tasks.filter((t) => !completed.has(String(t.id)));
|
|
144
|
+
if (!newlyJoined.length) return false;
|
|
145
|
+
const sent = this.send("round", `Supervision round complete: ${newlyJoined.length} task${newlyJoined.length === 1 ? "" : "s"} approved; report the results and open decisions to the person.`);
|
|
146
|
+
if (sent) {
|
|
147
|
+
this.rounds.set(peer, signature);
|
|
148
|
+
this.d.writeRound?.(peer, signature);
|
|
149
|
+
}
|
|
150
|
+
return sent;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export type SweepKind = "unaccepted-assignment" | "idle-owner" | "review-pending";
|
|
4
|
+
export interface SweepRecord {
|
|
5
|
+
kind: SweepKind;
|
|
6
|
+
/** Index of the last real activity, distinguishing even events at the same time. */
|
|
7
|
+
activity: number;
|
|
8
|
+
step: 1 | 2 | 3;
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
export interface TaskSweepConfig {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
interval_s: number;
|
|
14
|
+
unaccepted_min: number;
|
|
15
|
+
idle_min: number;
|
|
16
|
+
review_min: number;
|
|
17
|
+
ladder_min: number;
|
|
18
|
+
auto_reassign: boolean;
|
|
19
|
+
}
|
|
20
|
+
export const DEFAULT_TASK_SWEEP: TaskSweepConfig = {
|
|
21
|
+
enabled: false, interval_s: 300, unaccepted_min: 60, idle_min: 120, review_min: 120, ladder_min: 30, auto_reassign: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Config cannot silently enable a sweep or reassignment through a truthy string. */
|
|
25
|
+
export function taskSweepConfig(input: unknown): TaskSweepConfig {
|
|
26
|
+
if (input === undefined) return { ...DEFAULT_TASK_SWEEP };
|
|
27
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("task_sweep must be an object");
|
|
28
|
+
const out = { ...DEFAULT_TASK_SWEEP };
|
|
29
|
+
const values = input as Record<string, unknown>;
|
|
30
|
+
for (const key of ["enabled", "auto_reassign"] as const) {
|
|
31
|
+
if (values[key] === undefined) continue;
|
|
32
|
+
if (typeof values[key] !== "boolean") throw new Error(`task_sweep.${key} must be a boolean`);
|
|
33
|
+
out[key] = values[key];
|
|
34
|
+
}
|
|
35
|
+
for (const key of ["interval_s", "unaccepted_min", "idle_min", "review_min", "ladder_min"] as const) {
|
|
36
|
+
if (values[key] === undefined) continue;
|
|
37
|
+
const value = values[key];
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value * (key === "interval_s" ? 1000 : 60_000) > 2_147_483_647) throw new Error(`task_sweep.${key} must be a bounded number of at least 1`);
|
|
39
|
+
out[key] = value;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pure time/kind ladder; Tasks decides whether the responsible peer is available. */
|
|
45
|
+
export function nextSweep(task: Task, config: TaskSweepConfig, now: number): SweepRecord | undefined {
|
|
46
|
+
if (!config.enabled || !Number.isFinite(now)) return undefined;
|
|
47
|
+
const kind = task.state === "proposed" && task.owner ? "unaccepted-assignment"
|
|
48
|
+
: task.state === "in_progress" && task.owner ? "idle-owner"
|
|
49
|
+
: task.state === "in_review" && task.reviewer ? "review-pending" : undefined;
|
|
50
|
+
if (!kind) return undefined;
|
|
51
|
+
let activity = task.history.length - 1;
|
|
52
|
+
while (activity >= 0 && task.history[activity]?.sweep) activity--;
|
|
53
|
+
const at = task.history[activity]?.at ?? task.created;
|
|
54
|
+
const threshold = kind === "unaccepted-assignment" ? config.unaccepted_min : kind === "idle-owner" ? config.idle_min : config.review_min;
|
|
55
|
+
if (now - at < threshold * 60_000) return undefined;
|
|
56
|
+
const previous = task.history.flatMap((h) => h.sweep?.kind === kind && h.sweep.activity === activity ? [h.sweep] : []).at(-1);
|
|
57
|
+
if (previous && (previous.step === 3 || now - previous.at < config.ladder_min * 60_000)) return undefined;
|
|
58
|
+
return { kind, activity, step: previous ? (previous.step + 1) as 2 | 3 : 1, at: now };
|
|
59
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { isAbsolute, relative } from "node:path";
|
|
2
2
|
import type { Briefs } from "../memory/brief.ts";
|
|
3
3
|
import type { MemoryClient } from "../memory/client.ts";
|
|
4
|
-
import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
4
|
+
import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, TASK_MOVE_REASONS, type Board, type Task, type TaskClass, type TaskMoveReason, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
5
5
|
import type { Bus } from "./bus.ts";
|
|
6
6
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
7
7
|
import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
8
8
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
9
9
|
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
10
10
|
import { realPath } from "./project.ts";
|
|
11
|
+
import { nextSweep, taskSweepConfig, type TaskSweepConfig, type SweepRecord } from "./task-sweep.ts";
|
|
11
12
|
|
|
12
13
|
export interface TasksDeps {
|
|
13
14
|
board: Board;
|
|
@@ -68,6 +69,9 @@ export interface TasksDeps {
|
|
|
68
69
|
recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
|
|
69
70
|
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
70
71
|
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
72
|
+
sweep?: TaskSweepConfig;
|
|
73
|
+
/** Shutdown or coordinator work holds task sweeps too. */
|
|
74
|
+
sweepHeld?: () => boolean;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
const ESCALATE_AFTER = 2;
|
|
@@ -80,7 +84,7 @@ const DEMOTE_AT = 1.5;
|
|
|
80
84
|
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
81
85
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
82
86
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
83
|
-
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
87
|
+
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
84
88
|
|
|
85
89
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
86
90
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
@@ -134,8 +138,11 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
|
|
|
134
138
|
export class Tasks {
|
|
135
139
|
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
136
140
|
readonly cohorts: Cohorts;
|
|
141
|
+
private readonly sweepConfig: TaskSweepConfig;
|
|
142
|
+
private sweeping = false;
|
|
137
143
|
|
|
138
144
|
constructor(private readonly d: TasksDeps) {
|
|
145
|
+
this.sweepConfig = taskSweepConfig(d.sweep);
|
|
139
146
|
this.cohorts = new Cohorts({
|
|
140
147
|
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
141
148
|
idle: (peer) => this.d.idle?.(peer) ?? false,
|
|
@@ -157,6 +164,55 @@ export class Tasks {
|
|
|
157
164
|
|
|
158
165
|
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
159
166
|
|
|
167
|
+
/** Task-owned, default-off between-turn ladder. Persist before sending so restart cannot repeat a step. */
|
|
168
|
+
async sweep(now = Date.now()): Promise<{ task: number; finding: SweepRecord }[]> {
|
|
169
|
+
const recorded: { task: number; finding: SweepRecord }[] = [];
|
|
170
|
+
if (!this.sweepConfig.enabled || this.sweeping || this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) return recorded;
|
|
171
|
+
this.sweeping = true;
|
|
172
|
+
try {
|
|
173
|
+
for (const task of this.d.board.list()) {
|
|
174
|
+
if (this.d.sweepHeld?.() || this.d.bus.isRecoveryHeld || this.d.bus.storageError) break;
|
|
175
|
+
const finding = nextSweep(task, this.sweepConfig, now);
|
|
176
|
+
if (!finding || this.waitsFor(task).length || this.isChecking(task.id) || this.silentFor(task.id)) continue;
|
|
177
|
+
const responsible = finding.kind === "review-pending" ? task.reviewer! : task.owner!;
|
|
178
|
+
if (responsible !== USER && !this.sweepAvailable(responsible)) continue;
|
|
179
|
+
const title = this.publicTitle(task);
|
|
180
|
+
const message = `Task ${title}: ${finding.kind}, idle escalation ${finding.step}/3.`;
|
|
181
|
+
// Only identifiers, kind and step are kept in the ladder event. A crash after this write and before publish
|
|
182
|
+
// leaves the notice unpublished or uncertain; the durable-delivery journal owns any delivery retries.
|
|
183
|
+
this.d.board.update(task.id, HUB, "idle sweep", {}, undefined, { sweep: finding });
|
|
184
|
+
recorded.push({ task: task.id, finding });
|
|
185
|
+
if (finding.step === 1) {
|
|
186
|
+
this.sweepNotice(task, responsible, `${message} Check its current state with hub_task_list and accept, complete or review your assigned work.`);
|
|
187
|
+
} else if (finding.step === 2) {
|
|
188
|
+
this.d.notify(message);
|
|
189
|
+
for (const [peer, roles] of Object.entries(this.d.roles ?? {})) {
|
|
190
|
+
if (peer !== responsible && roles.includes("planner") && this.sweepAvailable(peer)) this.sweepNotice(task, peer, `${message} The assigned ${finding.kind === "review-pending" ? "reviewer" : "owner"} has not recorded fresh task activity; check the board before coordinating.`);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
const reviewer = finding.kind === "review-pending";
|
|
194
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), responsible], waitsFor: this.waitsFor(task), ...(reviewer ? { candidates: [], notReviewer: task.owner ?? undefined } : {}), ...this.weights(task.class) });
|
|
195
|
+
const candidate = reviewer ? a.reviewer : a.owner;
|
|
196
|
+
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
197
|
+
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
198
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally { this.sweeping = false; }
|
|
203
|
+
return recorded;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private sweepAvailable(peer: PeerId): boolean {
|
|
207
|
+
const bus = this.d.bus;
|
|
208
|
+
return bus.stateOf(peer) === "idle" && (this.d.idle?.(peer) ?? true) && !this.d.held?.()[peer] && !bus.queueSummary(peer).heldBy && !bus.queued(peer) && !bus.hasInFlight(peer);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private sweepNotice(task: Task, peer: PeerId, body: string): void {
|
|
212
|
+
if (peer === USER) this.d.notify(body);
|
|
213
|
+
else this.d.bus.publish(newEnvelope(HUB, body, { to: [peer], kind: "task", refs: { task: String(task.id) } }));
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
|
|
161
217
|
if (!this.d.executionBudget) return undefined;
|
|
162
218
|
if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
|
|
@@ -180,9 +236,19 @@ export class Tasks {
|
|
|
180
236
|
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
181
237
|
|
|
182
238
|
/** A task as a cloud peer may see it. */
|
|
183
|
-
publicView(task: Task): Record<string, unknown> {
|
|
239
|
+
publicView(task: Task, fullHistory = false): Record<string, unknown> {
|
|
184
240
|
const { title, detail, history, ...rest } = task;
|
|
185
|
-
|
|
241
|
+
if (this.isPii(task)) return { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" };
|
|
242
|
+
const screenHistoryText = (text: string, field: string): string => this.nameable(text) ? text : `[history ${field} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
|
|
243
|
+
const publicHistory = (fullHistory ? history : history.slice(-5)).map(h => ({
|
|
244
|
+
at: h.at, by: h.by, event: h.event,
|
|
245
|
+
...(h.note === undefined ? {} : { note: screenHistoryText(h.note, "note") }),
|
|
246
|
+
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
247
|
+
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
248
|
+
...(h.reason && TASK_MOVE_REASONS.includes(h.reason) ? { reason: h.reason } : {}),
|
|
249
|
+
...(h.sweep ? { sweep: { kind: h.sweep.kind, activity: h.sweep.activity, step: h.sweep.step, at: h.sweep.at } } : {}),
|
|
250
|
+
}));
|
|
251
|
+
return { ...rest, title, detail, history: publicHistory };
|
|
186
252
|
}
|
|
187
253
|
|
|
188
254
|
/** Console results expose direct approval and delivery holds, not just the chosen peer ids. */
|
|
@@ -585,7 +651,7 @@ export class Tasks {
|
|
|
585
651
|
if (!assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), peer], ...this.health() }).owner) continue;
|
|
586
652
|
try {
|
|
587
653
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, why) : task;
|
|
588
|
-
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", note: why });
|
|
654
|
+
const next = await this.assignOwner(back, HUB, { exclude: [peer], event: "reassigned", reason: "offline", note: why });
|
|
589
655
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner });
|
|
590
656
|
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
591
657
|
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
@@ -613,6 +679,11 @@ export class Tasks {
|
|
|
613
679
|
return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
|
|
614
680
|
}
|
|
615
681
|
|
|
682
|
+
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
683
|
+
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
684
|
+
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
685
|
+
}
|
|
686
|
+
|
|
616
687
|
private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
|
|
617
688
|
|
|
618
689
|
/** Why the task will approve directly at done: no reviewer could be picked, and who was skipped (issue #92). */
|
|
@@ -630,7 +701,7 @@ export class Tasks {
|
|
|
630
701
|
if (hold) this.d.notify(`task ${this.publicTitle(task)}: ${a.owner}'s queue is held (${hold}); the task arrives once the hold is resolved`);
|
|
631
702
|
}
|
|
632
703
|
|
|
633
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
|
|
704
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
|
|
634
705
|
const waits = this.waitsFor(task);
|
|
635
706
|
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) });
|
|
636
707
|
|
|
@@ -648,7 +719,7 @@ export class Tasks {
|
|
|
648
719
|
return task;
|
|
649
720
|
}
|
|
650
721
|
const profile = this.d.splitProfile?.(a.owner);
|
|
651
|
-
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, profile ? { profile } : {});
|
|
722
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, { ...(profile ? { profile } : {}), ...(opts.reason ? { reason: opts.reason } : {}) });
|
|
652
723
|
// What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
|
|
653
724
|
// escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
|
|
654
725
|
// yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
|
|
@@ -837,7 +908,7 @@ export class Tasks {
|
|
|
837
908
|
this.mine(task, by, "owner");
|
|
838
909
|
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
|
|
839
910
|
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
840
|
-
return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
|
|
911
|
+
return this.assignOwner(back, HUB, { event: "reassigned", reason: "declined", clearOnFail: true });
|
|
841
912
|
}
|
|
842
913
|
|
|
843
914
|
/** Tasks whose check is queued or running, and the owner it was started for. */
|
|
@@ -1126,7 +1197,7 @@ export class Tasks {
|
|
|
1126
1197
|
this.contradict(rejected);
|
|
1127
1198
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
|
|
1128
1199
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1129
|
-
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested
|
|
1200
|
+
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1130
1201
|
if (moved.owner !== rejected.owner) return moved;
|
|
1131
1202
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1132
1203
|
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
@@ -1138,7 +1209,7 @@ export class Tasks {
|
|
|
1138
1209
|
}
|
|
1139
1210
|
|
|
1140
1211
|
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
1141
|
-
async escalate(by: PeerId, id: unknown, why = "by hand"): Promise<Task> {
|
|
1212
|
+
async escalate(by: PeerId, id: unknown, why = "by hand", reason: TaskMoveReason | undefined = by === HUB ? undefined : "manual"): Promise<Task> {
|
|
1142
1213
|
let task = this.need(id, true);
|
|
1143
1214
|
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
1144
1215
|
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
@@ -1148,7 +1219,7 @@ export class Tasks {
|
|
|
1148
1219
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
1149
1220
|
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
1150
1221
|
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 });
|
|
1151
|
-
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1222
|
+
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1152
1223
|
if (next.owner && next.owner !== from) {
|
|
1153
1224
|
this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
1154
1225
|
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
@@ -1157,9 +1228,9 @@ export class Tasks {
|
|
|
1157
1228
|
return this.d.board.get(next.id)!;
|
|
1158
1229
|
}
|
|
1159
1230
|
|
|
1160
|
-
/**
|
|
1161
|
-
async assignTo(id: unknown, peer: PeerId): Promise<Task> {
|
|
1162
|
-
return this.assignOwner(this.need(id, true),
|
|
1231
|
+
/** Caller authority is checked by the daemon; keep the real actor in task history. */
|
|
1232
|
+
async assignTo(id: unknown, peer: PeerId, by: PeerId = USER): Promise<Task> {
|
|
1233
|
+
return this.assignOwner(this.need(id, true), by, { candidates: [peer], event: "reassigned", reason: "manual" });
|
|
1163
1234
|
}
|
|
1164
1235
|
|
|
1165
1236
|
/**
|
|
@@ -1176,7 +1247,7 @@ export class Tasks {
|
|
|
1176
1247
|
const pii = this.isPii(task);
|
|
1177
1248
|
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);
|
|
1178
1249
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
1179
|
-
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
|
|
1250
|
+
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
|
|
1180
1251
|
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1181
1252
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1182
1253
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|