agent-dealer 0.2.0 → 1.0.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 (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,65 @@
1
+ // Regression test for the bug `npm run dev` hit against any database that ran migrate()
2
+ // between NOT-93 (introduced `authority_attempts`) and NOT-91 (added its `stale_at`
3
+ // column + an index on it): schema.sql's own `CREATE INDEX idx_authority_attempts_stale
4
+ // ON authority_attempts(status, stale_at)` threw "no such column: stale_at" against the
5
+ // pre-existing table, aborting migrate() before it ever reached the incremental-ALTER
6
+ // section below it — see db/index.ts's pre-schema.sql guard.
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-authattempt-migrate-"));
13
+ const { migrate, getDb } = await import("./index.js");
14
+ test("migrate() upgrades a pre-NOT-91 authority_attempts table that has no stale_at column", () => {
15
+ migrate();
16
+ const db = getDb();
17
+ // Recreate the pre-NOT-91 world: the table and its two original indexes, but without
18
+ // stale_at or the index on it — exactly what a database that last ran migrate() between
19
+ // NOT-93 and NOT-91 landing would have.
20
+ db.exec(`
21
+ DROP INDEX IF EXISTS idx_authority_attempts_owner;
22
+ DROP INDEX IF EXISTS idx_authority_attempts_stale;
23
+ DROP INDEX IF EXISTS idx_authority_attempts_status;
24
+ DROP TABLE authority_attempts;
25
+ CREATE TABLE authority_attempts (
26
+ id TEXT PRIMARY KEY,
27
+ owner_kind TEXT NOT NULL,
28
+ owner_id TEXT NOT NULL,
29
+ idempotency_key TEXT NOT NULL,
30
+ authority_id TEXT,
31
+ deck_id TEXT NOT NULL,
32
+ run_id TEXT NOT NULL,
33
+ attempt_id TEXT NOT NULL,
34
+ ttl_ms INTEGER NOT NULL,
35
+ tool_scope_hint_json TEXT,
36
+ status TEXT NOT NULL,
37
+ expires_at TEXT,
38
+ created_at TEXT NOT NULL,
39
+ updated_at TEXT NOT NULL
40
+ );
41
+ CREATE INDEX idx_authority_attempts_owner ON authority_attempts(owner_kind, owner_id);
42
+ CREATE INDEX idx_authority_attempts_status ON authority_attempts(status);
43
+ `);
44
+ const preFixCols = db.prepare("PRAGMA table_info(authority_attempts)").all();
45
+ assert.ok(!preFixCols.some((c) => c.name === "stale_at"), "test setup must reproduce the pre-fix table exactly");
46
+ const now = new Date().toISOString();
47
+ db.prepare(`INSERT INTO authority_attempts (id, owner_kind, owner_id, idempotency_key, deck_id, run_id, attempt_id, ttl_ms, status, created_at, updated_at)
48
+ VALUES ('a1', 'developer', 'issue-1:developer', 'a1:1', 'deck-1', 'run-1', 'a1', 60000, 'acquiring', ?, ?)`).run(now, now);
49
+ // This is the actual regression: pre-fix, this throws "no such column: stale_at" from
50
+ // schema.sql's own CREATE INDEX, before ever reaching an incremental ALTER.
51
+ assert.doesNotThrow(() => migrate());
52
+ const cols = db.prepare("PRAGMA table_info(authority_attempts)").all();
53
+ assert.ok(cols.some((c) => c.name === "stale_at"), "stale_at must exist after migrate()");
54
+ // The pre-existing row survived the migration untouched.
55
+ const row = db.prepare("SELECT stale_at FROM authority_attempts WHERE id = 'a1'").get();
56
+ assert.equal(row.stale_at, null);
57
+ // The stale-tracking index now exists and is actually usable.
58
+ db.exec("UPDATE authority_attempts SET stale_at = '2026-01-01T00:00:00.000Z' WHERE id = 'a1'");
59
+ const staleRows = db
60
+ .prepare("SELECT id FROM authority_attempts WHERE status = 'acquiring' AND stale_at IS NOT NULL")
61
+ .all();
62
+ assert.deepEqual(staleRows.map((r) => r.id), ["a1"]);
63
+ // A second migrate() is a no-op (column already present) and still starts cleanly.
64
+ assert.doesNotThrow(() => migrate());
65
+ });
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-idem-migrate-"));
7
+ const { migrate, getDb } = await import("./index.js");
8
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
9
+ const { createIssue } = await import("../repository/issues.js");
10
+ test("migrate() upgrades a pre-fix non-unique idempotency index that already holds duplicates", () => {
11
+ migrate();
12
+ const db = getDb();
13
+ const issue = createIssue({
14
+ title: "T",
15
+ repo: "/r",
16
+ baseBranch: "main",
17
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
18
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
19
+ maxReviewRounds: 3,
20
+ maxInfraAttempts: 3,
21
+ source: "manual",
22
+ });
23
+ // Recreate the pre-fix world: non-unique index + duplicate provider keys.
24
+ db.exec("DROP INDEX idx_workflow_events_idempotency");
25
+ db.exec("CREATE INDEX idx_workflow_events_idempotency ON workflow_events(idempotency_key) WHERE idempotency_key IS NOT NULL");
26
+ const insert = db.prepare(`INSERT INTO workflow_events (id, issue_id, type, actor_type, stage, idempotency_key, causation_event_id, ts)
27
+ VALUES (?, ?, 'pull_request.updated', 'system', 'developing', ?, ?, ?)`);
28
+ insert.run("e1", issue.id, "dup-key", null, "2026-01-01T00:00:00.000Z");
29
+ insert.run("e2", issue.id, "dup-key", null, "2026-01-01T00:00:01.000Z");
30
+ insert.run("e3", issue.id, "dup-key", null, "2026-01-01T00:00:02.000Z");
31
+ insert.run("e4", issue.id, "other-key", null, "2026-01-01T00:00:03.000Z");
32
+ // A downstream event whose recorded cause is a duplicate that will be discarded.
33
+ insert.run("e5", issue.id, "later-key", "e2", "2026-01-01T00:00:04.000Z");
34
+ // A duplicate row that itself points at another duplicate.
35
+ insert.run("e6", issue.id, "dup-key", "e3", "2026-01-01T00:00:05.000Z");
36
+ assert.doesNotThrow(() => migrate());
37
+ // The first event recorded for the repeated key is kept; later copies dropped.
38
+ const kept = db
39
+ .prepare("SELECT id FROM workflow_events WHERE idempotency_key = 'dup-key' ORDER BY id")
40
+ .all();
41
+ assert.deepStrictEqual(kept.map((r) => r.id), ["e1"]);
42
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM workflow_events WHERE idempotency_key = 'other-key'").get().c, 1);
43
+ // Causality is remapped to the canonical event, not left dangling or dropped.
44
+ assert.equal(db.prepare("SELECT causation_event_id x FROM workflow_events WHERE id = 'e5'").get().x, "e1");
45
+ // Every causation reference still resolves to a surviving event.
46
+ assert.equal(db
47
+ .prepare(`SELECT COUNT(*) c FROM workflow_events e
48
+ WHERE e.causation_event_id IS NOT NULL
49
+ AND NOT EXISTS (SELECT 1 FROM workflow_events p WHERE p.id = e.causation_event_id)`)
50
+ .get().c, 0);
51
+ // The index is now unique and actually enforced.
52
+ const idx = db.prepare("PRAGMA index_list(workflow_events)").all().find((i) => i.name === "idx_workflow_events_idempotency");
53
+ assert.equal(idx?.unique, 1);
54
+ assert.throws(() => insert.run("e7", issue.id, "dup-key", null, "2026-01-01T00:00:06.000Z"), /UNIQUE constraint failed: workflow_events\.idempotency_key/);
55
+ // A second migrate() is a no-op (index already unique) and still starts cleanly.
56
+ assert.doesNotThrow(() => migrate());
57
+ });
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import Database from "better-sqlite3";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
- import { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID, CURSOR_DEFAULT_MODEL, } from "@agent-dealer/shared";
7
+ import { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID, BUILTIN_AGENT_CODEX_ID, CURSOR_DEFAULT_MODEL, } from "@agent-dealer/shared";
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  export function getDataDir() {
10
10
  const home = process.env.AGENT_DEALER_HOME ?? path.join(os.homedir(), ".agent-dealer");
@@ -23,8 +23,35 @@ export function getDb() {
23
23
  }
24
24
  return dbInstance;
25
25
  }
26
+ /** Tests only — drops the cached connection so a later getDb()/migrate() reopens against
27
+ * whatever AGENT_DEALER_HOME is set to next, instead of silently reusing a stale handle
28
+ * to the previous test's database. */
29
+ export function closeDb() {
30
+ dbInstance?.close();
31
+ dbInstance = null;
32
+ }
26
33
  export function migrate() {
27
34
  const db = getDb();
35
+ // NOT-91 added `stale_at` (and an index on it) to `authority_attempts`, but that table
36
+ // itself was introduced earlier (NOT-93). Every other incremental ALTER in this function
37
+ // runs AFTER the schema.sql exec below because schema.sql's CREATE TABLE/INDEX
38
+ // statements are themselves idempotent (IF NOT EXISTS) against an already-current table.
39
+ // This one column can't wait that long: schema.sql's own
40
+ // `CREATE INDEX idx_authority_attempts_stale ON authority_attempts(status, stale_at)`
41
+ // throws "no such column: stale_at" against a pre-NOT-91 `authority_attempts` table
42
+ // before the exec below ever reaches the incremental-ALTER section — aborting migrate()
43
+ // (and every table's schema updates after it) for any database that ran migrate()
44
+ // between NOT-93 and NOT-91 landing. Must run first, and only if the table already
45
+ // exists — a brand-new database gets the column from schema.sql's CREATE TABLE directly.
46
+ const authorityAttemptsExists = db
47
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'authority_attempts'")
48
+ .get();
49
+ if (authorityAttemptsExists) {
50
+ const authorityAttemptCols = db.prepare("PRAGMA table_info(authority_attempts)").all();
51
+ if (!authorityAttemptCols.some((c) => c.name === "stale_at")) {
52
+ db.exec("ALTER TABLE authority_attempts ADD COLUMN stale_at TEXT");
53
+ }
54
+ }
28
55
  const schema = readFileSync(path.join(__dirname, "schema.sql"), "utf8");
29
56
  db.exec(schema);
30
57
  const runCols = db.prepare("PRAGMA table_info(runs)").all();
@@ -55,12 +82,333 @@ export function migrate() {
55
82
  db.exec("ALTER TABLE runs ADD COLUMN plan_model TEXT");
56
83
  db.exec("ALTER TABLE runs ADD COLUMN execute_model TEXT");
57
84
  }
85
+ // NOT-60: role-neutral agent-profile columns + the frozen per-session execution
86
+ // snapshot. Additive ALTERs (SQLite has no ADD COLUMN IF NOT EXISTS), guarded by a
87
+ // PRAGMA check so re-running migrate() is a no-op.
88
+ const agentCols4 = db.prepare("PRAGMA table_info(agents)").all();
89
+ if (!agentCols4.some((c) => c.name === "default_model")) {
90
+ db.exec("ALTER TABLE agents ADD COLUMN default_model TEXT");
91
+ db.exec("ALTER TABLE agents ADD COLUMN default_budget_json TEXT");
92
+ db.exec("ALTER TABLE agents ADD COLUMN purpose TEXT");
93
+ db.exec("ALTER TABLE agents ADD COLUMN playbook_ids_json TEXT");
94
+ db.exec("ALTER TABLE agents ADD COLUMN external_memory_refs_json TEXT");
95
+ db.exec("ALTER TABLE agents ADD COLUMN permission_policy_json TEXT");
96
+ // Backfill the role-neutral defaults from the legacy phase columns — execute first,
97
+ // then plan (design §`agents`). The old columns stay legacy-readable for one release.
98
+ db.exec(`
99
+ UPDATE agents SET
100
+ default_model = COALESCE(default_execute_model, default_plan_model),
101
+ default_budget_json = COALESCE(default_execute_budget_json, default_plan_budget_json)
102
+ WHERE default_model IS NULL AND default_budget_json IS NULL
103
+ `);
104
+ }
105
+ // NOT-81: reasoning effort on the agent profile (frozen into the session snapshot).
106
+ const agentCols5 = db.prepare("PRAGMA table_info(agents)").all();
107
+ if (!agentCols5.some((c) => c.name === "default_effort")) {
108
+ db.exec("ALTER TABLE agents ADD COLUMN default_effort TEXT");
109
+ }
110
+ const workerSessionCols = db.prepare("PRAGMA table_info(worker_sessions)").all();
111
+ if (!workerSessionCols.some((c) => c.name === "profile_snapshot_json")) {
112
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN profile_snapshot_json TEXT");
113
+ }
114
+ // NOT-124: spawned-CLI liveness evidence. Existing rows stay NULL, which recovery reads
115
+ // as "no evidence" and falls back to the timestamp-only reclaim it has always done.
116
+ //
117
+ // Each column is guarded on its OWN presence, not on the pair. Every `ALTER TABLE` here
118
+ // commits separately, so a process killed between two of them leaves the table half
119
+ // upgraded — and a guard keyed on the first column would then skip the block forever
120
+ // while every worker_sessions INSERT still names the second, failing permanently. Checked
121
+ // independently, the same pass that could have produced that state also repairs it.
122
+ if (!workerSessionCols.some((c) => c.name === "process_pid")) {
123
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN process_pid INTEGER");
124
+ }
125
+ if (!workerSessionCols.some((c) => c.name === "process_owner")) {
126
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN process_owner TEXT");
127
+ }
128
+ // NOT-131: portable liveness evidence. Added NULL on existing rows, which processLiveness
129
+ // reads as "unknown" — a session in flight across the upgrade degrades to timestamp-only
130
+ // reclaim rather than being mistaken for alive.
131
+ if (!workerSessionCols.some((c) => c.name === "process_started_at")) {
132
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN process_started_at TEXT");
133
+ }
134
+ // Tighten the workflow-event idempotency index to UNIQUE for DBs created before the
135
+ // constraint (schema.sql's IF NOT EXISTS won't upgrade an existing non-unique index).
136
+ // A pre-fix DB may already hold duplicate keys — the old index was non-unique and
137
+ // appendWorkflowEvent() inserted unconditionally — so dedupe first, then swap the
138
+ // index, all inside one transaction so a failure never leaves the table indexless.
139
+ const wfIdemIdx = db.prepare("PRAGMA index_list(workflow_events)").all();
140
+ const nonUnique = wfIdemIdx.find((i) => i.name === "idx_workflow_events_idempotency" && i.unique === 0);
141
+ if (nonUnique) {
142
+ db.transaction(() => {
143
+ // Preservation policy: for each repeated provider key keep the first event
144
+ // recorded for it (lowest rowid = earliest insert) and drop the later copies —
145
+ // a duplicate delivery should collapse to its original, matching the runtime
146
+ // ON CONFLICT DO NOTHING behaviour.
147
+ //
148
+ // causation_event_id is a self-FK, so first repoint any reference that points at
149
+ // a discarded duplicate to the canonical (kept) event for that key — otherwise
150
+ // the DELETE below fails with FOREIGN KEY constraint failed and rolls back.
151
+ db.exec(`
152
+ WITH canon AS (
153
+ SELECT idempotency_key, MIN(rowid) AS keep_rowid
154
+ FROM workflow_events
155
+ WHERE idempotency_key IS NOT NULL
156
+ GROUP BY idempotency_key
157
+ ),
158
+ remap AS (
159
+ SELECT e.id AS dup_id, k.id AS canon_id
160
+ FROM workflow_events e
161
+ JOIN canon c ON c.idempotency_key = e.idempotency_key
162
+ JOIN workflow_events k ON k.rowid = c.keep_rowid
163
+ WHERE e.idempotency_key IS NOT NULL AND e.rowid <> c.keep_rowid
164
+ )
165
+ UPDATE workflow_events
166
+ SET causation_event_id = remap.canon_id
167
+ FROM remap
168
+ WHERE workflow_events.causation_event_id = remap.dup_id
169
+ `);
170
+ db.exec(`
171
+ DELETE FROM workflow_events
172
+ WHERE idempotency_key IS NOT NULL
173
+ AND rowid NOT IN (
174
+ SELECT MIN(rowid) FROM workflow_events
175
+ WHERE idempotency_key IS NOT NULL
176
+ GROUP BY idempotency_key
177
+ )
178
+ `);
179
+ db.exec("DROP INDEX idx_workflow_events_idempotency");
180
+ db.exec("CREATE UNIQUE INDEX idx_workflow_events_idempotency ON workflow_events(idempotency_key) WHERE idempotency_key IS NOT NULL");
181
+ })();
182
+ }
183
+ const artifactCols = db.prepare("PRAGMA table_info(artifacts)").all();
184
+ if (!artifactCols.some((c) => c.name === "issue_id")) {
185
+ // Additive per spec §"artifacts": issue_id/worker_session_id are nullable here since
186
+ // existing rows predate the issue model — every row the migration or new coordinator
187
+ // writes going forward populates issue_id.
188
+ db.exec("ALTER TABLE artifacts ADD COLUMN issue_id TEXT REFERENCES issues(id)");
189
+ db.exec("ALTER TABLE artifacts ADD COLUMN worker_session_id TEXT REFERENCES worker_sessions(id)");
190
+ }
191
+ // NOT-57 Task 1 amendment: issue-linked artifacts have no run, so run_id must be
192
+ // nullable — but SQLite can't ALTER a column's NOT NULL away, so rebuild the table for
193
+ // any database created before schema.sql dropped the constraint (fresh databases from
194
+ // the updated schema.sql never hit this branch).
195
+ const artifactRunIdCol = db.prepare("PRAGMA table_info(artifacts)").all().find((c) => c.name === "run_id");
196
+ if (artifactRunIdCol?.notnull === 1) {
197
+ db.exec(`
198
+ CREATE TABLE artifacts_new (
199
+ id TEXT PRIMARY KEY,
200
+ run_id TEXT REFERENCES runs(id),
201
+ kind TEXT NOT NULL,
202
+ content_json TEXT,
203
+ blob_path TEXT,
204
+ author TEXT NOT NULL,
205
+ created_at TEXT NOT NULL,
206
+ issue_id TEXT REFERENCES issues(id),
207
+ worker_session_id TEXT REFERENCES worker_sessions(id)
208
+ );
209
+ INSERT INTO artifacts_new (id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id)
210
+ SELECT id, run_id, kind, content_json, blob_path, author, created_at, issue_id, worker_session_id FROM artifacts;
211
+ DROP TABLE artifacts;
212
+ ALTER TABLE artifacts_new RENAME TO artifacts;
213
+ CREATE INDEX IF NOT EXISTS idx_artifacts_run ON artifacts(run_id);
214
+ `);
215
+ }
216
+ // NOT-63: infra-attempt budget, separate from the review-round budget above — a flaky
217
+ // session/git/gh/Agent Deck/publish failure must not spend the same counter as a
218
+ // reviewer's genuine `changes_requested`.
219
+ const issueCols = db.prepare("PRAGMA table_info(issues)").all();
220
+ if (!issueCols.some((c) => c.name === "max_infra_attempts")) {
221
+ db.exec("ALTER TABLE issues ADD COLUMN max_infra_attempts INTEGER NOT NULL DEFAULT 3");
222
+ db.exec("ALTER TABLE issues ADD COLUMN infra_attempts INTEGER NOT NULL DEFAULT 0");
223
+ }
224
+ // NOT-102: per-issue auto-merge after reviewer approve (kick UI defaults on; existing
225
+ // rows stay off so in-flight workflows keep the human final_review path).
226
+ const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
227
+ if (!issueColsForAutoMerge.some((c) => c.name === "auto_merge")) {
228
+ db.exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
229
+ }
230
+ // NOT-93: Deck's own correlation id for the INTERACTION_REQUIRED response that raised a
231
+ // deck_interaction_required action — lets a repeated signal for the same request dedupe
232
+ // to one open action instead of piling up duplicates.
233
+ const humanActionCols = db.prepare("PRAGMA table_info(human_actions)").all();
234
+ if (!humanActionCols.some((c) => c.name === "request_id")) {
235
+ db.exec("ALTER TABLE human_actions ADD COLUMN request_id TEXT");
236
+ }
237
+ // NOT-95: a Run-scoped action (outbound-draft delivery parking) has no Issue, so issue_id
238
+ // must become nullable — SQLite can't ALTER a column's NOT NULL away, so rebuild the table
239
+ // for any database created before schema.sql dropped the constraint (fresh databases from
240
+ // the updated schema.sql never hit this branch). Same recipe as the artifacts_new rebuild
241
+ // above.
242
+ const humanActionIssueIdCol = db.prepare("PRAGMA table_info(human_actions)").all().find((c) => c.name === "issue_id");
243
+ if (humanActionIssueIdCol?.notnull === 1) {
244
+ db.exec(`
245
+ CREATE TABLE human_actions_new (
246
+ id TEXT PRIMARY KEY,
247
+ issue_id TEXT REFERENCES issues(id),
248
+ run_id TEXT REFERENCES runs(id),
249
+ workflow_instance_id TEXT REFERENCES workflow_instances(id),
250
+ action_type TEXT NOT NULL,
251
+ reason TEXT NOT NULL,
252
+ question TEXT NOT NULL,
253
+ evidence_json TEXT,
254
+ response_options_json TEXT,
255
+ continuation_preview_json TEXT,
256
+ request_id TEXT,
257
+ status TEXT NOT NULL,
258
+ resolution_json TEXT,
259
+ resolved_by TEXT,
260
+ requested_at TEXT NOT NULL,
261
+ resolved_at TEXT
262
+ );
263
+ INSERT INTO human_actions_new (
264
+ id, issue_id, workflow_instance_id, action_type, reason, question, evidence_json,
265
+ response_options_json, continuation_preview_json, request_id, status, resolution_json,
266
+ resolved_by, requested_at, resolved_at
267
+ )
268
+ SELECT
269
+ id, issue_id, workflow_instance_id, action_type, reason, question, evidence_json,
270
+ response_options_json, continuation_preview_json, request_id, status, resolution_json,
271
+ resolved_by, requested_at, resolved_at
272
+ FROM human_actions;
273
+ DROP TABLE human_actions;
274
+ ALTER TABLE human_actions_new RENAME TO human_actions;
275
+ CREATE INDEX IF NOT EXISTS idx_human_actions_issue ON human_actions(issue_id);
276
+ CREATE INDEX IF NOT EXISTS idx_human_actions_status ON human_actions(status);
277
+ `);
278
+ }
279
+ else if (!humanActionCols.some((c) => c.name === "run_id")) {
280
+ // Rebuild already happened in a prior migrate() run (or this is a fresh schema.sql
281
+ // database missing only this additive column for some other reason) — plain add.
282
+ db.exec("ALTER TABLE human_actions ADD COLUMN run_id TEXT REFERENCES runs(id)");
283
+ }
284
+ // Always last, once run_id is guaranteed to exist (fresh schema.sql create, the rebuild
285
+ // above, or the plain ALTER above) — see schema.sql's comment on why this index isn't
286
+ // declared there.
287
+ db.exec("CREATE INDEX IF NOT EXISTS idx_human_actions_run ON human_actions(run_id)");
288
+ // NOT-91: findOpenHumanActionByRequestId's dedupe was a plain read-then-create check —
289
+ // two concurrent duplicate INTERACTION_REQUIRED deliveries could both see "no open
290
+ // action" and both insert. Tighten to a real constraint enforced via createHumanAction's
291
+ // `ON CONFLICT ... DO NOTHING` (repository/human-actions.ts). Must run AFTER the NOT-95
292
+ // rebuild above: that rebuild drops and recreates `human_actions` (to make issue_id
293
+ // nullable) without carrying over any index created before it, so creating this index
294
+ // earlier in the same migrate() call — as a prior version of this migration did — got it
295
+ // silently dropped by the very next block, and only a second restart (skipping the rebuild
296
+ // block on its now-nullable issue_id) actually created it (review-caught bug). Re-querying
297
+ // PRAGMA index_list here, after the rebuild, is what makes this idempotent no matter which
298
+ // branch above ran. A pre-fix database may already hold a duplicate open pair from exactly
299
+ // that race — dedupe first (keep the earliest per (issue_id, action_type, request_id),
300
+ // auto-resolve any later duplicate), then create the index, all in one transaction so a
301
+ // failure never leaves the table indexless.
302
+ const humanActionIdx = db.prepare("PRAGMA index_list(human_actions)").all();
303
+ if (!humanActionIdx.some((i) => i.name === "idx_human_actions_open_request")) {
304
+ db.transaction(() => {
305
+ db.exec(`
306
+ UPDATE human_actions SET
307
+ status = 'resolved',
308
+ resolution_json = '{"choice":"resolved_via_dedupe_migration"}',
309
+ resolved_by = 'system',
310
+ resolved_at = datetime('now')
311
+ WHERE status = 'open' AND request_id IS NOT NULL AND rowid NOT IN (
312
+ SELECT MIN(rowid) FROM human_actions
313
+ WHERE status = 'open' AND request_id IS NOT NULL
314
+ GROUP BY issue_id, action_type, request_id
315
+ )
316
+ `);
317
+ db.exec(`
318
+ CREATE UNIQUE INDEX idx_human_actions_open_request ON human_actions(issue_id, action_type, request_id)
319
+ WHERE status = 'open' AND request_id IS NOT NULL
320
+ `);
321
+ })();
322
+ }
323
+ // NOT-111: runtime usage-cap availability (one row per runtime account).
324
+ const runtimeAvail = db
325
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'runtime_availability'")
326
+ .get();
327
+ if (!runtimeAvail) {
328
+ db.exec(`
329
+ CREATE TABLE runtime_availability (
330
+ runtime TEXT PRIMARY KEY,
331
+ unavailable_until TEXT NOT NULL,
332
+ reason TEXT NOT NULL,
333
+ evidence_json TEXT,
334
+ observed_at TEXT NOT NULL
335
+ );
336
+ `);
337
+ }
338
+ // NOT-103: sequential issue admission queue.
339
+ const queueEntries = db
340
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'queue_entries'")
341
+ .get();
342
+ if (!queueEntries) {
343
+ db.exec(`
344
+ CREATE TABLE queue_entries (
345
+ id TEXT PRIMARY KEY,
346
+ issue_id TEXT NOT NULL,
347
+ position INTEGER NOT NULL,
348
+ enqueued_at TEXT NOT NULL,
349
+ state TEXT NOT NULL CHECK (state IN ('queued', 'admitted', 'removed')),
350
+ wait_reason TEXT,
351
+ wait_reason_at TEXT
352
+ );
353
+ CREATE UNIQUE INDEX idx_queue_entries_queued_issue
354
+ ON queue_entries(issue_id) WHERE state = 'queued';
355
+ CREATE INDEX idx_queue_entries_queued_position
356
+ ON queue_entries(position) WHERE state = 'queued';
357
+ `);
358
+ }
58
359
  seedBuiltinAgents(db);
59
360
  seedIntakeSettings(db);
60
361
  migrateLegacyAgentDeckPort(db);
362
+ migrateLinearInboxUnlock(db);
61
363
  // Default agents are normal rows — clear legacy built-in flag.
62
364
  db.exec("UPDATE agents SET is_builtin = 0 WHERE is_builtin = 1");
63
365
  }
366
+ /** One-shot: unlock assigneeMe / stateFilter only when they still hold pre-unlock defaults.
367
+ * Deliberate Settings customizations and env-seeded filters (e.g. Todo+Backlog) are left alone. */
368
+ function migrateLinearInboxUnlock(db) {
369
+ const flag = db
370
+ .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
371
+ .get("linear.inboxUnlockV1");
372
+ if (flag)
373
+ return;
374
+ const openStates = ["Backlog", "Todo", "In Progress", "In Review"];
375
+ const upsert = db.prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json");
376
+ const assigneeRow = db
377
+ .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
378
+ .get("linear.assigneeMe");
379
+ let assigneeMe = true; // pre-unlock default when missing
380
+ if (assigneeRow) {
381
+ try {
382
+ assigneeMe = JSON.parse(assigneeRow.value_json);
383
+ }
384
+ catch {
385
+ assigneeMe = true;
386
+ }
387
+ }
388
+ if (assigneeMe === true) {
389
+ upsert.run("linear.assigneeMe", JSON.stringify(false));
390
+ }
391
+ const stateRow = db
392
+ .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
393
+ .get("linear.stateFilter");
394
+ let stateFilter = ["Todo"]; // pre-unlock code default when missing
395
+ if (stateRow) {
396
+ try {
397
+ stateFilter = JSON.parse(stateRow.value_json);
398
+ }
399
+ catch {
400
+ stateFilter = ["Todo"];
401
+ }
402
+ }
403
+ if (isLegacyLinearStateFilterDefault(stateFilter)) {
404
+ upsert.run("linear.stateFilter", JSON.stringify(openStates));
405
+ }
406
+ upsert.run("linear.inboxUnlockV1", JSON.stringify(true));
407
+ }
408
+ /** Pre-unlock persisted default was exactly `["Todo"]` (see prior getPersistedLinearIntakeConfig). */
409
+ export function isLegacyLinearStateFilterDefault(value) {
410
+ return Array.isArray(value) && value.length === 1 && value[0] === "Todo";
411
+ }
64
412
  function migrateLegacyAgentDeckPort(db) {
65
413
  const row = db
66
414
  .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
@@ -78,15 +426,16 @@ function migrateLegacyAgentDeckPort(db) {
78
426
  }
79
427
  }
80
428
  function seedIntakeSettings(db) {
81
- const stateFilter = (process.env.LINEAR_STATE_FILTER ?? "Todo")
429
+ const openDefault = ["Backlog", "Todo", "In Progress", "In Review"];
430
+ const stateFilter = (process.env.LINEAR_STATE_FILTER ?? openDefault.join(","))
82
431
  .split(",")
83
432
  .map((s) => s.trim())
84
433
  .filter(Boolean);
85
434
  const teamId = process.env.LINEAR_TEAM_ID ?? null;
86
435
  const defaults = {
87
- "linear.stateFilter": stateFilter.length > 0 ? stateFilter : ["Todo"],
436
+ "linear.stateFilter": stateFilter.length > 0 ? stateFilter : openDefault,
88
437
  "linear.teamId": teamId,
89
- "linear.assigneeMe": true,
438
+ "linear.assigneeMe": false,
90
439
  "linear.defaultAgentId": null,
91
440
  "linear.syncEnabled": true,
92
441
  "linear.routingRules": [],
@@ -117,9 +466,12 @@ function seedBuiltinAgents(db) {
117
466
  `);
118
467
  insert.run(BUILTIN_AGENT_CLAUDE_ID, "Claude", "claude_code", now, now);
119
468
  insert.run(BUILTIN_AGENT_CURSOR_ID, "Cursor", "cursor_local", now, now);
469
+ insert.run(BUILTIN_AGENT_CODEX_ID, "Codex", "codex_local", now, now);
470
+ // NOT-71: seeds the role-neutral column the issue workflow actually reads. The legacy
471
+ // default_plan_model / default_execute_model columns are read-only compatibility now
472
+ // (profile-snapshot.ts falls back to them for profiles saved before default_model existed).
120
473
  db.prepare(`UPDATE agents SET
121
- default_plan_model = COALESCE(default_plan_model, ?),
122
- default_execute_model = COALESCE(default_execute_model, ?),
474
+ default_model = COALESCE(default_model, ?),
123
475
  updated_at = ?
124
- WHERE id = ? AND runtime = 'cursor_local'`).run(CURSOR_DEFAULT_MODEL, CURSOR_DEFAULT_MODEL, now, BUILTIN_AGENT_CURSOR_ID);
476
+ WHERE id = ? AND runtime = 'cursor_local'`).run(CURSOR_DEFAULT_MODEL, now, BUILTIN_AGENT_CURSOR_ID);
125
477
  }
@@ -0,0 +1,67 @@
1
+ import { test, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-inbox-unlock-"));
7
+ delete process.env.LINEAR_STATE_FILTER;
8
+ delete process.env.LINEAR_TEAM_ID;
9
+ const { migrate, getDb, closeDb } = await import("./index.js");
10
+ const { isLegacyLinearStateFilterDefault } = await import("./index.js");
11
+ function readSetting(key) {
12
+ const row = getDb()
13
+ .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
14
+ .get(key);
15
+ assert.ok(row, `expected setting ${key}`);
16
+ return JSON.parse(row.value_json);
17
+ }
18
+ function writeSetting(key, value) {
19
+ getDb()
20
+ .prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json")
21
+ .run(key, JSON.stringify(value));
22
+ }
23
+ function clearUnlockFlag() {
24
+ getDb().prepare("DELETE FROM intake_settings WHERE key = ?").run("linear.inboxUnlockV1");
25
+ }
26
+ beforeEach(() => {
27
+ closeDb();
28
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-inbox-unlock-"));
29
+ delete process.env.LINEAR_STATE_FILTER;
30
+ migrate();
31
+ });
32
+ afterEach(() => closeDb());
33
+ test("isLegacyLinearStateFilterDefault only matches the pre-unlock [Todo] default", () => {
34
+ assert.equal(isLegacyLinearStateFilterDefault(["Todo"]), true);
35
+ assert.equal(isLegacyLinearStateFilterDefault(["Todo", "Backlog"]), false);
36
+ assert.equal(isLegacyLinearStateFilterDefault(["Backlog", "Todo", "In Progress", "In Review"]), false);
37
+ assert.equal(isLegacyLinearStateFilterDefault("Todo"), false);
38
+ });
39
+ test("inbox unlock flips legacy assigneeMe true + [Todo] filter once", () => {
40
+ clearUnlockFlag();
41
+ writeSetting("linear.assigneeMe", true);
42
+ writeSetting("linear.stateFilter", ["Todo"]);
43
+ closeDb();
44
+ migrate();
45
+ assert.equal(readSetting("linear.assigneeMe"), false);
46
+ assert.deepEqual(readSetting("linear.stateFilter"), ["Backlog", "Todo", "In Progress", "In Review"]);
47
+ assert.equal(readSetting("linear.inboxUnlockV1"), true);
48
+ });
49
+ test("inbox unlock preserves customized assigneeMe false and Todo+Backlog filter", () => {
50
+ clearUnlockFlag();
51
+ writeSetting("linear.assigneeMe", false);
52
+ writeSetting("linear.stateFilter", ["Todo", "Backlog"]);
53
+ closeDb();
54
+ migrate();
55
+ assert.equal(readSetting("linear.assigneeMe"), false);
56
+ assert.deepEqual(readSetting("linear.stateFilter"), ["Todo", "Backlog"]);
57
+ assert.equal(readSetting("linear.inboxUnlockV1"), true);
58
+ });
59
+ test("inbox unlock is a no-op when the flag is already set even if values look legacy", () => {
60
+ writeSetting("linear.inboxUnlockV1", true);
61
+ writeSetting("linear.assigneeMe", true);
62
+ writeSetting("linear.stateFilter", ["Todo"]);
63
+ closeDb();
64
+ migrate();
65
+ assert.equal(readSetting("linear.assigneeMe"), true);
66
+ assert.deepEqual(readSetting("linear.stateFilter"), ["Todo"]);
67
+ });