@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/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
- /** Paths from refs and plan, symbols from the plan: the places a task says it touches (issue #31). */
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
- return { paths: [...new Set([...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])])], symbols: task.plan?.symbols ?? [] };
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.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}` : ""}`));
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
- 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];
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
- return opts.clearOnFail && task.owner ? this.d.board.update(task.id, by, "unassigned", { owner: null }) : task;
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
- 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
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
- 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.
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
- 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
- }
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.