@staix/agent-hub 0.12.3 → 0.12.5

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/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,31 @@ 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
+ /**
60
+ * A peer's split profile now (issue #109): the hub's version, its agent's and the hook profile. Each hand-over is
61
+ * tagged with it, and only records with a peer's current profile are its observations. Undefined while unknown.
62
+ */
63
+ splitProfile?: (peer: PeerId) => string | undefined;
64
+ /**
65
+ * A shadow split prediction, for the record (issue #109): `routing` when routing chose the owner of a task that
66
+ * overlaps another owner's task not started yet (what calibration reads), `cohort` when an overlap formed a cohort.
67
+ */
68
+ recordSplit?: (task: number, prediction: SplitPrediction, where: "routing" | "cohort") => void;
69
+ /** A cohort formed, changed or was lifted (issue #107), for the record: the benchmark's treatment check reads it. */
70
+ recordCohort?: (cohort: { id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: PeerId[] }) => void;
43
71
  }
44
72
 
45
73
  const ESCALATE_AFTER = 2;
@@ -65,6 +93,12 @@ export const samePlace = (a: string, b: string) => {
65
93
 
66
94
  /** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
67
95
  const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
96
+ /** The note on an accept recorded by the done call itself: its stages are unknown to the split prediction (issue #109). */
97
+ const WITH_DONE = "with its done";
98
+ /** How many times a task changed hands: a cohort member's generation (issue #107). */
99
+ const ownerGen = (t: Task) => t.history.filter((h) => OWNERSHIP_EVENTS.has(h.event)).length;
100
+ /** When the task was handed to its current owner (its creation, if it never changed hands). */
101
+ 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
102
 
69
103
  /** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
70
104
  const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
@@ -96,7 +130,14 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
96
130
  * PII handling, envelopes, briefs and memory notes are decided in one place.
97
131
  */
98
132
  export class Tasks {
133
+ /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
134
+ readonly cohorts: Cohorts;
135
+
99
136
  constructor(private readonly d: TasksDeps) {
137
+ this.cohorts = new Cohorts({
138
+ silence: (owners) => this.turnFree() && owners.every((p) => p !== USER && p !== HUB && (this.d.capable?.(p) ?? false)),
139
+ idle: (peer) => this.d.idle?.(peer) ?? false,
140
+ });
100
141
  // 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
142
  // board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
102
143
  d.bus.tap((e) => {
@@ -217,6 +258,68 @@ export class Tasks {
217
258
  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
259
  }
219
260
 
261
+ /**
262
+ * A peer's tasks of a class, across hub runs, as split observations (issue #109): each hand-over to it by someone
263
+ * else (claims left out) while it had the profile it has now, typed by outcome. Stages are the board's own proxies and
264
+ * stay unknown when the accept came with the done.
265
+ * ponytail: the profile is taken at the hand-over; a hub restarted with another version before the done mixes two in
266
+ * one record. Tag the done as well if that ever shows up in the data.
267
+ */
268
+ splitObservations(cls: TaskClass, peer: PeerId, exclude?: number): SplitObservation[] {
269
+ const profile = this.d.splitProfile?.(peer);
270
+ if (!profile) return [];
271
+ const failed = ["check failed", "changes_requested", "escalated", "released", "declined", "integration unresolved"];
272
+ return this.d.board.list().flatMap((t): SplitObservation[] => {
273
+ if (t.class !== cls || t.id === exclude) return [];
274
+ // One observation per time the task was handed to `peer` by someone else: what happened from that hand-over up to
275
+ // and with the next one is that peer's, so an escalation away or a decline counts against the peer that failed,
276
+ // never against the next owner, and work that ended elsewhere is still counted (no survivors only).
277
+ return t.history.flatMap((given, i): SplitObservation[] => {
278
+ if (!OWNERSHIP_EVENTS.has(given.event) || given.event === "unassigned" || given.owner !== peer || given.profile !== profile || given.by === peer) return [];
279
+ const next = t.history.findIndex((h, j) => j > i && OWNERSHIP_EVENTS.has(h.event));
280
+ const span = t.history.slice(i + 1, next < 0 ? undefined : next + 1);
281
+ if (span.some((h) => failed.includes(h.event))) return [{ outcome: "failed" }];
282
+ if (next >= 0 || t.state !== "approved") return []; // handed on without a failure (a relay), or still open
283
+ const accepted = span.find((h) => h.event === "accepted" && h.by === peer);
284
+ // The work stage ends at its first done call: checks and an integration step are not the work itself.
285
+ const intent = accepted && span.find((h) => h.at >= accepted.at && ["done", "done (checking)", "integration requested"].includes(h.event));
286
+ if (!accepted || !intent || accepted.note === WITH_DONE) return [{ outcome: "approved" }];
287
+ return [{ outcome: "approved", orient: accepted.at - given.at, work: intent.at - accepted.at }];
288
+ });
289
+ });
290
+ }
291
+
292
+ /**
293
+ * The shadow split prediction for routing `task` to `candidate` (issue #109): the pair it would form with the owner of
294
+ * an open task it overlaps (`unstarted`: one not started yet). Assignment never reads it; `route explain` shows it and
295
+ * assignment records it.
296
+ */
297
+ splitShadow(task: Task, candidate: PeerId | undefined, unstarted = false): SplitPrediction | undefined {
298
+ if (!candidate) return undefined;
299
+ const others = this.overlapHits({ ...task, owner: null }).map((h) => h.task).filter((t) => t.owner && t.owner !== candidate && t.owner !== USER && t.owner !== HUB);
300
+ // A task not started yet first: the pair the routing record is about, so explain and the record agree on it.
301
+ const other = others.find((t) => t.state === "proposed") ?? (unstarted ? undefined : others[0]);
302
+ if (!other) return undefined;
303
+ const peers: [PeerId, PeerId] = [candidate, other.owner!];
304
+ const states = this.states();
305
+ const failing = this.d.failing?.() ?? {};
306
+ // Other open work, and the overlapping task itself once its owner has started it: either way that owner would not
307
+ // start from orientation plus two whole units.
308
+ const open = this.d.board.list().filter((t) => OPEN.includes(t.state) && t.id !== task.id && (t.id !== other.id || t.state !== "proposed"));
309
+ // One task of a class is one unit: the only normalization the board supports, so another class is unknown.
310
+ const unit = task.class === other.class ? 1 : undefined;
311
+ return predictSplit({
312
+ peers,
313
+ observations: Object.fromEntries(peers.map((p) => [p, this.splitObservations(task.class, p, task.id)])),
314
+ units: [unit, unit],
315
+ profiles: Object.fromEntries(peers.map((p) => [p, this.d.splitProfile?.(p)])),
316
+ backlog: Object.fromEntries(peers.map((p) => [p, open.filter((t) => t.owner === p).length])),
317
+ // The routed peer may be busy taking this very task, and the other owner taking the overlapped task while it is not
318
+ // started yet (an owner goes busy as its task is delivered); busy otherwise, it is at work on something already.
319
+ available: Object.fromEntries(peers.map((p) => [p, !failing[p] && (states[p] === "idle" || (states[p] === "busy" && (p === candidate || other.state === "proposed")))])),
320
+ });
321
+ }
322
+
220
323
  /**
221
324
  * Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
222
325
  * within the window were contradicted. Each approval counts once.
@@ -302,13 +405,84 @@ export class Tasks {
302
405
  overlaps(task: Task, forOwner = true, found = this.overlapHits(task)): string {
303
406
  const hits = found.map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${this.where(h)}`);
304
407
  if (!hits.length) return "";
408
+ if (this.silentFor(task.id)) {
409
+ // A silent cohort (issue #107): the plans are what the owner works from, and nobody negotiates by message.
410
+ const plans = found.map((h) => (planText(h.task.plan) ? `#${h.task.id}'s plan: ${planText(h.task.plan)}` : "")).filter(Boolean);
411
+ const who = forOwner
412
+ ? `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(" | ")}` : ""}`
413
+ : `${task.owner ?? "Whoever takes it"} works alongside without messages (turn-free).`;
414
+ return `Overlaps ${hits.join("; ")}. ${who}`;
415
+ }
305
416
  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
417
  return `Overlaps ${hits.join("; ")}. ${who}`;
307
418
  }
308
419
 
420
+ turnFree = (): boolean => this.d.turnFree?.() ?? false;
421
+
422
+ /** Whether `task` is in a silent cohort: its owner works without messages to the other members (issue #107). */
423
+ silentFor = (task: number): boolean => this.turnFree() && !!this.cohorts.of(task)?.silent;
424
+
425
+ /** The live silent cohort that makes a message from `from` to `to` cohort coordination (issue #107). */
426
+ silenced = (from: PeerId, to: PeerId) => (this.turnFree() ? this.cohorts.silenced(from, to) : undefined);
427
+
428
+ /**
429
+ * What `peer`'s turn-free facts cover (issue #108): for each live cohort in which it still has an open task, the paths
430
+ * every member's task names, the other members' plans, and the task its own writes are reported under. Membership,
431
+ * not open overlap, so the last member to finish still sees the others' final edits. Undefined without such a task.
432
+ */
433
+ factScope = (peer: PeerId): { paths: string[]; plans: { task: number; owner: PeerId; text: string }[]; task?: { id: number; title: string }; since?: number } | undefined => {
434
+ const open = (task: number) => OPEN.includes(this.d.board.get(task)?.state ?? "approved");
435
+ const members = this.cohorts.list().filter((c) => [...c.members.values()].some((m) => m.owner === peer && open(m.task))).flatMap((c) => [...c.members.values()]);
436
+ const tasks = members.flatMap((m) => {
437
+ const t = this.d.board.get(m.task);
438
+ return t && t.owner === m.owner && !this.isPii(t) ? [t] : [];
439
+ });
440
+ const mine = tasks.filter((t) => t.owner === peer && OPEN.includes(t.state));
441
+ if (!mine.length) return undefined;
442
+ const paths = [...new Set(tasks.flatMap((t) => this.places(t).paths))].filter((p) => p !== "." && this.nameable(p));
443
+ const plans = tasks.filter((t) => t.owner !== peer).map((t) => ({ task: t.id, owner: t.owner!, text: planText(t.plan) })).filter((p) => p.text);
444
+ // When the earliest of its tasks was handed over: work from before facts were tracked is not covered.
445
+ const since = Math.min(...mine.map(handedAt));
446
+ return { paths, plans, task: { id: mine[0]!.id, title: mine[0]!.title }, since };
447
+ };
448
+
449
+ /** The tasks an overlap text for `task` names: their plans are in it (issue #108). */
450
+ overlapTasks = (task: Task): number[] => this.overlapHits(task).map((h) => h.task.id);
451
+
309
452
  /** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
310
453
  private releasing: PeerId | undefined;
311
454
 
455
+ /**
456
+ * Notices that only matter while their recipient's task is open (issue #106): envelope id -> recipient and task, the
457
+ * newest 1024. An envelope without a record (another kind, a restart, an evicted record) is delivered as before: its
458
+ * purpose is never guessed from its kind.
459
+ * ponytail: kept in memory, so after a restart such a notice is delivered whatever its task's state; persist the
460
+ * condition if stale notices after restarts show up.
461
+ */
462
+ private readonly conditional = new Map<string, { peer: PeerId; task: number; states: Task["state"][] }>();
463
+
464
+ /**
465
+ * Publish a notice about `task`, which only matters to `to` while that task is in one of `states` for it (issue
466
+ * #106): open work by default; the conflict notices of #91 count a task in review too.
467
+ */
468
+ whileOpen(to: PeerId, task: number, body: string, states: Task["state"][] = OPEN): void {
469
+ const env = newEnvelope(HUB, body, { to: [to], kind: "task", refs: { task: String(task) } });
470
+ this.conditional.set(env.id, { peer: to, task, states });
471
+ if (this.conditional.size > 1024) this.conditional.delete(this.conditional.keys().next().value as string);
472
+ this.d.bus.publish(env);
473
+ }
474
+
475
+ /**
476
+ * Whether a queued envelope still matters to `peer`; the bus asks per recipient at delivery (issue #106). False only
477
+ * for a recorded notice to this peer whose task is gone, has another owner, or left the states it was about.
478
+ */
479
+ relevant = (peer: PeerId, env: Envelope): boolean => {
480
+ const c = this.conditional.get(env.id);
481
+ if (!c || c.peer !== peer) return true;
482
+ const t = this.d.board.get(c.task);
483
+ return !!t && t.owner === peer && c.states.includes(t.state);
484
+ };
485
+
312
486
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
313
487
  nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
314
488
 
@@ -330,9 +504,28 @@ export class Tasks {
330
504
  return shown.length ? shown.join(", ") : "a path whose name is withheld (it matches a PII pattern)";
331
505
  }
332
506
 
333
- /** Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). */
507
+ /**
508
+ * Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). Models write paths
509
+ * absolute or relative: one inside the project is compared in its project-relative spelling.
510
+ */
334
511
  private places(task: Task): { paths: string[]; symbols: string[] } {
335
- return { paths: [...new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])])], symbols: task.plan?.symbols ?? [] };
512
+ const paths = [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])].map((p) => this.projectPath(p));
513
+ return { paths: [...new Set(paths)], symbols: task.plan?.symbols ?? [] };
514
+ }
515
+
516
+ private roots?: string[];
517
+ private projectPath(p: string): string {
518
+ if (!isAbsolute(p)) return normPath(p);
519
+ if (!this.roots) {
520
+ let real = this.d.cwd;
521
+ try { real = realPath(this.d.cwd); } catch { /* compared as given */ }
522
+ this.roots = [...new Set([this.d.cwd, real])];
523
+ }
524
+ for (const root of this.roots) {
525
+ const r = relative(root, p);
526
+ if (!r.startsWith("..") && !isAbsolute(r)) return normPath(r || ".");
527
+ }
528
+ return p;
336
529
  }
