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
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
// packages/server/src/coordinator/reflect-trigger.test.ts
|
|
2
2
|
//
|
|
3
|
-
// NOT-
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
6
|
-
//
|
|
7
|
-
// NOT-106: Deck calls go through launch-fixed deck headers via `deps.callTool`
|
|
8
|
-
// (adapters/reflect-authority.ts) — no mint.
|
|
3
|
+
// NOT-305: triggerIssueReflect records completion evidence — actual-use receipts for
|
|
4
|
+
// every terminal worker session plus one idempotent `signal_only` Deck report per
|
|
5
|
+
// failure/correction trigger. It never proposes a playbook patch (`kind: update`) and
|
|
6
|
+
// never appends generic `Notes` items.
|
|
9
7
|
import { test, before } from "node:test";
|
|
10
8
|
import assert from "node:assert/strict";
|
|
11
9
|
import fs from "node:fs";
|
|
12
10
|
import os from "node:os";
|
|
13
11
|
import path from "node:path";
|
|
12
|
+
import { DECK_CORRELATION_TOOL, validateSignalOnlyArgs } from "./playbook-feedback.js";
|
|
14
13
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reflect-"));
|
|
15
|
-
const { migrate
|
|
14
|
+
const { migrate } = await import("../db/index.js");
|
|
16
15
|
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
17
16
|
const { createIssue } = await import("../repository/issues.js");
|
|
18
17
|
const { createAgent } = await import("../repository/agents.js");
|
|
19
|
-
const { createWorkerSession } = await import("../repository/worker-sessions.js");
|
|
18
|
+
const { createWorkerSession, completeSession } = await import("../repository/worker-sessions.js");
|
|
20
19
|
const { buildProfileSnapshot, serializeProfileSnapshot } = await import("./profile-snapshot.js");
|
|
21
|
-
const {
|
|
22
|
-
const { createIssueArtifact } = await import("../repository/artifacts.js");
|
|
20
|
+
const { createHumanAction, resolveHumanAction } = await import("../repository/human-actions.js");
|
|
23
21
|
const { listArtifactsForIssue, listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
|
|
24
|
-
const { listHumanActionsForIssue
|
|
22
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
25
23
|
const { triggerIssueReflect, resolveReflectionInteractionAction } = await import("./reflect-trigger.js");
|
|
26
24
|
before(() => {
|
|
27
25
|
migrate();
|
|
28
26
|
});
|
|
27
|
+
const DECK = "11111111-1111-4111-a111-111111111111";
|
|
29
28
|
function seedIssue(developerAgentId) {
|
|
30
29
|
return createIssue({
|
|
31
30
|
title: "Reflect issue",
|
|
@@ -38,14 +37,11 @@ function seedIssue(developerAgentId) {
|
|
|
38
37
|
source: "manual",
|
|
39
38
|
});
|
|
40
39
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
const snapshot = { ...buildProfileSnapshot(agent, "developer"), deckId: overrides.deckId };
|
|
48
|
-
return createWorkerSession({
|
|
40
|
+
/** Terminal developer session on a NOT-149 profile: snapshot names the deck, no playbook IDs anywhere. */
|
|
41
|
+
function seedTerminalDeveloperSession(issueId, agent) {
|
|
42
|
+
assert.equal(agent.playbookIdsJson, null);
|
|
43
|
+
const snapshot = { ...buildProfileSnapshot(agent, "developer"), deckId: DECK };
|
|
44
|
+
const session = createWorkerSession({
|
|
49
45
|
issueId,
|
|
50
46
|
role: "developer",
|
|
51
47
|
round: 1,
|
|
@@ -53,185 +49,125 @@ function seedFinalDeveloperSession(issueId, agent, overrides) {
|
|
|
53
49
|
runtime: agent.runtime,
|
|
54
50
|
profileSnapshotJson: serializeProfileSnapshot(snapshot),
|
|
55
51
|
});
|
|
52
|
+
completeSession(session.id, { status: "done" });
|
|
53
|
+
return session;
|
|
56
54
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
throw new Error("waitFor: timed out waiting for predicate");
|
|
62
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/** Default deps: healthy deck, get_playbook/propose_playbook_patch both succeed. */
|
|
66
|
-
function makeDeps(overrides) {
|
|
67
|
-
return {
|
|
55
|
+
/** Default deps: healthy deck, correlation succeeds, signal_only succeeds. */
|
|
56
|
+
function makeDeps(overrides = {}) {
|
|
57
|
+
const calls = [];
|
|
58
|
+
const deps = {
|
|
68
59
|
checkHealth: overrides.checkHealth ?? (async () => true),
|
|
69
60
|
callTool: (async (opts) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return overrides.
|
|
73
|
-
? overrides.getPlaybook(playbookId)
|
|
74
|
-
: { ok: true, data: { id: playbookId, title: `Playbook ${playbookId}`, body: "" } };
|
|
61
|
+
calls.push({ toolName: opts.toolName, args: opts.arguments });
|
|
62
|
+
if (opts.toolName === DECK_CORRELATION_TOOL) {
|
|
63
|
+
return { ok: true, data: overrides.correlationData ?? { fetches: overrides.fetches ?? [] } };
|
|
75
64
|
}
|
|
76
65
|
if (opts.toolName === "propose_playbook_patch") {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
66
|
+
if (opts.arguments.kind !== "signal_only") {
|
|
67
|
+
throw new Error(`issue-centric reflection must only send signal_only, got ${opts.arguments.kind}`);
|
|
68
|
+
}
|
|
69
|
+
const schemaError = validateSignalOnlyArgs(opts.arguments);
|
|
70
|
+
if (schemaError) {
|
|
71
|
+
throw new Error(`signal payload violates Deck schema: ${schemaError}`);
|
|
72
|
+
}
|
|
73
|
+
return overrides.propose
|
|
74
|
+
? overrides.propose(opts.arguments)
|
|
75
|
+
: { ok: true, data: { id: `sig-${calls.length}` } };
|
|
82
76
|
}
|
|
83
77
|
throw new Error(`unexpected tool call: ${opts.toolName}`);
|
|
84
78
|
}),
|
|
85
79
|
};
|
|
80
|
+
return { deps, calls };
|
|
81
|
+
}
|
|
82
|
+
function sentSignals(issueId) {
|
|
83
|
+
return listArtifactsForIssueByKind(issueId, "deck_feedback_signal")
|
|
84
|
+
.map((a) => JSON.parse(a.contentJson))
|
|
85
|
+
.filter((s) => s.status === "sent");
|
|
86
|
+
}
|
|
87
|
+
async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
while (!predicate()) {
|
|
90
|
+
if (Date.now() - start > timeoutMs)
|
|
91
|
+
throw new Error("waitFor: timed out waiting for predicate");
|
|
92
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
93
|
+
}
|
|
86
94
|
}
|
|
87
|
-
test("skips when the
|
|
95
|
+
test("skips when the issue has no terminal sessions and no failure triggers", async () => {
|
|
88
96
|
const issue = seedIssue(BUILTIN_AGENT_CLAUDE_ID);
|
|
89
|
-
const
|
|
97
|
+
const { deps, calls } = makeDeps();
|
|
98
|
+
const result = await triggerIssueReflect(issue.id, deps);
|
|
90
99
|
assert.equal(result, "skipped");
|
|
100
|
+
assert.deepStrictEqual(calls, []);
|
|
91
101
|
});
|
|
92
|
-
test("
|
|
93
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId:
|
|
102
|
+
test("a clean completed run records the actual-use receipt and sends no signal", async () => {
|
|
103
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
94
104
|
const issue = seedIssue(dev.id);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
playbookIds: ["pb-1", "pb-2"],
|
|
98
|
-
});
|
|
99
|
-
const calledDeckIds = [];
|
|
100
|
-
const proposed = [];
|
|
101
|
-
const deps = makeDeps({
|
|
102
|
-
proposePatch: (playbookId) => {
|
|
103
|
-
proposed.push(playbookId);
|
|
104
|
-
return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
const wrapped = {
|
|
108
|
-
...deps,
|
|
109
|
-
callTool: (async (opts) => {
|
|
110
|
-
calledDeckIds.push(opts.deckId);
|
|
111
|
-
return deps.callTool(opts);
|
|
112
|
-
}),
|
|
113
|
-
};
|
|
114
|
-
const result = await triggerIssueReflect(issue.id, wrapped);
|
|
115
|
-
assert.equal(result, "triggered");
|
|
116
|
-
assert.ok(calledDeckIds.every((id) => id === "11111111-1111-4111-a111-111111111111"));
|
|
117
|
-
assert.deepStrictEqual(proposed.sort(), ["pb-1", "pb-2"]);
|
|
118
|
-
});
|
|
119
|
-
test("falls back to the live agent profile when the developer session has no frozen snapshot (legacy row)", async () => {
|
|
120
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "33333333-3333-4333-a333-333333333333" });
|
|
121
|
-
getDb()
|
|
122
|
-
.prepare("UPDATE agents SET playbook_ids_json = ? WHERE id = ?")
|
|
123
|
-
.run(JSON.stringify(["pb-live"]), dev.id);
|
|
124
|
-
const issue = seedIssue(dev.id);
|
|
125
|
-
createWorkerSession({ issueId: issue.id, role: "developer", round: 1, agentId: dev.id, runtime: "claude_code" });
|
|
126
|
-
const proposed = [];
|
|
127
|
-
const deps = makeDeps({
|
|
128
|
-
proposePatch: (playbookId) => {
|
|
129
|
-
proposed.push(playbookId);
|
|
130
|
-
return { ok: true, data: { id: "patch", playbookId } };
|
|
131
|
-
},
|
|
132
|
-
});
|
|
105
|
+
const session = seedTerminalDeveloperSession(issue.id, dev);
|
|
106
|
+
const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-1" }, { playbook_id: "pb-2" }] });
|
|
133
107
|
const result = await triggerIssueReflect(issue.id, deps);
|
|
134
108
|
assert.equal(result, "triggered");
|
|
135
|
-
|
|
109
|
+
const receipts = listArtifactsForIssueByKind(issue.id, "playbook_use_receipt").map((a) => JSON.parse(a.contentJson));
|
|
110
|
+
assert.equal(receipts.length, 1);
|
|
111
|
+
assert.deepStrictEqual(receipts[0].playbookIds, ["pb-1", "pb-2"]);
|
|
112
|
+
assert.equal(receipts[0].workerSessionId, session.id);
|
|
113
|
+
assert.deepStrictEqual(calls.filter((c) => c.toolName === "propose_playbook_patch"), []);
|
|
114
|
+
assert.deepStrictEqual(listArtifactsForIssueByKind(issue.id, "deck_feedback_signal"), []);
|
|
115
|
+
assert.deepStrictEqual(sentSignals(issue.id), []);
|
|
136
116
|
});
|
|
137
|
-
test("
|
|
138
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId:
|
|
139
|
-
getDb().prepare("UPDATE agents SET playbook_id = ? WHERE id = ?").run("pb-legacy", dev.id);
|
|
117
|
+
test("a human correction produces one signal_only report; a restart sends nothing more", async () => {
|
|
118
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
140
119
|
const issue = seedIssue(dev.id);
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
},
|
|
120
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
121
|
+
const action = createHumanAction({
|
|
122
|
+
issueId: issue.id,
|
|
123
|
+
actionType: "final_review",
|
|
124
|
+
reason: "Needs a repair round.",
|
|
125
|
+
question: "Repair?",
|
|
148
126
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
assert.
|
|
127
|
+
resolveHumanAction(action.id, "operator", { choice: "repair" });
|
|
128
|
+
const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-1" }] });
|
|
129
|
+
assert.equal(await triggerIssueReflect(issue.id, deps), "triggered");
|
|
130
|
+
const proposes = () => calls.filter((c) => c.toolName === "propose_playbook_patch");
|
|
131
|
+
assert.equal(proposes().length, 1);
|
|
132
|
+
assert.equal(proposes()[0].args.kind, "signal_only");
|
|
133
|
+
// Simulated restart: idempotent, no duplicate Deck report.
|
|
134
|
+
assert.equal(await triggerIssueReflect(issue.id, deps), "skipped");
|
|
135
|
+
assert.equal(proposes().length, 1);
|
|
136
|
+
assert.equal(sentSignals(issue.id).length, 1);
|
|
152
137
|
});
|
|
153
|
-
test("
|
|
154
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId:
|
|
138
|
+
test("records a visible failure and preserves state when the deck is offline", async () => {
|
|
139
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
155
140
|
const issue = seedIssue(dev.id);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
playbookIds: ["pb-1"],
|
|
159
|
-
});
|
|
160
|
-
const deps = {
|
|
161
|
-
checkHealth: async () => false,
|
|
162
|
-
callTool: (async () => {
|
|
163
|
-
throw new Error("should not be called");
|
|
164
|
-
}),
|
|
165
|
-
};
|
|
141
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
142
|
+
const { deps, calls } = makeDeps({ checkHealth: async () => false });
|
|
166
143
|
const result = await triggerIssueReflect(issue.id, deps);
|
|
167
|
-
assert.equal(result, "
|
|
144
|
+
assert.equal(result, "failed");
|
|
145
|
+
assert.deepStrictEqual(calls, []);
|
|
168
146
|
const artifacts = listArtifactsForIssue(issue.id);
|
|
169
|
-
assert.ok(artifacts.some((a) => a.kind === "reflect_status" && JSON.parse(a.contentJson).
|
|
147
|
+
assert.ok(artifacts.some((a) => a.kind === "reflect_status" || (a.kind === "playbook_use_receipt" && JSON.parse(a.contentJson).status === "error")));
|
|
170
148
|
});
|
|
171
|
-
test("
|
|
172
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId:
|
|
149
|
+
test("a retry after a failed signal send re-sends exactly once, never duplicating", async () => {
|
|
150
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
173
151
|
const issue = seedIssue(dev.id);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
playbookIds: ["pb-1", "pb-2"],
|
|
177
|
-
});
|
|
178
|
-
createIssueArtifact({
|
|
179
|
-
issueId: issue.id,
|
|
180
|
-
kind: "implementation_conclusion",
|
|
181
|
-
author: "agent",
|
|
182
|
-
content: { text: "Implemented the widget using the shared component." },
|
|
183
|
-
});
|
|
184
|
-
appendWorkflowEvent({
|
|
152
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
153
|
+
const action = createHumanAction({
|
|
185
154
|
issueId: issue.id,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
payload: { verdict: "changes_requested" },
|
|
190
|
-
});
|
|
191
|
-
appendWorkflowEvent({
|
|
192
|
-
issueId: issue.id,
|
|
193
|
-
type: "review.submitted",
|
|
194
|
-
actorType: "reviewer",
|
|
195
|
-
stage: "reviewing",
|
|
196
|
-
payload: { verdict: "approved" },
|
|
197
|
-
});
|
|
198
|
-
const proposed = [];
|
|
199
|
-
const deps = makeDeps({
|
|
200
|
-
proposePatch: (playbookId, rationale) => {
|
|
201
|
-
proposed.push(playbookId);
|
|
202
|
-
assert.match(rationale, /2 review round\(s\)/);
|
|
203
|
-
assert.match(rationale, /changes_requested → approved/);
|
|
204
|
-
assert.match(rationale, /Implemented the widget using the shared component\./);
|
|
205
|
-
return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
const result = await triggerIssueReflect(issue.id, deps);
|
|
209
|
-
assert.equal(result, "triggered");
|
|
210
|
-
assert.deepStrictEqual(proposed.sort(), ["pb-1", "pb-2"]);
|
|
211
|
-
const artifacts = listArtifactsForIssue(issue.id);
|
|
212
|
-
assert.equal(artifacts.filter((a) => a.kind === "playbook_patch").length, 2);
|
|
213
|
-
});
|
|
214
|
-
test("a retry after a mid-loop failure does not re-propose a playbook that already succeeded", async () => {
|
|
215
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
216
|
-
const issue = seedIssue(dev.id);
|
|
217
|
-
seedFinalDeveloperSession(issue.id, dev, {
|
|
218
|
-
deckId: "11111111-1111-4111-a111-111111111111",
|
|
219
|
-
playbookIds: ["pb-ok", "pb-fail"],
|
|
155
|
+
actionType: "final_review",
|
|
156
|
+
reason: "Needs a repair round.",
|
|
157
|
+
question: "Repair?",
|
|
220
158
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const deps = makeDeps({
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return { ok: false, kind: "infra_failure", reason: "temporary deck error" };
|
|
228
|
-
}
|
|
229
|
-
return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
|
|
230
|
-
},
|
|
159
|
+
resolveHumanAction(action.id, "operator", { choice: "repair" });
|
|
160
|
+
let failPropose = true;
|
|
161
|
+
const { deps, calls } = makeDeps({
|
|
162
|
+
propose: () => failPropose
|
|
163
|
+
? { ok: false, kind: "infra_failure", reason: "temporary deck error" }
|
|
164
|
+
: { ok: true, data: { id: "sig-1" } },
|
|
231
165
|
});
|
|
166
|
+
const proposes = () => calls.filter((c) => c.toolName === "propose_playbook_patch");
|
|
232
167
|
const first = await triggerIssueReflect(issue.id, deps);
|
|
233
|
-
assert.equal(first, "triggered");
|
|
234
|
-
assert.
|
|
168
|
+
assert.equal(first, "triggered"); // the receipt still recorded
|
|
169
|
+
assert.equal(proposes().length, 1);
|
|
170
|
+
assert.equal(sentSignals(issue.id).length, 0);
|
|
235
171
|
createHumanAction({
|
|
236
172
|
issueId: issue.id,
|
|
237
173
|
actionType: "reflection_interaction_required",
|
|
@@ -243,11 +179,12 @@ test("a retry after a mid-loop failure does not re-propose a playbook that alrea
|
|
|
243
179
|
],
|
|
244
180
|
});
|
|
245
181
|
const parkedAction = listHumanActionsForIssue(issue.id).find((a) => a.actionType === "reflection_interaction_required" && a.status === "open");
|
|
246
|
-
|
|
182
|
+
failPropose = false;
|
|
247
183
|
const resolved = resolveReflectionInteractionAction(parkedAction.id, "operator", "retry", deps);
|
|
248
184
|
assert.equal(resolved.ok, true);
|
|
249
|
-
await waitFor(() =>
|
|
250
|
-
assert.
|
|
185
|
+
await waitFor(() => sentSignals(issue.id).length >= 1);
|
|
186
|
+
assert.equal(proposes().length, 2);
|
|
187
|
+
assert.equal(sentSignals(issue.id).length, 1);
|
|
251
188
|
});
|
|
252
189
|
test("resolveReflectionInteractionAction:dismiss closes the action without further Deck calls", async () => {
|
|
253
190
|
const issue = seedIssue(BUILTIN_AGENT_CLAUDE_ID);
|
|
@@ -289,36 +226,48 @@ test("resolveReflectionInteractionAction rejects an invalid choice and already-r
|
|
|
289
226
|
const alreadyResolved = resolveReflectionInteractionAction(parkedAction.id, "operator", "retry");
|
|
290
227
|
assert.equal(alreadyResolved.ok, false);
|
|
291
228
|
});
|
|
292
|
-
test("
|
|
293
|
-
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId:
|
|
229
|
+
test("reflect_status names a malformed correlation response instead of blaming an offline Deck", async () => {
|
|
230
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
294
231
|
const issue = seedIssue(dev.id);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
232
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
233
|
+
const { deps } = makeDeps({ correlationData: { unexpected: "shape" } });
|
|
234
|
+
const result = await triggerIssueReflect(issue.id, deps);
|
|
235
|
+
assert.equal(result, "failed");
|
|
236
|
+
const statuses = listArtifactsForIssueByKind(issue.id, "reflect_status").map((a) => JSON.parse(a.contentJson));
|
|
237
|
+
assert.ok(statuses.length > 0);
|
|
238
|
+
const text = statuses.map((s) => `${s.reason ?? ""} ${s.error ?? ""}`).join("\n");
|
|
239
|
+
assert.match(text, /malformed/i);
|
|
240
|
+
assert.doesNotMatch(text, /offline/i);
|
|
241
|
+
});
|
|
242
|
+
test("reflect_status still says offline when the receipt error really is a Deck outage", async () => {
|
|
243
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
244
|
+
const issue = seedIssue(dev.id);
|
|
245
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
246
|
+
const { deps } = makeDeps({ checkHealth: async () => false });
|
|
247
|
+
assert.equal(await triggerIssueReflect(issue.id, deps), "failed");
|
|
248
|
+
const statuses = listArtifactsForIssueByKind(issue.id, "reflect_status").map((a) => JSON.parse(a.contentJson));
|
|
249
|
+
assert.ok(statuses.some((s) => /offline/i.test(`${s.reason ?? ""} ${s.error ?? ""}`)));
|
|
250
|
+
});
|
|
251
|
+
test("never calls propose_playbook_patch with kind:update and never writes Notes items", async () => {
|
|
252
|
+
const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
|
|
253
|
+
const issue = seedIssue(dev.id);
|
|
254
|
+
seedTerminalDeveloperSession(issue.id, dev);
|
|
255
|
+
const action = createHumanAction({
|
|
300
256
|
issueId: issue.id,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
257
|
+
actionType: "attempts_exhausted",
|
|
258
|
+
reason: "Rounds spent.",
|
|
259
|
+
question: "Close?",
|
|
304
260
|
});
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
261
|
+
resolveHumanAction(action.id, "operator", { choice: "close" });
|
|
262
|
+
const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-9" }] });
|
|
263
|
+
await triggerIssueReflect(issue.id, deps);
|
|
264
|
+
for (const call of calls) {
|
|
265
|
+
if (call.toolName !== "propose_playbook_patch")
|
|
266
|
+
continue;
|
|
267
|
+
assert.notEqual(call.args.kind, "update");
|
|
268
|
+
assert.equal(call.args.kind, "signal_only");
|
|
269
|
+
const ops = call.args.ops;
|
|
270
|
+
assert.ok(!ops || !ops.some((op) => op.section === "Notes"));
|
|
312
271
|
}
|
|
313
|
-
|
|
314
|
-
const deps = makeDeps({
|
|
315
|
-
proposePatch: (playbookId) => {
|
|
316
|
-
proposed.push(playbookId);
|
|
317
|
-
return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
const result = await triggerIssueReflect(issue.id, deps);
|
|
321
|
-
assert.equal(result, "triggered");
|
|
322
|
-
assert.deepStrictEqual(proposed, ["pb-next"]);
|
|
323
|
-
assert.equal(listArtifactsForIssueByKind(issue.id, "playbook_patch").length, 2);
|
|
272
|
+
assert.equal(listArtifactsForIssueByKind(issue.id, "playbook_patch").length, 0);
|
|
324
273
|
});
|
|
@@ -197,6 +197,7 @@ test("developer round 1 → reviewer changes_requested → developer round 2 on
|
|
|
197
197
|
assert.equal(issue.status, "final_review", "the cycle must end at a human final_review, not stuck or escalated");
|
|
198
198
|
assert.equal(issue.currentRound, 2, "exactly one genuine repair round — changes_requested — was spent");
|
|
199
199
|
assert.equal(issue.infraAttempts, 0, "nothing in this run was an infra failure");
|
|
200
|
+
assert.equal(issue.ciAttempts, 0, "nothing in this run was a CI failure either");
|
|
200
201
|
assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "final_review" && a.status === "open"));
|
|
201
202
|
// Same branch reused across both rounds — never re-created.
|
|
202
203
|
const branch = issueBranchName(issueId);
|
|
@@ -26,6 +26,7 @@ import os from "node:os";
|
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
|
|
28
28
|
import { getTaskSnapshot } from "./commands.js";
|
|
29
|
+
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
29
30
|
import { buildReviewerPrompt, formatDiffForPrompt, TOTAL_DIFF_LIMIT } from "./prompts.js";
|
|
30
31
|
import { guidanceForNextSession } from "./guidance.js";
|
|
31
32
|
import { realReviewerSpawn, reviewerSessionLogPath } from "./spawn.js";
|
|
@@ -34,7 +35,7 @@ import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fet
|
|
|
34
35
|
import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
|
|
35
36
|
import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
|
|
36
37
|
import { realGithubAdapter } from "../adapters/github.js";
|
|
37
|
-
import { getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
|
|
38
|
+
import { getOrAssignSessionCorrelationId, getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
|
|
38
39
|
import { COORDINATOR_PROCESS_OWNER, readProcessStartTime } from "./process-liveness.js";
|
|
39
40
|
import { reviewerSessionTimeoutMs } from "./session-timeouts.js";
|
|
40
41
|
import { getWorkItem } from "../repository/work-items.js";
|
|
@@ -196,6 +197,11 @@ function renderReviewBody(result, marker) {
|
|
|
196
197
|
`**Evidence assessment**`,
|
|
197
198
|
result.evidenceAssessment,
|
|
198
199
|
];
|
|
200
|
+
// NOT-306: the exit-predicate verdict rides with the review when the task
|
|
201
|
+
// carried an execution contract; legacy reviews simply omit the section.
|
|
202
|
+
if (result.exitPredicateAssessment?.trim()) {
|
|
203
|
+
lines.push(``, `**Exit predicate assessment**`, result.exitPredicateAssessment);
|
|
204
|
+
}
|
|
199
205
|
if (result.findings.length) {
|
|
200
206
|
lines.push(``, `**Findings**`);
|
|
201
207
|
for (const f of result.findings) {
|
|
@@ -222,6 +228,9 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
222
228
|
return { kind: "session_failed" };
|
|
223
229
|
const session = getWorkerSession(sessionId);
|
|
224
230
|
const snapshot = parseProfileSnapshot(session?.profileSnapshotJson);
|
|
231
|
+
// NOT-305: opaque per-session Deck correlation UUID, persisted before spawn and
|
|
232
|
+
// carried as observability metadata in the materialized launch config below.
|
|
233
|
+
const deckCorrelationId = session?.deckCorrelationId ?? getOrAssignSessionCorrelationId(sessionId);
|
|
225
234
|
const taskSnapshot = getTaskSnapshot(issue);
|
|
226
235
|
const runtime = snapshot?.runtime ?? "claude_code";
|
|
227
236
|
const round = workItem.round;
|
|
@@ -291,6 +300,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
291
300
|
worktreePath,
|
|
292
301
|
runtime,
|
|
293
302
|
policy,
|
|
303
|
+
correlationId: deckCorrelationId,
|
|
294
304
|
verifyCallTool: deps.deckCallTool,
|
|
295
305
|
});
|
|
296
306
|
if (!prepared.ok) {
|
|
@@ -327,6 +337,10 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
327
337
|
const { truncated: diffTruncated, omittedPaths } = formatDiffForPrompt(diff);
|
|
328
338
|
const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
|
|
329
339
|
const guidance = guidanceForNextSession(issue.id, sessionId);
|
|
340
|
+
// NOT-314: the frozen snapshot's operator criteria — the reviewer must not
|
|
341
|
+
// treat missing operator evidence as a defect (Dealer gates the merge), but
|
|
342
|
+
// the probe and doc must exist. Rendered only when non-empty.
|
|
343
|
+
const operatorCriteria = extractOperatorCriteria(taskSnapshot.acceptanceCriteria);
|
|
330
344
|
const prompt = buildReviewerPrompt({
|
|
331
345
|
taskSnapshot,
|
|
332
346
|
round: workItem.round,
|
|
@@ -339,6 +353,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
339
353
|
worktreePath,
|
|
340
354
|
deckId: snapshot?.deckId ?? null,
|
|
341
355
|
guidance: guidance.length ? guidance : undefined,
|
|
356
|
+
operatorCriteria: operatorCriteria.length > 0 ? operatorCriteria : undefined,
|
|
342
357
|
});
|
|
343
358
|
// NOT-83 review finding — see developer-effect.ts's identical check for the full
|
|
344
359
|
// rationale: re-verify this item is still leased right before the real spawn, since an
|
|
@@ -14,6 +14,12 @@ export const ReviewerResult = z.object({
|
|
|
14
14
|
headSha: z.string(),
|
|
15
15
|
acceptanceCriteriaAssessment: z.string(),
|
|
16
16
|
evidenceAssessment: z.string(),
|
|
17
|
+
/**
|
|
18
|
+
* NOT-306: whether the frozen exit predicate held at the reviewed tip. The
|
|
19
|
+
* reviewer sets this when the task carries an execution contract; legacy
|
|
20
|
+
* contract-free tasks omit it (optional so older outputs still parse).
|
|
21
|
+
*/
|
|
22
|
+
exitPredicateAssessment: z.string().optional(),
|
|
17
23
|
findings: z.array(ReviewerFinding),
|
|
18
24
|
risks: z.array(z.string()),
|
|
19
25
|
/** Present only when verdict is "escalated" and the reviewer identifies a missing product call. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// packages/server/src/coordinator/routing.ts
|
|
2
|
+
import { DEFAULT_MAX_CI_ATTEMPTS } from "@agent-dealer/shared";
|
|
2
3
|
import { normalizeReviewerResult } from "./reviewer-result.js";
|
|
3
4
|
/** Default N for NOT-147: escalate empty-tip timeout/crash after this many failures (one auto-retry). */
|
|
4
5
|
export const DEFAULT_NO_PROGRESS_INFRA_ATTEMPTS = 2;
|
|
@@ -14,6 +15,13 @@ function roundsRemain(limits) {
|
|
|
14
15
|
export function infraAttemptsRemain(limits) {
|
|
15
16
|
return limits.infraAttempts < limits.maxInfraAttempts;
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* NOT-313: the one definition of "is there CI-repair budget left", mirroring
|
|
20
|
+
* {@link infraAttemptsRemain}. Absent counters read as a fresh default budget.
|
|
21
|
+
*/
|
|
22
|
+
export function ciAttemptsRemain(limits) {
|
|
23
|
+
return (limits.ciAttempts ?? 0) < (limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS);
|
|
24
|
+
}
|
|
17
25
|
export function routeDeveloperOutcome(outcome, limits) {
|
|
18
26
|
switch (outcome.kind) {
|
|
19
27
|
case "clean_handoff":
|
|
@@ -103,7 +111,6 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
103
111
|
: { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
|
|
104
112
|
}
|
|
105
113
|
case "no_pr":
|
|
106
|
-
case "checks_failed":
|
|
107
114
|
case "deck_failure": {
|
|
108
115
|
// Unified infra-failure policy: the session/environment failed to produce a
|
|
109
116
|
// reviewable result at all — bounded auto-retry on max_infra_attempts, decoupled
|
|
@@ -113,6 +120,30 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
113
120
|
? { next: "retry_developer", reason }
|
|
114
121
|
: { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
|
|
115
122
|
}
|
|
123
|
+
case "checks_failed": {
|
|
124
|
+
// NOT-313: a failing CI check is the expected feedback loop, not an
|
|
125
|
+
// infrastructure failure — bounded on max_ci_attempts, never the infra
|
|
126
|
+
// budget. The retry reason names the attempt number so the next session's
|
|
127
|
+
// prompt reads "CI repair attempt N of M"; the escalation names the same
|
|
128
|
+
// count plus the failing checks (via the NOT-252 enrichment), never the
|
|
129
|
+
// infra-attempt text. policy_escalation (not attempts_exhausted) so a human
|
|
130
|
+
// "resume" resets budgets without spending a review round.
|
|
131
|
+
const spent = limits.ciAttempts ?? 0;
|
|
132
|
+
const max = limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS;
|
|
133
|
+
const base = checksFailedDetails(outcome);
|
|
134
|
+
if (ciAttemptsRemain(limits)) {
|
|
135
|
+
return {
|
|
136
|
+
next: "retry_developer",
|
|
137
|
+
reason: `CI repair attempt ${spent + 1} of ${max}: ${base}`,
|
|
138
|
+
budget: "ci",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
next: "human_action",
|
|
143
|
+
actionType: "policy_escalation",
|
|
144
|
+
reason: `CI checks still failing after ${spent} repair attempts (limit ${max}). ${base}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
116
147
|
case "session_failed":
|
|
117
148
|
case "timed_out": {
|
|
118
149
|
// Same infra budget as above, plus NOT-147: empty tip after N timeout/crashes → human
|
|
@@ -140,6 +171,11 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
140
171
|
// NOT-197: a hard-down network is not retryable on the infra-attempt timescale, and
|
|
141
172
|
// nothing was spawned, so there is no attempt to charge. Wait for the network instead.
|
|
142
173
|
return { next: "defer_work", reason: outcome.reason };
|
|
174
|
+
case "checks_pending":
|
|
175
|
+
// NOT-311: slow or not-yet-registered CI is not a developer failure — the
|
|
176
|
+
// session ended cleanly with a pushed branch and an open PR. Wait on backoff
|
|
177
|
+
// and re-poll; the deferral path (not this route) enforces the wait ceiling.
|
|
178
|
+
return { next: "defer_work", reason: outcome.reason };
|
|
143
179
|
}
|
|
144
180
|
}
|
|
145
181
|
function infraFailureReason(outcome) {
|
|
@@ -150,17 +186,20 @@ function infraFailureReason(outcome) {
|
|
|
150
186
|
return outcome.reason ?? "Developer session failed or crashed.";
|
|
151
187
|
case "timed_out":
|
|
152
188
|
return outcome.reason ?? "Developer session timed out.";
|
|
153
|
-
case "checks_failed":
|
|
154
|
-
// NOT-252: prefer the sanitized enrichment the effect threaded into `details`
|
|
155
|
-
// (failing check names + bounded untrusted excerpt at the verified head SHA).
|
|
156
|
-
// Blank/absent details keep today's exact generic reason.
|
|
157
|
-
return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
|
|
158
189
|
case "adapter_failure":
|
|
159
190
|
return `Git/GitHub verification failed: ${outcome.reason}`;
|
|
160
191
|
case "deck_failure":
|
|
161
192
|
return `Agent Deck ${outcome.reason}`;
|
|
162
193
|
}
|
|
163
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* NOT-252: prefer the sanitized enrichment the effect threaded into `details`
|
|
197
|
+
* (failing check names + bounded untrusted excerpt at the verified head SHA).
|
|
198
|
+
* Blank/absent details keep the exact generic reason.
|
|
199
|
+
*/
|
|
200
|
+
function checksFailedDetails(outcome) {
|
|
201
|
+
return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
|
|
202
|
+
}
|
|
164
203
|
/**
|
|
165
204
|
* NOT-147: when a timeout/crash left the branch with zero commits ahead of base, stop
|
|
166
205
|
* burning full agent spawns after N failures (default 2 = one auto-retry). Only trips when
|