agent-dealer 1.2.7 → 1.2.8

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 (87) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. package/package.json +1 -1
@@ -23,10 +23,10 @@ export function listArtifactsForIssue(issueId, opts) {
23
23
  const limit = opts?.limit ?? 50;
24
24
  const rows = opts?.before
25
25
  ? getDb()
26
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?")
26
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND created_at < ? ORDER BY created_at DESC, rowid DESC LIMIT ?")
27
27
  .all(issueId, opts.before, limit)
28
28
  : getDb()
29
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? ORDER BY created_at DESC LIMIT ?")
29
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?")
30
30
  .all(issueId, limit);
31
31
  return rows.map(rowToArtifact);
32
32
  }
@@ -36,7 +36,7 @@ export function listArtifactsForIssue(issueId, opts) {
36
36
  * scan) can't have an old row fall outside the window on an artifact-heavy issue. */
37
37
  export function listArtifactsForIssueByKind(issueId, kind) {
38
38
  const rows = getDb()
39
- .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND kind = ? ORDER BY created_at DESC")
39
+ .prepare("SELECT * FROM artifacts WHERE issue_id = ? AND kind = ? ORDER BY created_at DESC, rowid DESC")
40
40
  .all(issueId, kind);
41
41
  return rows.map(rowToArtifact);
42
42
  }
@@ -119,6 +119,20 @@ export function findOpenHumanActionByRequestId(issueId, actionType, requestId) {
119
119
  .get(issueId, actionType, requestId);
120
120
  return row ? rowToAction(row) : null;
121
121
  }
