@nanobpm/nano-workforce 0.26.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 (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
package/app/abandon.ts ADDED
@@ -0,0 +1,105 @@
1
+ // nano-workforce — cooperative abandon check (issue #76).
2
+ //
3
+ // Cancelling a mid-flight convergence/merge run terminates the engine instance, but the external
4
+ // `senior:*` agent servicing the current job keeps running and would still push a commit, open a
5
+ // PR, or re-request a review. The engine signal (the discarded job → a failing `completeJob`)
6
+ // arrives only AFTER that side effect. This module gives every side-effecting agent a per-PR
7
+ // capability URL it curls RIGHT BEFORE an irreversible action; a cancelled run returns
8
+ // `abandoned: true` and the agent stops without touching git.
9
+ //
10
+ // Design invariants (mirroring the blackboard, app/blackboard.ts):
11
+ // - CAPABILITY URL. The per-PR token IS the credential; the agent curls the exact URL it was
12
+ // handed in its prompt. An unknown token is a 404 (never leaks which PRs exist).
13
+ // - DERIVED, not a separate marker. `abandoned` is read straight off `pull_requests.status`,
14
+ // which `cancelRun` (app/service.ts) already sets to 'abandoned' on cancel. No new state to
15
+ // keep in sync.
16
+ // - ADVISORY. Like the blackboard, this never hard-locks; it narrows an unavoidable
17
+ // check-then-push (TOCTOU) window to near-zero. Job fencing in the harness (issue #76 layer 2)
18
+ // is what makes it airtight.
19
+ import type { DataLayer } from "@nanobpm/urban";
20
+ import { publicBaseUrl } from "./blackboard.ts";
21
+
22
+ /** The one app-row status that means "this run was cancelled". Convergence/merge terminal states
23
+ * `converged`/`merged` are NOT abandonment — only an explicit cancel flips a live run here. */
24
+ export const ABANDONED_STATUS = "abandoned";
25
+
26
+ /** True when a PR's app-row status means the run was cancelled and the agent must not act. */
27
+ export function isAbandoned(status: string | null | undefined): boolean {
28
+ return status === ABANDONED_STATUS;
29
+ }
30
+
31
+ /** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding).
32
+ * Same shape as the blackboard token; kept local so the two channels stay independent. */
33
+ export function mintAbandonToken(): string {
34
+ const bytes = new Uint8Array(24);
35
+ crypto.getRandomValues(bytes);
36
+ let bin = "";
37
+ for (const b of bytes) bin += String.fromCharCode(b);
38
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
39
+ }
40
+
41
+ /** The capability URL for a PR's abandon check: the token rides the query string so the agent can
42
+ * GET the exact string it was handed with no header assembly. */
43
+ export function abandonUrl(token: string, base: string = publicBaseUrl()): string {
44
+ return `${base}/hooks/abandon?token=${encodeURIComponent(token)}`;
45
+ }
46
+
47
+ /** Resolve an abandon token back to its PR key, or undefined when the token is unknown. */
48
+ export async function prKeyForAbandonToken(
49
+ data: DataLayer,
50
+ token: string,
51
+ ): Promise<string | undefined> {
52
+ if (!token) return undefined;
53
+ const row = await data
54
+ .table<{ pr_key: string; abandon_token: string | null }>("pull_requests", "pr_key")
55
+ .findOne({ abandon_token: token });
56
+ return row?.pr_key;
57
+ }
58
+
59
+ /** The abandon status of a PR, or undefined when the token is unknown. */
60
+ export async function abandonStatusForToken(
61
+ data: DataLayer,
62
+ token: string,
63
+ ): Promise<{ prKey: string; status: string; abandoned: boolean } | undefined> {
64
+ if (!token) return undefined;
65
+ const row = await data
66
+ .table<{ pr_key: string; abandon_token: string | null; status: string }>(
67
+ "pull_requests",
68
+ "pr_key",
69
+ )
70
+ .findOne({ abandon_token: token });
71
+ if (!row) return undefined;
72
+ return { prKey: row.pr_key, status: row.status, abandoned: isAbandoned(row.status) };
73
+ }
74
+
75
+ /** The instruction block appended (verbatim, via `appendPrompt`) to each side-effecting agent's
76
+ * prompt. It owns its own leading rule (the FEEL that injects it concatenates with no separator),
77
+ * and carries the concrete, curl-able URL for THIS run plus the abort contract. */
78
+ export function renderAbandonBrief(url: string): string {
79
+ return `
80
+
81
+ ---
82
+
83
+ ## Abort if this run was cancelled
84
+
85
+ This run can be **cancelled** by a human while you work. If it is, the orchestration instance is
86
+ gone and your eventual job completion will fail — so any commit, PR, or review you produce would be
87
+ an **orphaned side effect** on a run nobody is waiting for.
88
+
89
+ **Before every irreversible action — before you \`git push\`, open or update a PR, request a review,
90
+ or merge — check whether the run is still wanted:**
91
+
92
+ curl -fsS "${url}"
93
+
94
+ On success it returns \`{ "prKey": "...", "status": "...", "abandoned": true|false }\`. The \`-f\` is
95
+ important: it makes \`curl\` **exit non-zero on an HTTP error** (e.g. a 404 when the run has been torn
96
+ down), instead of silently printing an error body with exit 0.
97
+
98
+ - **Abort** — make no commits, push nothing, open no PR, request no review — if EITHER the command
99
+ **fails** (non-zero exit: the run was cancelled/torn down or the endpoint is unreachable) OR the
100
+ JSON reports \`"abandoned": true\`. Leave the working tree as-is and exit. A failure when you later
101
+ try to complete the job is EXPECTED after a cancel — do not treat it as an error to retry.
102
+ - **Proceed** only when the command **succeeds** AND reports \`"abandoned": false\` — and re-check
103
+ right before the push, since a cancel can land at any moment. Checking as late as possible keeps
104
+ the window tiny.`;
105
+ }
@@ -0,0 +1,35 @@
1
+ // Unit tests for the dead-end-base guard decision (#60).
2
+ import { assertEquals } from "jsr:@std/assert@1";
3
+ import { type BaseTarget, isDeadEndBase } from "./baseGuard.ts";
4
+
5
+ const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): BaseTarget => ({
6
+ base,
7
+ defaultBranch,
8
+ landed,
9
+ });
10
+
11
+ Deno.test("dead-end: a non-default base that has already landed", () => {
12
+ // The exact #54 case: base 'feat/coordination-blackboard' merged to 'main' but wasn't deleted.
13
+ assertEquals(isDeadEndBase(t("feat/coordination-blackboard", "main", "landed")), true);
14
+ });
15
+
16
+ Deno.test("NOT a dead-end: the base IS the default branch (the common straight-to-main PR)", () => {
17
+ // Even if a same-named 'main' somehow reported landed, a PR targeting the default branch is the
18
+ // normal terminal target — never a dead-end.
19
+ assertEquals(isDeadEndBase(t("main", "main", "landed")), false);
20
+ });
21
+
22
+ Deno.test("NOT a dead-end: a live stacked base whose PR is still open", () => {
23
+ assertEquals(isDeadEndBase(t("feat/tier2", "main", "open")), false);
24
+ });
25
+
26
+ Deno.test("NOT a dead-end on ambiguity: base has no PR / transport couldn't tell (unknown)", () => {
27
+ // We block only on a positive `landed` signal, so a legitimately-stacked feature branch that
28
+ // simply has no PR yet is never wrongly held.
29
+ assertEquals(isDeadEndBase(t("feat/tier2", "main", "unknown")), false);
30
+ });
31
+
32
+ Deno.test("unknown-safe: blank base or blank default is never a dead-end", () => {
33
+ assertEquals(isDeadEndBase(t("", "main", "landed")), false);
34
+ assertEquals(isDeadEndBase(t("feat/x", "", "landed")), false);
35
+ });
@@ -0,0 +1,62 @@
1
+ // Dead-end-base guard (#60).
2
+ //
3
+ // The merge stage was base-branch-blind: it ran `gh pr merge <n>` (or an enqueue) into whatever
4
+ // base the PR targeted, never checking that base. In a stacked-PR epic (the model of #49, where
5
+ // each decision PR stacks on the previous branch) a base branch can *itself* merge to the default
6
+ // branch. GitHub only auto-retargets an open PR when its base is DELETED on merge; a
7
+ // merged-but-undeleted base stays the target, `mergeStateStatus` reads CLEAN, and we would land
8
+ // the PR into a dead branch whose contents never reach the default branch.
9
+ //
10
+ // This module detects that case so the merge worker can escalate (retarget) instead of landing.
11
+
12
+ import { baseBranchLanded, fetchDefaultBranch, fetchPrBase } from "./github.ts";
13
+
14
+ /** The landing-target facts the dead-end decision is made from. */
15
+ export interface BaseTarget {
16
+ /** The PR's current base branch. */
17
+ base: string;
18
+ /** The repo's default branch (e.g. `main`). */
19
+ defaultBranch: string;
20
+ /** Whether the base branch has already landed — see {@link baseBranchLanded}. */
21
+ landed: "landed" | "open" | "unknown";
22
+ }
23
+
24
+ /** A base is a dead-end when it is **not** the default branch AND has already landed (a merged PR
25
+ * exists from it). Ambiguity (`open` / `unknown`) is deliberately never a dead-end: the guard
26
+ * blocks a merge only on a positive `landed` signal, so a legitimately-stacked PR whose base is
27
+ * still open — or a base with no PR at all — is never wrongly held. A blank base or default is
28
+ * unknown-safe → not a dead-end. */
29
+ export function isDeadEndBase(t: BaseTarget): boolean {
30
+ if (!t.base || !t.defaultBranch) return false;
31
+ if (t.base === t.defaultBranch) return false;
32
+ return t.landed === "landed";
33
+ }
34
+
35
+ export interface BaseGuardResult {
36
+ deadEnd: boolean;
37
+ base: string;
38
+ defaultBranch: string;
39
+ landed: "landed" | "open" | "unknown";
40
+ }
41
+
42
+ /** Resolve a PR's landing target and decide whether it is a dead-end. Cheap for the common case:
43
+ * a PR that targets the default branch short-circuits before the `baseBranchLanded` lookup. Best
44
+ * effort — when a fact can't be resolved (no transport / GitHub hiccup) it returns `deadEnd:false`
45
+ * so the guard never blocks a merge on ambiguity; the caller may `.catch()` a transport throw to
46
+ * the same effect. */
47
+ export async function checkBaseTarget(
48
+ repo: string,
49
+ number: number | string,
50
+ token: string,
51
+ ): Promise<BaseGuardResult> {
52
+ const [base, defaultBranch] = await Promise.all([
53
+ fetchPrBase(repo, number, token),
54
+ fetchDefaultBranch(repo, token),
55
+ ]);
56
+ if (!base || !defaultBranch || base === defaultBranch) {
57
+ return { deadEnd: false, base: base ?? "", defaultBranch: defaultBranch ?? "", landed: "unknown" };
58
+ }
59
+ const landed = await baseBranchLanded(repo, base, token);
60
+ const target: BaseTarget = { base, defaultBranch, landed };
61
+ return { deadEnd: isDeadEndBase(target), base, defaultBranch, landed };
62
+ }
@@ -0,0 +1,295 @@
1
+ // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
+ import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
3
+ import type { DataLayer } from "@nanobpm/urban";
4
+ import {
5
+ appendEntry,
6
+ blackboardUrl,
7
+ detectFileClaimConflicts,
8
+ mintBlackboardToken,
9
+ normalizeKind,
10
+ planKeyForToken,
11
+ publicBaseUrl,
12
+ readBlackboard,
13
+ readBlackboardPage,
14
+ renderCoordinationBrief,
15
+ } from "./blackboard.ts";
16
+
17
+ // A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
18
+ // blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
19
+ // deno-lint-ignore no-explicit-any
20
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
21
+ // deno-lint-ignore no-explicit-any
22
+ const stores: Record<string, any[]> = {};
23
+ const seq: Record<string, number> = {};
24
+ function tbl(name: string, pk = "id") {
25
+ // deno-lint-ignore no-explicit-any
26
+ const rows = (stores[name] ??= [] as any[]);
27
+ return {
28
+ // deno-lint-ignore no-explicit-any require-await
29
+ async insert(row: any) {
30
+ if (pk === "id") {
31
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
32
+ rows.push({ id, ...row });
33
+ return id;
34
+ }
35
+ rows.push({ ...row });
36
+ return row[pk];
37
+ },
38
+ // deno-lint-ignore no-explicit-any require-await
39
+ async find(where: any = {}) {
40
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
41
+ },
42
+ // deno-lint-ignore no-explicit-any require-await
43
+ async findOne(where: any = {}) {
44
+ return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
45
+ },
46
+ };
47
+ }
48
+ // deno-lint-ignore no-explicit-any
49
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
50
+ return { data, stores };
51
+ }
52
+
53
+ Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
54
+ const a = mintBlackboardToken();
55
+ const b = mintBlackboardToken();
56
+ assert(a !== b, "two mints must differ");
57
+ assert(/^[A-Za-z0-9_-]+$/.test(a), `token must be URL-safe base64url, got ${a}`);
58
+ assert(a.length >= 32, "token should carry enough entropy");
59
+ });
60
+
61
+ Deno.test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
62
+ assertEquals(publicBaseUrl("https://pr.example.com/"), "https://pr.example.com");
63
+ assertEquals(publicBaseUrl("https://pr.example.com///"), "https://pr.example.com");
64
+ });
65
+
66
+ Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
67
+ const prev = process.env.NANO_PR_BASE_URL;
68
+ delete process.env.NANO_PR_BASE_URL;
69
+ try {
70
+ assertEquals(publicBaseUrl(""), "http://localhost:3000");
71
+ assertEquals(publicBaseUrl(" "), "http://localhost:3000");
72
+ assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/hooks/blackboard?token=t");
73
+ } finally {
74
+ if (prev === undefined) delete process.env.NANO_PR_BASE_URL;
75
+ else process.env.NANO_PR_BASE_URL = prev;
76
+ }
77
+ });
78
+
79
+ Deno.test("blackboardUrl: capability token rides the query string", () => {
80
+ assertEquals(
81
+ blackboardUrl("tok+en/x", "https://h"),
82
+ "https://h/hooks/blackboard?token=tok%2Ben%2Fx",
83
+ );
84
+ });
85
+
86
+ Deno.test("normalizeKind: valid passes through, anything else becomes note", () => {
87
+ assertEquals(normalizeKind("file-claim"), "file-claim");
88
+ assertEquals(normalizeKind("constraint-change"), "constraint-change");
89
+ assertEquals(normalizeKind("bogus"), "note");
90
+ assertEquals(normalizeKind(undefined), "note");
91
+ });
92
+
93
+ Deno.test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
94
+ const url = "https://h/hooks/blackboard?token=abc";
95
+ const brief = renderCoordinationBrief(url);
96
+ assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
97
+ assertStringIncludes(brief, url);
98
+ // read + write halves of the protocol
99
+ assertStringIncludes(brief, "curl -s");
100
+ assertStringIncludes(brief, "-X POST");
101
+ assertStringIncludes(brief, "author_task");
102
+ assertStringIncludes(brief, "file-claim");
103
+ assertStringIncludes(brief, "dedupe_key");
104
+ // Tier 2: teaches incremental re-reading via the cursor and reacting to a claim conflict.
105
+ assertStringIncludes(brief, "cursor");
106
+ assertStringIncludes(brief, "&since=");
107
+ assertStringIncludes(brief, "conflicts");
108
+ });
109
+
110
+ Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
111
+ const { data } = memData();
112
+ await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
113
+ assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
114
+ assertEquals(await planKeyForToken(data, "nope"), undefined);
115
+ assertEquals(await planKeyForToken(data, ""), undefined);
116
+ });
117
+
118
+ Deno.test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
119
+ const { data } = memData();
120
+ await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
121
+ await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
122
+ await appendEntry(data, "o/r#2", { body: "other plan" }); // must not leak across plans
123
+
124
+ const entries = await readBlackboard(data, "o/r#1");
125
+ assertEquals(entries.map((e) => e.author_task), ["gap-2", "gap-8"], "write order, scoped to plan");
126
+ assertEquals(entries[0].files, ["a.rs"], "files decoded to an array");
127
+ assertEquals(entries[1].files, [], "no files → empty array");
128
+ assertEquals(entries[1].author_task, "gap-8");
129
+ });
130
+
131
+ Deno.test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
132
+ const { data } = memData();
133
+ await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
134
+ const [e] = await readBlackboard(data, "p");
135
+ assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
136
+ });
137
+
138
+ Deno.test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
139
+ const { data } = memData();
140
+ await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
141
+ const [e] = await readBlackboard(data, "p");
142
+ assertEquals(e.author_task, "system");
143
+ assertEquals(e.kind, "note");
144
+ });
145
+
146
+ Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
147
+ const { data, stores } = memData();
148
+ const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
149
+ const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
150
+ assertEquals(first.inserted, true);
151
+ assertEquals(again.inserted, false, "second write with same dedupe_key is a no-op");
152
+ assertEquals(again.id, first.id, "returns the existing id");
153
+ assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
154
+ });
155
+
156
+ Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
157
+ // Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
158
+ // pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
159
+ // catch branch must re-read the winner's row and return it rather than propagate the throw.
160
+ const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
161
+ let preCheckDone = false;
162
+ // deno-lint-ignore no-explicit-any
163
+ const table: any = {
164
+ // deno-lint-ignore require-await
165
+ async findOne() {
166
+ // Pre-check misses (row not yet visible); the recovery read after the collision hits.
167
+ if (!preCheckDone) {
168
+ preCheckDone = true;
169
+ return undefined;
170
+ }
171
+ return winner;
172
+ },
173
+ // deno-lint-ignore require-await
174
+ async insert() {
175
+ throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
176
+ code: "SQLITE_CONSTRAINT_UNIQUE",
177
+ });
178
+ },
179
+ };
180
+ // deno-lint-ignore no-explicit-any
181
+ const data = { table: () => table } as any as DataLayer;
182
+ const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
183
+ assertEquals(res.inserted, false, "a lost race is not a fresh insert");
184
+ assertEquals(res.id, 42, "returns the winning row's id");
185
+ });
186
+
187
+ Deno.test("appendEntry: a blank body is rejected", async () => {
188
+ const { data } = memData();
189
+ let threw = false;
190
+ try {
191
+ await appendEntry(data, "p", { body: " " });
192
+ } catch {
193
+ threw = true;
194
+ }
195
+ assert(threw, "blank body must throw");
196
+ });
197
+
198
+ Deno.test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
199
+ const { data } = memData();
200
+ await appendEntry(data, "p", { body: "one" });
201
+ await appendEntry(data, "p", { body: "two" });
202
+ await appendEntry(data, "p", { body: "three" });
203
+ const all = await readBlackboard(data, "p");
204
+ const tail = await readBlackboard(data, "p", { since: all[0].id });
205
+ assertEquals(tail.map((e) => e.body), ["two", "three"]);
206
+ });
207
+
208
+ Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
209
+ const { data } = memData();
210
+ await appendEntry(data, "p", { body: "one" });
211
+ await appendEntry(data, "p", { body: "two" });
212
+
213
+ const first = await readBlackboardPage(data, "p");
214
+ assertEquals(first.entries.map((e) => e.body), ["one", "two"]);
215
+ assertEquals(first.cursor, first.entries[1].id, "cursor is the head id");
216
+
217
+ // Poll again from the cursor: nothing new, and the cursor holds at the head (not reset to 0).
218
+ const caughtUp = await readBlackboardPage(data, "p", { since: first.cursor });
219
+ assertEquals(caughtUp.entries, []);
220
+ assertEquals(caughtUp.cursor, first.cursor, "a caught-up poll keeps the head cursor");
221
+
222
+ // A sibling posts; the next poll from the cursor returns only the new entry and advances.
223
+ await appendEntry(data, "p", { body: "three" });
224
+ const next = await readBlackboardPage(data, "p", { since: first.cursor });
225
+ assertEquals(next.entries.map((e) => e.body), ["three"]);
226
+ assertEquals(next.cursor, next.entries[0].id);
227
+ });
228
+
229
+ Deno.test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
230
+ const { data } = memData();
231
+ const page = await readBlackboardPage(data, "empty");
232
+ assertEquals(page.entries, []);
233
+ assertEquals(page.cursor, 0);
234
+ });
235
+
236
+ Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
237
+ const { data } = memData();
238
+ await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
239
+
240
+ const conflicts = await detectFileClaimConflicts(data, "p", {
241
+ author_task: "gap-8",
242
+ files: ["engine/state.rs", "engine/mine.rs"],
243
+ });
244
+ assertEquals(conflicts.length, 1, "only the overlapping file is a conflict");
245
+ assertEquals(conflicts[0].file, "engine/state.rs");
246
+ assertEquals(conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
247
+ });
248
+
249
+ Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
250
+ const { data } = memData();
251
+ await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
252
+ await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
253
+
254
+ // Re-claiming my own file: no self-conflict, and the sibling's note (not a file-claim) is ignored.
255
+ assertEquals(
256
+ await detectFileClaimConflicts(data, "p", { author_task: "gap-2", files: ["a.rs"] }),
257
+ [],
258
+ );
259
+ // No files to claim → nothing to conflict on.
260
+ assertEquals(await detectFileClaimConflicts(data, "p", { author_task: "gap-9", files: [] }), []);
261
+ });
262
+
263
+ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
264
+ const { data } = memData();
265
+ const prior = await appendEntry(data, "p", {
266
+ author_task: "gap-2",
267
+ kind: "file-claim",
268
+ files: ["a.rs"],
269
+ body: "prior sibling claim",
270
+ });
271
+ const mine = await appendEntry(data, "p", {
272
+ author_task: "gap-8",
273
+ kind: "file-claim",
274
+ files: ["a.rs"],
275
+ body: "my claim",
276
+ });
277
+ const later = await appendEntry(data, "p", {
278
+ author_task: "gap-9",
279
+ kind: "file-claim",
280
+ files: ["a.rs"],
281
+ body: "sibling that claimed after me",
282
+ });
283
+
284
+ // Computed after my insert, filtered to id < mine: only the strictly-prior sibling is a conflict —
285
+ // my own row and the later sibling's row are excluded even though both overlap the file.
286
+ const conflicts = await detectFileClaimConflicts(data, "p", {
287
+ author_task: "gap-8",
288
+ files: ["a.rs"],
289
+ beforeId: Number(mine.id),
290
+ });
291
+ assertEquals(conflicts.length, 1);
292
+ assertEquals(conflicts[0].id, Number(prior.id));
293
+ assertEquals(conflicts[0].author_task, "gap-2");
294
+ assert(Number(later.id) > Number(mine.id));
295
+ });