agent-dealer 1.2.7 → 1.2.9
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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +84 -5
- package/bundle/server/dist/adapters/github.test.js +83 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +244 -4
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +284 -23
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +224 -4
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +36 -1
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +211 -2
- package/bundle/server/dist/coordinator/prompts.test.js +231 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +76 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +18 -0
- package/bundle/server/dist/db/schema.sql +5 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +67 -6
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +17 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +176 -16
- package/bundle/shared/dist/issues.js +22 -0
- package/bundle/shared/dist/issues.test.js +3 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +16 -0
- package/bundle/shared/dist/workflow.test.js +3 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
|
@@ -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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// this must not hold the HTTP response hostage behind it: a
|
|
63
|
-
// otherwise risk a client timeout on an already-resolved
|
|
64
|
-
// a spurious 409. Fire-and-forget; triggerIssueReflect
|
|
65
|
-
// outcome as artifacts), so there is nothing here
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
// no per-attempt XDG dirs, no settings.json — those belong to the coordinator wiring (NOT-181).
|
|
4
4
|
// Reviewer argv, resume and cancellation are out of scope.
|
|
5
5
|
export const MUSE_COMMAND = "muse";
|
|
6
|
+
/**
|
|
7
|
+
* NOT-316: what the Muse developer sandbox cannot do, derived from the real
|
|
8
|
+
* launch flags in `buildMuseDeveloperInvocation` below. The developer prompt's
|
|
9
|
+
* "Environment limits" section is rendered from this constant (not hand-written
|
|
10
|
+
* prose), and `muse-code-args.test.ts` asserts the pairing — changing
|
|
11
|
+
* `--sandbox-network` without updating this constant fails a test.
|
|
12
|
+
*
|
|
13
|
+
* Live probe results behind these values: under `restricted` there is no DNS
|
|
14
|
+
* (npm registry and GitHub API unreachable) and loopback `listen()` fails with
|
|
15
|
+
* EPERM; under `proxy-only` network works through a local proxy but `listen()`
|
|
16
|
+
* still fails; under `enabled` both work. No headless browser or
|
|
17
|
+
* credentials/Keychain are provisioned into the developer session.
|
|
18
|
+
*/
|
|
19
|
+
export const MUSE_SANDBOX_CAPABILITIES = {
|
|
20
|
+
/** Must equal the `--sandbox-network` value passed in the developer argv. */
|
|
21
|
+
sandboxNetwork: "restricted",
|
|
22
|
+
/** Effective network access under that sandbox mode. */
|
|
23
|
+
network: "none",
|
|
24
|
+
/** Whether loopback `listen()` works. */
|
|
25
|
+
loopbackListen: false,
|
|
26
|
+
/** Whether a browser is available for visual QA / screenshots. */
|
|
27
|
+
browser: false,
|
|
28
|
+
/** Whether credentials / Keychain are provisioned. */
|
|
29
|
+
credentialsKeychain: false,
|
|
30
|
+
};
|
|
6
31
|
/** The launcher re-execs a newer binary hourly unless this is set, so the pinned version can drift. */
|
|
7
32
|
export const MUSE_NO_AUTO_UPDATE_ENV = { MUSE_NO_AUTO_UPDATE: "1" };
|
|
8
33
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// packages/server/src/runners/muse-code-args.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-316: `MUSE_SANDBOX_CAPABILITIES` is mechanically tied to the real launch
|
|
4
|
+
// flags — changing `--sandbox-network` without updating the constant fails a test.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { buildMuseDeveloperInvocation, MUSE_SANDBOX_CAPABILITIES, } from "./muse-code-args.js";
|
|
8
|
+
const baseOpts = {
|
|
9
|
+
model: "muse-spark-1.3-contributor",
|
|
10
|
+
maxModelSteps: 40,
|
|
11
|
+
sessionId: "0b6c3f0e-4c2a-4f0e-9d3b-2f6f1a9a7c11",
|
|
12
|
+
prompt: "Implement NOT-1",
|
|
13
|
+
};
|
|
14
|
+
function sandboxNetworkFlag() {
|
|
15
|
+
const args = buildMuseDeveloperInvocation(baseOpts).args;
|
|
16
|
+
const i = args.indexOf("--sandbox-network");
|
|
17
|
+
assert.notEqual(i, -1, "--sandbox-network must be present in the developer argv");
|
|
18
|
+
const value = args[i + 1];
|
|
19
|
+
assert.ok(value, "--sandbox-network must have a value");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
test("NOT-316: MUSE_SANDBOX_CAPABILITIES.sandboxNetwork matches the --sandbox-network flag actually passed", () => {
|
|
23
|
+
assert.equal(sandboxNetworkFlag(), MUSE_SANDBOX_CAPABILITIES.sandboxNetwork);
|
|
24
|
+
});
|
|
25
|
+
test("NOT-316: sandbox capabilities state the restricted-sandbox limits (no network/listen/browser/credentials)", () => {
|
|
26
|
+
assert.equal(MUSE_SANDBOX_CAPABILITIES.network, "none");
|
|
27
|
+
assert.equal(MUSE_SANDBOX_CAPABILITIES.loopbackListen, false);
|
|
28
|
+
assert.equal(MUSE_SANDBOX_CAPABILITIES.browser, false);
|
|
29
|
+
assert.equal(MUSE_SANDBOX_CAPABILITIES.credentialsKeychain, false);
|
|
30
|
+
});
|