122
+ /**
123
+ * NOT-308: version-scoped Muse capability escalations live on whichever issue first
124
+ * observed the verdict, but later admissions for *other* issues must find the same
125
+ * action instead of raising a duplicate — so the lookup is by (type, request_id)
126
+ * across issues, not per issue. Returns open actions first so callers can skip
127
+ * creation; resolved ones are included so callers can distinguish "already handled".
128
+ */
129
+ export function listHumanActionsByRequestId(actionType, requestId) {
130
+ const rows = getDb()
131
+ .prepare(`SELECT * FROM human_actions WHERE action_type = ? AND request_id = ?
132
+ ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, requested_at ASC`)
133
+ .all(actionType, requestId);
134
+ return rows.map(rowToAction);
135
+ }
122
136
  export function listHumanActionsForIssue(issueId) {
123
137
  const rows = getDb()
124
138
  .prepare("SELECT * FROM human_actions WHERE issue_id = ? ORDER BY requested_at ASC")
@@ -1,4 +1,4 @@
1
- import { canTransitionIssue, CreateIssueInput, looksLikeLocalRepoPath, parseGitHubRepoInput, TERMINAL_ISSUE_STATUSES, } from "@agent-dealer/shared";
1
+ import { canTransitionIssue, compileExecutionContract, CreateIssueInput, looksLikeLocalRepoPath, parseGitHubRepoInput, resolveIssueContractFields, tryCompileContract, TERMINAL_ISSUE_STATUSES, } from "@agent-dealer/shared";
2
2
  import { v4 as uuid } from "uuid";
3
3
  import { getDb } from "../db/index.js";
4
4
  function rowToIssue(row) {
@@ -11,6 +11,11 @@ function rowToIssue(row) {
11
11
  title: row.title,
12
12
  description: row.description,
13
13
  acceptanceCriteria: row.acceptance_criteria,
14
+ // NOT-306: the contract is derived from the stored description on every
15
+ // read — the description stays the untouched source of truth, so no
16
+ // migration or second authoring surface exists. A description that fails
17
+ // validation (written before validation existed) reads as a legacy null.
18
+ executionContract: tryCompileContract(row.description),
14
19
  repo: row.repo,
15
20
  baseBranch: row.base_branch,
16
21
  status: row.status,
@@ -49,6 +54,12 @@ export function createIssue(raw) {
49
54
  const { repo: repoRaw, ...rest } = raw;
50
55
  const input = CreateIssueInput.omit({ repo: true }).parse(rest);
51
56
  const repo = normalizeStoredIssueRepo(repoRaw);
57
+ // NOT-306: validate the ticket's execution contract here so every creation
58
+ // path (web, API, CLI-via-API, Linear import) shares one compiler — an
59
+ // ambiguous/malformed contract throws instead of silently dropping fields.
60
+ // A missing explicit acceptanceCriteria is filled from the description; an
61
+ // explicit value always wins and the description is stored untouched.
62
+ const resolved = resolveIssueContractFields(input.description, input.acceptanceCriteria);
52
63
  const db = getDb();
53
64
  const now = new Date().toISOString();
54
65
  const id = uuid();
@@ -60,7 +71,7 @@ export function createIssue(raw) {
60
71
  external_url: input.externalUrl ?? null,
61
72
  title: input.title,
62
73
  description: input.description ?? null,
63
- acceptance_criteria: input.acceptanceCriteria ?? null,
74
+ acceptance_criteria: resolved.acceptanceCriteria,
64
75
  repo,
65
76
  base_branch: input.baseBranch,
66
77
  status: "ready",
@@ -263,6 +274,20 @@ export function updateIssue(id, patch) {
263
274
  const current = getIssue(id);
264
275
  if (!current)
265
276
  throw new Error(`Issue not found: ${id}`);
277
+ // NOT-306: an edited description recompiles through the same compiler as
278
+ // creation — an ambiguous/malformed contract throws instead of landing
279
+ // half-parsed. A missing acceptanceCriteria is backfilled from the new
280
+ // description only when the issue has none; an explicit value always wins.
281
+ // Validation runs only when the patch touches the description, so unrelated
282
+ // updates (e.g. a base-branch-only sync) never fail on a stored description
283
+ // that predates validation.
284
+ const nextDescription = patch.description !== undefined ? patch.description : current.description;
285
+ const compiled = patch.description !== undefined ? compileExecutionContract(nextDescription) : null;
286
+ const nextAcceptanceCriteria = patch.acceptanceCriteria !== undefined
287
+ ? patch.acceptanceCriteria
288
+ : current.acceptanceCriteria?.trim()
289
+ ? current.acceptanceCriteria
290
+ : (compiled?.acceptanceCriteria ?? current.acceptanceCriteria);
266
291
  const now = new Date().toISOString();
267
292
  getDb()
268
293
  .prepare(`
@@ -283,8 +308,8 @@ export function updateIssue(id, patch) {
283
308
  .run({
284
309
  id,
285
310
  title: patch.title ?? current.title,
286
- description: patch.description !== undefined ? patch.description : current.description,
287
- acceptance_criteria: patch.acceptanceCriteria !== undefined ? patch.acceptanceCriteria : current.acceptanceCriteria,
311
+ description: nextDescription,
312
+ acceptance_criteria: nextAcceptanceCriteria,
288
313
  repo: patch.repo !== undefined ? normalizeStoredIssueRepo(patch.repo) : current.repo,
289
314
  base_branch: patch.baseBranch ?? current.baseBranch,
290
315
  developer_agent_id: patch.developerAgentId ?? current.developerAgentId,
@@ -22,6 +22,7 @@ function rowToSession(row) {
22
22
  processPid: row.process_pid,
23
23
  processOwner: row.process_owner,
24
24
  processStartedAt: row.process_started_at,
25
+ deckCorrelationId: row.deck_correlation_id ?? null,
25
26
  createdAt: row.created_at,
26
27
  startedAt: row.started_at,
27
28
  heartbeatAt: row.heartbeat_at,
@@ -53,6 +54,9 @@ export function createWorkerSession(input) {
53
54
  process_pid: null,
54
55
  process_owner: null,
55
56
  process_started_at: null,
57
+ // NOT-305: one opaque Deck correlation UUID per session, persisted before spawn so
58
+ // every runtime launch config can carry it as observability metadata.
59
+ deck_correlation_id: uuid(),
56
60
  created_at: now,
57
61
  started_at: null,
58
62
  heartbeat_at: null,
@@ -63,17 +67,35 @@ export function createWorkerSession(input) {
63
67
  INSERT INTO worker_sessions (
64
68
  id, issue_id, role, round, agent_id, runtime, model, budget_json, worktree_path,
65
69
  input_sha, status, session_ref, log_path, exit_code, error_json, metadata_json,
66
- profile_snapshot_json, process_pid, process_owner, process_started_at, created_at, started_at, heartbeat_at,
70
+ profile_snapshot_json, process_pid, process_owner, process_started_at, deck_correlation_id, created_at, started_at, heartbeat_at,
67
71
  completed_at, updated_at
68
72
  ) VALUES (
69
73
  @id, @issue_id, @role, @round, @agent_id, @runtime, @model, @budget_json, @worktree_path,
70
74
  @input_sha, @status, @session_ref, @log_path, @exit_code, @error_json, @metadata_json,
71
- @profile_snapshot_json, @process_pid, @process_owner, @process_started_at, @created_at, @started_at, @heartbeat_at,
75
+ @profile_snapshot_json, @process_pid, @process_owner, @process_started_at, @deck_correlation_id, @created_at, @started_at, @heartbeat_at,
72
76
  @completed_at, @updated_at
73
77
  )
74
78
  `).run(row);
75
79
  return rowToSession(row);
76
80
  }
81
+ /**
82
+ * NOT-305: returns the session's Deck correlation UUID, assigning and persisting one
83
+ * when a pre-feature row has none. New rows always carry one from creation; this only
84
+ * repairs rows written before the column existed so their launch configs and receipts
85
+ * still correlate. Returns null when the session does not exist.
86
+ */
87
+ export function getOrAssignSessionCorrelationId(id) {
88
+ const current = getWorkerSession(id);
89
+ if (!current)
90
+ return null;
91
+ if (current.deckCorrelationId)
92
+ return current.deckCorrelationId;
93
+ const correlationId = uuid();
94
+ getDb()
95
+ .prepare("UPDATE worker_sessions SET deck_correlation_id = ?, updated_at = ? WHERE id = ?")
96
+ .run(correlationId, new Date().toISOString(), id);
97
+ return correlationId;
98
+ }
77
99
  export function getWorkerSession(id) {
78
100
  const row = getDb().prepare("SELECT * FROM worker_sessions WHERE id = ?").get(id);
79
101
  return row ? rowToSession(row) : null;
@@ -165,6 +187,33 @@ export function recordSessionProcess(id, pid, owner, startTime = null) {
165
187
  WHERE id = ? AND status = 'running'`)
166
188
  .run(pid, owner, startTime, new Date().toISOString(), id);
167
189
  }
190
+ /**
191
+ * NOT-307: shallow-merges `patch` into the session's `metadata_json` (read +
192
+ * JSON-merge + write; unparseable existing content restarts from `{}`). Works on
193
+ * any status — the Muse lane records stall evidence right after spawn, before the
194
+ * terminal `completeSession`. No-op for unknown ids; never throws for malformed
195
+ * JSON. Callers needing atomicity should not use this (it is best-effort evidence).
196
+ */
197
+ export function mergeSessionMetadata(id, patch) {
198
+ const current = getWorkerSession(id);
199
+ if (!current)
200
+ return;
201
+ let base = {};
202
+ if (current.metadataJson) {
203
+ try {
204
+ const parsed = JSON.parse(current.metadataJson);
205
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
206
+ base = parsed;
207
+ }
208
+ }
209
+ catch {
210
+ base = {};
211
+ }
212
+ }
213
+ getDb()
214
+ .prepare("UPDATE worker_sessions SET metadata_json = ?, updated_at = ? WHERE id = ?")
215
+ .run(JSON.stringify({ ...base, ...patch }), new Date().toISOString(), id);
216
+ }
168
217
  /** Most recent still-running session for an issue — drives the Issue Detail live strip. */
169
218
  export function getActiveWorkerSessionForIssue(issueId) {
170
219
  const row = getDb()
@@ -57,14 +57,16 @@ export async function registerHumanActionRoutes(app) {
57
57
  });
58
58
  if (!result.ok)
59
59
  return reply.status(result.code).send({ error: result.error });
60
- // Reflect is a best-effort network call to Agent Deck (health check + a sequential
61
- // fetch/propose round trip per playbook) — resolution has already committed above, so
62
- // this must not hold the HTTP response hostage behind it: a slow/offline deck would
63
- // otherwise risk a client timeout on an already-resolved action, whose retry then gets
64
- // a spurious 409. Fire-and-forget; triggerIssueReflect never throws (it records its own
65
- // outcome as artifacts), so there is nothing here to await or react to.
66
- // triggerReflect is set after a successful merge-to-done (auto-merge or human complete).
67
- if (result.triggerReflect && action.issueId) {
60
+ // NOT-305: completion evidence is a best-effort Deck read (receipts) plus at most
61
+ // one `signal_only` report per failure/correction trigger — resolution has already
62
+ // committed above, so this must not hold the HTTP response hostage behind it: a
63
+ // slow/offline deck would otherwise risk a client timeout on an already-resolved
64
+ // action, whose retry then gets a spurious 409. Fire-and-forget; triggerIssueReflect
65
+ // never throws (it records its own outcome as artifacts), so there is nothing here
66
+ // to await or react to. Fires for every instance-completing resolution (done AND
67
+ // closed): an attempts-exhausted close still owes its actual-use receipts and its
68
+ // exhaustion signal even though no merge follows.
69
+ if (result.instanceCompleted && action.issueId) {
68
70
  void triggerIssueReflect(action.issueId).catch(() => { });
69
71
  }
70
72
  return {
@@ -0,0 +1,321 @@
1
+ // NOT-306: Linear/Planner tickets compile into frozen execution contracts.
2
+ // Web, API, and CLI creation all funnel through POST /api/issues, so these
3
+ // route tests cover each intake path with its equivalent request shape while
4
+ // the shared compiler owns the parsing (no page-local regex anywhere).
5
+ import { test, before, after, beforeEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import Fastify from "fastify";
11
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-contract-"));
12
+ const { migrate, getDb } = await import("../db/index.js");
13
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
14
+ const { registerIssueRoutes } = await import("./issues.js");
15
+ const { getIssue } = await import("../repository/issues.js");
16
+ const { claimWorkItem } = await import("../repository/work-items.js");
17
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
18
+ const { applyCompletion, resolveHumanActionAndAdvance, getTaskSnapshot } = await import("../coordinator/commands.js");
19
+ const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
20
+ const { setAdmissionHealthCheckerForTests } = await import("../coordinator/admission.js");
21
+ before(() => {
22
+ migrate();
23
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
24
+ });
25
+ after(() => setAdmissionHealthCheckerForTests(null));
26
+ beforeEach(() => {
27
+ getDb().exec(`
28
+ DELETE FROM work_items;
29
+ DELETE FROM human_actions;
30
+ DELETE FROM workflow_events;
31
+ DELETE FROM worker_sessions;
32
+ DELETE FROM artifacts;
33
+ DELETE FROM workflow_instances;
34
+ DELETE FROM queue_entries;
35
+ DELETE FROM issues;
36
+ `);
37
+ });
38
+ async function buildApp() {
39
+ const app = Fastify();
40
+ await registerIssueRoutes(app);
41
+ return app;
42
+ }
43
+ /** Planner-authored Linear ticket carrying every contract-v1 heading. */
44
+ const CONTRACT_DESCRIPTION = [
45
+ "Compile the Planner ticket into a frozen execution contract.",
46
+ "",
47
+ "## Builder execution mode",
48
+ "feature",
49
+ "",
50
+ "## Non-goals",
51
+ "- Selecting a playbook or reasoning about implementation architecture",
52
+ "- Adding a generic workflow/schema editor",
53
+ "",
54
+ "## Exit predicate",
55
+ "Importing a Planner-authored Linear ticket yields a versioned frozen execution contract.",
56
+ "",
57
+ "## One-PR stopping point",
58
+ "Stop after the contract compiles, freezes, and renders in both worker prompts.",
59
+ "",
60
+ "## Acceptance criteria",
61
+ "- [ ] Full ticket imports into the exact structured schema",
62
+ " Evidence: shared compiler fixtures | run the compiler test | every field asserted",
63
+ "- [ ] Legacy issues without headings still start",
64
+ " Evidence: legacy suite | run the legacy tests | green",
65
+ "",
66
+ ].join("\n");
67
+ const EXPECTED_CONTRACT = {
68
+ version: "v1",
69
+ executionMode: "feature",
70
+ nonGoals: [
71
+ "Selecting a playbook or reasoning about implementation architecture",
72
+ "Adding a generic workflow/schema editor",
73
+ ],
74
+ exitPredicate: "Importing a Planner-authored Linear ticket yields a versioned frozen execution contract.",
75
+ onePrStoppingPoint: "Stop after the contract compiles, freezes, and renders in both worker prompts.",
76
+ acceptanceCriteria: [
77
+ {
78
+ text: "Full ticket imports into the exact structured schema",
79
+ evidence: "shared compiler fixtures | run the compiler test | every field asserted",
80
+ },
81
+ {
82
+ text: "Legacy issues without headings still start",
83
+ evidence: "legacy suite | run the legacy tests | green",
84
+ },
85
+ ],
86
+ };
87
+ const BASE_AGENTS = {
88
+ repo: "acme/app",
89
+ baseBranch: "main",
90
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
91
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
92
+ };
93
+ test("web/API/CLI-shaped creates compile the same contract and keep the source untouched", async () => {
94
+ const app = await buildApp();
95
+ // Web Linear import: full ticket description, no separate criteria field —
96
+ // the server derives it with the shared compiler.
97
+ const webShaped = {
98
+ title: "NOT-306: frozen execution contracts",
99
+ description: CONTRACT_DESCRIPTION,
100
+ source: "linear",
101
+ externalId: "linear-uuid-web",
102
+ externalLabel: "NOT-306",
103
+ externalUrl: "https://linear.app/not-so-fat/issue/NOT-306/x",
104
+ ...BASE_AGENTS,
105
+ };
106
+ // API direct create: same description plus an explicit override stays explicit.
107
+ const apiShaped = {
108
+ title: "Contract via API",
109
+ description: CONTRACT_DESCRIPTION,
110
+ acceptanceCriteria: "Operator-typed override",
111
+ ...BASE_AGENTS,
112
+ };
113
+ // CLI agent create: source agent, description carried through.
114
+ const cliShaped = {
115
+ title: "Contract via CLI",
116
+ description: CONTRACT_DESCRIPTION,
117
+ source: "agent",
118
+ ...BASE_AGENTS,
119
+ };
120
+ for (const [name, payload] of [["web", webShaped], ["api", apiShaped], ["cli", cliShaped]]) {
121
+ const res = await app.inject({ method: "POST", url: "/api/issues", payload });
122
+ assert.equal(res.statusCode, 200, `${name}: ${res.body}`);
123
+ const created = res.json();
124
+ assert.deepStrictEqual(created.executionContract, EXPECTED_CONTRACT, name);
125
+ assert.equal(created.description, CONTRACT_DESCRIPTION, `${name}: source Markdown stored unchanged`);
126
+ const detail = (await app.inject({ method: "GET", url: `/api/issues/${created.id}` })).json();
127
+ assert.deepStrictEqual(detail.issue.executionContract, EXPECTED_CONTRACT, `${name} detail`);
128
+ assert.equal(detail.issue.description, CONTRACT_DESCRIPTION, `${name} detail source unchanged`);
129
+ }
130
+ // The override wins; the derived rendering fills only when nothing was given.
131
+ const apiCreated = (await app.inject({ method: "POST", url: "/api/issues", payload: { ...apiShaped, title: "again" } })).json();
132
+ assert.equal(apiCreated.acceptanceCriteria, "Operator-typed override");
133
+ const webCreated = (await app.inject({ method: "POST", url: "/api/issues", payload: { ...webShaped, title: "again", externalId: "linear-uuid-web-2" } })).json();
134
+ assert.ok(webCreated.acceptanceCriteria.includes("- [ ] Full ticket imports into the exact structured schema"));
135
+ assert.ok(webCreated.acceptanceCriteria.includes("Evidence: shared compiler fixtures"));
136
+ await app.close();
137
+ });
138
+ test("a contract ticket starts — readiness is satisfied by the derived criteria", async () => {
139
+ const app = await buildApp();
140
+ const created = (await app.inject({
141
+ method: "POST",
142
+ url: "/api/issues",
143
+ payload: { title: "Startable contract", description: CONTRACT_DESCRIPTION, ...BASE_AGENTS },
144
+ })).json();
145
+ const startRes = await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` });
146
+ assert.equal(startRes.statusCode, 200, startRes.body);
147
+ // Starting freezes the compiled contract alongside the source description.
148
+ const frozen = getTaskSnapshot(getIssue(created.id));
149
+ assert.deepStrictEqual(frozen.executionContract, EXPECTED_CONTRACT);
150
+ assert.equal(frozen.description, CONTRACT_DESCRIPTION);
151
+ await app.close();
152
+ });
153
+ test("legacy issues with no contract headings keep current behavior and stay startable", async () => {
154
+ const app = await buildApp();
155
+ const created = (await app.inject({
156
+ method: "POST",
157
+ url: "/api/issues",
158
+ payload: { title: "Legacy", description: "Just a plain description.", acceptanceCriteria: "It works", ...BASE_AGENTS },
159
+ })).json();
160
+ assert.equal(created.executionContract, null);
161
+ const startRes = await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` });
162
+ assert.equal(startRes.statusCode, 200, startRes.body);
163
+ const frozen = getTaskSnapshot(getIssue(created.id));
164
+ assert.equal(frozen.executionContract, null);
165
+ await app.close();
166
+ });
167
+ test("an Acceptance-criteria-only ticket keeps the legacy extraction and stays startable", async () => {
168
+ const app = await buildApp();
169
+ const created = (await app.inject({
170
+ method: "POST",
171
+ url: "/api/issues",
172
+ payload: {
173
+ title: "AC-only legacy",
174
+ description: "Ship it.\n\n## Acceptance criteria\nIt works and it ships.",
175
+ ...BASE_AGENTS,
176
+ },
177
+ })).json();
178
+ assert.equal(created.executionContract, null);
179
+ assert.equal(created.acceptanceCriteria, "It works and it ships.");
180
+ const startRes = await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` });
181
+ assert.equal(startRes.statusCode, 200, startRes.body);
182
+ await app.close();
183
+ });
184
+ const INVALID_DESCRIPTIONS = [
185
+ {
186
+ name: "unknown execution mode",
187
+ description: CONTRACT_DESCRIPTION.replace("feature", "teleport"),
188
+ match: /unknown execution mode/,
189
+ },
190
+ {
191
+ name: "empty exit predicate",
192
+ description: CONTRACT_DESCRIPTION.replace("Importing a Planner-authored Linear ticket yields a versioned frozen execution contract.", ""),
193
+ match: /Exit predicate/,
194
+ },
195
+ {
196
+ name: "duplicate criterion",
197
+ description: CONTRACT_DESCRIPTION.replace("- [ ] Legacy issues without headings still start", "- [ ] Full ticket imports into the exact structured schema"),
198
+ match: /duplicate criterion/i,
199
+ },
200
+ {
201
+ name: "empty Evidence line",
202
+ description: CONTRACT_DESCRIPTION.replace(" Evidence: legacy suite | run the legacy tests | green", " Evidence:"),
203
+ match: /Evidence/,
204
+ },
205
+ {
206
+ name: "duplicate heading",
207
+ description: `${CONTRACT_DESCRIPTION}\n## Non-goals\n- one more\n`,
208
+ match: /duplicate.*Non-goals/i,
209
+ },
210
+ {
211
+ name: "missing section once a signal is present",
212
+ description: "## Builder execution mode\nfeature\n\n## Acceptance criteria\n- [ ] Done\n",
213
+ match: /missing.*Non-goals/i,
214
+ },
215
+ ];
216
+ for (const { name, description, match } of INVALID_DESCRIPTIONS) {
217
+ test(`POST rejects an invalid contract with an actionable error: ${name}`, async () => {
218
+ const app = await buildApp();
219
+ const res = await app.inject({
220
+ method: "POST",
221
+ url: "/api/issues",
222
+ payload: { title: "Bad contract", description, ...BASE_AGENTS },
223
+ });
224
+ assert.equal(res.statusCode, 400, `${name}: ${res.body}`);
225
+ assert.match(res.json().error, match, name);
226
+ await app.close();
227
+ });
228
+ }
229
+ test("PATCH validates an edited description and backfills missing criteria", async () => {
230
+ const app = await buildApp();
231
+ const created = (await app.inject({
232
+ method: "POST",
233
+ url: "/api/issues",
234
+ payload: { title: "Editable", description: "plain", ...BASE_AGENTS },
235
+ })).json();
236
+ // A contract-breaking edit is a 400, not a silent half-parse.
237
+ const bad = await app.inject({
238
+ method: "PATCH",
239
+ url: `/api/issues/${created.id}`,
240
+ payload: { description: CONTRACT_DESCRIPTION.replace("feature", "teleport") },
241
+ });
242
+ assert.equal(bad.statusCode, 400, bad.body);
243
+ assert.match(bad.json().error, /unknown execution mode/);
244
+ // Adding the contract fills the missing criteria from the ticket.
245
+ const good = await app.inject({
246
+ method: "PATCH",
247
+ url: `/api/issues/${created.id}`,
248
+ payload: { description: CONTRACT_DESCRIPTION },
249
+ });
250
+ assert.equal(good.statusCode, 200, good.body);
251
+ const patched = good.json();
252
+ assert.equal(patched.description, CONTRACT_DESCRIPTION);
253
+ assert.deepStrictEqual(patched.executionContract, EXPECTED_CONTRACT);
254
+ assert.ok(patched.acceptanceCriteria.includes("- [ ] Full ticket imports into the exact structured schema"));
255
+ await app.close();
256
+ });
257
+ test("an active workflow keeps its frozen contract — later edits are refused", async () => {
258
+ const app = await buildApp();
259
+ const created = (await app.inject({
260
+ method: "POST",
261
+ url: "/api/issues",
262
+ payload: { title: "Frozen", description: CONTRACT_DESCRIPTION, ...BASE_AGENTS },
263
+ })).json();
264
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` })).statusCode, 200);
265
+ const before = getTaskSnapshot(getIssue(created.id));
266
+ assert.deepStrictEqual(before.executionContract, EXPECTED_CONTRACT);
267
+ const refused = await app.inject({
268
+ method: "PATCH",
269
+ url: `/api/issues/${created.id}`,
270
+ payload: { description: CONTRACT_DESCRIPTION.replace("feature", "refactor") },
271
+ });
272
+ assert.equal(refused.statusCode, 409);
273
+ const after = getTaskSnapshot(getIssue(created.id));
274
+ assert.deepStrictEqual(after, before);
275
+ await app.close();
276
+ });
277
+ test("NOT-185 + NOT-306: a parked re-freeze updates source and compiled contract atomically", async () => {
278
+ const app = await buildApp();
279
+ const created = (await app.inject({
280
+ method: "POST",
281
+ url: "/api/issues",
282
+ payload: {
283
+ title: "Parked contract",
284
+ description: CONTRACT_DESCRIPTION,
285
+ ...BASE_AGENTS,
286
+ maxReviewRounds: 1,
287
+ },
288
+ })).json();
289
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` })).statusCode, 200);
290
+ const complete = async (outcome) => {
291
+ const item = claimWorkItem("route-test", { leaseMs: 60_000 });
292
+ await applyCompletion(item.id, item.leaseToken, outcome);
293
+ };
294
+ const patch = (payload) => app.inject({ method: "PATCH", url: `/api/issues/${created.id}`, payload });
295
+ await complete({ kind: "clean_handoff", branch: "b", headSha: "abc", baseSha: "base", prNumber: 1, prUrl: "https://gh/pr/1" });
296
+ await complete({
297
+ kind: "verdict",
298
+ result: ReviewerResult.parse({
299
+ verdict: "changes_requested",
300
+ baseSha: "b",
301
+ headSha: "h",
302
+ acceptanceCriteriaAssessment: "ok",
303
+ evidenceAssessment: "ok",
304
+ findings: [],
305
+ risks: [],
306
+ }),
307
+ });
308
+ // Re-scope the ticket at the park: new exit predicate, one criterion dropped.
309
+ const rescoped = CONTRACT_DESCRIPTION.replace("Importing a Planner-authored Linear ticket yields a versioned frozen execution contract.", "Importing a Planner-authored Linear ticket yields a versioned frozen execution contract, rescoped.").replace("- [ ] Legacy issues without headings still start\n Evidence: legacy suite | run the legacy tests | green\n", "");
310
+ const parked = await patch({ description: rescoped });
311
+ assert.equal(parked.statusCode, 200, parked.body);
312
+ const action = listHumanActionsForIssue(created.id).find((a) => a.actionType === "attempts_exhausted");
313
+ assert.equal(resolveHumanActionAndAdvance(action.id, "operator", "retry").ok, true);
314
+ // Source and compiled contract re-froze together — never one without the other.
315
+ const frozen = getTaskSnapshot(getIssue(created.id));
316
+ assert.equal(frozen.description, rescoped);
317
+ assert.equal(frozen.executionContract?.exitPredicate, "Importing a Planner-authored Linear ticket yields a versioned frozen execution contract, rescoped.");
318
+ assert.equal(frozen.executionContract?.acceptanceCriteria.length, 1);
319
+ assert.deepStrictEqual(getIssue(created.id).executionContract, frozen.executionContract);
320
+ await app.close();
321
+ });
@@ -1,6 +1,6 @@
1
1
  // packages/server/src/routes/issues.ts
