@staix/agent-hub 0.12.2 → 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,9 +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";
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";
7
11
 
8
12
  export interface TasksDeps {
9
13
  board: Board;
@@ -27,6 +31,8 @@ export interface TasksDeps {
27
31
  recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
28
32
  /** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
29
33
  quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
34
+ /** Optional persisted task/run execution limits (issue #102). */
35
+ executionBudget?: ExecutionBudget;
30
36
  /** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
31
37
  review?: { adaptive: boolean; min_reviews: number };
32
38
  /** Peers whose recent deliveries all failed, with the reason (issue #89); routing skips them. */
@@ -37,6 +43,25 @@ export interface TasksDeps {
37
43
  roles?: Record<string, string[]>;
38
44
  /** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
39
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;
40
65
  }
41
66
 
42
67
  const ESCALATE_AFTER = 2;
@@ -62,6 +87,12 @@ export const samePlace = (a: string, b: string) => {
62
87
 
63
88
  /** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
64
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();
65
96
 
66
97
  /** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
67
98
  const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
@@ -93,7 +124,14 @@ const planText = (plan: TaskPlan = {}) => PLAN_KEYS.filter((k) => plan[k]?.lengt
93
124
  * PII handling, envelopes, briefs and memory notes are decided in one place.
94
125
  */
95
126
  export class Tasks {
127
+ /** Turn-free cohorts (issue #107): who works without messages, and who integrates. */
128
+ readonly cohorts: Cohorts;
129
+
96
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
+ });
97
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
98
136
  // board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
99
137
  d.bus.tap((e) => {
@@ -108,6 +146,25 @@ export class Tasks {
108
146
 
109
147
  isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
110
148
 
149
+ configureExecutionBudget = (config: ExecutionBudgetConfig): ExecutionBudgetStatus | undefined => {
150
+ if (!this.d.executionBudget) return undefined;
151
+ if (config.kind === "task" && (!config.taskId || !this.d.board.get(config.taskId))) throw new Error(`task #${config.taskId ?? "?"} does not exist`);
152
+ return this.d.executionBudget.configure(config);
153
+ };
154
+ disableExecutionBudget = (id: string): boolean => this.d.executionBudget?.disable(id) ?? false;
155
+ executionBudgetStatus = (id?: string): ExecutionBudgetStatus | ExecutionBudgetStatus[] | undefined => this.d.executionBudget?.status(id);
156
+ /** Charge only budgets matching the task actually delivered to this peer, plus active run budgets. */
157
+ admitExecution = (taskId: number | undefined, peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => this.d.executionBudget?.admitTask(taskId, peer, unit, amount) ?? [];
158
+ /** A digest may include several tasks. Charge each matching task budget and each run budget once, atomically. */
159
+ admitExecutionEnvelopes = (envs: Envelope[], peer: PeerId, unit: ExecutionUnit, amount = 1): ExecutionBudgetDecision[] => {
160
+ const ids = [...new Set(envs.flatMap((e) => {
161
+ const value = e.refs?.task;
162
+ const id = typeof value === "string" ? Number(value) : NaN;
163
+ return Number.isSafeInteger(id) && id > 0 ? [id] : [];
164
+ }))];
165
+ return this.d.executionBudget?.admitTasks(ids, peer, unit, amount) ?? [];
166
+ };
167
+
111
168
  /** What peers other than the owner, the console stream and the log may see of a task. */
112
169
  publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
113
170
 
@@ -195,6 +252,53 @@ export class Tasks {
195
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 } } : {}) };
196
253
  }
197
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
+
198
302
  /**
199
303
  * Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
200
304
  * within the window were contradicted. Each approval counts once.
@@ -280,13 +384,84 @@ export class Tasks {
280
384
  overlaps(task: Task, forOwner = true, found = this.overlapHits(task)): string {
281
385
  const hits = found.map((h) => `#${h.task.id} (owner ${h.task.owner}) on ${this.where(h)}`);
282
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
+ }
283
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.`;
284
396
  return `Overlaps ${hits.join("; ")}. ${who}`;
285
397
  }
286
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
+
287
431
  /** While a gone owner's tasks move, its other tasks are about to move too: they are no one to settle with. */
288
432
  private releasing: PeerId | undefined;
289
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
+
290
465
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
291
466
  nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
292
467
 
@@ -308,9 +483,28 @@ export class Tasks {
308
483
  return shown.length ? shown.join(", ") : "a path whose name is withheld (it matches a PII pattern)";
309
484
  }
310
485
 
311
- /** 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
+ */
312
490
  private places(task: Task): { paths: string[]; symbols: string[] } {
313
- 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;
314
508
  }
315
509
 
316
510
  private overlapHits(task: Task): { task: Task; paths: string[]; symbols: string[] }[] {
@@ -340,7 +534,8 @@ export class Tasks {
340
534
  const plan = planText(task.plan);
341
535
  for (const hit of hits) {
342
536
  if (hit.task.owner === USER || hit.task.owner === HUB) continue;
343
- 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}` : ""}`));
344
539
  }
345
540
  }
346
541
 
