agent-dealer 1.0.2 → 1.0.4
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.js +68 -13
- package/bundle/server/dist/adapters/agent-deck.test.js +91 -1
- package/bundle/server/dist/adapters/agent-health.js +175 -51
- package/bundle/server/dist/adapters/agent-health.test.js +338 -210
- package/bundle/server/dist/adapters/linear-graphql.js +200 -6
- package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
- package/bundle/server/dist/coordinator/admission.js +41 -30
- package/bundle/server/dist/coordinator/admission.test.js +92 -1
- package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
- package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
- package/bundle/server/dist/coordinator/commands.js +6 -2
- package/bundle/server/dist/coordinator/developer-effect.js +76 -12
- package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
- package/bundle/server/dist/coordinator/projection.test.js +26 -0
- package/bundle/server/dist/coordinator/prompts.js +4 -1
- package/bundle/server/dist/coordinator/prompts.test.js +12 -0
- package/bundle/server/dist/coordinator/routing.js +43 -2
- package/bundle/server/dist/coordinator/routing.test.js +51 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +37 -0
- package/bundle/server/dist/coordinator/worker-loop.js +20 -2
- package/bundle/server/dist/coordinator/worker-loop.test.js +55 -2
- package/bundle/server/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
- package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
- package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
- package/bundle/server/dist/coordinator/workflows/types.js +1 -0
- package/bundle/server/dist/index.js +2 -0
- package/bundle/server/dist/routes/index.js +3 -0
- package/bundle/server/dist/routes/issues.js +10 -1
- package/bundle/server/dist/routes/issues.test.js +42 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
- package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-0kT1vk6L.js +0 -60
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// packages/server/src/coordinator/branch-tip-status.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-148: Issue Detail surfaces commits-ahead / "no tip yet", restart risk, and
|
|
4
|
+
// dirty/preserved worktree hints next to live progress so operators can see cold-start
|
|
5
|
+
// retries (and salvage leftovers) before another hour burns.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { classifyIssueRepo } from "../adapters/managed-repo.js";
|
|
8
|
+
import { findWorktreeForBranch, inspectLeftoverWorktree, } from "../adapters/git-worktree.js";
|
|
9
|
+
import { baseRefCandidates, developerBranchName, inspectBranchProgress, } from "./branch-progress.js";
|
|
10
|
+
function tipLabelFor(progress) {
|
|
11
|
+
switch (progress.state) {
|
|
12
|
+
case "absent":
|
|
13
|
+
case "empty":
|
|
14
|
+
return { commitsAhead: 0, tipLabel: "no tip yet" };
|
|
15
|
+
case "unpushed":
|
|
16
|
+
case "published":
|
|
17
|
+
return {
|
|
18
|
+
commitsAhead: progress.ahead,
|
|
19
|
+
tipLabel: `${progress.ahead} ahead`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function samePath(a, b) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return pathNorm(a) === pathNorm(b);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function pathNorm(p) {
|
|
32
|
+
return p.replace(/\/+$/, "");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pure classifier used by the API once branch progress (and optional failure context) is known.
|
|
36
|
+
* `hadFailedAttempt` is true when infraAttempts > 0 or a latest session failure is shown.
|
|
37
|
+
*/
|
|
38
|
+
export function classifyBranchTipStatus(opts) {
|
|
39
|
+
const worktree = opts.worktree ?? null;
|
|
40
|
+
if (!opts.progress) {
|
|
41
|
+
return {
|
|
42
|
+
branch: opts.branch,
|
|
43
|
+
state: "unknown",
|
|
44
|
+
commitsAhead: null,
|
|
45
|
+
tipLabel: "unknown",
|
|
46
|
+
restartRisk: opts.hadFailedAttempt,
|
|
47
|
+
worktree,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const { commitsAhead, tipLabel } = tipLabelFor(opts.progress);
|
|
51
|
+
// Empty tip = no publishable work on the branch. Do NOT treat `ahead === 0` on a
|
|
52
|
+
// `published`/`unpushed` progress object as empty — `inspectBranchProgress` can report
|
|
53
|
+
// `ahead ?? 0` when the base ref is unresolved while the branch is still publishable.
|
|
54
|
+
const emptyTip = opts.progress.state === "absent" || opts.progress.state === "empty";
|
|
55
|
+
return {
|
|
56
|
+
branch: opts.branch,
|
|
57
|
+
state: opts.progress.state,
|
|
58
|
+
commitsAhead,
|
|
59
|
+
tipLabel,
|
|
60
|
+
restartRisk: emptyTip && opts.hadFailedAttempt,
|
|
61
|
+
worktree,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Resolve a local clone path for read-only tip inspection — never clones. */
|
|
65
|
+
function localRepoPathForTip(repoField) {
|
|
66
|
+
try {
|
|
67
|
+
const classified = classifyIssueRepo(repoField);
|
|
68
|
+
if (!fs.existsSync(classified.repoPath))
|
|
69
|
+
return null;
|
|
70
|
+
return classified.repoPath;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Dirty leftover on the issue branch that is not the live running session's checkout.
|
|
78
|
+
* Mid-run WIP is normal; a leftover after failure/escalation is the salvage signal.
|
|
79
|
+
*/
|
|
80
|
+
async function preservedDirtyWorktreeHint(opts) {
|
|
81
|
+
const wtPath = await findWorktreeForBranch(opts.repoPath, opts.branch).catch(() => null);
|
|
82
|
+
if (!wtPath)
|
|
83
|
+
return null;
|
|
84
|
+
if (opts.activeWorktreePath && samePath(opts.activeWorktreePath, wtPath)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const leftover = await inspectLeftoverWorktree(wtPath);
|
|
88
|
+
if (leftover !== "dirty_or_unpushed")
|
|
89
|
+
return null;
|
|
90
|
+
return { path: wtPath, dirty: true, preserved: true };
|
|
91
|
+
}
|
|
92
|
+
/** Read-only tip status for Issue Detail while developing / retrying. */
|
|
93
|
+
export async function branchTipStatusForIssue(issue, opts) {
|
|
94
|
+
// Surface during active develop/repair (and reviewing, so a just-handed-off tip stays visible).
|
|
95
|
+
if (!["developing", "repairing", "reviewing"].includes(issue.status))
|
|
96
|
+
return null;
|
|
97
|
+
const branch = developerBranchName(issue);
|
|
98
|
+
const hadFailedAttempt = opts?.hadFailedAttempt ?? issue.infraAttempts > 0;
|
|
99
|
+
const repoPath = localRepoPathForTip(issue.repo);
|
|
100
|
+
let progress = null;
|
|
101
|
+
let worktree = null;
|
|
102
|
+
if (repoPath) {
|
|
103
|
+
try {
|
|
104
|
+
progress = await inspectBranchProgress({
|
|
105
|
+
repo: repoPath,
|
|
106
|
+
branch,
|
|
107
|
+
baseRefs: baseRefCandidates(issue),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
progress = null;
|
|
112
|
+
}
|
|
113
|
+
worktree = await preservedDirtyWorktreeHint({
|
|
114
|
+
repoPath,
|
|
115
|
+
branch,
|
|
116
|
+
activeWorktreePath: opts?.activeWorktreePath,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return classifyBranchTipStatus({ branch, progress, hadFailedAttempt, worktree });
|
|
120
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// packages/server/src/coordinator/branch-tip-status.test.ts
|
|
2
|
+
import { test, before, after } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
// Worktrees / managed clones resolve under AGENT_DEALER_HOME — set before adapters load.
|
|
9
|
+
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-tip-home-")));
|
|
10
|
+
process.env.AGENT_DEALER_HOME = home;
|
|
11
|
+
const { classifyBranchTipStatus, branchTipStatusForIssue, } = await import("./branch-tip-status.js");
|
|
12
|
+
const { managedRepoPath } = await import("../adapters/managed-repo.js");
|
|
13
|
+
const { createRoleWorktree } = await import("../adapters/git-worktree.js");
|
|
14
|
+
test("NOT-148: empty tip after a failed attempt flags restart risk", () => {
|
|
15
|
+
const status = classifyBranchTipStatus({
|
|
16
|
+
branch: "issue-x",
|
|
17
|
+
progress: { state: "empty", branch: "issue-x" },
|
|
18
|
+
hadFailedAttempt: true,
|
|
19
|
+
});
|
|
20
|
+
assert.equal(status.commitsAhead, 0);
|
|
21
|
+
assert.equal(status.tipLabel, "no tip yet");
|
|
22
|
+
assert.equal(status.restartRisk, true);
|
|
23
|
+
assert.equal(status.worktree, null);
|
|
24
|
+
});
|
|
25
|
+
test("NOT-148: empty tip with no prior failure is not restart risk yet", () => {
|
|
26
|
+
const status = classifyBranchTipStatus({
|
|
27
|
+
branch: "issue-x",
|
|
28
|
+
progress: { state: "absent", branch: "issue-x" },
|
|
29
|
+
hadFailedAttempt: false,
|
|
30
|
+
});
|
|
31
|
+
assert.equal(status.tipLabel, "no tip yet");
|
|
32
|
+
assert.equal(status.restartRisk, false);
|
|
33
|
+
});
|
|
34
|
+
test("NOT-148: tip with commits ahead is not restart risk even after failures", () => {
|
|
35
|
+
const status = classifyBranchTipStatus({
|
|
36
|
+
branch: "issue-x",
|
|
37
|
+
progress: { state: "unpushed", branch: "issue-x", ahead: 2, unpushed: 2 },
|
|
38
|
+
hadFailedAttempt: true,
|
|
39
|
+
});
|
|
40
|
+
assert.equal(status.commitsAhead, 2);
|
|
41
|
+
assert.equal(status.tipLabel, "2 ahead");
|
|
42
|
+
assert.equal(status.restartRisk, false);
|
|
43
|
+
});
|
|
44
|
+
test("NOT-148: published tip with ahead=0 (unresolved base) is not treated as empty tip", () => {
|
|
45
|
+
// inspectBranchProgress can report published with ahead ?? 0 when base is unresolved.
|
|
46
|
+
const status = classifyBranchTipStatus({
|
|
47
|
+
branch: "issue-x",
|
|
48
|
+
progress: { state: "published", branch: "issue-x", ahead: 0 },
|
|
49
|
+
hadFailedAttempt: true,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(status.state, "published");
|
|
52
|
+
assert.equal(status.tipLabel, "0 ahead");
|
|
53
|
+
assert.equal(status.restartRisk, false);
|
|
54
|
+
});
|
|
55
|
+
test("NOT-148: unknown progress after failure still flags restart risk conservatively", () => {
|
|
56
|
+
const status = classifyBranchTipStatus({
|
|
57
|
+
branch: "issue-x",
|
|
58
|
+
progress: null,
|
|
59
|
+
hadFailedAttempt: true,
|
|
60
|
+
});
|
|
61
|
+
assert.equal(status.state, "unknown");
|
|
62
|
+
assert.equal(status.tipLabel, "unknown");
|
|
63
|
+
assert.equal(status.restartRisk, true);
|
|
64
|
+
});
|
|
65
|
+
test("NOT-148: dirty preserved worktree hint passes through on the tip status", () => {
|
|
66
|
+
const status = classifyBranchTipStatus({
|
|
67
|
+
branch: "issue-x",
|
|
68
|
+
progress: { state: "empty", branch: "issue-x" },
|
|
69
|
+
hadFailedAttempt: true,
|
|
70
|
+
worktree: { path: "/tmp/wt", dirty: true, preserved: true },
|
|
71
|
+
});
|
|
72
|
+
assert.deepEqual(status.worktree, { path: "/tmp/wt", dirty: true, preserved: true });
|
|
73
|
+
});
|
|
74
|
+
function git(cwd, ...args) {
|
|
75
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
76
|
+
}
|
|
77
|
+
const identity = "github.com/acme/tip-status-fixture";
|
|
78
|
+
let repo;
|
|
79
|
+
let remote;
|
|
80
|
+
before(() => {
|
|
81
|
+
repo = managedRepoPath(identity);
|
|
82
|
+
fs.mkdirSync(path.dirname(repo), { recursive: true });
|
|
83
|
+
// Fresh clone dir each suite run under the temp home.
|
|
84
|
+
if (fs.existsSync(repo))
|
|
85
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
86
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
87
|
+
git(repo, "init", "-b", "main");
|
|
88
|
+
git(repo, "config", "user.email", "test@example.com");
|
|
89
|
+
git(repo, "config", "user.name", "Test");
|
|
90
|
+
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
|
91
|
+
git(repo, "add", ".");
|
|
92
|
+
git(repo, "commit", "-m", "init");
|
|
93
|
+
remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-tip-remote-"));
|
|
94
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
|
|
95
|
+
git(repo, "remote", "add", "origin", remote);
|
|
96
|
+
git(repo, "push", "-q", "origin", "main");
|
|
97
|
+
});
|
|
98
|
+
after(() => {
|
|
99
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
100
|
+
if (remote)
|
|
101
|
+
fs.rmSync(remote, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
test("NOT-148: branchTipStatusForIssue resolves managed github.com identity to local clone", async () => {
|
|
104
|
+
const branch = "issue-tip-ahead";
|
|
105
|
+
git(repo, "checkout", "-b", branch);
|
|
106
|
+
fs.writeFileSync(path.join(repo, "feat.txt"), "one\n");
|
|
107
|
+
git(repo, "add", ".");
|
|
108
|
+
git(repo, "commit", "-m", "feat");
|
|
109
|
+
git(repo, "checkout", "main");
|
|
110
|
+
const status = await branchTipStatusForIssue({
|
|
111
|
+
id: "tip-ahead",
|
|
112
|
+
branch,
|
|
113
|
+
repo: identity,
|
|
114
|
+
baseBranch: "main",
|
|
115
|
+
baseSha: null,
|
|
116
|
+
infraAttempts: 1,
|
|
117
|
+
status: "developing",
|
|
118
|
+
}, { hadFailedAttempt: true });
|
|
119
|
+
assert.ok(status);
|
|
120
|
+
assert.equal(status.commitsAhead, 1);
|
|
121
|
+
assert.equal(status.tipLabel, "1 ahead");
|
|
122
|
+
assert.equal(status.restartRisk, false);
|
|
123
|
+
assert.equal(status.worktree, null);
|
|
124
|
+
});
|
|
125
|
+
test("NOT-148: dirty leftover worktree (not the live session) surfaces as preserved", async () => {
|
|
126
|
+
const branch = "issue-tip-dirty";
|
|
127
|
+
git(repo, "branch", branch, "main");
|
|
128
|
+
const wt = await createRoleWorktree({
|
|
129
|
+
repo,
|
|
130
|
+
role: "developer",
|
|
131
|
+
sessionId: "s-dirty-tip",
|
|
132
|
+
ref: branch,
|
|
133
|
+
});
|
|
134
|
+
fs.writeFileSync(path.join(wt.path, "wip.txt"), "uncommitted\n");
|
|
135
|
+
const status = await branchTipStatusForIssue({
|
|
136
|
+
id: "tip-dirty",
|
|
137
|
+
branch,
|
|
138
|
+
repo: identity,
|
|
139
|
+
baseBranch: "main",
|
|
140
|
+
baseSha: null,
|
|
141
|
+
infraAttempts: 1,
|
|
142
|
+
status: "developing",
|
|
143
|
+
}, {
|
|
144
|
+
hadFailedAttempt: true,
|
|
145
|
+
activeWorktreePath: null,
|
|
146
|
+
});
|
|
147
|
+
assert.ok(status);
|
|
148
|
+
assert.ok(status.worktree);
|
|
149
|
+
assert.equal(status.worktree.dirty, true);
|
|
150
|
+
assert.equal(status.worktree.preserved, true);
|
|
151
|
+
assert.ok(status.worktree.path.includes("s-dirty-tip"));
|
|
152
|
+
try {
|
|
153
|
+
execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repo });
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
fs.rmSync(wt.path, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
test("NOT-148: dirty worktree owned by the live running session is not flagged as preserved", async () => {
|
|
160
|
+
const branch = "issue-tip-live-dirty";
|
|
161
|
+
git(repo, "branch", branch, "main");
|
|
162
|
+
const wt = await createRoleWorktree({
|
|
163
|
+
repo,
|
|
164
|
+
role: "developer",
|
|
165
|
+
sessionId: "s-live-dirty",
|
|
166
|
+
ref: branch,
|
|
167
|
+
});
|
|
168
|
+
fs.writeFileSync(path.join(wt.path, "editing.txt"), "mid-run\n");
|
|
169
|
+
const status = await branchTipStatusForIssue({
|
|
170
|
+
id: "tip-live",
|
|
171
|
+
branch,
|
|
172
|
+
repo: identity,
|
|
173
|
+
baseBranch: "main",
|
|
174
|
+
baseSha: null,
|
|
175
|
+
infraAttempts: 0,
|
|
176
|
+
status: "developing",
|
|
177
|
+
}, {
|
|
178
|
+
hadFailedAttempt: false,
|
|
179
|
+
activeWorktreePath: wt.path,
|
|
180
|
+
});
|
|
181
|
+
assert.ok(status);
|
|
182
|
+
assert.equal(status.worktree, null, "live WIP must not read as salvage preserve");
|
|
183
|
+
try {
|
|
184
|
+
execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repo });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
fs.rmSync(wt.path, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -19,7 +19,10 @@ import { parseHumanResolution, resolveHumanActionOutcome } from "./human-resolut
|
|
|
19
19
|
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
20
20
|
import { capEscalationEvents, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
21
21
|
import { markQueueEntryAdmitted } from "../repository/queue-entries.js";
|
|
22
|
-
|
|
22
|
+
import { getWorkflow } from "./workflows/registry.js";
|
|
23
|
+
import { DEV_REVIEWER_V1_VERSION } from "./workflows/dev-reviewer-v1.js";
|
|
24
|
+
/** Default start template — resolved through the workflow registry (NOT-70). */
|
|
25
|
+
export const WORKFLOW_VERSION = getWorkflow(DEV_REVIEWER_V1_VERSION).version;
|
|
23
26
|
/** NOT-103 queue admission reads the same cap state as the coordinator deferral path. */
|
|
24
27
|
export { runtimeAvailability } from "../repository/runtime-availability.js";
|
|
25
28
|
/**
|
|
@@ -165,7 +168,8 @@ export function startWorkflowCore(issueId) {
|
|
|
165
168
|
}
|
|
166
169
|
// Criteria were added since a pre-start gate opened — close it in the same txn as start.
|
|
167
170
|
clearStaleProductScopeDecision(issue);
|
|
168
|
-
const
|
|
171
|
+
const template = getWorkflow(WORKFLOW_VERSION);
|
|
172
|
+
const instance = startWorkflowInstance(issueId, template.version);
|
|
169
173
|
freezeTaskSnapshot(issue);
|
|
170
174
|
appendWorkflowEvent({
|
|
171
175
|
issueId,
|
|
@@ -41,6 +41,48 @@ import { reasonForDirtyWorktree, reasonForSessionCrash } from "./failure-reason.
|
|
|
41
41
|
import { emitSessionMilestone, setLiveIntent, shortWorktreePath, startActivitySampler, taskBriefIsComplete, } from "./session-progress.js";
|
|
42
42
|
import { VERIFICATION_RECEIPT_KIND, extractVerificationReceiptFromLog, parseVerificationReceipt, receiptForCurrentHead, receiptSupersededByFailedChecks, shouldCarryVerificationReceipt, } from "./verification-receipt.js";
|
|
43
43
|
const num = (name, dflt) => Number(process.env[name] ?? dflt);
|
|
44
|
+
/**
|
|
45
|
+
* NOT-147: attach commits-ahead + worktree/log pointers on timeout/crash so routing can
|
|
46
|
+
* escalate empty tips instead of burning another full spawn. Prefers counting from the
|
|
47
|
+
* worktree HEAD while it still exists; falls back to inspecting the issue branch ref.
|
|
48
|
+
* Only sets `commitsAhead` when progress is known (`empty` → 0, or a positive ahead count).
|
|
49
|
+
* `absent` / unresolvable git stays omitted so routing keeps the budget-only path.
|
|
50
|
+
*/
|
|
51
|
+
async function crashOrTimeoutOutcome(opts) {
|
|
52
|
+
let commitsAheadKnown = opts.commitsAhead;
|
|
53
|
+
if (commitsAheadKnown === undefined) {
|
|
54
|
+
const fromWorktree = await commitsAhead({
|
|
55
|
+
worktreePath: opts.worktreePath,
|
|
56
|
+
baseRef: `origin/${opts.baseBranch}`,
|
|
57
|
+
}).catch(() => null);
|
|
58
|
+
if (fromWorktree !== null) {
|
|
59
|
+
commitsAheadKnown = fromWorktree;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const progress = await inspectBranchProgress({
|
|
63
|
+
repo: opts.repoPath,
|
|
64
|
+
branch: opts.branchName,
|
|
65
|
+
baseRefs: baseRefCandidates({
|
|
66
|
+
baseSha: opts.baseSha ?? null,
|
|
67
|
+
baseBranch: opts.baseBranch,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
if (progress.state === "empty")
|
|
71
|
+
commitsAheadKnown = 0;
|
|
72
|
+
else if (progress.state === "unpushed" || progress.state === "published") {
|
|
73
|
+
commitsAheadKnown = progress.ahead;
|
|
74
|
+
}
|
|
75
|
+
// absent → leave undefined (unknown)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
kind: opts.timedOut ? "timed_out" : "session_failed",
|
|
80
|
+
reason: opts.reason,
|
|
81
|
+
...(commitsAheadKnown !== undefined ? { commitsAhead: commitsAheadKnown } : {}),
|
|
82
|
+
worktreePath: opts.worktreePath,
|
|
83
|
+
...(opts.logPath ? { logPath: opts.logPath } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
44
86
|
export const developerEffectConfig = {
|
|
45
87
|
get sessionTimeoutMs() {
|
|
46
88
|
return developerSessionTimeoutMs();
|
|
@@ -714,6 +756,24 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
714
756
|
const salvageKind = spawned.timedOut ? "timeout" : "crash";
|
|
715
757
|
const salvaged = await salvageDirtyWorktree(worktreePath, salvageKind);
|
|
716
758
|
if (salvaged.ok) {
|
|
759
|
+
// Measure progress while the worktree still exists (same rule as the clean
|
|
760
|
+
// timeout/crash path below), then remove. Salvage always lands ≥1 tip commit.
|
|
761
|
+
const crashReason = reasonForSessionCrash({
|
|
762
|
+
timedOut: Boolean(spawned.timedOut),
|
|
763
|
+
logPath: spawned.logPath,
|
|
764
|
+
runtime,
|
|
765
|
+
});
|
|
766
|
+
const salvageNote = `Salvaged uncommitted work as ${salvaged.message} (${salvaged.commitSha.slice(0, 7)}).`;
|
|
767
|
+
const crashOutcome = await crashOrTimeoutOutcome({
|
|
768
|
+
timedOut: Boolean(spawned.timedOut),
|
|
769
|
+
reason: `${crashReason} ${salvageNote}`,
|
|
770
|
+
worktreePath,
|
|
771
|
+
logPath: spawned.logPath,
|
|
772
|
+
repoPath,
|
|
773
|
+
branchName,
|
|
774
|
+
baseBranch,
|
|
775
|
+
baseSha: issue.baseSha,
|
|
776
|
+
});
|
|
717
777
|
// Lens (NOT-145 class post-salvage-remove-unchecked): only route infra retry when
|
|
718
778
|
// the checkout is actually gone. If remove preserves (or throws), escalate with
|
|
719
779
|
// path+recovery instead of retrying into a branch still held by a leftover tree.
|
|
@@ -739,15 +799,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
739
799
|
recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
|
|
740
800
|
};
|
|
741
801
|
}
|
|
742
|
-
|
|
743
|
-
timedOut: Boolean(spawned.timedOut),
|
|
744
|
-
logPath: spawned.logPath,
|
|
745
|
-
runtime,
|
|
746
|
-
});
|
|
747
|
-
const salvageNote = `Salvaged uncommitted work as ${salvaged.message} (${salvaged.commitSha.slice(0, 7)}).`;
|
|
748
|
-
return spawned.timedOut
|
|
749
|
-
? { kind: "timed_out", reason: `${crashReason} ${salvageNote}` }
|
|
750
|
-
: { kind: "session_failed", reason: `${crashReason} ${salvageNote}` };
|
|
802
|
+
return crashOutcome;
|
|
751
803
|
}
|
|
752
804
|
// Salvage failed — do not remove. Escalate with actionable recovery.
|
|
753
805
|
const dirtyReason = reasonForDirtyWorktree(spawned.logPath, runtime);
|
|
@@ -758,10 +810,22 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
758
810
|
recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
|
|
759
811
|
};
|
|
760
812
|
}
|
|
813
|
+
const crashOutcome = await crashOrTimeoutOutcome({
|
|
814
|
+
timedOut: Boolean(spawned.timedOut),
|
|
815
|
+
reason: reasonForSessionCrash({
|
|
816
|
+
timedOut: Boolean(spawned.timedOut),
|
|
817
|
+
logPath: spawned.logPath,
|
|
818
|
+
runtime,
|
|
819
|
+
}),
|
|
820
|
+
worktreePath,
|
|
821
|
+
logPath: spawned.logPath,
|
|
822
|
+
repoPath,
|
|
823
|
+
branchName,
|
|
824
|
+
baseBranch,
|
|
825
|
+
baseSha: issue.baseSha,
|
|
826
|
+
});
|
|
761
827
|
await bestEffortRemove(repoPath, worktreePath);
|
|
762
|
-
return
|
|
763
|
-
? { kind: "timed_out", reason: reasonForSessionCrash({ timedOut: true, logPath: spawned.logPath, runtime }) }
|
|
764
|
-
: { kind: "session_failed", reason: reasonForSessionCrash({ timedOut: false, logPath: spawned.logPath, runtime }) };
|
|
828
|
+
return crashOutcome;
|
|
765
829
|
}
|
|
766
830
|
// Persisted as soon as the session itself completes — a review round found these
|
|
767
831
|
// dropped on any later verification failure (checks failing, a gh error), even though
|
|
@@ -183,6 +183,8 @@ test("clean handoff: real worktree, real push, fake GitHub — issue moves to re
|
|
|
183
183
|
assert.ok(kinds.includes("implementation_conclusion"));
|
|
184
184
|
assert.ok(kinds.includes("developer_transcript"));
|
|
185
185
|
assert.ok(kinds.includes("checks_evidence"));
|
|
186
|
+
// NOT-146: clean handoff is commits + publish — not gated on a full-suite / Lens receipt.
|
|
187
|
+
assert.ok(!kinds.includes("verification_receipt"), "NOT-146: handoff must succeed without a full-suite verification receipt");
|
|
186
188
|
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
|
|
187
189
|
const usage = listUsageEventsForIssue(issueId);
|
|
188
190
|
assert.equal(usage.length, 1);
|
|
@@ -411,6 +413,24 @@ test("timed_out (session): the spawn wall-clock timeout is reported distinctly f
|
|
|
411
413
|
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
412
414
|
assert.equal(dev.status, "timed_out");
|
|
413
415
|
});
|
|
416
|
+
test("NOT-147: empty tip + 2 session timeouts escalates to human — no third spawn", async () => {
|
|
417
|
+
const issueId = await makeIssue();
|
|
418
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: timedOutSpawn, github: fakeGithub() }));
|
|
419
|
+
startWorkflow(issueId);
|
|
420
|
+
await pump(1); // failure #1 → auto-retry
|
|
421
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
422
|
+
assert.equal(getIssue(issueId).infraAttempts, 1);
|
|
423
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer" && i.status === "pending").length, 1);
|
|
424
|
+
await pump(1); // failure #2 with still-empty tip → policy_escalation
|
|
425
|
+
const issue = getIssue(issueId);
|
|
426
|
+
assert.equal(issue.status, "needs_human");
|
|
427
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer" && i.status === "pending").length, 0, "must not enqueue a third hour-long spawn");
|
|
428
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
429
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
430
|
+
assert.ok(action);
|
|
431
|
+
assert.equal(action.actionType, "policy_escalation");
|
|
432
|
+
assert.match(action.reason, /stuck: no commits after 2 timeouts\/crashes/i);
|
|
433
|
+
});
|
|
414
434
|
test("checks_failed: CI failure after a clean push/PR retries without a human action", async () => {
|
|
415
435
|
const issueId = await makeIssue();
|
|
416
436
|
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "failure" }) }));
|
|
@@ -119,3 +119,29 @@ test("infra-class retries — including a stale re-review — spend the infra bu
|
|
|
119
119
|
const stale = projectReviewerRoute(routeReviewerOutcome({ kind: "stale", currentHeadSha: "new" }, REVIEW_ROUNDS_LEFT, PINNED_HEAD), 1, false);
|
|
120
120
|
assert.equal(stale.advance, "infra");
|
|
121
121
|
});
|
|
122
|
+
test("NOT-147: empty-tip no-progress escalation projects to needs_human without spending infra budget", () => {
|
|
123
|
+
const INFRA_ATTEMPTS_LEFT = {
|
|
124
|
+
currentRound: 1,
|
|
125
|
+
maxReviewRounds: 3,
|
|
126
|
+
infraAttempts: 1,
|
|
127
|
+
maxInfraAttempts: 3,
|
|
128
|
+
};
|
|
129
|
+
const route = routeDeveloperOutcome({
|
|
130
|
+
kind: "timed_out",
|
|
131
|
+
commitsAhead: 0,
|
|
132
|
+
reason: "Developer session timed out.",
|
|
133
|
+
worktreePath: "/data/worktrees/s-dev-developer",
|
|
134
|
+
logPath: "/data/logs/s-dev.log",
|
|
135
|
+
}, INFRA_ATTEMPTS_LEFT);
|
|
136
|
+
assert.equal(route.next, "human_action");
|
|
137
|
+
const { projection, effect, advance } = projectDeveloperRoute(route, "developing", 1);
|
|
138
|
+
assert.equal(projection.issueStatus, "needs_human");
|
|
139
|
+
assert.equal(projection.currentOwner, "human");
|
|
140
|
+
assert.equal(advance, "none", "escalation must not burn another infra attempt");
|
|
141
|
+
assert.equal(effect.kind, "human_action");
|
|
142
|
+
if (effect.kind === "human_action") {
|
|
143
|
+
assert.equal(effect.actionType, "policy_escalation");
|
|
144
|
+
assert.match(effect.reason, /stuck: no commits after 2 timeouts\/crashes/i);
|
|
145
|
+
}
|
|
146
|
+
assertLegal("developing", projection.issueStatus);
|
|
147
|
+
});
|
|
@@ -68,7 +68,10 @@ export function buildDeveloperPrompt(input) {
|
|
|
68
68
|
parts.push(`## Required`,
|
|
69
69
|
// NOT-115: encourage incremental commits so a mid-session death leaves less
|
|
70
70
|
// uncommitted work for dirty_worktree escalation — soft mitigation only.
|
|
71
|
-
|
|
71
|
+
// NOT-146: timed-spawn handoff bar is commits + targeted tests for the change.
|
|
72
|
+
// Full suite / Lens run on a separate verify/review budget (NOT-74) — they do not
|
|
73
|
+
// gate developer clean_handoff; the coordinator does not block on full-suite alone.
|
|
74
|
+
`Run targeted tests for the change. A clean handoff is valid with commits plus those targeted tests — full suite / Lens checks are not required inside this timed developer spawn (they run on a separate review/verify budget). Make incremental commits with \`git\` at coherent slice boundaries (for example after the tests for that slice pass) — do not leave all work uncommitted until the very end. A final commit of any remaining changes and the **implementation conclusion** remain required before you exit.`, `Commit any remaining changes with \`git\` — do NOT push and do NOT open a pull request; the coordinator pushes your branch and opens/updates the draft PR after this session ends.`, `End your reply with a short **implementation conclusion**: what changed, why, any deviations from the acceptance criteria, and known follow-ups. This is distinct from the PR description and is required every round.`);
|
|
72
75
|
return parts.join("\n");
|
|
73
76
|
}
|
|
74
77
|
const REVIEWER_RESULT_SHAPE = '{"verdict":"approved"|"changes_requested"|"escalated","baseSha":"...","headSha":"...","acceptanceCriteriaAssessment":"...","evidenceAssessment":"...","findings":[{"fingerprint":"stable-slug","severity":"blocking"|"non_blocking","title":"...","rationale":"...","file":"...","line":0}],"risks":["..."],"productScopeQuestion":"..."}';
|
|
@@ -25,6 +25,18 @@ test("developer prompt asks for incremental commits at slice boundaries and keep
|
|
|
25
25
|
assert.match(prompt, /implementation conclusion/i);
|
|
26
26
|
assert.match(prompt, /do NOT push and do NOT open a pull request/);
|
|
27
27
|
});
|
|
28
|
+
// NOT-146: timed developer spawn covers implement + targeted tests; full suite / Lens
|
|
29
|
+
// live on a separate budget (NOT-74), not inside the same wall clock.
|
|
30
|
+
test("NOT-146: developer prompt handoff bar is commits + targeted tests, not full suite/Lens in-spawn", () => {
|
|
31
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1 });
|
|
32
|
+
assert.match(prompt, /targeted tests?/i);
|
|
33
|
+
assert.match(prompt, /handoff/i);
|
|
34
|
+
assert.match(prompt, /full suite/i);
|
|
35
|
+
assert.match(prompt, /[Ll]ens/);
|
|
36
|
+
// Must not still demand Lens inside the timed spawn as a hard Required step.
|
|
37
|
+
assert.doesNotMatch(prompt, /^Run tests and Lens checks\./m);
|
|
38
|
+
assert.match(prompt, /do NOT push and do NOT open a pull request/);
|
|
39
|
+
});
|
|
28
40
|
test("repair round includes findings and references the round number", () => {
|
|
29
41
|
const prompt = buildDeveloperPrompt({
|
|
30
42
|
taskSnapshot,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// packages/server/src/coordinator/routing.ts
|
|
2
2
|
import { normalizeReviewerResult } from "./reviewer-result.js";
|
|
3
|
+
/** Default N for NOT-147: escalate empty-tip timeout/crash after this many failures (one auto-retry). */
|
|
4
|
+
export const DEFAULT_NO_PROGRESS_INFRA_ATTEMPTS = 2;
|
|
3
5
|
function roundsRemain(limits) {
|
|
4
6
|
return limits.currentRound < limits.maxReviewRounds;
|
|
5
7
|
}
|
|
@@ -78,8 +80,6 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
78
80
|
: { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
|
|
79
81
|
}
|
|
80
82
|
case "no_pr":
|
|
81
|
-
case "session_failed":
|
|
82
|
-
case "timed_out":
|
|
83
83
|
case "checks_failed":
|
|
84
84
|
case "deck_failure": {
|
|
85
85
|
// Unified infra-failure policy: the session/environment failed to produce a
|
|
@@ -90,6 +90,23 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
90
90
|
? { next: "retry_developer", reason }
|
|
91
91
|
: { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
|
|
92
92
|
}
|
|
93
|
+
case "session_failed":
|
|
94
|
+
case "timed_out": {
|
|
95
|
+
// Same infra budget as above, plus NOT-147: empty tip after N timeout/crashes → human
|
|
96
|
+
// gate so we do not burn attempts 3–4 on a branch that never moved.
|
|
97
|
+
const reason = infraFailureReason(outcome);
|
|
98
|
+
if (!infraAttemptsRemain(limits)) {
|
|
99
|
+
return {
|
|
100
|
+
next: "human_action",
|
|
101
|
+
actionType: "policy_escalation",
|
|
102
|
+
reason: `${reason} (infra-attempt limit reached).`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const noProgressGate = emptyTipNoProgressGate(outcome, limits, reason);
|
|
106
|
+
if (noProgressGate)
|
|
107
|
+
return noProgressGate;
|
|
108
|
+
return { next: "retry_developer", reason };
|
|
109
|
+
}
|
|
93
110
|
case "usage_capped":
|
|
94
111
|
return { next: "defer_work", until: outcome.until, reason: outcome.reason };
|
|
95
112
|
case "deck_unavailable":
|
|
@@ -114,6 +131,30 @@ function infraFailureReason(outcome) {
|
|
|
114
131
|
return `Agent Deck ${outcome.reason}`;
|
|
115
132
|
}
|
|
116
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* NOT-147: when a timeout/crash left the branch with zero commits ahead of base, stop
|
|
136
|
+
* burning full agent spawns after N failures (default 2 = one auto-retry). Only trips when
|
|
137
|
+
* `commitsAhead` is explicitly known to be 0 — missing progress keeps the budget-only path.
|
|
138
|
+
*/
|
|
139
|
+
function emptyTipNoProgressGate(outcome, limits, lastFailureReason) {
|
|
140
|
+
if (outcome.commitsAhead !== 0)
|
|
141
|
+
return null;
|
|
142
|
+
const n = limits.noProgressInfraAttempts ?? DEFAULT_NO_PROGRESS_INFRA_ATTEMPTS;
|
|
143
|
+
// infraAttempts = retries already spent; +1 includes the failure being routed.
|
|
144
|
+
const failuresIncludingCurrent = limits.infraAttempts + 1;
|
|
145
|
+
if (failuresIncludingCurrent < n)
|
|
146
|
+
return null;
|
|
147
|
+
const pointers = [
|
|
148
|
+
outcome.worktreePath ? `worktree: ${outcome.worktreePath}` : null,
|
|
149
|
+
outcome.logPath ? `log: ${outcome.logPath}` : null,
|
|
150
|
+
].filter((p) => p != null);
|
|
151
|
+
const pointerSuffix = pointers.length > 0 ? ` ${pointers.join("; ")}.` : "";
|
|
152
|
+
return {
|
|
153
|
+
next: "human_action",
|
|
154
|
+
actionType: "policy_escalation",
|
|
155
|
+
reason: `stuck: no commits after ${n} timeouts/crashes. Last failure: ${lastFailureReason}.${pointerSuffix}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
117
158
|
export function routeReviewerOutcome(outcome, limits, pinnedHeadSha) {
|
|
118
159
|
switch (outcome.kind) {
|
|
119
160
|
case "stale":
|