@staix/agent-hub 0.12.3 → 0.12.4
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 +8 -0
- package/docs/cooperbench.md +35 -2
- package/docs/events.md +9 -0
- package/docs/operations.md +158 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +30 -1
- package/docs/verification/2026-10-02-0.12.4.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5 -4
- package/src/adapters/claude-channel.ts +2 -1
- package/src/adapters/codex-appserver.ts +38 -0
- package/src/cli/facts-hook.ts +40 -0
- package/src/cli/launch.ts +27 -3
- package/src/cli/main.ts +31 -2
- package/src/cli/upgrade-runtime.ts +1 -1
- package/src/hub/bus.ts +75 -3
- package/src/hub/cohorts.ts +308 -0
- package/src/hub/control-client.ts +3 -3
- package/src/hub/daemon.ts +348 -14
- package/src/hub/events.ts +9 -0
- package/src/hub/facts.ts +754 -0
- package/src/hub/report.ts +3 -0
- package/src/hub/routing.ts +85 -0
- package/src/hub/tasks.ts +380 -19
- package/templates/AGENTS.block.md +1 -1
package/src/hub/tasks.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
1
2
|
import type { Briefs } from "../memory/brief.ts";
|
|
2
3
|
import type { MemoryClient } from "../memory/client.ts";
|
|
3
4
|
import { CLASSES, OUTCOMES_KEPT_MS, PLAN_KEYS, type Board, type Task, type TaskClass, type TaskPlan, type TaskRefs } from "./board.ts";
|
|
4
5
|
import type { Bus } from "./bus.ts";
|
|
5
6
|
import { HUB, newEnvelope, NOTE_KINDS, noteLine, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
|
-
import { assign, detectSignals, LOCAL, PI, type Assignment, type Routing } from "./routing.ts";
|
|
7
|
+
import { assign, detectSignals, LOCAL, PI, predictSplit, type Assignment, type Routing, type SplitObservation, type SplitPrediction } from "./routing.ts";
|
|
7
8
|
import { ExecutionBudget, type ExecutionBudgetConfig, type ExecutionBudgetDecision, type ExecutionBudgetStatus, type ExecutionUnit } from "./execution-budget.ts";
|
|
9
|
+
import { Cohorts, MAX_REQUESTS, type Cohort, type Completion } from "./cohorts.ts";
|
|
10
|
+
import { realPath } from "./project.ts";
|
|
8
11
|
|
|
9
12
|
export interface TasksDeps {
|
|
10
13
|
board: Board;
|
|
@@ -40,6 +43,25 @@ export interface TasksDeps {
|
|
|
40
43
|
roles?: Record<string, string[]>;
|
|
41
44
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
42
45
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
46
|
+
/** Turn-free coordination (issue #107): configured and no PII task open. Asked at each use. */
|
|
47
|
+
turnFree?: () => boolean;
|
|
48
|
+
/** Whether a peer's context path for facts is verified in its current session (issue #108). */
|
|
49
|
+
capable?: (peer: PeerId) => boolean;
|
|
50
|
+
/** Whether `peer` is between native turns now (issue #107): Codex not busy, Claude stopped since its last tool call. */
|
|
51
|
+
idle?: (peer: PeerId) => boolean;
|
|
52
|
+
/** One hash over these project files as they are now: an integration target (issue #107). */
|
|
53
|
+
treeHash?: (paths: string[], windows: { peer: PeerId; since: number; until?: number }[]) => string;
|
|
54
|
+
/** The facts due for a peer, offered with an integration request; acknowledged by its next done. */
|
|
55
|
+
integrationFacts?: (peer: PeerId) => { id: string; text: string } | undefined;
|
|
56
|
+
ackFacts?: (peer: PeerId, id: string) => void;
|
|
57
|
+
/** Whether a peer has been shown every change to its files. */
|
|
58
|
+
factsCurrent?: (peer: PeerId) => boolean;
|
|
59
|
+
/** When this hub run started: split observations come from it only, one version and one hook profile (issue #109). */
|
|
60
|
+
since?: number;
|
|
61
|
+
/** A shadow split prediction made at an assignment, for the record (issue #109). */
|
|
62
|
+
recordSplit?: (task: number, prediction: SplitPrediction) => void;
|
|
63
|
+
/** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
|
|
64
|
+
recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
const ESCALATE_AFTER = 2;
|
|
@@ -65,6 +87,12 @@ export const samePlace = (a: string, b: string) => {
|
|
|
65
87
|
|
|
66
88
|
/** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
|
|
67
89
|
const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
|
|
90
|
+
/** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
|
|
91
|
+
const WITH_DONE = "with its done";
|
|
92
|
+
/** How many times a task changed hands: a cohort member's generation (issue #107). */
|
|
93
|
+
const ownerGen = (t: Task) => t.history.filter((h) => OWNERSHIP_EVENTS.has(h.event)).length;
|
|
94
|
+
/** When the task was handed to its current owner (its creation, if it never changed hands). */
|
|
95
|
+
const handedAt = (t: Task) => [...t.history].reverse().find((h) => OWNERSHIP_EVENTS.has(h.event) && h.event !== "unassigned")?.at ?? t.history[0]?.at ?? Date.now();
|
|
68
96
|
|
|
69
97
|
/** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
|
|
70
98
|
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
|
|
@@ -96,7 +124,14 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
|
|
|
96
124
|
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
97
125
|
*/
|
|
98
126
|
export class Tasks {
|
|
127
|
+
/** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
|
|
128
|
+
readonly cohorts: Cohorts;
|
|
129
|
+
|
|
99
130
|
constructor(private readonly d: TasksDeps) {
|
|
131
|
+
this.cohorts = new Cohorts({
|
|
132
|
+
silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
|
|
133
|
+
idle: (peer) => this.d.idle?.(peer) ?? false,
|
|
134
|
+
});
|
|
100
135
|
// What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
|
|
101
136
|
// board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
|
|
102
137
|
d.bus.tap((e) => {
|
|
@@ -217,6 +252,53 @@ export class Tasks {
|
|
|
217
252
|
return { ...this.health(), now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
218
253
|
}
|
|
219
254
|
|
|
255
|
+
/**
|
|
256
|
+
* A peer's tasks of a class in this hub run, as split observations (issue #109): handed to it by someone else (claims
|
|
257
|
+
* left out), typed by outcome. Stages are the board's own proxies and stay unknown when the accept came with the done.
|
|
258
|
+
*/
|
|
259
|
+
splitObservations(cls: TaskClass, peer: PeerId, exclude?: number): SplitObservation[] {
|
|
260
|
+
const since = this.d.since ?? 0;
|
|
261
|
+
return this.d.board.list().flatMap((t): SplitObservation[] => {
|
|
262
|
+
if (t.class !== cls || t.owner !== peer || t.id === exclude) return [];
|
|
263
|
+
const given = [...t.history].reverse().find((h) => OWNERSHIP_EVENTS.has(h.event) && h.event !== "unassigned" && (h.owner === undefined || h.owner === peer));
|
|
264
|
+
if (!given || given.at < since || given.by === peer) return [];
|
|
265
|
+
const after = t.history.filter((h) => h.at >= given.at);
|
|
266
|
+
if (after.some((h) => ["check failed", "changes_requested", "escalated", "released", "declined", "integration unresolved"].includes(h.event))) return [{ outcome: "failed" }];
|
|
267
|
+
if (t.state !== "approved") return [];
|
|
268
|
+
const accepted = after.find((h) => h.event === "accepted" && h.by === peer);
|
|
269
|
+
// The work stage ends at its first done call: checks and an integration step are not the work itself.
|
|
270
|
+
const intent = accepted && after.find((h) => h.at >= accepted.at && ["done", "done (checking)", "integration requested"].includes(h.event));
|
|
271
|
+
if (!accepted || !intent || accepted.note === WITH_DONE) return [{ outcome: "approved" }];
|
|
272
|
+
return [{ outcome: "approved", orient: accepted.at - given.at, work: intent.at - accepted.at }];
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The shadow split prediction for routing `task` to `candidate` (issue #109): the pair it would form with the owner of
|
|
278
|
+
* an open task it overlaps. Assignment never reads it; `route explain` shows it and assignment records it.
|
|
279
|
+
*/
|
|
280
|
+
splitShadow(task: Task, candidate: PeerId | undefined): SplitPrediction | undefined {
|
|
281
|
+
if (!candidate) return undefined;
|
|
282
|
+
const other = this.overlapHits({ ...task, owner: null }).map((h) => h.task).find((t) => t.owner && t.owner !== candidate && t.owner !== USER && t.owner !== HUB);
|
|
283
|
+
if (!other) return undefined;
|
|
284
|
+
const peers: [PeerId, PeerId] = [candidate, other.owner!];
|
|
285
|
+
const states = this.states();
|
|
286
|
+
const failing = this.d.failing?.() ?? {};
|
|
287
|
+
// Other open work, and the overlapping task itself once its owner has started it: either way that owner would not
|
|
288
|
+
// start from orientation plus two whole units.
|
|
289
|
+
const open = this.d.board.list().filter((t) => OPEN.includes(t.state) && t.id !== task.id && (t.id !== other.id || t.state !== "proposed"));
|
|
290
|
+
// One task of a class is one unit: the only normalization the board supports, so another class is unknown.
|
|
291
|
+
const unit = task.class === other.class ? 1 : undefined;
|
|
292
|
+
return predictSplit({
|
|
293
|
+
peers,
|
|
294
|
+
observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
|
|
295
|
+
units: [unit, unit],
|
|
296
|
+
backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
|
|
297
|
+
// The routed peer may be busy taking this very task; the other owner, busy, is at work on something already.
|
|
298
|
+
available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || (states[p] === "busy" && p === candidate))])),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
220
302
|
/**
|
|
221
303
|
* Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
|
|
222
304
|
* within the window were contradicted. Each approval counts once.
|
|
@@ -302,13 +384,84 @@ export class Tasks {
|
|
|
302
384
|
overlaps(task: Task, forOwner = true, found = this.overlapHits(task)): string {
|
|
303
385
|
const hits = found.map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${this.where(h)}`);
|
|
304
386
|
if (!hits.length) return "";
|
|
387
|
+
if (this.silentFor(task.id)) {
|
|
388
|
+
// A silent cohort (issue #107): the plans are what the owner works from, and nobody negotiates by message.
|
|
389
|
+
const plans = found.map((h) => (planText(h.task.plan) ? `#${h.task.id}'s plan: ${planText(h.task.plan)}` : "")).filter(Boolean);
|
|
390
|
+
const who = forOwner
|
|
391
|
+
? `Do not message ${found.length > 1 ? "those owners" : "that owner"}: you are in one turn-free cohort, the hub shows you their changes as you work and asks the last of you to finish to check the work against the others.${plans.length ? ` ${plans.join(" | ")}` : ""}`
|
|
392
|
+
: `${task.owner ?? "Whoever takes it"} works alongside without messages (turn-free).`;
|
|
393
|
+
return `Overlaps ${hits.join("; ")}. ${who}`;
|
|
394
|
+
}
|
|
305
395
|
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.`;
|
|
306
396
|
return `Overlaps ${hits.join("; ")}. ${who}`;
|
|
307
397
|
}
|
|
308
398
|
|
|
399
|
+
turnFree = (): boolean => this.d.turnFree?.() ?? false;
|
|
400
|
+
|
|
401
|
+
/** Whether `task` is in a silent cohort: its owner works without messages to the other members (issue #107). */
|
|
402
|
+
silentFor = (task: number): boolean => this.turnFree() && !!this.cohorts.of(task)?.silent;
|
|
403
|
+
|
|
404
|
+
/** The live silent cohort that makes a message from `from` to `to` cohort coordination (issue #107). */
|
|
405
|
+
silenced = (from: PeerId, to: PeerId) => (this.turnFree() ? this.cohorts.silenced(from, to) : undefined);
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* What `peer`'s turn-free facts cover (issue #108): for each live cohort in which it still has an open task, the paths
|
|
409
|
+
* every member's task names, the other members' plans, and the task its own writes are reported under. Membership,
|
|
410
|
+
* not open overlap, so the last member to finish still sees the others' final edits. Undefined without such a task.
|
|
411
|
+
*/
|
|
412
|
+
factScope = (peer: PeerId): { paths: string[]; plans: { task: number; owner: PeerId; text: string }[]; task?: { id: number; title: string }; since?: number } | undefined => {
|
|
413
|
+
const open = (task: number) => OPEN.includes(this.d.board.get(task)?.state ?? "approved");
|
|
414
|
+
const members = this.cohorts.list().filter((c) => [...c.members.values()].some((m) => m.owner === peer && open(m.task))).flatMap((c) => [...c.members.values()]);
|
|
415
|
+
const tasks = members.flatMap((m) => {
|
|
416
|
+
const t = this.d.board.get(m.task);
|
|
417
|
+
return t && t.owner === m.owner && !this.isPii(t) ? [t] : [];
|
|
418
|
+
});
|
|
419
|
+
const mine = tasks.filter((t) => t.owner === peer && OPEN.includes(t.state));
|
|
420
|
+
if (!mine.length) return undefined;
|
|
421
|
+
const paths = [...new Set(tasks.flatMap((t) => this.places(t).paths))].filter((p) => p !== "." && this.nameable(p));
|
|
422
|
+
const plans = tasks.filter((t) => t.owner !== peer).map((t) => ({ task: t.id, owner: t.owner!, text: planText(t.plan) })).filter((p) => p.text);
|
|
423
|
+
// When the earliest of its tasks was handed over: work from before facts were tracked is not covered.
|
|
424
|
+
const since = Math.min(...mine.map(handedAt));
|
|
425
|
+
return { paths, plans, task: { id: mine[0]!.id, title: mine[0]!.title }, since };
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
|
|
429
|
+
overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
|
|
430
|
+
|
|
309
431
|
/** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
|
|
310
432
|
private releasing: PeerId | undefined;
|
|
311
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Notices that only matter while their recipient's task is open (issue #106): envelope id -> recipient and task, the
|
|
436
|
+
* newest 1024. An envelope without a record (another kind, a restart, an evicted record) is delivered as before: its
|
|
437
|
+
* purpose is never guessed from its kind.
|
|
438
|
+
* ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
|
|
439
|
+
* condition if stale notices after restarts show up.
|
|
440
|
+
*/
|
|
441
|
+
private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
|
|
445
|
+
* #106): open work by default; the conflict notices of #91 count a task in review too.
|
|
446
|
+
*/
|
|
447
|
+
whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
|
|
448
|
+
const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
|
|
449
|
+
this.conditional.set(env.id, { peer: to, task, states });
|
|
450
|
+
if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
|
|
451
|
+
this.d.bus.publish(env);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Whether a queued envelope still matters to `peer`; the bus asks per recipient at delivery (issue #106). False only
|
|
456
|
+
* for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
|
|
457
|
+
*/
|
|
458
|
+
relevant = (peer: PeerId, env: Envelope): boolean => {
|
|
459
|
+
const c = this.conditional.get(env.id);
|
|
460
|
+
if (!c || c.peer !== peer) return true;
|
|
461
|
+
const t = this.d.board.get(c.task);
|
|
462
|
+
return !!t && t.owner === peer && c.states.includes(t.state);
|
|
463
|
+
};
|
|
464
|
+
|
|
312
465
|
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
313
466
|
nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
|
|
314
467
|
|
|
@@ -330,9 +483,28 @@ export class Tasks {
|
|
|
330
483
|
return shown.length ? shown.join(", ") : "a path whose name is withheld (it matches a PII pattern)";
|
|
331
484
|
}
|
|
332
485
|
|
|
333
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). Models write paths
|
|
488
|
+
* absolute or relative: one inside the project is compared in its project-relative spelling.
|
|
489
|
+
*/
|
|
334
490
|
private places(task: Task): { paths: string[]; symbols: string[] } {
|
|
335
|
-
|
|
491
|
+
const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
|
|
492
|
+
return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private roots?: string[];
|
|
496
|
+
private projectPath(p: string): string {
|
|
497
|
+
if (!isAbsolute(p)) return normPath(p);
|
|
498
|
+
if (!this.roots) {
|
|
499
|
+
let real = this.d.cwd;
|
|
500
|
+
try { real = realPath(this.d.cwd); } catch { /* compared as given */ }
|
|
501
|
+
this.roots = [...new Set([this.d.cwd, real])];
|
|
502
|
+
}
|
|
503
|
+
for (const root of this.roots) {
|
|
504
|
+
const r = relative(root, p);
|
|
505
|
+
if (!r.startsWith("..") && !isAbsolute(r)) return normPath(r || ".");
|
|
506
|
+
}
|
|
507
|
+
return p;
|
|
336
508
|
}
|
|
337
509
|
|
|
338
510
|
private overlapHits(task: Task): { task: Task; paths: string[]; symbols: string[] }[] {
|
|
@@ -362,7 +534,8 @@ export class Tasks {
|
|
|
362
534
|
const plan = planText(task.plan);
|
|
363
535
|
for (const hit of hits) {
|
|
364
536
|
if (hit.task.owner === USER || hit.task.owner === HUB) continue;
|
|
365
|
-
this.
|
|
537
|
+
const how = this.silentFor(task.id) ? `do not message ${task.owner}: you are in one turn-free cohort and the hub shows you its changes as you work` : `${task.owner} is told to settle it`;
|
|
538
|
+
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)}; ${how}${plan ? `. Its plan (full: hub_task_list): ${plan}` : ""}`));
|
|
366
539
|
}
|
|
367
540
|
}
|
|
368
541
|
|
|
@@ -403,7 +576,9 @@ export class Tasks {
|
|
|
403
576
|
if (typeof target === "number") {
|
|
404
577
|
const task = this.d.board.get(target);
|
|
405
578
|
if (!task) throw new Error(`no task #${target}`);
|
|
406
|
-
|
|
579
|
+
const a = assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
|
|
580
|
+
// The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
|
|
581
|
+
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...a.trace, ...(this.splitShadow(task, task.owner ?? a.owner)?.trace ?? [])];
|
|
407
582
|
}
|
|
408
583
|
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
409
584
|
return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
|
|
@@ -429,6 +604,7 @@ export class Tasks {
|
|
|
429
604
|
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; claim?: boolean } = {}): Promise<Task> {
|
|
430
605
|
const waits = this.waitsFor(task);
|
|
431
606
|
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) });
|
|
607
|
+
|
|
432
608
|
if (waits.length) {
|
|
433
609
|
this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
434
610
|
return task;
|
|
@@ -436,10 +612,15 @@ export class Tasks {
|
|
|
436
612
|
if (!a.owner) {
|
|
437
613
|
this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
438
614
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
439
|
-
|
|
615
|
+
if (opts.clearOnFail && task.owner) {
|
|
616
|
+
this.cohorts.leave(task.id);
|
|
617
|
+
return this.d.board.update(task.id, by, "unassigned", { owner: null });
|
|
618
|
+
}
|
|
619
|
+
return task;
|
|
440
620
|
}
|
|
441
621
|
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}`);
|
|
442
622
|
const hits = this.overlapHits(next);
|
|
623
|
+
this.formCohort(next, hits);
|
|
443
624
|
if (hits.length) {
|
|
444
625
|
this.announceOverlap(next, hits);
|
|
445
626
|
this.tellEarlierOwners(next, hits);
|
|
@@ -550,6 +731,7 @@ export class Tasks {
|
|
|
550
731
|
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
551
732
|
if (cleaned) {
|
|
552
733
|
const hits = this.overlapHits(next);
|
|
734
|
+
this.formCohort(next, hits);
|
|
553
735
|
const fresh = hits.filter((h) => !before.has(h.task.id));
|
|
554
736
|
if (fresh.length) this.announceOverlap(next, fresh);
|
|
555
737
|
this.tellEarlierOwners(next, hits);
|
|
@@ -557,6 +739,49 @@ export class Tasks {
|
|
|
557
739
|
return next;
|
|
558
740
|
}
|
|
559
741
|
|
|
742
|
+
/**
|
|
743
|
+
* Put `task` and the open tasks it overlaps into one cohort (issue #107). A silent cohort that an owner without a
|
|
744
|
+
* verified context path joins speaks again, and its members hear so.
|
|
745
|
+
*/
|
|
746
|
+
private formCohort(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
|
|
747
|
+
if (!task.owner || this.isPii(task)) return;
|
|
748
|
+
const before = this.cohorts.of(task.id)?.revision;
|
|
749
|
+
const joined = this.cohorts.join(task, hits.map((h) => h.task), ownerGen, handedAt);
|
|
750
|
+
if (!joined) return;
|
|
751
|
+
const c = joined.cohort;
|
|
752
|
+
if (joined.formed || c.revision !== before) {
|
|
753
|
+
this.d.recordCohort?.({ id: c.id, event: joined.formed ? "formed" : "joined", silent: c.silent, tasks: [...c.members.keys()].sort((a, b) => a - b), owners: [...new Set([...c.members.values()].map((m) => m.owner))].sort() });
|
|
754
|
+
// A shadow split prediction for the pair that just overlapped, named owner or not (issue #109): for the record only.
|
|
755
|
+
const shadow = this.splitShadow(task, task.owner);
|
|
756
|
+
if (shadow) this.d.recordSplit?.(task.id, shadow);
|
|
757
|
+
}
|
|
758
|
+
if (joined.lifted) this.announceLift(c, `${task.owner} cannot be shown the others' changes`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Members of a cohort that is no longer silent may message each other again. */
|
|
762
|
+
announceLift(cohort: Cohort, why: string): void {
|
|
763
|
+
const owners = [...new Set([...cohort.members.values()].map((m) => m.owner))];
|
|
764
|
+
this.d.notify(`turn-free cohort #${cohort.id} (${owners.join(", ")}) is no longer silent: ${why}`);
|
|
765
|
+
this.d.recordCohort?.({ id: cohort.id, event: "lifted", silent: false, tasks: [...cohort.members.keys()].sort((a, b) => a - b), owners: [...owners].sort() });
|
|
766
|
+
// Members still at work hear it, with the completed-change notices the silence held (they replace the integration
|
|
767
|
+
// step that will not run); a member whose task closed is not started on a turn for it (#106).
|
|
768
|
+
for (const m of cohort.members.values()) {
|
|
769
|
+
const t = this.d.board.get(m.task);
|
|
770
|
+
if (m.owner === USER || m.owner === HUB || !t || !OPEN.includes(t.state)) continue;
|
|
771
|
+
const evidence = this.heldEvidence(cohort, t);
|
|
772
|
+
this.whileOpen(m.owner, m.task, [`Task #${m.task}: turn-free silence is lifted for the overlapping work of ${owners.filter((o) => o !== m.owner).join(", ")} (${why}). Settle overlaps with them via hub_send, as usual.`, evidence ?? ""].filter(Boolean).join("\n"));
|
|
773
|
+
}
|
|
774
|
+
cohort.held.clear();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Text a done result carries besides the board line (issue #107): held notices when no integration ran. */
|
|
778
|
+
private readonly doneNotes = new Map<number, string>();
|
|
779
|
+
takeDoneNote = (id: number): string | undefined => {
|
|
780
|
+
const note = this.doneNotes.get(id);
|
|
781
|
+
this.doneNotes.delete(id);
|
|
782
|
+
return note;
|
|
783
|
+
};
|
|
784
|
+
|
|
560
785
|
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
561
786
|
const task = this.need(id, true);
|
|
562
787
|
this.mine(task, by, "owner");
|
|
@@ -584,7 +809,35 @@ export class Tasks {
|
|
|
584
809
|
// The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
|
|
585
810
|
throw new Error(this.checking.get(task.id) === task.owner ? `task #${task.id}: its check is still running; its result comes as a task message` : `task #${task.id}: a check from before it changed hands is still running; call hub_task_done again in a few minutes`);
|
|
586
811
|
}
|
|
587
|
-
|
|
812
|
+
// A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
|
|
813
|
+
const cohort = this.cohorts.of(task.id);
|
|
814
|
+
const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
|
|
815
|
+
if (silentMember && by === USER) {
|
|
816
|
+
// The console finishing a member: its done still counts as an intent, so the last member integrates; what the
|
|
817
|
+
// silence held goes to the console.
|
|
818
|
+
this.cohorts.intent(cohort!, task, ownerGen(task));
|
|
819
|
+
const evidence = this.heldEvidence(cohort!, task);
|
|
820
|
+
if (evidence) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
|
|
821
|
+
} else if (silentMember) {
|
|
822
|
+
const gen = ownerGen(task);
|
|
823
|
+
// A retry right after the request: the same request again, nothing acknowledged or counted.
|
|
824
|
+
if (this.cohorts.isRetry(cohort!, task, gen)) return this.d.board.get(task.id)!;
|
|
825
|
+
const ig = cohort!.integration;
|
|
826
|
+
if (ig?.task === task.id && ig.offer) this.d.ackFacts?.(task.owner!, ig.offer); // this call is the proof the request arrived
|
|
827
|
+
const r = this.cohorts.completion(cohort!, task, { gen, tree: this.tree(cohort!), factsCurrent: this.d.factsCurrent?.(task.owner!) ?? true, handed: handedAt(task) });
|
|
828
|
+
if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
|
|
829
|
+
if (r.action === "unresolved") {
|
|
830
|
+
this.d.notify(`task ${this.publicTitle(task)}: turn-free integration unresolved after ${MAX_REQUESTS} requests (${r.why}); its done is recorded, check the overlapping work by hand`);
|
|
831
|
+
task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
|
|
832
|
+
} else if (r.integrated) task = this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`);
|
|
833
|
+
} else if (cohort?.held.size && !this.isPii(task)) {
|
|
834
|
+
// No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
|
|
835
|
+
// for it (issue #107, AC4).
|
|
836
|
+
const evidence = this.heldEvidence(cohort, task);
|
|
837
|
+
if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
|
|
838
|
+
else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
|
|
839
|
+
}
|
|
840
|
+
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }, WITH_DONE); // done without a separate accept
|
|
588
841
|
const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
|
|
589
842
|
if (!command) return this.complete(task, by, summary, refs);
|
|
590
843
|
// The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
|
|
@@ -600,6 +853,81 @@ export class Tasks {
|
|
|
600
853
|
return pending;
|
|
601
854
|
}
|
|
602
855
|
|
|
856
|
+
/**
|
|
857
|
+
* After a restart (issue #107) the cohorts, intents and turn ends are gone, so an integration that was asked for and
|
|
858
|
+
* not confirmed can never be: it is recorded as unresolved, and the owner's next done is recorded as usual.
|
|
859
|
+
*/
|
|
860
|
+
recoverIntegrations(): void {
|
|
861
|
+
const marks = new Set(["integration requested", "integrated", "integration unresolved", "done", "done (checking)", "reopened", "accepted", "assigned", "reassigned", "escalated"]);
|
|
862
|
+
for (const t of this.d.board.list()) {
|
|
863
|
+
if (!OPEN.includes(t.state) || [...t.history].reverse().find((h) => marks.has(h.event))?.event !== "integration requested") continue;
|
|
864
|
+
this.d.board.update(t.id, HUB, "integration unresolved", {}, "the hub restarted before the integration was confirmed");
|
|
865
|
+
this.d.notify(`task ${this.publicTitle(t)}: turn-free integration unresolved (the hub restarted); check the overlapping work by hand`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* After a restart in a turn-free project (issue #107) the cohorts are gone, and with them what their silence held. When
|
|
871
|
+
* `peer` first attaches, each of its open tasks that overlaps another owner's work hears that overlaps are settled by
|
|
872
|
+
* message again, with the completed-change notices of the overlapping tasks finished since it was handed over. A
|
|
873
|
+
* duplicate notice is the price of never losing one.
|
|
874
|
+
*/
|
|
875
|
+
replayHeld(peer: PeerId): void {
|
|
876
|
+
const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
|
|
877
|
+
for (const t of this.d.board.list()) {
|
|
878
|
+
if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
|
|
879
|
+
const open = this.overlapHits(t).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
880
|
+
const mine = this.places(t);
|
|
881
|
+
const done = finished.flatMap((u) => {
|
|
882
|
+
const at = [...u.history].reverse().find((h) => h.event === "done");
|
|
883
|
+
if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
|
|
884
|
+
const theirs = this.places(u);
|
|
885
|
+
const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
|
|
886
|
+
const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
|
|
887
|
+
return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note)] : [];
|
|
888
|
+
});
|
|
889
|
+
if (!open.length && !done.length) continue;
|
|
890
|
+
const owners = [...new Set(open.map((h) => h.task.owner!))];
|
|
891
|
+
const head = owners.length ? `Task #${t.id}: the hub restarted, so overlaps with ${owners.join(", ")} are settled via hub_send again (any turn-free silence is lifted).` : `Task #${t.id}: the hub restarted; overlapping work finished meanwhile.`;
|
|
892
|
+
this.whileOpen(peer, t.id, [head, ...done].join("\n"));
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** One hash over the files a cohort's tasks name and its members wrote while at work, as they are now: the integration target (issue #107). */
|
|
897
|
+
private tree(cohort: Cohort): string {
|
|
898
|
+
const paths = [...new Set([...cohort.members.keys()].flatMap((id) => { const t = this.d.board.get(id); return t ? this.places(t).paths : []; }))];
|
|
899
|
+
// Each member's writes count from when its owner was handed the task until it settled: settling keeps its files in
|
|
900
|
+
// the target, and its later writes are its next task's.
|
|
901
|
+
const windows = [...cohort.members.values()].map((m) => ({ peer: m.owner, since: m.since, ...(m.settledAt !== undefined ? { until: m.settledAt } : {}) }));
|
|
902
|
+
return this.d.treeHash?.(paths, windows) ?? "";
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* What the integrating member reads as its done result (issue #107): what the others finished, the facts it has not
|
|
907
|
+
* been shown, and what makes the next done count. Its facts offer is acknowledged by that next done.
|
|
908
|
+
*/
|
|
909
|
+
private integrationRequest(task: Task, r: Extract<Completion, { action: "request" }>): string {
|
|
910
|
+
const others = [...r.cohort.members.values()].filter((m) => m.task !== task.id).flatMap((m) => { const t = this.d.board.get(m.task); return t ? [t] : []; });
|
|
911
|
+
const doneAt = (t: Task) => [...t.history].reverse().find((h) => h.event === "done" || h.event === "done (checking)");
|
|
912
|
+
const lines = others.map((t) => {
|
|
913
|
+
const files = this.places(t).paths.filter(this.nameable);
|
|
914
|
+
const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
|
|
915
|
+
const first = (doneAt(t)?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
916
|
+
return [
|
|
917
|
+
`- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
|
|
918
|
+
files.length ? ` changed files: ${files.join(", ")}` : "",
|
|
919
|
+
signatures.length ? ` signatures: ${signatures.join("; ")}` : "",
|
|
920
|
+
first && this.nameable(first) ? ` summary: ${first}` : "",
|
|
921
|
+
].filter(Boolean).join("\n");
|
|
922
|
+
});
|
|
923
|
+
const facts = task.owner ? this.d.integrationFacts?.(task.owner) : undefined;
|
|
924
|
+
if (facts && r.cohort.integration) r.cohort.integration.offer = facts.id;
|
|
925
|
+
const head = r.requests === 1
|
|
926
|
+
? `Before task #${task.id} is recorded as done: you are the last of turn-free cohort #${r.cohort.id} to finish. Check your work against the others' below, fix what conflicts, then call hub_task_done again.`
|
|
927
|
+
: `Task #${task.id} is not recorded as done yet (integration request ${r.requests} of ${MAX_REQUESTS}): ${r.why}. Check again, then call hub_task_done again.`;
|
|
928
|
+
return [head, "The done counts once the files did not change between two calls and the others have stopped.", ...lines, facts?.text ?? ""].filter(Boolean).join("\n");
|
|
929
|
+
}
|
|
930
|
+
|
|
603
931
|
private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
|
|
604
932
|
const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
|
|
605
933
|
this.checking.delete(id);
|
|
@@ -616,12 +944,20 @@ export class Tasks {
|
|
|
616
944
|
return;
|
|
617
945
|
}
|
|
618
946
|
if (result.code === 0 && !result.timedOut) {
|
|
947
|
+
// An integrating member's check counts only for the target it confirmed (issue #107).
|
|
948
|
+
const cohort = this.cohorts.of(id);
|
|
949
|
+
if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
|
|
950
|
+
this.d.board.update(id, HUB, "check finished late", {}, `${outcome}; the integration target changed while it ran`);
|
|
951
|
+
this.tell(task, `Task #${id}: its check passed, but the overlapping work or the files changed while it ran (turn-free integration). Call hub_task_done again.`, pii);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
619
954
|
this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
|
|
620
955
|
// The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
|
|
621
956
|
await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
|
|
622
957
|
return;
|
|
623
958
|
}
|
|
624
959
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
960
|
+
this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
|
|
625
961
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
626
962
|
this.contradict(task);
|
|
627
963
|
this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
|
|
@@ -631,6 +967,7 @@ export class Tasks {
|
|
|
631
967
|
private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
|
|
632
968
|
const reviewer = task.reviewer;
|
|
633
969
|
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
|
|
970
|
+
this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
|
|
634
971
|
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
|
|
635
972
|
this.tellCompleted(next, summary);
|
|
636
973
|
if (!reviewer) {
|
|
@@ -650,22 +987,45 @@ export class Tasks {
|
|
|
650
987
|
*/
|
|
651
988
|
private tellCompleted(task: Task, summary?: string): void {
|
|
652
989
|
const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
990
|
+
const cohort = this.cohorts.of(task.id);
|
|
991
|
+
const silent = this.silentFor(task.id);
|
|
992
|
+
for (const hit of hits) {
|
|
993
|
+
// A member of the same silent cohort (issue #107): the member that finishes last integrates instead
|
|
994
|
+
// (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
|
|
995
|
+
if (silent && cohort?.members.has(hit.task.id)) {
|
|
996
|
+
cohort.held.add(task.id);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
|
|
1005
|
+
* included), and any item that matches a PII pattern is left out of what other owners get.
|
|
1006
|
+
*/
|
|
1007
|
+
private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
|
|
656
1008
|
const paths = this.places(task).paths.filter(this.nameable);
|
|
657
1009
|
const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
|
|
658
1010
|
const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
|
|
659
1011
|
const line = first && this.nameable(first) ? first : undefined;
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1012
|
+
return [
|
|
1013
|
+
`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.`,
|
|
1014
|
+
paths.length ? `Changed files: ${paths.join(", ")}` : "",
|
|
1015
|
+
signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
|
|
1016
|
+
line ? `Summary: ${line}` : "",
|
|
1017
|
+
].filter(Boolean).join("\n");
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The held notices of a cohort, as one list for a member that gets no integration step (issue #107). */
|
|
1021
|
+
private heldEvidence(cohort: Cohort, task: Task): string | undefined {
|
|
1022
|
+
const lines = [...cohort.held].filter((id) => id !== task.id).flatMap((id) => {
|
|
1023
|
+
const t = this.d.board.get(id);
|
|
1024
|
+
if (!t || this.isPii(t)) return [];
|
|
1025
|
+
const done = [...t.history].reverse().find((h) => h.event === "done");
|
|
1026
|
+
return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note)];
|
|
1027
|
+
});
|
|
1028
|
+
return lines.length ? lines.join("\n") : undefined;
|
|
669
1029
|
}
|
|
670
1030
|
|
|
671
1031
|
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
@@ -711,6 +1071,7 @@ export class Tasks {
|
|
|
711
1071
|
return next;
|
|
712
1072
|
}
|
|
713
1073
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
1074
|
+
this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
|
|
714
1075
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
715
1076
|
this.contradict(rejected);
|
|
716
1077
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
|
|
@@ -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`, 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.
|
|
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. In a project with `coordination: "turn-free"` in `.agenthub/config.json`, a task's texts say when its owners form a silent cohort: then they do not message each other, the hub shows each of them the other's changes as they work, and the last to finish is asked to check its work against the others and call `hub_task_done` again. `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.
|