337
530
 
338
531
  private overlapHits(task: Task): { task: Task; paths: string[]; symbols: string[] }[] {
@@ -362,7 +555,8 @@ export class Tasks {
362
555
  const plan = planText(task.plan);
363
556
  for (const hit of hits) {
364
557
  if (hit.task.owner === USER || hit.task.owner === HUB) continue;
365
- 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}` : ""}`));
558
+ 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`;
559
+ 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
560
  }
367
561
  }
368
562
 
@@ -403,7 +597,9 @@ export class Tasks {
403
597
  if (typeof target === "number") {
404
598
  const task = this.d.board.get(target);
405
599
  if (!task) throw new Error(`no task #${target}`);
406
- return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) }).trace];
600
+ const a = assign(task, this.states(), routing, { exclude: this.declined(task), waitsFor: this.waitsFor(task), ...this.weights(task.class) });
601
+ // The split trace is for the pair the record is about: the task's owner when it has one (issue #109).
602
+ 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
603
  }
408
604
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
409
605
  return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
@@ -429,6 +625,7 @@ export class Tasks {
429
625
  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
626
  const waits = this.waitsFor(task);
431
627
  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) });
628
+
432
629
  if (waits.length) {
433
630
  this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
434
631
  return task;
@@ -436,10 +633,25 @@ export class Tasks {
436
633
  if (!a.owner) {
437
634
  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
635
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
439
- return opts.clearOnFail && task.owner ? this.d.board.update(task.id, by, "unassigned", { owner: null }) : task;
636
+ if (opts.clearOnFail && task.owner) {
637
+ this.cohorts.leave(task.id);
638
+ return this.d.board.update(task.id, by, "unassigned", { owner: null });
639
+ }
640
+ return task;
641
+ }
642
+ const profile = this.d.splitProfile?.(a.owner);
643
+ const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`, profile ? { profile } : {});
644
+ // What calibration reads (issue #109): routing chose the first owner (no single named candidate, no claim; not an
645
+ // escalation, relay or reassignment of work already begun), and the work overlaps another owner's task not started
646
+ // yet. For the record only. Work routed back to its proposer is left out, as its observations are (by === owner).
647
+ if ((opts.event ?? "assigned") === "assigned" && !opts.claim && opts.candidates?.length !== 1 && a.owner !== by) {
648
+ try {
649
+ const shadow = this.splitShadow(next, a.owner, true);
650
+ if (shadow) this.d.recordSplit?.(next.id, shadow, "routing");
651
+ } catch { /* shadow only: never between the board write and the delivery */ }
440
652
  }
441
- 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
653
  const hits = this.overlapHits(next);
654
+ this.formCohort(next, hits);
443
655
  if (hits.length) {
444
656
  this.announceOverlap(next, hits);
445
657
  this.tellEarlierOwners(next, hits);
@@ -550,6 +762,7 @@ export class Tasks {
550
762
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
551
763
  if (cleaned) {
552
764
  const hits = this.overlapHits(next);
765
+ this.formCohort(next, hits);
553
766
  const fresh = hits.filter((h) => !before.has(h.task.id));
554
767
  if (fresh.length) this.announceOverlap(next, fresh);
555
768
  this.tellEarlierOwners(next, hits);
@@ -557,6 +770,49 @@ export class Tasks {
557
770
  return next;
558
771
  }
559
772
 
773
+ /**
774
+ * Put `task` and the open tasks it overlaps into one cohort (issue #107). A silent cohort that an owner without a
775
+ * verified context path joins speaks again, and its members hear so.
776
+ */
777
+ private formCohort(task: Task, hits: ReturnType<Tasks["overlapHits"]>): void {
778
+ if (!task.owner || this.isPii(task)) return;
779
+ const before = this.cohorts.of(task.id)?.revision;
780
+ const joined = this.cohorts.join(task, hits.map((h) => h.task), ownerGen, handedAt);
781
+ if (!joined) return;
782
+ const c = joined.cohort;
783
+ if (joined.formed || c.revision !== before) {
784
+ 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() });
785
+ // A shadow split prediction for the pair that just overlapped, named owner or not (issue #109): for the record only.
786
+ const shadow = this.splitShadow(task, task.owner);
787
+ if (shadow) this.d.recordSplit?.(task.id, shadow, "cohort");
788
+ }
789
+ if (joined.lifted) this.announceLift(c, `${task.owner} cannot be shown the others' changes`);
790
+ }
791
+
792
+ /** Members of a cohort that is no longer silent may message each other again. */
793
+ announceLift(cohort: Cohort, why: string): void {
794
+ const owners = [...new Set([...cohort.members.values()].map((m) => m.owner))];
795
+ this.d.notify(`turn-free cohort #${cohort.id} (${owners.join(", ")}) is no longer silent: ${why}`);
796
+ this.d.recordCohort?.({ id: cohort.id, event: "lifted", silent: false, tasks: [...cohort.members.keys()].sort((a, b) => a - b), owners: [...owners].sort() });
797
+ // Members still at work hear it, with the completed-change notices the silence held (they replace the integration
798
+ // step that will not run); a member whose task closed is not started on a turn for it (#106).
799
+ for (const m of cohort.members.values()) {
800
+ const t = this.d.board.get(m.task);
801
+ if (m.owner === USER || m.owner === HUB || !t || !OPEN.includes(t.state)) continue;
802
+ const evidence = this.heldEvidence(cohort, t);
803
+ 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"));
804
+ }
805
+ cohort.held.clear();
806
+ }
807
+
808
+ /** Text a done result carries besides the board line (issue #107): held notices when no integration ran. */
809
+ private readonly doneNotes = new Map<number, string>();
810
+ takeDoneNote = (id: number): string | undefined => {
811
+ const note = this.doneNotes.get(id);
812
+ this.doneNotes.delete(id);
813
+ return note;
814
+ };
815
+
560
816
  async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
561
817
  const task = this.need(id, true);
562
818
  this.mine(task, by, "owner");
@@ -584,7 +840,35 @@ export class Tasks {
584
840
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
585
841
  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
842
  }
587
- if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }); // done without a separate accept
843
+ // A member of a silent cohort (issue #107): its done is an intent, and the last of them integrates first.
844
+ const cohort = this.cohorts.of(task.id);
845
+ const silentMember = !!cohort?.silent && this.turnFree() && !!task.owner && !this.isPii(task);
846
+ if (silentMember && by === USER) {
847
+ // The console finishing a member: its done still counts as an intent, so the last member integrates; what the
848
+ // silence held goes to the console.
849
+ this.cohorts.intent(cohort!, task, ownerGen(task));
850
+ const evidence = this.heldEvidence(cohort!, task);
851
+ if (evidence) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
852
+ } else if (silentMember) {
853
+ const gen = ownerGen(task);
854
+ // A retry right after the request: the same request again, nothing acknowledged or counted.
855
+ if (this.cohorts.isRetry(cohort!, task, gen)) return this.d.board.get(task.id)!;
856
+ const ig = cohort!.integration;
857
+ if (ig?.task === task.id && ig.offer) this.d.ackFacts?.(task.owner!, ig.offer); // this call is the proof the request arrived
858
+ const r = this.cohorts.completion(cohort!, task, { gen, tree: this.tree(cohort!), factsCurrent: this.d.factsCurrent?.(task.owner!) ?? true, handed: handedAt(task) });
859
+ if (r.action === "request") return this.d.board.update(task.id, HUB, "integration requested", {}, this.integrationRequest(task, r));
860
+ if (r.action === "unresolved") {
861
+ 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`);
862
+ task = this.d.board.update(task.id, HUB, "integration unresolved", {}, r.why);
863
+ } else if (r.integrated) task = this.d.board.update(task.id, HUB, "integrated", {}, `cohort #${cohort!.id}, revision ${cohort!.revision}`);
864
+ } else if (cohort?.held.size && !this.isPii(task)) {
865
+ // No integration step for this member (silence lifted, a PII task open): the notices the silence held stand in
866
+ // for it (issue #107, AC4).
867
+ const evidence = this.heldEvidence(cohort, task);
868
+ if (evidence && by === USER) this.d.notify(`task ${this.publicTitle(task)} done by the console; overlapping work finished meanwhile:\n${evidence}`);
869
+ else if (evidence) this.doneNotes.set(task.id, `Overlapping work finished while you worked (no turn-free integration step ran):\n${evidence}`);
870
+ }
871
+ 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
872
  const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
589
873
  if (!command) return this.complete(task, by, summary, refs);
590
874
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
@@ -600,6 +884,81 @@ export class Tasks {
600
884
  return pending;
601
885
  }
602
886
 
887
+ /**
888
+ * After a restart (issue #107) the cohorts, intents and turn ends are gone, so an integration that was asked for and
889
+ * not confirmed can never be: it is recorded as unresolved, and the owner's next done is recorded as usual.
890
+ */
891
+ recoverIntegrations(): void {
892
+ const marks = new Set(["integration requested", "integrated", "integration unresolved", "done", "done (checking)", "reopened", "accepted", "assigned", "reassigned", "escalated"]);
893
+ for (const t of this.d.board.list()) {
894
+ if (!OPEN.includes(t.state) || [...t.history].reverse().find((h) => marks.has(h.event))?.event !== "integration requested") continue;
895
+ this.d.board.update(t.id, HUB, "integration unresolved", {}, "the hub restarted before the integration was confirmed");
896
+ this.d.notify(`task ${this.publicTitle(t)}: turn-free integration unresolved (the hub restarted); check the overlapping work by hand`);
897
+ }
898
+ }
899
+
900
+ /**
901
+ * After a restart in a turn-free project (issue #107) the cohorts are gone, and with them what their silence held. When
902
+ * `peer` first attaches, each of its open tasks that overlaps another owner's work hears that overlaps are settled by
903
+ * message again, with the completed-change notices of the overlapping tasks finished since it was handed over. A
904
+ * duplicate notice is the price of never losing one.
905
+ */
906
+ replayHeld(peer: PeerId): void {
907
+ const finished = this.d.board.list().filter((u) => (u.state === "approved" || u.state === "in_review") && u.owner && !this.isPii(u));
908
+ for (const t of this.d.board.list()) {
909
+ if (!OPEN.includes(t.state) || t.owner !== peer || this.isPii(t)) continue;
910
+ const open = this.overlapHits(t).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
911
+ const mine = this.places(t);
912
+ const done = finished.flatMap((u) => {
913
+ const at = [...u.history].reverse().find((h) => h.event === "done");
914
+ if (u.owner === t.owner || !at || at.at < handedAt(t)) return [];
915
+ const theirs = this.places(u);
916
+ const paths = mine.paths.filter((p) => theirs.paths.some((q) => samePlace(p, q)));
917
+ const symbols = mine.symbols.filter((x) => theirs.symbols.includes(x));
918
+ return paths.length || symbols.length ? [this.completedNotice(u, { task: t, paths, symbols }, at.note)] : [];
919
+ });
920
+ if (!open.length && !done.length) continue;
921
+ const owners = [...new Set(open.map((h) => h.task.owner!))];
922
+ 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.`;
923
+ this.whileOpen(peer, t.id, [head, ...done].join("\n"));
924
+ }
925
+ }
926
+
927
+ /** 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). */
928
+ private tree(cohort: Cohort): string {
929
+ const paths = [...new Set([...cohort.members.keys()].flatMap((id) => { const t = this.d.board.get(id); return t ? this.places(t).paths : []; }))];
930
+ // Each member's writes count from when its owner was handed the task until it settled: settling keeps its files in
931
+ // the target, and its later writes are its next task's.
932
+ const windows = [...cohort.members.values()].map((m) => ({ peer: m.owner, since: m.since, ...(m.settledAt !== undefined ? { until: m.settledAt } : {}) }));
933
+ return this.d.treeHash?.(paths, windows) ?? "";
934
+ }
935
+
936
+ /**
937
+ * What the integrating member reads as its done result (issue #107): what the others finished, the facts it has not
938
+ * been shown, and what makes the next done count. Its facts offer is acknowledged by that next done.
939
+ */
940
+ private integrationRequest(task: Task, r: Extract<Completion, { action: "request" }>): string {
941
+ 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] : []; });
942
+ const doneAt = (t: Task) => [...t.history].reverse().find((h) => h.event === "done" || h.event === "done (checking)");
943
+ const lines = others.map((t) => {
944
+ const files = this.places(t).paths.filter(this.nameable);
945
+ const signatures = (t.plan?.signatures ?? []).filter(this.nameable);
946
+ const first = (doneAt(t)?.note ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
947
+ return [
948
+ `- ${this.publicTitle(t)} (owner ${t.owner}, ${t.state})`,
949
+ files.length ? ` changed files: ${files.join(", ")}` : "",
950
+ signatures.length ? ` signatures: ${signatures.join("; ")}` : "",
951
+ first && this.nameable(first) ? ` summary: ${first}` : "",
952
+ ].filter(Boolean).join("\n");
953
+ });
954
+ const facts = task.owner ? this.d.integrationFacts?.(task.owner) : undefined;
955
+ if (facts && r.cohort.integration) r.cohort.integration.offer = facts.id;
956
+ const head = r.requests === 1
957
+ ? `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.`
958
+ : `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.`;
959
+ 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");
960
+ }
961
+
603
962
  private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
604
963
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
605
964
  this.checking.delete(id);
@@ -616,12 +975,20 @@ export class Tasks {
616
975
  return;
617
976
  }
618
977
  if (result.code === 0 && !result.timedOut) {
978
+ // An integrating member's check counts only for the target it confirmed (issue #107).
979
+ const cohort = this.cohorts.of(id);
980
+ if (cohort?.silent && !this.cohorts.holds(id, ownerGen(task), this.tree(cohort))) {
981
+ this.d.board.update(id, HUB, "check finished late", {}, `${outcome}; the integration target changed while it ran`);
982
+ 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);
983
+ return;
984
+ }
619
985
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
620
986
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
621
987
  await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
622
988
  return;
623
989
  }
624
990
  this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
991
+ this.cohorts.withdraw(id); // open again: its done intent no longer counts (issue #107)
625
992
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
626
993
  this.contradict(task);
627
994
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
@@ -631,6 +998,7 @@ export class Tasks {
631
998
  private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
632
999
  const reviewer = task.reviewer;
633
1000
  const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, checkOutput ? `${summary ?? ""}\n${checkOutput}`.trim() : summary);
1001
+ this.cohorts.closed(next.id); // its owner's next native turn end settles it in its cohort (issue #107)
634
1002
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
635
1003
  this.tellCompleted(next, summary);
636
1004
  if (!reviewer) {
@@ -650,22 +1018,45 @@ export class Tasks {
650
1018
  */
651
1019
  private tellCompleted(task: Task, summary?: string): void {
652
1020
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
653
- if (!hits.length) return;
654
- // Files, signatures and the summary are the owner's own words (paths given at done included): any item that
655
- // matches a PII pattern is left out of what other owners get.
1021
+ const cohort = this.cohorts.of(task.id);
1022
+ const silent = this.silentFor(task.id);
1023
+ for (const hit of hits) {
1024
+ // A member of the same silent cohort (issue #107): the member that finishes last integrates instead
1025
+ // (Cohorts.completion). The notice is held, so something replaces it if no integration runs. Others hear it.
1026
+ if (silent && cohort?.members.has(hit.task.id)) {
1027
+ cohort.held.add(task.id);
1028
+ continue;
1029
+ }
1030
+ this.whileOpen(hit.task.owner!, hit.task.id, this.completedNotice(task, hit, summary));
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * The completed-change notice: files, signatures and the summary are the owner's own words (paths given at done
1036
+ * included), and any item that matches a PII pattern is left out of what other owners get.
1037
+ */
1038
+ private completedNotice(task: Task, hit: { task: Task; paths: string[]; symbols: string[] }, summary?: string): string {
656
1039
  const paths = this.places(task).paths.filter(this.nameable);
657
1040
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
658
1041
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
659
1042
  const line = first && this.nameable(first) ? first : undefined;
660
- for (const hit of hits) {
661
- const body = [
662
- `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.`,
663
- paths.length ? `Changed files: ${paths.join(", ")}` : "",
664
- signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
665
- line ? `Summary: ${line}` : "",
666
- ].filter(Boolean).join("\n");
667
- this.d.bus.publish(newEnvelope(HUB, body, { to: [hit.task.owner!], kind: "task", refs: { task: String(hit.task.id) } }));
668
- }
1043
+ return [
1044
+ `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.`,
1045
+ paths.length ? `Changed files: ${paths.join(", ")}` : "",
1046
+ signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
1047
+ line ? `Summary: ${line}` : "",
1048
+ ].filter(Boolean).join("\n");
1049
+ }
1050
+
1051
+ /** The held notices of a cohort, as one list for a member that gets no integration step (issue #107). */
1052
+ private heldEvidence(cohort: Cohort, task: Task): string | undefined {
1053
+ const lines = [...cohort.held].filter((id) => id !== task.id).flatMap((id) => {
1054
+ const t = this.d.board.get(id);
1055
+ if (!t || this.isPii(t)) return [];
1056
+ const done = [...t.history].reverse().find((h) => h.event === "done");
1057
+ return [this.completedNotice(t, { task, paths: this.places(task).paths.filter((p) => this.places(t).paths.some((q) => samePlace(p, q))), symbols: [] }, done?.note)];
1058
+ });
1059
+ return lines.length ? lines.join("\n") : undefined;
669
1060
  }
670
1061
 
671
1062
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
@@ -711,6 +1102,7 @@ export class Tasks {
711
1102
  return next;
712
1103
  }
713
1104
  const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
1105
+ this.cohorts.withdraw(rejected.id); // open again: its done intent no longer counts (issue #107)
714
1106
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
715
1107
  this.contradict(rejected);
716
1108
  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.