@staix/agent-hub 0.7.11 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/README.md +2 -2
- package/docs/events.md +37 -0
- package/docs/operations.md +91 -10
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -0
- package/docs/smoke.md +36 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +16 -4
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/codex-appserver.ts +43 -3
- package/src/cli/main.ts +91 -0
- package/src/hub/board.ts +29 -9
- package/src/hub/budget.ts +3 -0
- package/src/hub/daemon.ts +139 -17
- package/src/hub/events.ts +91 -0
- package/src/hub/hub-tools.ts +15 -3
- package/src/hub/lifecycle.ts +13 -2
- package/src/hub/report.ts +68 -0
- package/src/hub/snapshots.ts +191 -0
- package/src/hub/tasks.ts +119 -28
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +2 -1
package/src/hub/hub-tools.ts
CHANGED
|
@@ -9,6 +9,18 @@ const refs = {
|
|
|
9
9
|
properties: { branch: str, commit: str, paths: { type: "array", items: str } },
|
|
10
10
|
additionalProperties: false,
|
|
11
11
|
} as const;
|
|
12
|
+
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
13
|
+
const plan = {
|
|
14
|
+
type: "object",
|
|
15
|
+
description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
|
|
16
|
+
properties: {
|
|
17
|
+
paths: list("files you will edit or create"),
|
|
18
|
+
symbols: list("functions, types or other names you will change, as they appear in code (e.g. Bus.publish)"),
|
|
19
|
+
signatures: list("new or changed signatures, as you will write them"),
|
|
20
|
+
insertion_points: list("where new code goes (file and the function or line it follows)"),
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
} as const;
|
|
12
24
|
|
|
13
25
|
export interface HubTool {
|
|
14
26
|
name: string;
|
|
@@ -23,8 +35,8 @@ const tool = (name: string, description: string, properties: Record<string, unkn
|
|
|
23
35
|
});
|
|
24
36
|
|
|
25
37
|
export const TASK_TOOLS: HubTool[] = [
|
|
26
|
-
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
27
|
-
tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
|
|
38
|
+
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
39
|
+
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
|
|
28
40
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
29
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"]),
|
|
30
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"] } }),
|
|
@@ -38,7 +50,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
38
50
|
/** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
|
|
39
51
|
export const ROLE_TEXT: Record<string, string> = {
|
|
40
52
|
planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs) instead of doing everything yourself.",
|
|
41
|
-
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.",
|
|
53
|
+
implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
|
|
42
54
|
verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
|
|
43
55
|
reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
|
|
44
56
|
};
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -179,6 +179,15 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
179
179
|
} finally { registry.close(); }
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Whether `instanceId` still holds the project's registry claim, counted as held while its process may be alive. */
|
|
183
|
+
function claimHeld(id: string, instanceId: string): boolean {
|
|
184
|
+
const registry = new Registry();
|
|
185
|
+
try {
|
|
186
|
+
const row = registry.get(id);
|
|
187
|
+
return row?.instanceId === instanceId && (row.pid === null || processAlive(row.pid) !== false);
|
|
188
|
+
} finally { registry.close(); }
|
|
189
|
+
}
|
|
190
|
+
|
|
182
191
|
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
183
192
|
assertLifecycleAvailable();
|
|
184
193
|
const inspection = await inspectProject(project);
|
|
@@ -194,8 +203,10 @@ export async function stopProject(project: Project, expectedInstance?: string):
|
|
|
194
203
|
const deadline = Date.now() + 10_000;
|
|
195
204
|
while (Date.now() < deadline) {
|
|
196
205
|
const current = readControl(project.stateDir);
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
// The daemon removes its manifest only after owned children stop; its process releases the registry claim just
|
|
207
|
+
// after that. Until then `projects remove` refuses and `up` reads the project as starting, so wait for both.
|
|
208
|
+
if (!current && !claimHeld(project.id, instanceId)) return;
|
|
209
|
+
if (current && current.instanceId !== instanceId) throw new Error("a new hub instance started; it was not stopped");
|
|
199
210
|
await Bun.sleep(100);
|
|
200
211
|
}
|
|
201
212
|
throw new Error("shutdown is still pending; state and ownership were retained");
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { StampedEvent } from "./events.ts";
|
|
2
|
+
|
|
3
|
+
export interface Report {
|
|
4
|
+
from?: string;
|
|
5
|
+
to?: string;
|
|
6
|
+
peers: Record<string, { turns: number; busyMinutes: number; tokens: number }>;
|
|
7
|
+
messages: { total: number; dropped: Record<string, number>; overflow: number; undeliverable: number; perTask: number };
|
|
8
|
+
overlaps: { warnings: number; pairs: number };
|
|
9
|
+
tasks: Record<string, number>;
|
|
10
|
+
quota: { readings: number; hard: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The numbers `ahub report` prints, from `events.jsonl` alone (issue #40). */
|
|
14
|
+
export function summarize(events: StampedEvent[]): Report {
|
|
15
|
+
const r: Report = { peers: {}, messages: { total: 0, dropped: {}, overflow: 0, undeliverable: 0, perTask: 0 }, overlaps: { warnings: 0, pairs: 0 }, tasks: {}, quota: { readings: 0, hard: 0 } };
|
|
16
|
+
const peer = (id: string) => (r.peers[id] ??= { turns: 0, busyMinutes: 0, tokens: 0 });
|
|
17
|
+
const pairs = new Set<string>();
|
|
18
|
+
const taskMessages = new Map<string, number>();
|
|
19
|
+
for (const e of events) {
|
|
20
|
+
r.from ??= e.at;
|
|
21
|
+
r.to = e.at;
|
|
22
|
+
switch (e.type) {
|
|
23
|
+
case "envelope":
|
|
24
|
+
r.messages.total++;
|
|
25
|
+
if (e.dropped) r.messages.dropped[e.dropped] = (r.messages.dropped[e.dropped] ?? 0) + 1;
|
|
26
|
+
if (e.task) taskMessages.set(e.task, (taskMessages.get(e.task) ?? 0) + 1);
|
|
27
|
+
break;
|
|
28
|
+
case "overflow":
|
|
29
|
+
case "undeliverable":
|
|
30
|
+
r.messages[e.type]++;
|
|
31
|
+
break;
|
|
32
|
+
case "turn_end":
|
|
33
|
+
peer(e.peer).turns++;
|
|
34
|
+
peer(e.peer).busyMinutes += e.ms / 60_000;
|
|
35
|
+
break;
|
|
36
|
+
case "tokens":
|
|
37
|
+
peer(e.peer).tokens += e.n;
|
|
38
|
+
break;
|
|
39
|
+
case "task":
|
|
40
|
+
r.tasks[e.event] = (r.tasks[e.event] ?? 0) + 1;
|
|
41
|
+
break;
|
|
42
|
+
case "overlap":
|
|
43
|
+
r.overlaps.warnings++;
|
|
44
|
+
for (const o of e.others) pairs.add([e.task, o.task].sort((a, b) => a - b).join("-"));
|
|
45
|
+
break;
|
|
46
|
+
case "quota":
|
|
47
|
+
r.quota.readings++;
|
|
48
|
+
if (e.hard) r.quota.hard++;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
r.overlaps.pairs = pairs.size;
|
|
53
|
+
r.messages.perTask = taskMessages.size ? Number(([...taskMessages.values()].reduce((a, b) => a + b, 0) / taskMessages.size).toFixed(1)) : 0;
|
|
54
|
+
for (const p of Object.values(r.peers)) p.busyMinutes = Number(p.busyMinutes.toFixed(1));
|
|
55
|
+
return r;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatReport(r: Report): string[] {
|
|
59
|
+
const lines = [`period: ${r.from ?? "-"} .. ${r.to ?? "-"}`];
|
|
60
|
+
for (const [id, p] of Object.entries(r.peers).sort(([a], [b]) => a.localeCompare(b))) lines.push(`peer ${id}: ${p.turns} turn${p.turns === 1 ? "" : "s"}, ${p.busyMinutes} busy minutes, ${p.tokens || "-"} tokens`);
|
|
61
|
+
const dropped = Object.entries(r.messages.dropped).map(([k, n]) => `${n} ${k}`).join(", ");
|
|
62
|
+
lines.push(`messages: ${r.messages.total} (dropped: ${dropped || "none"}; overflow ${r.messages.overflow}; undeliverable ${r.messages.undeliverable}); ${r.messages.perTask} per task that had any`);
|
|
63
|
+
lines.push(`overlap warnings: ${r.overlaps.warnings}, task pairs: ${r.overlaps.pairs}`);
|
|
64
|
+
const tasks = Object.entries(r.tasks).sort().map(([k, n]) => `${k} ${n}`).join(", ");
|
|
65
|
+
lines.push(`task events: ${tasks || "none"}`);
|
|
66
|
+
lines.push(`quota readings: ${r.quota.readings} (${r.quota.hard} hard limits)`);
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { childEnv } from "./child-process.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
10
|
+
* and HEAD never change. They hold tracked and unignored files as `git add -A` sees them, shell-made changes included.
|
|
11
|
+
* ponytail: snapshots run synchronously on the turn boundary (before the agent sees its prompt); a very large tree
|
|
12
|
+
* costs that much latency per turn, `snapshots.enabled` is the switch and `turn_end.snapshotMs` the measurement.
|
|
13
|
+
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
14
|
+
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
15
|
+
*/
|
|
16
|
+
const git = (top: string, args: string[], env: Record<string, string> = {}) =>
|
|
17
|
+
spawnSync("git", ["-C", top, ...args], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
env: { ...childEnv(process.env), ...env },
|
|
20
|
+
// These run inside the bus's state change: a hung git (a huge new directory, a stuck filter) must not freeze the hub.
|
|
21
|
+
timeout: SNAPSHOT_TIMEOUT_MS,
|
|
22
|
+
});
|
|
23
|
+
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
/** A repository and where the project sits in it: `prefix` is "" at the top level, else "sub/dir/". */
|
|
26
|
+
export interface Repo {
|
|
27
|
+
top: string;
|
|
28
|
+
dir: string;
|
|
29
|
+
prefix: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The repository holding `root`, or undefined when it is not inside a work tree. */
|
|
33
|
+
export function repoOf(root: string): Repo | undefined {
|
|
34
|
+
const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
|
|
35
|
+
if (r.status !== 0) return undefined;
|
|
36
|
+
const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
|
|
37
|
+
return top && dir ? { top, dir, prefix } : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A path as a pathspec that means only itself: `app/[slug]/page.tsx` must not match `app/s/page.tsx`. Per-path
|
|
42
|
+
* `:(literal)` rather than --literal-pathspecs, which would also switch off the state exclude's glob.
|
|
43
|
+
*/
|
|
44
|
+
const literal = (path: string) => `:(literal)${path}`;
|
|
45
|
+
/** Only the project's own files: a hub in a subdirectory of a larger repository snapshots that subdirectory. */
|
|
46
|
+
const scope = (repo: Repo) => (repo.prefix ? literal(repo.prefix) : ".");
|
|
47
|
+
|
|
48
|
+
/** A tree id for the project's files as they are now, leaving out the hub's own state. */
|
|
49
|
+
export function snapshot(repo: Repo): string | undefined {
|
|
50
|
+
const tmp = mkdtempSync(join(tmpdir(), "ahub-snap-"));
|
|
51
|
+
try {
|
|
52
|
+
const env = { GIT_INDEX_FILE: join(tmp, "index") };
|
|
53
|
+
if (existsSync(join(repo.dir, "index"))) copyFileSync(join(repo.dir, "index"), env.GIT_INDEX_FILE);
|
|
54
|
+
if (git(repo.top, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
|
|
55
|
+
// The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
|
|
56
|
+
// only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
|
|
57
|
+
// matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
|
|
58
|
+
if (git(repo.top, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**"], env).status !== 0) return undefined;
|
|
59
|
+
const r = git(repo.top, ["write-tree"], env);
|
|
60
|
+
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Project paths (relative to the top level) that differ between two trees. */
|
|
67
|
+
export function changedPaths(repo: Repo, from: string, to: string): string[] {
|
|
68
|
+
const r = git(repo.top, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)]);
|
|
69
|
+
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const inTree = (top: string, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
|
73
|
+
|
|
74
|
+
/** Whether a snapshot's tree is still in the object store (`git gc` prunes unreferenced ones eventually). */
|
|
75
|
+
export const hasTree = (top: string, tree: string): boolean => git(top, ["cat-file", "-e", `${tree}^{tree}`]).status === 0;
|
|
76
|
+
|
|
77
|
+
export interface UndoPlan {
|
|
78
|
+
restore: string[];
|
|
79
|
+
/** Changed again after the turn ended (by anyone): restoring them would destroy that work. */
|
|
80
|
+
changedSince: string[];
|
|
81
|
+
/** Also changed by another peer's turn that overlapped this one: the change may be theirs. */
|
|
82
|
+
concurrent: string[];
|
|
83
|
+
/** Every path the turn changed is already as it was when the turn started. */
|
|
84
|
+
undone: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What undoing a turn would do. The current state is read the way the snapshots are (one more snapshot), so modes,
|
|
89
|
+
* symlinks, directories and filters compare like for like. A path is refused when anything at or under it differs
|
|
90
|
+
* from what the turn left, or when an overlapping turn of another peer changed it too.
|
|
91
|
+
*/
|
|
92
|
+
export function planUndo(repo: Repo, turn: { start_tree: string; end_tree: string; changed: string[] }, overlapping: string[] = []): UndoPlan {
|
|
93
|
+
const now = snapshot(repo);
|
|
94
|
+
if (!now) throw new Error("could not read the work tree (see git's output above, or try again)");
|
|
95
|
+
const hit = (paths: string[], p: string) => paths.some((d) => d === p || d.startsWith(`${p}/`));
|
|
96
|
+
const sinceEnd = changedPaths(repo, turn.end_tree, now);
|
|
97
|
+
const sinceStart = changedPaths(repo, turn.start_tree, now);
|
|
98
|
+
const plan: UndoPlan = { restore: [], changedSince: [], concurrent: [], undone: turn.changed.length > 0 && turn.changed.every((p) => !hit(sinceStart, p)) };
|
|
99
|
+
for (const p of turn.changed) {
|
|
100
|
+
if (hit(sinceEnd, p)) plan.changedSince.push(p);
|
|
101
|
+
else if (hit(overlapping, p)) plan.concurrent.push(p);
|
|
102
|
+
else plan.restore.push(p);
|
|
103
|
+
}
|
|
104
|
+
return plan;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Puts each path back as it was when the turn started; a file the turn created is removed (never a directory: the
|
|
109
|
+
* plan refuses a path with anything under it). The index is untouched.
|
|
110
|
+
*/
|
|
111
|
+
export function restore(repo: Repo, startTree: string, paths: string[]): void {
|
|
112
|
+
// Removals first: after a case-only rename on a case-insensitive disk, `Foo.ts` and `foo.ts` are one file, and
|
|
113
|
+
// removing the created spelling after restoring the old one would delete it.
|
|
114
|
+
const back = paths.filter((p) => inTree(repo.top, startTree, p));
|
|
115
|
+
for (const path of paths.filter((p) => !back.includes(p))) rmSync(join(repo.top, path), { force: true });
|
|
116
|
+
for (const path of back) {
|
|
117
|
+
if (git(repo.top, ["restore", `--source=${startTree}`, "--worktree", "--", literal(path)]).status !== 0) throw new Error(`could not restore ${path}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface TurnRecord {
|
|
122
|
+
id: string;
|
|
123
|
+
peer: string;
|
|
124
|
+
started: number;
|
|
125
|
+
ended: number | null;
|
|
126
|
+
start_tree: string | null;
|
|
127
|
+
end_tree: string | null;
|
|
128
|
+
changed: string[];
|
|
129
|
+
native: string | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Turn records in hub.db: the daemon writes them, `ahub turns` and `ahub undo` read them. */
|
|
133
|
+
export class Turns {
|
|
134
|
+
private readonly db: Database;
|
|
135
|
+
constructor(path: string, readonly?: boolean) {
|
|
136
|
+
this.db = new Database(path, readonly ? { readonly: true } : { create: true });
|
|
137
|
+
if (readonly) return;
|
|
138
|
+
this.db.run("PRAGMA journal_mode = WAL");
|
|
139
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
|
|
140
|
+
start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]', native TEXT)`);
|
|
141
|
+
// Opened by a starting hub: a turn still open was cut short when the last run stopped. It has no end snapshot, and
|
|
142
|
+
// it may have run until now, so its window ends now (other turns that overlapped it stay unknowable).
|
|
143
|
+
this.db.query("UPDATE turns SET ended = ? WHERE ended IS NULL").run(Date.now());
|
|
144
|
+
}
|
|
145
|
+
begin(id: string, peer: string, startTree: string | undefined): void {
|
|
146
|
+
this.db.query("INSERT OR REPLACE INTO turns (id, peer, started, start_tree) VALUES (?, ?, ?, ?)").run(id, peer, Date.now(), startTree ?? null);
|
|
147
|
+
}
|
|
148
|
+
/** The first native turn of a hub turn: reverting the conversation drops it and every later one. */
|
|
149
|
+
native(id: string, native: string): void {
|
|
150
|
+
this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
|
|
151
|
+
}
|
|
152
|
+
end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
|
|
153
|
+
this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), endTree ?? null, JSON.stringify(changed), id);
|
|
154
|
+
const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
|
|
155
|
+
if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
|
|
156
|
+
}
|
|
157
|
+
list(peer?: string, limit = 20): TurnRecord[] {
|
|
158
|
+
const rows = (peer
|
|
159
|
+
? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
|
|
160
|
+
: this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
|
|
161
|
+
return rows.map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
162
|
+
}
|
|
163
|
+
get(id: string): TurnRecord | undefined {
|
|
164
|
+
const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
|
|
165
|
+
return r ? { ...r, changed: JSON.parse(r.changed) as string[] } : undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
|
|
169
|
+
* those whose changes cannot be known (still running, cut short by a stop, a failed snapshot, a PII turn), and a
|
|
170
|
+
* peer whose `keep` records all start after this turn: its turns from then may have been pruned.
|
|
171
|
+
*/
|
|
172
|
+
// ponytail: pruning is inferred from `keep` as the CLI reads it now; a `keep` raised while the hub runs (which prunes
|
|
173
|
+
// with the value it started with) can hide a pruned overlap until the hub restarts. Record a per-peer "pruned
|
|
174
|
+
// before" time in Turns.end if that ever matters.
|
|
175
|
+
overlapping(turn: TurnRecord, keep: number): { paths: string[]; unknown: string[] } {
|
|
176
|
+
const rows = this.db.query("SELECT id, changed, ended, start_tree, end_tree FROM turns WHERE id != ? AND peer != ? AND started < ? AND (ended IS NULL OR ended > ?)").all(turn.id, turn.peer, turn.ended ?? Date.now(), turn.started) as { id: string; changed: string; ended: number | null; start_tree: string | null; end_tree: string | null }[];
|
|
177
|
+
const known = rows.filter((r) => r.ended !== null && r.start_tree !== null && r.end_tree !== null);
|
|
178
|
+
const pruned = (this.db.query("SELECT peer, MIN(started) AS oldest FROM turns WHERE peer != ? GROUP BY peer HAVING COUNT(*) >= ?").all(turn.peer, keep) as { peer: string; oldest: number }[])
|
|
179
|
+
.filter((p) => p.oldest > turn.started)
|
|
180
|
+
.map((p) => `${p.peer} (its turns from then were pruned)`);
|
|
181
|
+
return { paths: [...new Set(known.flatMap((r) => JSON.parse(r.changed) as string[]))], unknown: [...rows.filter((r) => !known.includes(r)).map((r) => r.id), ...pruned] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The latest turn of a peer: a conversation can only be reverted from its latest turn. */
|
|
185
|
+
latest(peer: string): TurnRecord | undefined {
|
|
186
|
+
return this.list(peer, 1)[0];
|
|
187
|
+
}
|
|
188
|
+
close(): void {
|
|
189
|
+
this.db.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Briefs } from "../memory/brief.ts";
|
|
2
2
|
import type { MemoryClient } from "../memory/client.ts";
|
|
3
|
-
import { CLASSES, type Board, type Task, type TaskClass, type TaskRefs } from "./board.ts";
|
|
3
|
+
import { CLASSES, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
4
4
|
import type { Bus } from "./bus.ts";
|
|
5
5
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
6
|
import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
|
|
@@ -23,6 +23,8 @@ export interface TasksDeps {
|
|
|
23
23
|
share?: (by: PeerId, line: string) => void;
|
|
24
24
|
/** Hands one peer a line that rides on its next delivery, without a turn of its own (issue #6). */
|
|
25
25
|
tell?: (peer: PeerId, line: string) => void;
|
|
26
|
+
/** Structured overlap records for telemetry (issue #40); the notice line stays for the human. */
|
|
27
|
+
recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
|
|
26
28
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
27
29
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
28
30
|
}
|
|
@@ -39,17 +41,31 @@ const samePlace = (a: string, b: string) => {
|
|
|
39
41
|
return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
|
|
40
42
|
};
|
|
41
43
|
|
|
44
|
+
/** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
|
|
45
|
+
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
|
|
46
|
+
/** A list of short strings; a lone string counts as a list of one. */
|
|
47
|
+
const textList = (v: unknown) => (Array.isArray(v) ? v : typeof v === "string" ? [v] : []).map(text).filter((p): p is string => !!p).slice(0, 50);
|
|
48
|
+
const fields = (input: unknown) => (input && typeof input === "object" && !Array.isArray(input) ? input : {}) as Record<string, unknown>;
|
|
49
|
+
|
|
42
50
|
/** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
|
|
43
51
|
function cleanRefs(input: unknown): TaskRefs {
|
|
44
|
-
const r = (input
|
|
45
|
-
const
|
|
46
|
-
const paths = (Array.isArray(r.paths) ? r.paths : typeof r.paths === "string" ? [r.paths] : []).map(text).filter((p): p is string => !!p).slice(0, 50);
|
|
52
|
+
const r = fields(input);
|
|
53
|
+
const paths = textList(r.paths);
|
|
47
54
|
const out: TaskRefs = {};
|
|
48
55
|
for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
|
|
49
56
|
if (paths.length) out.paths = paths;
|
|
50
57
|
return out;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
61
|
+
function cleanPlan(input: unknown): TaskPlan {
|
|
62
|
+
const r = fields(input);
|
|
63
|
+
return Object.fromEntries(PLAN_KEYS.map((k) => [k, textList(r[k])] as const).filter(([, v]) => v.length));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
67
|
+
const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.length).map((k) => `${k.replace("_", " ")}: ${plan[k]!.join("; ")}`).join(" | ");
|
|
68
|
+
|
|
53
69
|
/**
|
|
54
70
|
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
55
71
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
@@ -76,20 +92,23 @@ export class Tasks {
|
|
|
76
92
|
/** A task as a cloud peer may see it. */
|
|
77
93
|
publicView(task: Task): Record<string, unknown> {
|
|
78
94
|
const { title, detail, history, ...rest } = task;
|
|
79
|
-
return this.isPii(task) ? { ...rest, refs: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
|
|
95
|
+
return this.isPii(task) ? { ...rest, refs: {}, plan: {}, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
private states(): Record<PeerId, PeerState> {
|
|
83
99
|
return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; owner?: PeerId }): Promise<Task> {
|
|
87
|
-
|
|
102
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId }): Promise<Task> {
|
|
103
|
+
// Callers are models: a title is one line (it is part of console and hub.log lines), not a document.
|
|
104
|
+
const title = String(input.title ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
88
105
|
if (!title) throw new Error("title is required");
|
|
89
106
|
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
90
107
|
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
|
|
91
108
|
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
92
|
-
const
|
|
109
|
+
const plan = cleanPlan(input.plan);
|
|
110
|
+
// The plan reaches other owners, so a PII pattern in it makes the task a PII task like one in the detail would.
|
|
111
|
+
const signals = detectSignals({ ...text, detail: [text.detail, planText(plan)].join("\n") }, this.d.routing(), this.d.cwd);
|
|
93
112
|
let cls = given as TaskClass | undefined;
|
|
94
113
|
let triaged = false;
|
|
95
114
|
if (!cls && this.d.triage) {
|
|
@@ -103,7 +122,7 @@ export class Tasks {
|
|
|
103
122
|
if (defaulted) cls = "implement";
|
|
104
123
|
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
|
|
105
124
|
const draft = { ...text, class: cls };
|
|
106
|
-
let task = this.d.board.propose(by, { ...draft, signals });
|
|
125
|
+
let task = this.d.board.propose(by, { ...draft, plan, signals });
|
|
107
126
|
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
108
127
|
if (defaulted) task = this.d.board.update(task.id, "hub", "class defaulted", {}, "class implement for a claim without one");
|
|
109
128
|
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
@@ -116,8 +135,8 @@ export class Tasks {
|
|
|
116
135
|
* only the new owner reads "settle it"; anyone else learns that the owner was told. PII tasks are left out on both
|
|
117
136
|
* sides: their refs are hidden from cloud peers.
|
|
118
137
|
*/
|
|
119
|
-
overlaps(task: Task, forOwner = true): string {
|
|
120
|
-
const hits =
|
|
138
|
+
overlaps(task: Task, forOwner = true, found = this.overlapHits(task)): string {
|
|
139
|
+
const hits = found.map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${this.where(h)}`);
|
|
121
140
|
if (!hits.length) return "";
|
|
122
141
|
const who = forOwner ? "Settle it with that owner via hub_send before editing those paths." : `${task.owner ?? "Whoever takes it"} is told to settle it.`;
|
|
123
142
|
return `Overlaps ${hits.join("; ")}. ${who}`;
|
|
@@ -126,22 +145,48 @@ export class Tasks {
|
|
|
126
145
|
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
127
146
|
private releasing: PeerId | undefined;
|
|
128
147
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
149
|
+
private nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
|
|
150
|
+
|
|
151
|
+
/** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
|
|
152
|
+
private where(hit: { paths: string[]; symbols: string[] }): string {
|
|
153
|
+
const shown = [...hit.paths.filter(this.nameable), ...hit.symbols.filter(this.nameable).map((s) => `symbol ${s}`)];
|
|
154
|
+
return shown.length ? shown.join(", ") : "a path whose name is withheld (it matches a PII pattern)";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). */
|
|
158
|
+
private places(task: Task): { paths: string[]; symbols: string[] } {
|
|
159
|
+
return { paths: [...new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])])], symbols: task.plan?.symbols ?? [] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private overlapHits(task: Task): { task: Task; paths: string[]; symbols: string[] }[] {
|
|
163
|
+
const mine = this.places(task);
|
|
164
|
+
if ((!mine.paths.length && !mine.symbols.length) || this.isPii(task)) return [];
|
|
132
165
|
return this.d.board.list().flatMap((t) => {
|
|
133
166
|
if (t.id === task.id || !t.owner || t.owner === task.owner || t.owner === this.releasing || !OPEN.includes(t.state) || this.isPii(t)) return [];
|
|
134
|
-
const
|
|
135
|
-
|
|
167
|
+
const theirs = this.places(t);
|
|
168
|
+
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
169
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
170
|
+
return paths.length || symbols.length ? [{ task: t, paths, symbols }] : [];
|
|
136
171
|
});
|
|
137
172
|
}
|
|
138
173
|
|
|
139
|
-
/** The
|
|
140
|
-
private
|
|
174
|
+
/** The console notice and the telemetry record of an overlap; the two are counted against each other (issue #40). */
|
|
175
|
+
private announceOverlap(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
|
|
176
|
+
this.d.notify(`task ${this.publicTitle(task)} (${task.owner}): ${this.overlaps(task, false, hits)}`);
|
|
177
|
+
this.d.recordOverlap?.(task.id, task.owner!, hits.map((h) => ({ task: h.task.id, owner: h.task.owner!, paths: h.paths.filter(this.nameable), ...(h.symbols.length ? { symbols: h.symbols.filter(this.nameable) } : {}) })));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The earlier owners hear of an overlap on their next delivery, costing them no turn; the newcomer settles it (#6).
|
|
182
|
+
* Its plan rides along, so they know what it will touch (issue #31).
|
|
183
|
+
*/
|
|
184
|
+
private tellEarlierOwners(task: Task, hits = this.overlapHits(task)): void {
|
|
141
185
|
if (!task.owner || !this.d.tell) return;
|
|
142
|
-
|
|
186
|
+
const plan = planText(task.plan);
|
|
187
|
+
for (const hit of hits) {
|
|
143
188
|
if (hit.task.owner === USER || hit.task.owner === HUB) continue;
|
|
144
|
-
this.d.tell(hit.task.owner!, noteLine(HUB, "finding", `task #${task.id} (owner ${task.owner}) now overlaps your #${hit.task.id} on ${
|
|
189
|
+
this.d.tell(hit.task.owner!, noteLine(HUB, "finding", `task #${task.id} (owner ${task.owner}) now overlaps your #${hit.task.id} on ${this.where(hit)}; ${task.owner} is told to settle it${plan ? `. Its plan (full: hub_task_list): ${plan}` : ""}`));
|
|
145
190
|
}
|
|
146
191
|
}
|
|
147
192
|
|
|
@@ -198,17 +243,17 @@ export class Tasks {
|
|
|
198
243
|
return opts.clearOnFail && task.owner ? this.d.board.update(task.id, by, "unassigned", { owner: null }) : task;
|
|
199
244
|
}
|
|
200
245
|
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}`);
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
this.
|
|
204
|
-
this.tellEarlierOwners(next);
|
|
246
|
+
const hits = this.overlapHits(next);
|
|
247
|
+
if (hits.length) {
|
|
248
|
+
this.announceOverlap(next, hits);
|
|
249
|
+
this.tellEarlierOwners(next, hits);
|
|
205
250
|
}
|
|
206
251
|
if (opts.claim && a.owner === by) {
|
|
207
252
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
208
253
|
this.d.notify(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
209
254
|
return claimed;
|
|
210
255
|
}
|
|
211
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next));
|
|
256
|
+
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits));
|
|
212
257
|
return next;
|
|
213
258
|
}
|
|
214
259
|
|
|
@@ -217,16 +262,18 @@ export class Tasks {
|
|
|
217
262
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
218
263
|
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
|
|
219
264
|
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
265
|
+
const plan = planText(task.plan);
|
|
220
266
|
const body = [
|
|
221
267
|
`Task #${task.id} [${task.class}] ${task.title}`,
|
|
222
268
|
task.detail,
|
|
223
269
|
`Facts: ${facts}`,
|
|
270
|
+
plan ? `Plan so far: ${plan}` : "",
|
|
224
271
|
overlap,
|
|
225
272
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
226
273
|
brief ?? "",
|
|
227
274
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
228
275
|
context && !pii ? `Handoff from the previous owner:\n${context.slice(0, 3000)}` : "",
|
|
229
|
-
`Take it with hub_task_accept {id: ${task.id}} or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
276
|
+
`Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
230
277
|
].filter(Boolean).join("\n\n");
|
|
231
278
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
232
279
|
}
|
|
@@ -243,11 +290,29 @@ export class Tasks {
|
|
|
243
290
|
return task;
|
|
244
291
|
}
|
|
245
292
|
|
|
246
|
-
|
|
293
|
+
/**
|
|
294
|
+
* With a plan, the owners of overlapping tasks get it as a ride-along line, and an overlap only the plan reveals is
|
|
295
|
+
* announced like one found at assignment (issue #31).
|
|
296
|
+
*/
|
|
297
|
+
accept(by: PeerId, id: unknown, plan?: unknown): Task {
|
|
247
298
|
const task = this.need(id);
|
|
248
299
|
this.mine(task, by, "owner");
|
|
249
|
-
|
|
300
|
+
// Models send null or {} for an optional field they leave empty: neither replaces a plan.
|
|
301
|
+
const given = plan == null ? undefined : cleanPlan(plan);
|
|
302
|
+
const cleaned = given && Object.keys(given).length ? given : undefined;
|
|
303
|
+
// A task's signals are fixed when it is proposed: text that matches a PII pattern cannot be let in afterwards.
|
|
304
|
+
if (cleaned && !this.isPii(task) && this.isPii({ signals: detectSignals({ title: "", detail: planText(cleaned), refs: {} }, this.d.routing(), this.d.cwd) })) {
|
|
305
|
+
throw new Error("this plan matches a PII pattern and is not kept: other owners would see it");
|
|
306
|
+
}
|
|
307
|
+
const before = new Set(this.overlapHits(task).map((h) => h.task.id));
|
|
308
|
+
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress", ...(cleaned ? { plan: cleaned } : {}) });
|
|
250
309
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
310
|
+
if (cleaned) {
|
|
311
|
+
const hits = this.overlapHits(next);
|
|
312
|
+
const fresh = hits.filter((h) => !before.has(h.task.id));
|
|
313
|
+
if (fresh.length) this.announceOverlap(next, fresh);
|
|
314
|
+
this.tellEarlierOwners(next, hits);
|
|
315
|
+
}
|
|
251
316
|
return next;
|
|
252
317
|
}
|
|
253
318
|
|
|
@@ -318,12 +383,38 @@ export class Tasks {
|
|
|
318
383
|
const reviewer = task.reviewer;
|
|
319
384
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
|
|
320
385
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
|
|
386
|
+
this.tellCompleted(next, summary);
|
|
321
387
|
if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
322
388
|
else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
323
389
|
else this.sendReview(next, reviewer);
|
|
324
390
|
return next;
|
|
325
391
|
}
|
|
326
392
|
|
|
393
|
+
/**
|
|
394
|
+
* "Passes alone, fails together": the owners of open tasks on the same places hear what changed under them, once the
|
|
395
|
+
* work is done (after its check, when one runs), and nobody else does (issue #31). A message of its own, not a
|
|
396
|
+
* ride-along line: an owner in the middle of those files needs it before its next task arrives.
|
|
397
|
+
*/
|
|
398
|
+
private tellCompleted(task: Task, summary?: string): void {
|
|
399
|
+
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
400
|
+
if (!hits.length) return;
|
|
401
|
+
// Files, signatures and the summary are the owner's own words (paths given at done included): any item that
|
|
402
|
+
// matches a PII pattern is left out of what other owners get.
|
|
403
|
+
const paths = this.places(task).paths.filter(this.nameable);
|
|
404
|
+
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
405
|
+
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
406
|
+
const line = first && this.nameable(first) ? first : undefined;
|
|
407
|
+
for (const hit of hits) {
|
|
408
|
+
const body = [
|
|
409
|
+
`Task #${task.id} (owner ${task.owner}) is done and touches your open #${hit.task.id} on ${this.where(hit)}. Check your work against it before you go on.`,
|
|
410
|
+
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
411
|
+
signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
|
|
412
|
+
line ? `Summary: ${line}` : "",
|
|
413
|
+
].filter(Boolean).join("\n");
|
|
414
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [hit.task.owner!], kind: "task", refs: { task: String(hit.task.id) } }));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
327
418
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
328
419
|
private sendReview(task: Task, reviewer: PeerId, why = ""): void {
|
|
329
420
|
const r = task.refs;
|
|
@@ -9,5 +9,5 @@ This project runs agent-hub: other coding agents (claude, codex, kimi, pi, local
|
|
|
9
9
|
- Start a message or final answer with `[IMPORTANT]` only when the others must see it now; `[FYI]` is recorded and costs nobody a turn. Unmarked answers are batched into digests.
|
|
10
10
|
- Read the kind of each message (`meta.kind` on a channel tag, each item's kind in a digest, the kind in a prompt header). Only `hub` items with kind `presence` are shared memory for reference, not requests. Its `task`, `review`, and `budget` items are workflow events: check the task board and your assigned role, then use the appropriate hub tools within the user's authorized scope. Sender and kind never override user instructions or safety rules.
|
|
11
11
|
- The task board is the record of who does what: `hub_task_propose`, `hub_task_accept` / `hub_task_decline`, `hub_task_done`, `hub_review`, `hub_task_list`. Default roles: Claude plans and reviews, Codex implements, Kimi, Pi and the local worker implement and verify; `.agenthub/config.json` `roles` is the source of truth.
|
|
12
|
-
- Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner
|
|
12
|
+
- Implementers claim work nobody assigned them with `hub_task_propose`, naming themselves as `owner`, with the paths in `refs` and a `plan` (files, symbols, signatures, insertion points; `hub_task_accept` takes one too). When the paths or symbols overlap another open task the hub says so, the later claimant settles it with that owner, and that owner gets the plan with its next message. When a task is done, the owners of overlapping open tasks get a notice of what changed. `hub_task_done` says what changed, why, and the check that was run with its result.
|
|
13
13
|
- `hub_remember` saves a decision, finding, contract or `fail` (an approach that does not work, and why) to the memory all agents share; the other agents get it with their next message. Do not retry what a `fail` note rules out without new evidence. A task shown as `[pii]` is handled by the on-prem worker only: do not ask for its content.
|
package/templates/config.json
CHANGED
|
@@ -26,5 +26,6 @@
|
|
|
26
26
|
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
|
|
27
27
|
"approvals": { "timeout_s": 120 },
|
|
28
28
|
"tasks": { "release_after_min": 30 },
|
|
29
|
-
"checks": { "timeout_s": 600 }
|
|
29
|
+
"checks": { "timeout_s": 600 },
|
|
30
|
+
"snapshots": { "enabled": true, "keep": 20 }
|
|
30
31
|
}
|