@@ -381,7 +576,9 @@ export class Tasks {
381
576
  if (typeof target === "number") {
382
577
  const task = this.d.board.get(target);
383
578
  if (!task) throw new Error(`no task #${target}`);
384
- 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 ?? [])];
385
582
  }
386
583
  const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
387
584
  return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing, this.weights(target.class)).trace;
@@ -407,6 +604,7 @@ export class Tasks {
407
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> {
408
605
  const waits = this.waitsFor(task);
409
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
+
410
608
  if (waits.length) {
411
609
  this.d.notify(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
412
610
  return task;
@@ -414,10 +612,15 @@ export class Tasks {
414
612
  if (!a.owner) {
415
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>`);
416
614
  // Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
417
- 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;
418
620
  }
419
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}`);
420
622
  const hits = this.overlapHits(next);
623
+ this.formCohort(next, hits);
421
624
  if (hits.length) {
422
625
  this.announceOverlap(next, hits);
423
626
  this.tellEarlierOwners(next, hits);
@@ -528,6 +731,7 @@ export class Tasks {
528
731
  this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
529
732
  if (cleaned) {
530
733
  const hits = this.overlapHits(next);
734
+ this.formCohort(next, hits);
531
735
  const fresh = hits.filter((h) => !before.has(h.task.id));
532
736
  if (fresh.length) this.announceOverlap(next, fresh);
533
737
  this.tellEarlierOwners(next, hits);
@@ -535,6 +739,49 @@ export class Tasks {
535
739
  return next;
536
740
  }
537
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
+
538
785
  async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
539
786
  const task = this.need(id, true);
540
787
  this.mine(task, by, "owner");
@@ -562,7 +809,35 @@ export class Tasks {
562
809
  // The result goes only to the owner the check was started for: anyone who took the task since hears nothing.
563
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`);
564
811
  }
565
- 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
566
841
  const command = this.d.runCheck ? this.d.check?.(task.class) : undefined;
567
842
  if (!command) return this.complete(task, by, summary, refs);
568
843
  // The tool call returns now; a check can outlast an agent's tool timeout. The result decides what comes next.
@@ -578,6 +853,81 @@ export class Tasks {
578
853
  return pending;
579
854
  }
580
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
+
581
931
  private async finishChecked(id: number, by: PeerId, summary: string | undefined, command: string, seen: { events: number; owner: PeerId | null }): Promise<void> {
582
932
  const result = await this.d.runCheck!(command).catch((e: Error) => ({ code: null, timedOut: false, interrupted: false, tail: e.message }));
583
933
  this.checking.delete(id);
@@ -594,12 +944,20 @@ export class Tasks {
594
944
  return;
595
945
  }
596
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
+ }
597
954
  this.d.board.update(id, HUB, "check passed", {}, `${outcome}\n${result.tail}`.trim());
598
955
  // The output goes to the reviewer with the done note, not into shared memory: nobody screened it.
599
956
  await this.complete(this.d.board.get(id)!, by, `${summary ?? ""}\nCheck: ${outcome}`.trim(), undefined, result.tail);
600
957
  return;
601
958
  }
602
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)
603
961
  if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
604
962
  this.contradict(task);
605
963
  this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
@@ -609,6 +967,7 @@ export class Tasks {
609
967
  private async complete(task: Task, by: PeerId, summary?: string, refs?: TaskRefs, checkOutput = ""): Promise<Task> {
610
968
  const reviewer = task.reviewer;
611
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)
612
971
  this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
613
972
  this.tellCompleted(next, summary);
614
973
  if (!reviewer) {
@@ -628,22 +987,45 @@ export class Tasks {
628
987
  */
629
988
  private tellCompleted(task: Task, summary?: string): void {
630
989
  const hits = this.overlapHits(task).filter((h) => h.task.owner !== USER && h.task.owner !== HUB);
631
- if (!hits.length) return;
632
- // Files, signatures and the summary are the owner's own words (paths given at done included): any item that
633
- // 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 {
634
1008
  const paths = this.places(task).paths.filter(this.nameable);
635
1009
  const signatures = (task.plan?.signatures ?? []).filter(this.nameable);
636
1010
  const first = (summary ?? "").split("\n").find((l) => l.trim())?.trim().slice(0, 300);
637
1011
  const line = first && this.nameable(first) ? first : undefined;
638
- for (const hit of hits) {
639
- const body = [
640
- `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.`,
641
- paths.length ? `Changed files: ${paths.join(", ")}` : "",
642
- signatures.length ? `New or changed signatures: ${signatures.join("; ")}` : "",
643
- line ? `Summary: ${line}` : "",
644
- ].filter(Boolean).join("\n");
645
- this.d.bus.publish(newEnvelope(HUB, body, { to: [hit.task.owner!], kind: "task", refs: { task: String(hit.task.id) } }));
646
- }
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;
647
1029
  }
648
1030
 
649
1031
  /** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
@@ -689,6 +1071,7 @@ export class Tasks {
689
1071
  return next;
690
1072
  }
691
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)
692
1075
  if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
693
1076
  this.contradict(rejected);
694
1077
  this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);