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.
Files changed (36) hide show
  1. package/bundle/server/dist/adapters/agent-deck.js +68 -13
  2. package/bundle/server/dist/adapters/agent-deck.test.js +91 -1
  3. package/bundle/server/dist/adapters/agent-health.js +175 -51
  4. package/bundle/server/dist/adapters/agent-health.test.js +338 -210
  5. package/bundle/server/dist/adapters/linear-graphql.js +200 -6
  6. package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
  7. package/bundle/server/dist/coordinator/admission.js +41 -30
  8. package/bundle/server/dist/coordinator/admission.test.js +92 -1
  9. package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
  10. package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
  11. package/bundle/server/dist/coordinator/commands.js +6 -2
  12. package/bundle/server/dist/coordinator/developer-effect.js +76 -12
  13. package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
  14. package/bundle/server/dist/coordinator/projection.test.js +26 -0
  15. package/bundle/server/dist/coordinator/prompts.js +4 -1
  16. package/bundle/server/dist/coordinator/prompts.test.js +12 -0
  17. package/bundle/server/dist/coordinator/routing.js +43 -2
  18. package/bundle/server/dist/coordinator/routing.test.js +51 -0
  19. package/bundle/server/dist/coordinator/usage-cap-defer.js +37 -0
  20. package/bundle/server/dist/coordinator/worker-loop.js +20 -2
  21. package/bundle/server/dist/coordinator/worker-loop.test.js +55 -2
  22. package/bundle/server/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
  23. package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
  24. package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
  25. package/bundle/server/dist/coordinator/workflows/types.js +1 -0
  26. package/bundle/server/dist/index.js +2 -0
  27. package/bundle/server/dist/routes/index.js +3 -0
  28. package/bundle/server/dist/routes/issues.js +10 -1
  29. package/bundle/server/dist/routes/issues.test.js +42 -0
  30. package/bundle/server/package.json +2 -2
  31. package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
  32. package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
  33. package/bundle/server/static-ui/index.html +2 -2
  34. package/bundle/shared/package.json +1 -1
  35. package/package.json +1 -1
  36. 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
- export const WORKFLOW_VERSION = "dev_reviewer_v1";
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 instance = startWorkflowInstance(issueId, WORKFLOW_VERSION);
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
- const crashReason = reasonForSessionCrash({
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 spawned.timedOut
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
- `Run tests and Lens checks. 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.`);
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":