2
2
  import fs from "node:fs";
3
- import { CreateIssueInput, IssueStatus, UpdateIssueInput, } from "@agent-dealer/shared";
3
+ import { CreateIssueInput, ExecutionContractError, IssueStatus, UpdateIssueInput, } from "@agent-dealer/shared";
4
4
  import { getDb } from "../db/index.js";
5
5
  import { createIssue, getIssue, listIssues, findActiveIssueByExternalId, listIssuesByExternalId, queryIssues, updateIssue, listRecentRepos } from "../repository/issues.js";
6
6
  import { listWorkerSessionsForIssue, getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
@@ -224,7 +224,18 @@ export async function registerIssueRoutes(app) {
224
224
  });
225
225
  }
226
226
  }
227
- const issue = createIssue(input);
227
+ // NOT-306: an ambiguous/malformed execution contract in the ticket
228
+ // description is a 400 with an actionable message, never a silent drop.
229
+ let issue;
230
+ try {
231
+ issue = createIssue(input);
232
+ }
233
+ catch (err) {
234
+ if (err instanceof ExecutionContractError) {
235
+ return reply.status(400).send({ error: err.message });
236
+ }
237
+ throw err;
238
+ }
228
239
  appendWorkflowEvent({ issueId: issue.id, type: "issue.created", actorType: "human", stage: issue.status });
229
240
  // NOT-118: create enqueues, it never starts. Server-side so the UI, CLI and agents all
230
241
  // behave the same — callers hold no workflow logic. `enqueue: false` creates a draft.
@@ -320,6 +331,12 @@ export async function registerIssueRoutes(app) {
320
331
  })();
321
332
  }
322
333
  catch (err) {
334
+ // NOT-306: a contract edit that turns ambiguous/malformed is a 400 —
335
+ // the guard above already answered the 409s (wrong status / active
336
+ // workflow / parked-field allowlist).
337
+ if (err instanceof ExecutionContractError) {
338
+ return reply.status(400).send({ error: err.message });
339
+ }
323
340
  const code = err.code;
324
341
  const message = err instanceof Error ? err.message : String(err);
325
342
  if (code === 404)