@staix/agent-hub 0.12.44 → 0.13.0

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.
Files changed (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
package/src/hub/board.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
2
+ import { planFingerprint } from "./plan-conformance.ts";
1
3
  import type { ReviewerPoolReason } from "./reviewer-pool.ts";
2
4
  import type { GitHubSource } from "./github-import.ts";
3
5
  import { Database } from "bun:sqlite";
@@ -6,6 +8,7 @@ import { HUB, type PeerId } from "./envelope.ts";
6
8
  import type { SweepRecord } from "./task-sweep.ts";
7
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
10
  import type { SdlcReceipt } from "./sdlc.ts";
11
+ import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
9
12
  import type { CostPlan, ReviewCost } from "./cost-governance.ts";
10
13
 
11
14
  export const CLASSES = ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] as const;
@@ -28,7 +31,7 @@ export interface TaskRefs {
28
31
  /** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
29
32
  export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
30
33
  export const DOCUMENT_PLAN_KEYS = ["paths", "sections", "sources", "claims"] as const;
31
- export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims"] as const;
34
+ export const ALL_PLAN_KEYS = [...PLAN_KEYS, "sections", "sources", "claims", "proof", "risks"] as const;
32
35
  export type TaskPlan = Partial<Record<(typeof ALL_PLAN_KEYS)[number], string[]>> & CostPlan;
33
36
  /** Task outcomes older than this are pruned: demotion (issue #36) reads one week, seven half-lives. */
34
37
  export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
@@ -61,6 +64,13 @@ export interface HistoryEntry {
61
64
  reviewCost?: ReviewCost;
62
65
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
63
66
  reviewContext?: "fresh" | "continuing";
67
+ /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
68
+ reviewFindings?: ReviewFindingSet;
69
+ /** Host-authored plan content revision, separate from backlog planningRevision. */
70
+ planRevision?: { revision: number; fingerprint: string };
71
+ planObservation?: { repo: string; pr: number; head: string; revision: number; fingerprint: string };
72
+ planDepartureInvalidated?: true;
73
+ planDeparture?: { generation: string; scope?: string; repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] };
64
74
  reviewerFamily?: string;
65
75
  reviewerReason?: ReviewerPoolReason;
66
76
  reviewBlocked?: true;
@@ -96,6 +106,8 @@ export interface Task extends PlanningFields {
96
106
  plan?: TaskPlan;
97
107
  /** Artifact identities and bounded evidence, owned by Tasks (#257). */
98
108
  sdlc?: SdlcReceipt;
109
+ /** Host-derived observation for one head, never accepted from task tool arguments. */
110
+ codeSymbols?: TaskCodeSymbols;
99
111
  /** Tasks that must be approved before this one is offered; planning edits are limited to unstarted backlog. */
100
112
  deps?: number[];
101
113
  /** Who routing offers the task first once nothing is left to wait for (issue #207); null when nobody was named. */
@@ -146,6 +158,8 @@ const MOVES: Record<TaskState, TaskState[]> = {
146
158
  };
147
159
 
148
160
  const JSON_COLS = ["refs", "plan", "deps", "signals", "history", "sdlc"] as const;
161
+ // Separate storage keeps older SELECT * readers from exporting unscreened derived metadata.
162
+ const TASK_SELECT = "SELECT tasks.*, (SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 65536 THEN payload END FROM task_code_symbols WHERE task=tasks.id) AS code_symbols FROM tasks";
149
163
 
150
164
  /** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
151
165
  export class Board {
@@ -153,6 +167,8 @@ export class Board {
153
167
  private pendingChanges: { task: Task; entry: HistoryEntry }[] | undefined;
154
168
  /** Every recorded change, as it lands (telemetry, issue #40). */
155
169
  onChange?: (task: Task, entry: HistoryEntry) => void;
170
+ /** Host lifecycle policy, installed by Tasks; off projects keep their original history shape. */
171
+ trackPlanRevisions?: () => boolean;
156
172
  private changed(task: Task, entry: HistoryEntry): void {
157
173
  if (this.pendingChanges) { this.pendingChanges.push({ task, entry }); return; }
158
174
  try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
@@ -214,6 +230,8 @@ export class Board {
214
230
  if (!have.has("external_issue")) this.db.run("ALTER TABLE tasks ADD COLUMN external_issue INTEGER");
215
231
  this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS tasks_external_issue ON tasks(external_repo,external_issue) WHERE external_repo IS NOT NULL AND external_issue IS NOT NULL");
216
232
  if (!have.has("sdlc")) this.db.run("ALTER TABLE tasks ADD COLUMN sdlc TEXT");
233
+ this.db.run("CREATE TABLE IF NOT EXISTS task_code_symbols (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
234
+ this.db.run("DELETE FROM task_code_symbols WHERE task NOT IN (SELECT id FROM tasks) OR task IN (SELECT id FROM tasks WHERE json_valid(signals) AND EXISTS (SELECT 1 FROM json_each(signals) WHERE value IN ('pii','pii_path')))");
217
235
  if (!have.has("task_role")) this.db.run("ALTER TABLE tasks ADD COLUMN task_role TEXT");
218
236
  if (!have.has("project_profile")) this.db.run("ALTER TABLE tasks ADD COLUMN project_profile TEXT");
219
237
  for (const col of ["rank", "priority", "release", "planned_start", "due"]) {
@@ -232,7 +250,7 @@ export class Board {
232
250
 
233
251
  propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; profile?: import("./profiles.ts").ProfileName; profileClass?: string; customProfile?: true; role?: string; refs?: TaskRefs; plan?: TaskPlan; deps?: number[]; reserved?: PeerId; signals?: string[]; backlog?: boolean; external?: GitHubSource }): Task {
234
252
  const now = Date.now();
235
- const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed" }];
253
+ const history: HistoryEntry[] = [{ at: now, by, event: t.backlog ? "backlogged" : "proposed", ...(this.trackPlanRevisions?.() && t.plan && Object.keys(t.plan).length ? { planRevision: { revision: 1, fingerprint: planFingerprint(t.plan) } } : {}) }];
236
254
  const { lastInsertRowid } = this.taskWrite(() => this.db
237
255
  .query("INSERT INTO tasks (title, detail, class, state, refs, plan, deps, reserved, signals, history, created, updated, project_profile, task_role, external_repo, external_issue) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
238
256
  .run(t.title, t.detail ?? "", t.class, t.backlog ? "backlog" : "proposed", JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.deps ?? []), t.reserved ?? null, JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now, t.profile && (t.profile !== "dev" || t.customProfile || t.profileClass !== undefined && t.profileClass !== t.class) ? JSON.stringify({ profile: t.profile, profileClass: t.profileClass ?? t.class }) : null, t.role ?? null, t.external?.repo.toLowerCase() ?? null, t.external?.issue ?? null));
@@ -242,17 +260,17 @@ export class Board {
242
260
  }
243
261
 
244
262
  imported(repo: string, issue: number): Task | undefined {
245
- const row = this.db.query("SELECT * FROM tasks WHERE external_repo=? AND external_issue=?").get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
263
+ const row = this.db.query(`${TASK_SELECT} WHERE external_repo=? AND external_issue=?`).get(repo.toLowerCase(), issue) as Record<string, unknown> | null;
246
264
  return row ? parse(row) : undefined;
247
265
  }
248
266
 
249
267
  get(id: number): Task | undefined {
250
- const row = this.db.query("SELECT * FROM tasks WHERE id = ?").get(id) as Record<string, unknown> | null;
268
+ const row = this.db.query(`${TASK_SELECT} WHERE id = ?`).get(id) as Record<string, unknown> | null;
251
269
  return row ? parse(row) : undefined;
252
270
  }
253
271
 
254
272
  list(state?: TaskState): Task[] {
255
- const rows = state ? this.db.query("SELECT * FROM tasks WHERE state = ? ORDER BY id").all(state) : this.db.query("SELECT * FROM tasks ORDER BY id").all();
273
+ const rows = state ? this.db.query(`${TASK_SELECT} WHERE state = ? ORDER BY id`).all(state) : this.db.query(`${TASK_SELECT} ORDER BY id`).all();
256
274
  return (rows as Record<string, unknown>[]).map(parse);
257
275
  }
258
276
 
@@ -307,7 +325,7 @@ export class Board {
307
325
 
308
326
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
309
327
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
310
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
328
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
311
329
  const task = this.get(id);
312
330
  if (!task) throw new Error(`no task #${id}`);
313
331
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -317,16 +335,48 @@ export class Board {
317
335
  throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
318
336
  }
319
337
  const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
338
+ const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
339
+ const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
340
+ if (Object.hasOwn(patch, "codeSymbols")) {
341
+ if (codeSymbolsAuthority !== HOST_CODE_SYMBOLS) throw new Error("code symbols require host authority");
342
+ const observation = patch.codeSymbols === undefined ? undefined : parseTaskCodeSymbols(patch.codeSymbols);
343
+ if (patch.codeSymbols !== undefined && !observation) throw new Error("invalid task code symbols");
344
+ if (observation && (privateSymbols || observation.head !== headOf(next))) throw new Error("code symbol head or privacy differs from task");
345
+ next.codeSymbols = observation;
346
+ } else if (privateSymbols || headOf(task) !== headOf(next) || task.refs.repo !== next.refs.repo || JSON.stringify(task.refs.paths) !== JSON.stringify(next.refs.paths) || task.sdlc?.repo !== next.sdlc?.repo) {
347
+ next.codeSymbols = undefined;
348
+ }
320
349
  if (next.state === "backlog" && (next.owner || next.reviewer)) throw new Error(`task #${id}: backlog cannot have an owner or reviewer`);
321
350
  if (!untouchedReturn) {
322
351
  if (patch.state === "backlog" && task.owner) throw new Error(`task #${id}: owned work cannot return to backlog`);
323
352
  }
324
353
  if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
325
354
  const reviewMetadata = reviewerHistory(extra);
326
- const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
327
- this.taskWrite(() => this.db
328
- .query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
329
- .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
355
+ let reviewFindings: ReviewFindingSet | undefined;
356
+ if (extra.reviewFindings !== undefined) {
357
+ if (!["approved", "changes_requested"].includes(event) || patch.state !== event) throw new Error("finding metadata requires a saved review verdict");
358
+ const head = extra.reviewFindings.head;
359
+ if (head !== undefined && !/^[a-f0-9]{40}$/.test(head)) throw new Error("finding metadata requires a full reviewed head");
360
+ reviewFindings = { ...(head ? { head } : {}), items: parseReviewFindings(extra.reviewFindings.items, { person: by === "user", maxNits: 64 }) };
361
+ }
362
+ if (extra.planDeparture && (by !== "user" || event !== "plan departure accepted")) throw new Error("plan departure acceptance is person-only");
363
+ const trackPlan = this.trackPlanRevisions?.() === true, hasDeparture = task.history.some(h => h.planDeparture);
364
+ const fingerprint = patch.plan && (trackPlan || hasDeparture) ? planFingerprint(next.plan ?? {}) : undefined;
365
+ const planChanged = fingerprint !== undefined && fingerprint !== planFingerprint(task.plan ?? {});
366
+ const planRevision = trackPlan && planChanged ? { revision: (task.history.findLast(h => h.planRevision)?.planRevision?.revision ?? 0) + 1, fingerprint: fingerprint! } : undefined;
367
+ // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
368
+ const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
369
+ const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
370
+ const history = [...task.history, { at: Date.now(), by, event, ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
371
+ this.taskWrite(() => {
372
+ const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
373
+ .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id);
374
+ if (result.changes) {
375
+ if (next.codeSymbols) this.db.query("INSERT INTO task_code_symbols(task,payload) VALUES(?,?) ON CONFLICT(task) DO UPDATE SET payload=excluded.payload").run(id, JSON.stringify(next.codeSymbols));
376
+ else this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(id);
377
+ }
378
+ return result;
379
+ });
330
380
  const updated = this.get(id)!;
331
381
  this.changed(updated, history.at(-1)!);
332
382
  return updated;
@@ -405,7 +455,10 @@ export class Board {
405
455
  }
406
456
  if (this.list().some(task => task.id !== id && !ids.has(task.id) && (task.deps?.some(dep => ids.has(dep)) || task.sdlc?.phase?.tasks.some(ref => ids.has(ref.id) || ref.after.some(dep => ids.has(dep)))))) throw new Error("split children are referenced by other work");
407
457
  const restored = this.plan(id, by, { deps: entry!.planning!.previous.deps ?? [] }, revision, `undo split revision ${revision}`);
408
- for (const child of ids) this.taskWrite(() => this.db.query("DELETE FROM tasks WHERE id=?").run(child));
458
+ for (const child of ids) this.taskWrite(() => {
459
+ this.db.query("DELETE FROM task_code_symbols WHERE task=?").run(child);
460
+ return this.db.query("DELETE FROM tasks WHERE id=?").run(child);
461
+ });
409
462
  return restored;
410
463
  });
411
464
  }
@@ -491,6 +544,14 @@ function parse(row: Record<string, unknown>): Task {
491
544
  if (col === "sdlc" && row[col] == null) delete out[col];
492
545
  else out[col] = JSON.parse(String(row[col]));
493
546
  }
547
+ try {
548
+ const rawSymbols = row.code_symbols == null ? undefined : String(row.code_symbols);
549
+ const symbols = rawSymbols === undefined || Buffer.byteLength(rawSymbols) > 64 * 1024 ? undefined : parseTaskCodeSymbols(JSON.parse(rawSymbols));
550
+ const head = (out.refs as TaskRefs).commit ?? (out.sdlc as SdlcReceipt | undefined)?.head ?? (out.sdlc as SdlcReceipt | undefined)?.localHead;
551
+ const privateSymbols = (out.signals as string[]).some(signal => signal === "pii" || signal === "pii_path");
552
+ if (symbols && symbols.head === head && !privateSymbols) out.codeSymbols = symbols;
553
+ } catch { /* Malformed optional derived evidence stays unavailable. */ }
554
+ delete out.code_symbols;
494
555
  if (out.project_profile != null) Object.assign(out, JSON.parse(String(out.project_profile)));
495
556
  delete out.project_profile;
496
557
  if (out.external_repo != null && out.external_issue != null) out.external = { provider: "github", repo: String(out.external_repo), issue: Number(out.external_issue), url: `https://github.com/${out.external_repo}/issues/${out.external_issue}` };
package/src/hub/budget.ts CHANGED
@@ -49,6 +49,13 @@ export interface BudgetConfig {
49
49
  // wait_max_min is off here, as approvals.notify is: a project config turns it on (30 unless it says otherwise).
50
50
  export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
51
51
 
52
+ export type BudgetLedgerEntry =
53
+ | { type: "budget_pause"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
54
+ | { type: "budget_resume"; peer: PeerId; reason: string; window: string; resetsAt: number; by: "hub" | "person" }
55
+ | { type: "quota_reset"; peer: PeerId; window: string; cause: "resets_at_moved" | "used_dropped"; resetsAt?: number; previousResetsAt?: number; used: number; previousUsed: number; measuredAt?: string };
56
+ /** The window a pause record names: the id before ` window at` in its reason (`5h window at 93% (...)`), else unknown. */
57
+ const reasonWindow = (reason: string): string => /^(\S+) window at /.exec(reason)?.[1] ?? "unknown";
58
+
52
59
  export interface BudgetHooks {
53
60
  /** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
54
61
  definitionOf?(peer: PeerId): AdapterDefinition | undefined;
@@ -70,10 +77,23 @@ export interface BudgetHooks {
70
77
  resumed(record: PauseRecord): void;
71
78
  /** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
72
79
  reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
80
+ /**
81
+ * The limit ledger (#683 U1): a pause or resume of the account, and a window reset derived from its readings. Ids, windows and
82
+ * numbers only. Telemetry never costs a pause or a reading, so a throwing hook is ignored.
83
+ */
84
+ ledger?(entry: BudgetLedgerEntry): void;
73
85
  notify(line: string): void;
74
86
  }
75
87
 
76
88
  const HYSTERESIS = 0.1;
89
+ /** A window whose `used` falls by this much inside one window id has been reset (#683 U1). */
90
+ const RESET_DROP = 0.5;
91
+ /**
92
+ * A provider's `resetsAt` for one window wobbles by seconds between readings (observed on the real log: 25 of 85 first-cut resets were jumps
93
+ * of 1 to 9 s with `used` unchanged, five more of about 10 min) while a real reset moves it by hours (the smallest of the 53 real ones: 2.4 h).
94
+ * A forward move of this much or less is the same window, not a reset (#683 U1).
95
+ */
96
+ const RESET_JITTER_MS = 15 * 60_000;
77
97
  /** Mark a pause whose handoff was a wait, and one whose wait was undone, in its reason (issue #36). */
78
98
  const WAIT_NOTE = "; keeps its work:";
79
99
  const WAITED_NOTE = "; waited, then the reset moved to";
@@ -90,6 +110,10 @@ export class Budget {
90
110
  private readonly readings = new Map<PeerId, Map<string, Reading>>();
91
111
  private readonly pausing = new Map<PeerId, object>();
92
112
  private readonly handingOff = new Set<PeerId>();
113
+ /** The last reading of each window of an account, for the derived `quota_reset` (see `deriveResets`). */
114
+ private readonly lastSeen = new Map<PeerId, Map<string, { used: number; resetsAt?: number; at: number }>>();
115
+ /** `peer|window|resetsAt` of every reset already written this run, so a duplicate reading adds no reset. */
116
+ private readonly resetsRecorded = new Set<string>();
93
117
  /** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
94
118
  private readonly ignoreUntil = new Map<PeerId, number>();
95
119
  /** Chosen fallback bounds belong to a window, never to every window of the peer. */
@@ -216,6 +240,7 @@ export class Budget {
216
240
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
217
241
  }
218
242
  try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
243
+ this.deriveResets(peer, windows, at);
219
244
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
220
245
  this.readings.set(peer, mine);
221
246
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
@@ -230,6 +255,45 @@ export class Budget {
230
255
  return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
231
256
  }
232
257
 
258
+ /**
259
+ * `quota_reset` (#683 U1), derived from two readings of one window id of the account: the window's `resetsAt` moved forward (a new
260
+ * window began, by more than RESET_JITTER_MS), or `used` fell by RESET_DROP or more inside one window id (the provider emptied it before its stated reset). The
261
+ * baseline is the last reading seen, kept apart from `readings` (a resume clears those) so a reset that ended a pause is still seen.
262
+ * One entry per peer, window and `resetsAt`: a repeated reading, a replay after a restart of the same value, or a second member of the
263
+ * account adds none. A stale file reading (`at` older than the baseline) never counts: it describes an earlier window.
264
+ */
265
+ private deriveResets(peer: PeerId, windows: UsageWindow[], at: number): void {
266
+ const last = this.lastSeen.get(peer) ?? new Map<string, { used: number; resetsAt?: number; at: number }>();
267
+ this.lastSeen.set(peer, last);
268
+ for (const w of windows) {
269
+ const used = Math.max(0, Math.min(1, w.used)), before = last.get(w.id);
270
+ if (before && at >= before.at) {
271
+ const moved = w.resetsAt !== undefined && before.resetsAt !== undefined && w.resetsAt - before.resetsAt > RESET_JITTER_MS;
272
+ const sameWindow = w.resetsAt === undefined || before.resetsAt === undefined || Math.abs(w.resetsAt - before.resetsAt) <= RESET_JITTER_MS;
273
+ const dropped = !moved && before.used - used >= RESET_DROP && sameWindow;
274
+ if (moved || dropped) {
275
+ const key = `${peer}|${w.id}|${w.resetsAt ?? "none"}`;
276
+ if (!this.resetsRecorded.has(key)) {
277
+ this.resetsRecorded.add(key);
278
+ this.writeLedger({ type: "quota_reset", peer, window: w.id, cause: moved ? "resets_at_moved" : "used_dropped", ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}),
279
+ ...(before.resetsAt !== undefined ? { previousResetsAt: before.resetsAt } : {}), used, previousUsed: before.used, ...(Math.abs(this.now() - at) > 60_000 ? { measuredAt: new Date(at).toISOString() } : {}) });
280
+ }
281
+ }
282
+ }
283
+ if (!before || at >= before.at) last.set(w.id, { used, ...(w.resetsAt !== undefined ? { resetsAt: w.resetsAt } : {}), at });
284
+ }
285
+ }
286
+
287
+ /** The resets already in the ledger from earlier hub runs, so a replayed reading adds none (the daemon reads them from events.jsonl). */
288
+ seedResets(known: Iterable<{ peer: PeerId; window: string; resetsAt?: number }>): void {
289
+ for (const r of known) this.resetsRecorded.add(`${r.peer}|${r.window}|${r.resetsAt ?? "none"}`);
290
+ }
291
+
292
+ /** One ledger entry; a failing hook never costs the pause, the resume or the reading it describes. */
293
+ private writeLedger(entry: BudgetLedgerEntry): void {
294
+ try { this.hooks.ledger?.(entry); } catch { /* telemetry */ }
295
+ }
296
+
233
297
  /** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
234
298
  override(peer: PeerId): boolean {
235
299
  peer = this.account(peer);
@@ -237,7 +301,7 @@ export class Budget {
237
301
  if (!open) return false;
238
302
  this.ignoreUntil.set(peer, open.resetsAt);
239
303
  const bounds = this.resetBounds.get(peer);
240
- this.resume(peer, "overridden by the console user");
304
+ this.resume(peer, "overridden by the console user", "person");
241
305
  // A new refusal may end the override before this same window has reset.
242
306
  if (bounds) this.resetBounds.set(peer, bounds);
243
307
  return true;
@@ -298,6 +362,10 @@ export class Budget {
298
362
  if (this.recoveryHeld) { this.deferred = true; return; }
299
363
  if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
300
364
  this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary, moved) VALUES (?, ?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null, JSON.stringify(open?.moved ?? []));
365
+ // A new pause that replaces an expired record (its window reset while nobody was attached to be resumed) ends that pause: without this
366
+ // the ledger would hold two open pauses for one peer, and a pause-duration timeline would be wrong (#683 U1).
367
+ if (open) this.writeLedger({ type: "budget_resume", peer, reason: "replaced by a new pause after its window reset", window: reasonWindow(open.reason), resetsAt: open.resetsAt, by: "hub" });
368
+ this.writeLedger({ type: "budget_pause", peer, reason, window: worst.id, resetsAt, by: "hub" });
301
369
  this.hooks.pause(peer);
302
370
  this.hooks.notify(`budget: ${peer} paused, ${reason}; resumes ${new Date(resetsAt).toLocaleTimeString()}${summary ? "; checkpoint received" : hard ? "; hard limit, no checkpoint" : "; no checkpoint"}`);
303
371
  await this.finishHandoff(peer, summary);
@@ -371,11 +439,12 @@ export class Budget {
371
439
  }
372
440
  }
373
441
 
374
- private resume(peer: PeerId, why: string): void {
442
+ private resume(peer: PeerId, why: string, by: "hub" | "person" = "hub"): void {
375
443
  peer = this.account(peer);
376
444
  const record = this.record(peer);
377
445
  if (!record) return;
378
446
  this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
447
+ this.writeLedger({ type: "budget_resume", peer, reason: why, window: reasonWindow(record.reason), resetsAt: record.resetsAt, by });
379
448
  this.readings.get(peer)?.clear(); // the old numbers describe the window that just ended
380
449
  this.resetBounds.delete(peer);
381
450
  // The notice is queued while the peer is still paused, so it leads the first delivery: the peer learns what moved
package/src/hub/bus.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
1
+ import { appendNote, DIGEST, frame, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
@@ -103,6 +103,9 @@ const MAX_ATTEMPTS = 3;
103
103
  // ponytail: one digest carries at most ten originals; larger supervision windows split into bounded deliveries.
104
104
  // Revisit this ceiling after measuring native supervision cost, rather than bypassing the delivery limit.
105
105
  const DIGEST_MAX = 10;
106
+ // #713: a pull-only peer reads between steps, often minutes apart, so one pull drains its queue up to this many bytes of rendered text instead of ten items.
107
+ // Bounded, not unbounded: a queue larger than the budget takes more than one pull, and the reply says how many are still waiting. Push peers keep DIGEST_MAX.
108
+ const PULL_BUDGET_BYTES = 64 * 1024;
106
109
  const NOTES_KEPT = 10;
107
110
 
108
111
  /**
@@ -479,7 +482,11 @@ export class Bus {
479
482
  return;
480
483
  }
481
484
  const recipientConsent = actor === `peer:${record.peer}` && action === "retry" && record.state === "needs_review";
482
- if (["dispatching", "accepted"].includes(record.state) || (this.peers.get(record.peer)?.state === "busy" && !recipientConsent)) throw new Error("active delivery cannot be resolved");
485
+ // #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
486
+ // discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
487
+ if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
488
+ const queuedDiscard = record.state === "queued" && action === "discard";
489
+ if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
483
490
  if (record.revision !== revision) throw new Error("stale delivery revision");
484
491
  if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
485
492
  if (action === "retry" && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
@@ -907,6 +914,18 @@ export class Bus {
907
914
  if (this.withdrawn.has(env.id)) return;
908
915
  const queue = this.queues.get(id) ?? [];
909
916
  this.queues.set(id, queue);
917
+ // #713: a newer "task T is done, check your work" notice for this recipient replaces an earlier queued one for the same T: only the newest is useful, and the older
918
+ // one is discarded through the same stale/discard path with a journal outcome. Never one that already failed once or is being prepared, as for supervision.
919
+ const completedFor = env.from === HUB && env.kind === "task" && Number.isSafeInteger(env.refs?.completedFor) ? env.refs!.completedFor : undefined;
920
+ if (!front && completedFor !== undefined) {
921
+ for (let i = queue.length - 1; i >= 0; i--) {
922
+ const old = queue[i]!;
923
+ if (old.from !== HUB || old.refs?.completedFor !== completedFor || this.attempts.has(`${id}:${old.id}`)) continue;
924
+ queue.splice(i, 1);
925
+ this.markWithdrawn(old.id);
926
+ this.discardQueued(id, old, `superseded completed-change notice for task #${completedFor}`);
927
+ }
928
+ }
910
929
  const key = env.from === HUB && env.refs?.supervision ? env.refs.supervisionKey : undefined;
911
930
  const replace = !front && key ? queue.findIndex((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`)) : -1;
912
931
  const preparing = !front && key ? this.preparing.get(id)?.find((old) => old.from === HUB && old.refs?.supervision && old.refs.supervisionKey === key && !this.attempts.has(`${id}:${old.id}`) && !this.withdrawn.has(old.id)) : undefined;
@@ -1012,7 +1031,8 @@ export class Bus {
1012
1031
  const preface = this.prefaces.get(id);
1013
1032
  if (!preface && !queue.length) return [];
1014
1033
  const queued = [...queue];
1015
- const batch = [...(preface ? [preface] : []), ...this.take(id, queue)];
1034
+ // A pull-only peer drains within a byte budget (oldest first, important ones marked as they always were); an attached channel session that pulls keeps one push's worth (#493).
1035
+ const batch = [...(preface ? [preface] : []), ...(this.peers.get(id)?.pullOnly ? this.takeBudget(id, queue) : this.take(id, queue))];
1016
1036
  let out: Envelope[] = [];
1017
1037
  const deliveryId = crypto.randomUUID();
1018
1038
  try {
@@ -1158,6 +1178,26 @@ export class Bus {
1158
1178
  return batch;
1159
1179
  }
1160
1180
 
1181
+ /**
1182
+ * #713: what one pull hands a pull-only peer. Oldest first, so the oldest ordinary item is never kept behind newer important ones (the push order is important
1183
+ * first; a pull is a catch-up). The budget counts each item's rendered bytes as the peer will read them (`frame`), and the first item always goes, so one huge
1184
+ * envelope cannot stall the queue. An envelope that already failed once still goes alone, as in `take`.
1185
+ */
1186
+ private takeBudget(id: PeerId, queue: Envelope[]): Envelope[] {
1187
+ const batch: Envelope[] = [];
1188
+ let bytes = 0;
1189
+ for (const env of queue) {
1190
+ const failedBefore = this.attempts.has(`${id}:${env.id}`);
1191
+ if (failedBefore && batch.length) break;
1192
+ const size = Buffer.byteLength(frame(env)) + 2;
1193
+ if (batch.length && bytes + size > PULL_BUDGET_BYTES) break;
1194
+ batch.push(env); bytes += size;
1195
+ if (failedBefore) break;
1196
+ }
1197
+ for (const env of batch) queue.splice(queue.indexOf(env), 1);
1198
+ return batch;
1199
+ }
1200
+
1161
1201
  private arm(id: PeerId, ms: number): void {
1162
1202
  clearTimeout(this.timers.get(id));
1163
1203
  const timer = setTimeout(() => void this.drain(id), ms);
package/src/hub/checks.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { spawn } from "node:child_process";
2
1
  import { childEnv } from "./child-process.ts";
3
2
 
4
3
  export interface CheckResult {
@@ -10,24 +9,64 @@ export interface CheckResult {
10
9
  tail: string;
11
10
  }
12
11
 
12
+ /** The part of a `Bun.spawn` child a check reads; tests substitute their own. */
13
+ export interface CheckProcess {
14
+ pid: number;
15
+ /** Resolves with the exit code once the child is gone (128 + signal for a signalled child). */
16
+ exited: Promise<number>;
17
+ signalCode: string | null;
18
+ stdout: ReadableStream<Uint8Array>;
19
+ stderr: ReadableStream<Uint8Array>;
20
+ }
21
+ export type CheckSpawn = (argv: string[], options: { cwd: string; env: NodeJS.ProcessEnv }) => CheckProcess;
22
+
23
+ /**
24
+ * Why this is `Bun.spawn` and not `node:child_process` (#558): under `bun test` on a loaded macOS runner, node's spawn can
25
+ * reap a quick child inside `spawn()` itself and emit its exit and close events (and destroy its pipes, output unread)
26
+ * before any listener exists (oven-sh/bun#45483). The check then waited for events that had come already and returned a
27
+ * timeout, empty tail included, after its limit plus the kill grace. `Bun.spawn` hands over `exited` and the stream
28
+ * readers as values, so an early exit is still there to read. Measured under CPU load, one quick check per test file:
29
+ * node:child_process failed 9 of 300 rounds of 60 files, `Bun.spawn` 0 of 300.
30
+ */
31
+ const bunSpawn: CheckSpawn = (argv, options) => Bun.spawn(argv, { cwd: options.cwd, env: options.env as Record<string, string>, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true });
32
+
13
33
  const TAIL_LINES = 40;
14
34
  const TAIL_CHARS = 4000;
15
35
  const KEEP_CHARS = 64 * 1024;
16
36
  /** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
17
37
  const DRAIN_MS = 500;
18
- /** How long a killed check may wait for its exit and close events before it settles without them (#558). */
38
+ /** How long a killed check may wait for its exit and its pipes before it settles without them (#558). */
19
39
  const KILL_GRACE_MS = 1000;
20
40
 
41
+ /** The last lines of the output, capped by lines and characters: the one shape every result's tail has, a failed spawn's included. */
42
+ const tailOf = (text: string): string => text.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
43
+
21
44
  /** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
22
- export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: typeof spawn = spawn): Promise<CheckResult> {
45
+ export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>, spawnChild: CheckSpawn = bunSpawn): Promise<CheckResult> {
23
46
  return new Promise((resolve) => {
24
47
  let out = "";
25
48
  let timedOut = false;
26
49
  let interrupted = false;
27
50
  let settled = false;
28
51
  let exited = false;
29
- const child = spawnChild("sh", ["-c", command], { cwd, env: childEnv(process.env), stdio: ["ignore", "pipe", "pipe"], detached: true });
52
+ const readers: { cancel(): Promise<unknown> }[] = [];
30
53
  let grace: ReturnType<typeof setTimeout> | undefined;
54
+ let drain: ReturnType<typeof setTimeout> | undefined;
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ let child: CheckProcess;
57
+ const stopListening = () => {
58
+ clearTimeout(timer);
59
+ clearTimeout(grace);
60
+ clearTimeout(drain);
61
+ for (const reader of readers) reader.cancel().catch(() => {});
62
+ };
63
+ const done = (code: number | null) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ stopListening();
67
+ running?.delete(stop);
68
+ resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail: tailOf(out) });
69
+ };
31
70
  const killGroup = () => {
32
71
  try {
33
72
  if (child.pid) process.kill(-child.pid, "SIGKILL");
@@ -35,51 +74,61 @@ export function runCheck(command: string, cwd: string, timeoutMs: number, runnin
35
74
  // already gone
36
75
  }
37
76
  };
38
- /** A kill that is a verdict (timeout, hub stop) settles even if the child's events never come. */
77
+ /** A kill that is a verdict (timeout, hub stop) settles even if the child's exit and pipes never report. */
39
78
  const killAndSettle = () => {
40
79
  killGroup();
41
80
  grace ??= setTimeout(() => done(null), KILL_GRACE_MS);
42
81
  };
43
- const stop = () => {
44
- if (exited || settled) return; // finished before the hub stopped, or already settled: its result stands and the group may be reused
82
+ function stop() {
83
+ if (exited || settled) return; // finished in time or already settled: its result stands and the group may be reused
45
84
  interrupted = true;
46
85
  killAndSettle();
47
- };
86
+ }
87
+ try {
88
+ child = spawnChild(["sh", "-c", command], { cwd, env: childEnv(process.env) });
89
+ } catch (e) {
90
+ resolve({ code: null, timedOut: false, tail: tailOf(e instanceof Error ? e.message : String(e)) });
91
+ return;
92
+ }
48
93
  running?.add(stop);
49
94
  const keep = (chunk: string) => {
50
95
  out += chunk;
51
96
  if (out.length > KEEP_CHARS) out = out.slice(-KEEP_CHARS);
52
97
  };
53
- for (const s of [child.stdout, child.stderr]) {
54
- s?.setEncoding("utf8"); // decodes across chunk edges, so Korean output is not cut into U+FFFD
55
- s?.on("data", keep);
56
- }
57
- const timer = setTimeout(() => {
98
+ /** Reads one pipe to its end; resolves when it closes, or when `done` cancels it. */
99
+ const pump = (stream: ReadableStream<Uint8Array>): Promise<void> => {
100
+ const reader = stream.getReader();
101
+ readers.push(reader);
102
+ const decoder = new TextDecoder(); // decodes across chunk edges, so Korean output is not cut into U+FFFD
103
+ return (async () => {
104
+ try {
105
+ for (;;) {
106
+ const { value, done: ended } = await reader.read();
107
+ if (ended) break;
108
+ keep(decoder.decode(value, { stream: true }));
109
+ }
110
+ keep(decoder.decode());
111
+ } catch {
112
+ // cancelled or broken: what was read stands
113
+ }
114
+ })();
115
+ };
116
+ const pipes = Promise.all([pump(child.stdout), pump(child.stderr)]);
117
+ timer = setTimeout(() => {
58
118
  timedOut = true;
59
119
  killAndSettle();
60
120
  }, timeoutMs);
61
- const done = (code: number | null) => {
62
- if (settled) return;
63
- settled = true;
64
- clearTimeout(timer);
65
- clearTimeout(grace);
66
- running?.delete(stop);
67
- child.stdout?.destroy();
68
- child.stderr?.destroy();
69
- const tail = out.replace(/\s+$/, "").split("\n").slice(-TAIL_LINES).join("\n").slice(-TAIL_CHARS);
70
- resolve({ code: timedOut || interrupted ? null : code, timedOut, ...(interrupted ? { interrupted } : {}), tail });
71
- };
72
- child.on("error", (e) => {
73
- out += `\n${e.message}`;
74
- done(null);
75
- });
76
- child.on("exit", (code) => {
121
+ child.exited.then((code) => {
77
122
  if (settled) return; // the kill grace settled it: the process group id is no longer ours to signal
78
123
  exited = true;
79
124
  clearTimeout(timer); // it finished in time; only its output may still be on the way
80
125
  killGroup(); // a background job it left behind (sh has no job control, so it is still in the group)
81
- setTimeout(() => done(code), DRAIN_MS);
126
+ const result = child.signalCode ? null : code;
127
+ drain = setTimeout(() => done(result), DRAIN_MS);
128
+ void pipes.then(() => done(result)); // every pipe closed: nothing more is coming
129
+ }, (e: unknown) => {
130
+ out += `\n${e instanceof Error ? e.message : String(e)}`;
131
+ done(null);
82
132
  });
83
- child.on("close", (code) => done(code));
84
133
  });
85
134
  }