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,189 @@
1
+ // packages/server/src/repository/authority-attempts.ts
2
+ //
3
+ // Durable checkout ledger for short-lived Agent Deck execution authority (NOT-91). A row is
4
+ // written BEFORE the mint call (status `acquiring`) so a crash between "asked Deck for
5
+ // authority" and "released it" leaves something for restart reconciliation
6
+ // (authority-lifecycle.ts's reconcileAuthoritiesAtStartup) and worker-death recovery
7
+ // (coordinator/recovery.ts) to revoke against, instead of an authority that only ever lived
8
+ // in one in-process async call's local variables.
9
+ import { v4 as uuid } from "uuid";
10
+ import { getDb } from "../db/index.js";
11
+ function rowToAttempt(row) {
12
+ return {
13
+ id: row.id,
14
+ ownerKind: row.owner_kind,
15
+ ownerId: row.owner_id,
16
+ idempotencyKey: row.idempotency_key,
17
+ authorityId: row.authority_id,
18
+ deckId: row.deck_id,
19
+ runId: row.run_id,
20
+ attemptId: row.attempt_id,
21
+ ttlMs: row.ttl_ms,
22
+ toolScopeHint: row.tool_scope_hint_json ? JSON.parse(row.tool_scope_hint_json) : null,
23
+ status: row.status,
24
+ staleAt: row.stale_at,
25
+ expiresAt: row.expires_at,
26
+ createdAt: row.created_at,
27
+ updatedAt: row.updated_at,
28
+ };
29
+ }
30
+ /** Inserted BEFORE the mint call — `acquiring` must never survive a process boundary. */
31
+ export function createAuthorityAttempt(input) {
32
+ const now = new Date().toISOString();
33
+ const row = {
34
+ id: uuid(),
35
+ owner_kind: input.ownerKind,
36
+ owner_id: input.ownerId,
37
+ idempotency_key: input.idempotencyKey,
38
+ authority_id: null,
39
+ deck_id: input.deckId,
40
+ run_id: input.runId,
41
+ attempt_id: input.attemptId,
42
+ ttl_ms: input.ttlMs,
43
+ tool_scope_hint_json: input.toolScopeHint ? JSON.stringify(input.toolScopeHint) : null,
44
+ status: "acquiring",
45
+ stale_at: null,
46
+ expires_at: null,
47
+ created_at: now,
48
+ updated_at: now,
49
+ };
50
+ getDb()
51
+ .prepare(`
52
+ INSERT INTO authority_attempts (
53
+ id, owner_kind, owner_id, idempotency_key, authority_id, deck_id, run_id, attempt_id,
54
+ ttl_ms, tool_scope_hint_json, status, stale_at, expires_at, created_at, updated_at
55
+ ) VALUES (
56
+ @id, @owner_kind, @owner_id, @idempotency_key, @authority_id, @deck_id, @run_id, @attempt_id,
57
+ @ttl_ms, @tool_scope_hint_json, @status, @stale_at, @expires_at, @created_at, @updated_at
58
+ )
59
+ `)
60
+ .run(row);
61
+ return rowToAttempt(row);
62
+ }
63
+ /** `acquiring` → `active`, once mint + verify + materialize all succeed. CAS-fenced on the
64
+ * row still being `acquiring` — a no-op if it was already revoked out from under this call
65
+ * (e.g. a concurrent abort). Returns the updated row, or null if the CAS missed. */
66
+ export function activateAuthorityAttempt(id, input) {
67
+ const now = new Date().toISOString();
68
+ const row = getDb()
69
+ .prepare(`
70
+ UPDATE authority_attempts SET
71
+ status = 'active', authority_id = @authority_id, expires_at = @expires_at, updated_at = @now
72
+ WHERE id = @id AND status = 'acquiring'
73
+ RETURNING *
74
+ `)
75
+ .get({ id, authority_id: input.authorityId, expires_at: input.expiresAt, now });
76
+ return row ? rowToAttempt(row) : null;
77
+ }
78
+ function setTerminalStatus(id, status) {
79
+ const now = new Date().toISOString();
80
+ const row = getDb()
81
+ .prepare(`
82
+ UPDATE authority_attempts SET status = @status, updated_at = @now
83
+ WHERE id = @id AND status IN ('acquiring', 'active')
84
+ RETURNING *
85
+ `)
86
+ .get({ id, status, now });
87
+ return row ? rowToAttempt(row) : null;
88
+ }
89
+ /** Normal end-of-use — the caller already revoked (or is about to revoke) the authority on
90
+ * Deck's side; this just closes the ledger row. */
91
+ export function closeAuthorityAttempt(id) {
92
+ return setTerminalStatus(id, "closed");
93
+ }
94
+ /** Mint/verify/materialize failed — the caller already revoked (or has nothing to revoke,
95
+ * e.g. mint itself never returned an authorityId). */
96
+ export function markAuthorityAttemptFailed(id) {
97
+ return setTerminalStatus(id, "failed");
98
+ }
99
+ /** One row, by id — the per-row counterpart to `revokeOpenActiveAuthorityAttempts`'s
100
+ * per-owner bulk flip, and what a resolved `acquiring` row is set to. */
101
+ export function markAuthorityAttemptRevoked(id) {
102
+ return setTerminalStatus(id, "revoked");
103
+ }
104
+ export function getAuthorityAttempt(id) {
105
+ const row = getDb().prepare("SELECT * FROM authority_attempts WHERE id = ?").get(id);
106
+ return row ? rowToAttempt(row) : null;
107
+ }
108
+ /** Stamps a still-`acquiring` row as needing resolution — see schema.sql's `stale_at`
109
+ * comment. CAS-fenced on `status = 'acquiring'` (a no-op once the row has moved on) and on
110
+ * `stale_at IS NULL` (a no-op if already stamped), so calling this from every path that
111
+ * identifies a row as needing resolution is always safe to repeat. */
112
+ export function markAuthorityAttemptStale(id) {
113
+ const now = new Date().toISOString();
114
+ const row = getDb()
115
+ .prepare(`
116
+ UPDATE authority_attempts SET stale_at = @now, updated_at = @now
117
+ WHERE id = @id AND status = 'acquiring' AND stale_at IS NULL
118
+ RETURNING *
119
+ `)
120
+ .get({ id, now });
121
+ return row ? rowToAttempt(row) : null;
122
+ }
123
+ /** Every row durably flagged as needing resolution and not yet resolved — the periodic
124
+ * retry sweep's input (authority-lifecycle.ts's `retryStaleAuthorityAttempts`). Unlike the
125
+ * startup sweep, this never re-derives staleness from a fresh scan of every open row: it
126
+ * only ever returns rows some other call already positively marked via
127
+ * `markAuthorityAttemptStale`, so it stays safe to call on a running process without risking
128
+ * a legitimately in-flight mint. */
129
+ export function listStaleAcquiringAuthorityAttempts() {
130
+ const rows = getDb()
131
+ .prepare("SELECT * FROM authority_attempts WHERE status = 'acquiring' AND stale_at IS NOT NULL ORDER BY stale_at ASC")
132
+ .all();
133
+ return rows.map(rowToAttempt);
134
+ }
135
+ /** Every row for this owner still `acquiring`/`active` — a fresh attempt (a new
136
+ * idempotencyKey for the same owner) revokes these first (NOT-91's "revoke-before-new-
137
+ * attempt"): a prior attempt's authority has no legitimate further use once a new one
138
+ * starts. */
139
+ export function listOpenAuthorityAttemptsForOwner(ownerKind, ownerId) {
140
+ const rows = getDb()
141
+ .prepare("SELECT * FROM authority_attempts WHERE owner_kind = ? AND owner_id = ? AND status IN ('acquiring', 'active') ORDER BY created_at ASC")
142
+ .all(ownerKind, ownerId);
143
+ return rows.map(rowToAttempt);
144
+ }
145
+ /**
146
+ * DB-only CAS: flips every `active` row for this owner (a known `authorityId`) to `revoked`
147
+ * and returns them, so the caller can best-effort revoke each on Deck's side. Synchronous and
148
+ * side-effect-free on Deck, so it is safe to call from inside a `better-sqlite3` transaction
149
+ * (recovery.ts's reclaim/dead-letter, commands.ts's abortIssue) — the actual network revoke
150
+ * happens only after that transaction commits.
151
+ *
152
+ * Deliberately excludes `acquiring` rows (no `authorityId` yet): a coordinator can crash
153
+ * after Deck committed a mint but before that row was activated with the resulting id, so an
154
+ * `acquiring` row alone can't be assumed to have nothing live behind it. Terminalizing it here
155
+ * on a guess is exactly the leak NOT-91's review caught — it must instead be resolved by
156
+ * replaying its stored idempotencyKey (authority-lifecycle.ts's `resolveAcquiringAttempts`),
157
+ * which needs an async mint() call this synchronous DB-only helper can't make. Callers pair
158
+ * this with `listOpenAcquiringAuthorityAttempts` for that owner and resolve those separately,
159
+ * outside any surrounding transaction.
160
+ */
161
+ export function revokeOpenActiveAuthorityAttempts(ownerKind, ownerId) {
162
+ const now = new Date().toISOString();
163
+ const rows = getDb()
164
+ .prepare(`
165
+ UPDATE authority_attempts SET status = 'revoked', updated_at = @now
166
+ WHERE owner_kind = @owner_kind AND owner_id = @owner_id AND status = 'active'
167
+ RETURNING *
168
+ `)
169
+ .all({ owner_kind: ownerKind, owner_id: ownerId, now });
170
+ return rows.map(rowToAttempt);
171
+ }
172
+ /** Read-only: every `acquiring` row for this owner (no `authorityId` yet) — the counterpart
173
+ * to `revokeOpenActiveAuthorityAttempts` a caller resolves separately, asynchronously, via
174
+ * `authority-lifecycle.ts`'s `resolveAcquiringAttempts`. Never mutates status itself, so it is
175
+ * safe to call from inside a `better-sqlite3` transaction alongside the active-row revoke. */
176
+ export function listOpenAcquiringAuthorityAttempts(ownerKind, ownerId) {
177
+ const rows = getDb()
178
+ .prepare("SELECT * FROM authority_attempts WHERE owner_kind = ? AND owner_id = ? AND status = 'acquiring' ORDER BY created_at ASC")
179
+ .all(ownerKind, ownerId);
180
+ return rows.map(rowToAttempt);
181
+ }
182
+ /** Every row still `acquiring`/`active` for any owner — the startup sweep's input
183
+ * (authority-lifecycle.ts's reconcileAuthoritiesAtStartup). */
184
+ export function listAllOpenAuthorityAttempts() {
185
+ const rows = getDb()
186
+ .prepare("SELECT * FROM authority_attempts WHERE status IN ('acquiring', 'active') ORDER BY created_at ASC")
187
+ .all();
188
+ return rows.map(rowToAttempt);
189
+ }
@@ -0,0 +1,70 @@
1
+ import { v4 as uuid } from "uuid";
2
+ import { getDb } from "../db/index.js";
3
+ function rowToFinding(row) {
4
+ return {
5
+ id: row.id,
6
+ issueId: row.issue_id,
7
+ fingerprint: row.fingerprint,
8
+ severity: row.severity,
9
+ title: row.title,
10
+ rationale: row.rationale,
11
+ evidenceRef: row.evidence_ref,
12
+ file: row.file,
13
+ line: row.line,
14
+ status: row.status,
15
+ firstRound: row.first_round,
16
+ lastRound: row.last_round,
17
+ };
18
+ }
19
+ /**
20
+ * Inserts a new finding, or — if one with the same (issue, fingerprint) is
21
+ * open/recurring — marks it recurring and bumps last_round (PRD §6.4).
22
+ */
23
+ export function reconcileFinding(input) {
24
+ const db = getDb();
25
+ const existing = db
26
+ .prepare(`SELECT * FROM findings WHERE issue_id = ? AND fingerprint = ? AND status IN ('open', 'recurring')`)
27
+ .get(input.issueId, input.fingerprint);
28
+ if (existing) {
29
+ db.prepare("UPDATE findings SET status = 'recurring', last_round = ? WHERE id = ?").run(input.round, existing.id);
30
+ const row = db.prepare("SELECT * FROM findings WHERE id = ?").get(existing.id);
31
+ return rowToFinding(row);
32
+ }
33
+ const row = {
34
+ id: uuid(),
35
+ issue_id: input.issueId,
36
+ fingerprint: input.fingerprint,
37
+ severity: input.severity,
38
+ title: input.title,
39
+ rationale: input.rationale,
40
+ evidence_ref: input.evidenceRef ?? null,
41
+ file: input.file ?? null,
42
+ line: input.line ?? null,
43
+ status: "open",
44
+ first_round: input.round,
45
+ last_round: input.round,
46
+ };
47
+ db.prepare(`
48
+ INSERT INTO findings (
49
+ id, issue_id, fingerprint, severity, title, rationale, evidence_ref, file, line,
50
+ status, first_round, last_round
51
+ ) VALUES (
52
+ @id, @issue_id, @fingerprint, @severity, @title, @rationale, @evidence_ref, @file, @line,
53
+ @status, @first_round, @last_round
54
+ )
55
+ `).run(row);
56
+ return rowToFinding(row);
57
+ }
58
+ export function resolveFinding(id) {
59
+ getDb().prepare("UPDATE findings SET status = 'resolved' WHERE id = ?").run(id);
60
+ const row = getDb().prepare("SELECT * FROM findings WHERE id = ?").get(id);
61
+ if (!row)
62
+ throw new Error(`Finding not found: ${id}`);
63
+ return rowToFinding(row);
64
+ }
65
+ export function listFindingsForIssue(issueId) {
66
+ const rows = getDb()
67
+ .prepare("SELECT * FROM findings WHERE issue_id = ? ORDER BY first_round ASC")
68
+ .all(issueId);
69
+ return rows.map(rowToFinding);
70
+ }
@@ -0,0 +1,68 @@
1
+ import { test, before } 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-findings-"));
7
+ const { migrate } = await import("../db/index.js");
8
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
9
+ const { createIssue } = await import("./issues.js");
10
+ const { reconcileFinding, resolveFinding, listFindingsForIssue } = await import("./findings.js");
11
+ before(() => {
12
+ migrate();
13
+ });
14
+ function seedIssue(title) {
15
+ return createIssue({
16
+ title,
17
+ repo: "acme/app",
18
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
19
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
20
+ baseBranch: "main",
21
+ maxReviewRounds: 3,
22
+ maxInfraAttempts: 3,
23
+ source: "manual"
24
+ }).id;
25
+ }
26
+ test("creates a new finding as open", () => {
27
+ const issueId = seedIssue("Finding issue 1");
28
+ const finding = reconcileFinding({
29
+ issueId,
30
+ fingerprint: "fp-1",
31
+ severity: "blocking",
32
+ title: "Missing null check",
33
+ rationale: "user can be undefined",
34
+ round: 1
35
+ });
36
+ assert.equal(finding.status, "open");
37
+ assert.equal(finding.firstRound, 1);
38
+ assert.equal(finding.lastRound, 1);
39
+ });
40
+ test("marks a repeated fingerprint as recurring and bumps last_round", () => {
41
+ const issueId = seedIssue("Finding issue 2");
42
+ reconcileFinding({ issueId, fingerprint: "fp-1", severity: "blocking", title: "T", rationale: "R", round: 1 });
43
+ const again = reconcileFinding({
44
+ issueId,
45
+ fingerprint: "fp-1",
46
+ severity: "blocking",
47
+ title: "T",
48
+ rationale: "R",
49
+ round: 2
50
+ });
51
+ assert.equal(again.status, "recurring");
52
+ assert.equal(again.firstRound, 1);
53
+ assert.equal(again.lastRound, 2);
54
+ });
55
+ test("resolves a finding", () => {
56
+ const issueId = seedIssue("Finding issue 3");
57
+ const finding = reconcileFinding({
58
+ issueId,
59
+ fingerprint: "fp-1",
60
+ severity: "blocking",
61
+ title: "T",
62
+ rationale: "R",
63
+ round: 1
64
+ });
65
+ const resolved = resolveFinding(finding.id);
66
+ assert.equal(resolved.status, "resolved");
67
+ assert.equal(listFindingsForIssue(issueId).find((f) => f.id === finding.id)?.status, "resolved");
68
+ });
@@ -0,0 +1,142 @@
1
+ import { v4 as uuid } from "uuid";
2
+ import { getDb } from "../db/index.js";
3
+ function rowToAction(row) {
4
+ return {
5
+ id: row.id,
6
+ issueId: row.issue_id,
7
+ runId: row.run_id,
8
+ workflowInstanceId: row.workflow_instance_id,
9
+ actionType: row.action_type,
10
+ reason: row.reason,
11
+ question: row.question,
12
+ evidenceJson: row.evidence_json,
13
+ responseOptionsJson: row.response_options_json,
14
+ continuationPreviewJson: row.continuation_preview_json,
15
+ requestId: row.request_id,
16
+ status: row.status,
17
+ resolutionJson: row.resolution_json,
18
+ resolvedBy: row.resolved_by,
19
+ requestedAt: row.requested_at,
20
+ resolvedAt: row.resolved_at,
21
+ };
22
+ }
23
+ export function createHumanAction(input) {
24
+ if (Boolean(input.issueId) === Boolean(input.runId)) {
25
+ throw new Error("createHumanAction requires exactly one of issueId or runId");
26
+ }
27
+ const db = getDb();
28
+ const now = new Date().toISOString();
29
+ const row = {
30
+ id: uuid(),
31
+ issue_id: input.issueId ?? null,
32
+ run_id: input.runId ?? null,
33
+ workflow_instance_id: input.workflowInstanceId ?? null,
34
+ action_type: input.actionType,
35
+ reason: input.reason,
36
+ question: input.question,
37
+ evidence_json: input.evidence !== undefined ? JSON.stringify(input.evidence) : null,
38
+ response_options_json: input.responseOptions !== undefined ? JSON.stringify(input.responseOptions) : null,
39
+ continuation_preview_json: input.continuationPreview !== undefined ? JSON.stringify(input.continuationPreview) : null,
40
+ request_id: input.requestId ?? null,
41
+ status: "open",
42
+ resolution_json: null,
43
+ resolved_by: null,
44
+ requested_at: now,
45
+ resolved_at: null,
46
+ };
47
+ // ON CONFLICT's target must repeat the partial index's predicate verbatim (schema.sql's
48
+ // idx_human_actions_open_request) — a bare `ON CONFLICT (issue_id, action_type,
49
+ // request_id)` throws SQLITE_ERROR against a partial unique index. A request_id-less
50
+ // insert (the vast majority of action types) never matches this partial index at all, so
51
+ // it always proceeds as a plain insert.
52
+ const info = db.prepare(`
53
+ INSERT INTO human_actions (
54
+ id, issue_id, run_id, workflow_instance_id, action_type, reason, question, evidence_json,
55
+ response_options_json, continuation_preview_json, request_id, status, resolution_json, resolved_by,
56
+ requested_at, resolved_at
57
+ ) VALUES (
58
+ @id, @issue_id, @run_id, @workflow_instance_id, @action_type, @reason, @question, @evidence_json,
59
+ @response_options_json, @continuation_preview_json, @request_id, @status, @resolution_json, @resolved_by,
60
+ @requested_at, @resolved_at
61
+ )
62
+ ON CONFLICT (issue_id, action_type, request_id) WHERE status = 'open' AND request_id IS NOT NULL DO NOTHING
63
+ `).run(row);
64
+ if (info.changes === 0 && input.requestId && input.issueId) {
65
+ // Lost the race to dedupe onto an already-open action for this exact request — return
66
+ // the winner, never a phantom row this insert never actually created. (The partial
67
+ // index's NULL-issue_id rows — Run-scoped actions — never collide with each other at
68
+ // all under standard SQL NULL semantics, so this race is only reachable Issue-scoped.)
69
+ const existing = findOpenHumanActionByRequestId(input.issueId, input.actionType, input.requestId);
70
+ if (existing)
71
+ return existing;
72
+ }
73
+ return rowToAction(row);
74
+ }
75
+ export function resolveHumanAction(id, resolvedBy, resolution) {
76
+ const now = new Date().toISOString();
77
+ getDb()
78
+ .prepare(`
79
+ UPDATE human_actions SET
80
+ status = 'resolved', resolution_json = ?, resolved_by = ?, resolved_at = ?
81
+ WHERE id = ? AND status = 'open'
82
+ `)
83
+ .run(JSON.stringify(resolution), resolvedBy, now, id);
84
+ const row = getDb().prepare("SELECT * FROM human_actions WHERE id = ?").get(id);
85
+ if (!row)
86
+ throw new Error(`Human action not found: ${id}`);
87
+ if (row.status !== "resolved")
88
+ throw new Error(`Human action already resolved or missing: ${id}`);
89
+ return rowToAction(row);
90
+ }
91
+ export function getHumanAction(id) {
92
+ const row = getDb().prepare("SELECT * FROM human_actions WHERE id = ?").get(id);
93
+ return row ? rowToAction(row) : null;
94
+ }
95
+ export function listOpenHumanActions() {
96
+ const rows = getDb()
97
+ .prepare("SELECT * FROM human_actions WHERE status = 'open' ORDER BY requested_at ASC")
98
+ .all();
99
+ return rows.map(rowToAction);
100
+ }
101
+ /** The one open action of this type for the issue, if any — used to make re-raising a
102
+ * pre-start action (e.g. product_scope_decision) idempotent instead of piling up
103
+ * duplicates across repeated calls. */
104
+ export function findOpenHumanAction(issueId, actionType) {
105
+ const row = getDb()
106
+ .prepare("SELECT * FROM human_actions WHERE issue_id = ? AND action_type = ? AND status = 'open' ORDER BY requested_at ASC LIMIT 1")
107
+ .get(issueId, actionType);
108
+ return row ? rowToAction(row) : null;
109
+ }
110
+ /** Dedupe key for a repeated Deck INTERACTION_REQUIRED signal that names a request id
111
+ * (NOT-93) — a second signal for the same request must land on the one open action it
112
+ * already raised, never a duplicate. Callers (reflect-trigger.ts, commands.ts) still read
113
+ * this first to decide whether to raise at all; the actual race-proofing against a
114
+ * concurrent duplicate insert is `createHumanAction`'s own `ON CONFLICT ... DO NOTHING`
115
+ * against `idx_human_actions_open_request` (NOT-91) — this function alone is not atomic. */
116
+ export function findOpenHumanActionByRequestId(issueId, actionType, requestId) {
117
+ const row = getDb()
118
+ .prepare("SELECT * FROM human_actions WHERE issue_id = ? AND action_type = ? AND request_id = ? AND status = 'open' ORDER BY requested_at ASC LIMIT 1")
119
+ .get(issueId, actionType, requestId);
120
+ return row ? rowToAction(row) : null;
121
+ }
122
+ export function listHumanActionsForIssue(issueId) {
123
+ const rows = getDb()
124
+ .prepare("SELECT * FROM human_actions WHERE issue_id = ? ORDER BY requested_at ASC")
125
+ .all(issueId);
126
+ return rows.map(rowToAction);
127
+ }
128
+ /** Run-scoped equivalent of `findOpenHumanAction` (NOT-95 — outbound-draft delivery
129
+ * parking has no Issue). */
130
+ export function findOpenHumanActionForRun(runId, actionType) {
131
+ const row = getDb()
132
+ .prepare("SELECT * FROM human_actions WHERE run_id = ? AND action_type = ? AND status = 'open' ORDER BY requested_at ASC LIMIT 1")
133
+ .get(runId, actionType);
134
+ return row ? rowToAction(row) : null;
135
+ }
136
+ /** Run-scoped equivalent of `listHumanActionsForIssue` (NOT-95). */
137
+ export function listHumanActionsForRun(runId) {
138
+ const rows = getDb()
139
+ .prepare("SELECT * FROM human_actions WHERE run_id = ? ORDER BY requested_at ASC")
140
+ .all(runId);
141
+ return rows.map(rowToAction);
142
+ }
@@ -0,0 +1,123 @@
1
+ import { test, before } 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-actions-"));
7
+ const { migrate } = await import("../db/index.js");
8
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
9
+ const { createIssue } = await import("./issues.js");
10
+ const { createRun } = await import("./runs.js");
11
+ const { createHumanAction, resolveHumanAction, listOpenHumanActions, listHumanActionsForIssue, findOpenHumanActionByRequestId } = await import("./human-actions.js");
12
+ before(() => {
13
+ migrate();
14
+ });
15
+ function seedIssue(title) {
16
+ return createIssue({
17
+ title,
18
+ repo: "acme/app",
19
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
20
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
21
+ baseBranch: "main",
22
+ maxReviewRounds: 3,
23
+ maxInfraAttempts: 3,
24
+ source: "manual"
25
+ }).id;
26
+ }
27
+ test("creates an open action and lists it globally and per-issue", () => {
28
+ const issueId = seedIssue("Action issue 1");
29
+ const action = createHumanAction({
30
+ issueId,
31
+ actionType: "final_review",
32
+ reason: "Reviewer approved",
33
+ question: "Accept?",
34
+ responseOptions: ["complete", "repair", "close"]
35
+ });
36
+ assert.equal(action.status, "open");
37
+ assert.ok(listOpenHumanActions().some((a) => a.id === action.id));
38
+ assert.deepStrictEqual(listHumanActionsForIssue(issueId).map((a) => a.id), [action.id]);
39
+ });
40
+ test("resolves an action and removes it from the open queue", () => {
41
+ const issueId = seedIssue("Action issue 2");
42
+ const action = createHumanAction({
43
+ issueId,
44
+ actionType: "final_review",
45
+ reason: "Reviewer approved",
46
+ question: "Accept?",
47
+ responseOptions: ["complete", "repair", "close"]
48
+ });
49
+ const resolved = resolveHumanAction(action.id, "yusuke", { choice: "complete" });
50
+ assert.equal(resolved.status, "resolved");
51
+ assert.equal(resolved.resolvedBy, "yusuke");
52
+ assert.equal(listOpenHumanActions().some((a) => a.id === action.id), false);
53
+ });
54
+ test("createHumanAction persists Deck's requestId and defaults to null when none is supplied (NOT-93)", () => {
55
+ const issueId = seedIssue("Action issue 3");
56
+ const withRequestId = createHumanAction({
57
+ issueId,
58
+ actionType: "deck_interaction_required",
59
+ reason: "Control-plane decision required",
60
+ question: "Resolve it in Agent Deck, then resume?",
61
+ responseOptions: [{ choice: "resume", label: "Resume" }],
62
+ requestId: "req_abc123"
63
+ });
64
+ assert.equal(withRequestId.requestId, "req_abc123");
65
+ const withoutRequestId = createHumanAction({
66
+ issueId,
67
+ actionType: "policy_escalation",
68
+ reason: "infra hiccup",
69
+ question: "Resume?",
70
+ responseOptions: [{ choice: "resume", label: "Resume" }]
71
+ });
72
+ assert.equal(withoutRequestId.requestId, null);
73
+ });
74
+ test("findOpenHumanActionByRequestId dedupes a repeated Deck signal to the one open action it already raised", () => {
75
+ const issueId = seedIssue("Action issue 4");
76
+ const action = createHumanAction({
77
+ issueId,
78
+ actionType: "deck_interaction_required",
79
+ reason: "Control-plane decision required",
80
+ question: "Resolve it in Agent Deck, then resume?",
81
+ responseOptions: [{ choice: "resume", label: "Resume" }],
82
+ requestId: "req_dup1"
83
+ });
84
+ // A repeat of the same request id on the same issue/action type finds the existing
85
+ // open action instead of nothing (the caller uses this to skip creating a duplicate).
86
+ const found = findOpenHumanActionByRequestId(issueId, "deck_interaction_required", "req_dup1");
87
+ assert.equal(found?.id, action.id);
88
+ // A different request id, action type, or issue never matches.
89
+ assert.equal(findOpenHumanActionByRequestId(issueId, "deck_interaction_required", "req_other"), null);
90
+ assert.equal(findOpenHumanActionByRequestId(issueId, "policy_escalation", "req_dup1"), null);
91
+ assert.equal(findOpenHumanActionByRequestId(seedIssue("Action issue 5"), "deck_interaction_required", "req_dup1"), null);
92
+ // Once resolved, it's no longer open — a later repeat of the same request id (a
93
+ // genuinely new attempt reusing an old id would be a Deck bug, not Dealer's to guard)
94
+ // must not resurrect or match the resolved row.
95
+ resolveHumanAction(action.id, "yusuke", { choice: "resume" });
96
+ assert.equal(findOpenHumanActionByRequestId(issueId, "deck_interaction_required", "req_dup1"), null);
97
+ });
98
+ test("the open list includes run-scoped actions that have no issue", () => {
99
+ // NOT-71 acceptance: deleting the standalone Human actions page was only safe because the
100
+ // Issues home lists *every* open action. A run-scoped action (outbound-draft delivery
101
+ // parking, NOT-95) has no issue to open, so if it were missing from this list it would be
102
+ // unreachable in the UI entirely — the gap the ticket called out by name.
103
+ const run = createRun({
104
+ title: "outbound draft",
105
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
106
+ repo: "acme/app",
107
+ taskCategory: "other",
108
+ status: "plan_pending"
109
+ });
110
+ const action = createHumanAction({
111
+ runId: run.id,
112
+ actionType: "outbound_delivery_interaction_required",
113
+ reason: "Delivery needs a human",
114
+ question: "Send the draft?",
115
+ responseOptions: ["complete", "close"]
116
+ });
117
+ assert.equal(action.issueId, null);
118
+ const listed = listOpenHumanActions().find((a) => a.id === action.id);
119
+ assert.ok(listed, "run-scoped action must appear in the global open list");
120
+ // ...and it must carry the choices the home screen resolves it with, since there is no
121
+ // issue page to fall back to.
122
+ assert.ok((listed.responseOptionsJson ?? "").includes("complete"));
123
+ });