agent-dealer 1.0.2 → 1.0.3

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 (30) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +148 -40
  2. package/bundle/server/dist/adapters/agent-health.test.js +319 -210
  3. package/bundle/server/dist/adapters/linear-graphql.js +200 -6
  4. package/bundle/server/dist/adapters/linear-graphql.test.js +91 -1
  5. package/bundle/server/dist/coordinator/admission.test.js +36 -0
  6. package/bundle/server/dist/coordinator/branch-tip-status.js +120 -0
  7. package/bundle/server/dist/coordinator/branch-tip-status.test.js +189 -0
  8. package/bundle/server/dist/coordinator/commands.js +6 -2
  9. package/bundle/server/dist/coordinator/developer-effect.js +76 -12
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +20 -0
  11. package/bundle/server/dist/coordinator/projection.test.js +26 -0
  12. package/bundle/server/dist/coordinator/prompts.js +4 -1
  13. package/bundle/server/dist/coordinator/prompts.test.js +12 -0
  14. package/bundle/server/dist/coordinator/routing.js +43 -2
  15. package/bundle/server/dist/coordinator/routing.test.js +51 -0
  16. package/bundle/server/dist/coordinator/workflows/dev-reviewer-v1.js +8 -0
  17. package/bundle/server/dist/coordinator/workflows/registry.js +14 -0
  18. package/bundle/server/dist/coordinator/workflows/registry.test.js +28 -0
  19. package/bundle/server/dist/coordinator/workflows/types.js +1 -0
  20. package/bundle/server/dist/index.js +2 -0
  21. package/bundle/server/dist/routes/index.js +3 -0
  22. package/bundle/server/dist/routes/issues.js +10 -1
  23. package/bundle/server/dist/routes/issues.test.js +42 -0
  24. package/bundle/server/package.json +2 -2
  25. package/bundle/server/static-ui/assets/{index-hXICi1rX.css → index-CYZRXBZj.css} +1 -1
  26. package/bundle/server/static-ui/assets/index-DXTVo_vz.js +60 -0
  27. package/bundle/server/static-ui/index.html +2 -2
  28. package/bundle/shared/package.json +1 -1
  29. package/package.json +1 -1
  30. package/bundle/server/static-ui/assets/index-0kT1vk6L.js +0 -60
@@ -21,23 +21,63 @@ function capHealthIssues(runtime) {
21
21
  ];
22
22
  }
23
23
  const RUNTIME_CACHE_MS = 60_000;
24
+ /** Soft probe failures must not stick for the full health TTL — sleep/wake flakes recover on the next tick. */
25
+ const SOFT_PROBE_CACHE_MS = 15_000;
26
+ const DEFAULT_PROBE_TIMEOUT_MS = 8000;
27
+ /** After the first soft fail, retry with these delays before publishing unconfirmed auth. */
28
+ const DEFAULT_SOFT_RETRY_BACKOFFS_MS = [250, 750];
29
+ /** Require this many consecutive soft-fail rounds (each round already retried) before flipping healthy→unhealthy. */
30
+ const SOFT_FAIL_STREAK_TO_UNHEALTHY = 2;
31
+ /** Hold a recent healthy result across a single soft-fail streak after host sleep. */
32
+ const HEALTHY_GRACE_MS = 5 * 60_000;
24
33
  const runtimeIssueCache = new Map();
25
34
  let githubIssueCache = null;
26
- /** Exported for tests clears the shared github + runtime health caches. */
27
- export function clearAgentHealthCaches() {
28
- runtimeIssueCache.clear();
29
- githubIssueCache = null;
35
+ /** Cursor soft-fail streak across health ticks (NOT-157). Reset on hard auth or success. */
36
+ let cursorSoftFailStreak = 0;
37
+ let cursorLastHealthyAt = null;
38
+ let probeTimeoutMsForTests = null;
39
+ let softRetryBackoffsMsForTests = null;
40
+ /**
41
+ * Shorten probe timeout / retry backoff in unit tests so sleep-stub scenarios stay fast.
42
+ * Pass `null` to restore production defaults.
43
+ */
44
+ export function setCursorProbeTimingForTests(opts) {
45
+ if (opts == null) {
46
+ probeTimeoutMsForTests = null;
47
+ softRetryBackoffsMsForTests = null;
48
+ return;
49
+ }
50
+ probeTimeoutMsForTests = opts.timeoutMs === undefined ? probeTimeoutMsForTests : opts.timeoutMs;
51
+ softRetryBackoffsMsForTests =
52
+ opts.retryBackoffsMs === undefined ? softRetryBackoffsMsForTests : opts.retryBackoffsMs;
30
53
  }
31
- function runCommand(cmd, args, timeoutMs = 8000) {
54
+ function probeTimeoutMs() {
55
+ return probeTimeoutMsForTests ?? DEFAULT_PROBE_TIMEOUT_MS;
56
+ }
57
+ function softRetryBackoffsMs() {
58
+ return softRetryBackoffsMsForTests ?? DEFAULT_SOFT_RETRY_BACKOFFS_MS;
59
+ }
60
+ function sleep(ms) {
61
+ return new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+ function defaultRunCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
32
64
  return new Promise((resolve) => {
33
65
  const child = spawn(cmd, args, {
34
66
  stdio: ["ignore", "pipe", "pipe"],
35
67
  env: process.env,
36
68
  });
37
69
  let output = "";
70
+ let settled = false;
71
+ const finish = (result) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ resolve(result);
77
+ };
38
78
  const timer = setTimeout(() => {
39
79
  child.kill("SIGTERM");
40
- resolve({ ok: false, output: output || "timeout" });
80
+ finish({ ok: false, output: output || "timeout", timedOut: true });
41
81
  }, timeoutMs);
42
82
  child.stdout?.on("data", (d) => {
43
83
  output += d.toString();
@@ -46,15 +86,102 @@ function runCommand(cmd, args, timeoutMs = 8000) {
46
86
  output += d.toString();
47
87
  });
48
88
  child.on("error", (err) => {
49
- clearTimeout(timer);
50
- resolve({ ok: false, output: err.message });
89
+ finish({ ok: false, output: err.message, timedOut: false });
51
90
  });
52
91
  child.on("close", (code) => {
53
- clearTimeout(timer);
54
- resolve({ ok: code === 0, output });
92
+ finish({ ok: code === 0, output, timedOut: false });
55
93
  });
56
94
  });
57
95
  }
96
+ let runCommandImpl = defaultRunCommand;
97
+ /**
98
+ * Replace the process spawner in unit tests (sequence injection for soft-fail / timeout).
99
+ * Pass `null` to restore the real spawner.
100
+ */
101
+ export function setRunCommandForTests(fn) {
102
+ runCommandImpl = fn ?? defaultRunCommand;
103
+ }
104
+ function runCommand(cmd, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
105
+ return runCommandImpl(cmd, args, timeoutMs);
106
+ }
107
+ /** Exported for tests — clears the shared github + runtime health caches and soft-fail streak. */
108
+ export function clearAgentHealthCaches() {
109
+ runtimeIssueCache.clear();
110
+ githubIssueCache = null;
111
+ cursorSoftFailStreak = 0;
112
+ cursorLastHealthyAt = null;
113
+ }
114
+ function isSoftCursorProbeIssue(issue) {
115
+ return (issue.code === "runtime_auth" &&
116
+ (/probe timed out/i.test(issue.message) || /probe failed/i.test(issue.message)));
117
+ }
118
+ function softCursorProbeIssue(result) {
119
+ if (result.timedOut || result.output.trim() === "timeout") {
120
+ return {
121
+ code: "runtime_auth",
122
+ message: "Could not confirm Cursor auth — `cursor-agent status` probe timed out",
123
+ };
124
+ }
125
+ const detail = result.output.trim().split("\n").slice(-1)[0] ?? "no output";
126
+ return {
127
+ code: "runtime_auth",
128
+ message: `Could not confirm Cursor auth — \`cursor-agent status\` probe failed (${detail})`,
129
+ };
130
+ }
131
+ /**
132
+ * NOT-157: classified logged-out / keychain is a hard fail (immediate). Probe timeout /
133
+ * unclassified non-zero exit is soft — retry with backoff, and do not flip a recent healthy
134
+ * result on a single soft-fail streak after host sleep.
135
+ */
136
+ async function cursorRuntimeIssues() {
137
+ const backoffs = softRetryBackoffsMs();
138
+ const attempts = 1 + backoffs.length;
139
+ let last = { ok: false, output: "no probe", timedOut: false };
140
+ for (let i = 0; i < attempts; i++) {
141
+ if (i > 0)
142
+ await sleep(backoffs[i - 1]);
143
+ last = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]), probeTimeoutMs());
144
+ // A failed *spawn* resolves with the error message as its output (`spawn cursor-agent
145
+ // ENOENT`), not with empty output — so an absent binary must be recognised here or it
146
+ // falls through to the unconfirmed-auth branch below and names the wrong remedy.
147
+ if (!last.ok && (/\bENOENT\b/.test(last.output) || (!last.output.trim() && !cursorBinExists()))) {
148
+ cursorSoftFailStreak = 0;
149
+ cursorLastHealthyAt = null;
150
+ return [
151
+ {
152
+ code: "cli_missing",
153
+ message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash",
154
+ },
155
+ ];
156
+ }
157
+ const authIssue = cursorAuthIssueFromOutput(last.output);
158
+ if (authIssue) {
159
+ cursorSoftFailStreak = 0;
160
+ cursorLastHealthyAt = null;
161
+ console.warn(`[agent-health] cursor-agent status: classified auth failure (${authIssue.code})`);
162
+ return [authIssue];
163
+ }
164
+ if (last.ok) {
165
+ cursorSoftFailStreak = 0;
166
+ cursorLastHealthyAt = Date.now();
167
+ return [];
168
+ }
169
+ // Soft fail this attempt — try again before publishing.
170
+ const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
171
+ console.warn(`[agent-health] cursor-agent status probe ${kind} (attempt ${i + 1}/${attempts})`);
172
+ }
173
+ // Retries exhausted with only soft failures.
174
+ cursorSoftFailStreak += 1;
175
+ const softIssue = softCursorProbeIssue(last);
176
+ const kind = last.timedOut || last.output.trim() === "timeout" ? "timed out" : "failed";
177
+ console.warn(`[agent-health] cursor-agent status probe ${kind} after retries (streak=${cursorSoftFailStreak})`);
178
+ const withinGrace = cursorLastHealthyAt != null && Date.now() - cursorLastHealthyAt < HEALTHY_GRACE_MS;
179
+ if (withinGrace && cursorSoftFailStreak < SOFT_FAIL_STREAK_TO_UNHEALTHY) {
180
+ // Hold the recent healthy result — one post-sleep timeout must not park the queue.
181
+ return [];
182
+ }
183
+ return [softIssue];
184
+ }
58
185
  /** Exported for direct testing — bypasses the 60s cache in runtimeIssues(). */
59
186
  export async function runtimeIssuesUncached(runtime) {
60
187
  const issues = [];
@@ -107,34 +234,10 @@ export async function runtimeIssuesUncached(runtime) {
107
234
  }
108
235
  return issues;
109
236
  }
110
- const status = await runCommand(resolveCursorBin(), cursorInvokeArgs(["status"]));
111
- // A failed *spawn* resolves with the error message as its output (`spawn cursor-agent
112
- // ENOENT`), not with empty output so an absent binary must be recognised here or it
113
- // falls through to the unconfirmed-auth branch below and names the wrong remedy.
114
- if (!status.ok && (/\bENOENT\b/.test(status.output) || (!status.output.trim() && !cursorBinExists()))) {
115
- issues.push({ code: "cli_missing", message: "cursor-agent not found — run: curl https://cursor.com/install -fsS | bash" });
116
- return issues;
117
- }
118
- const authIssue = cursorAuthIssueFromOutput(status.output);
119
- if (authIssue) {
120
- issues.push(authIssue);
121
- return issues;
122
- }
123
- // NOT-133: an unclassified *failure* of the probe itself (non-zero exit, timeout) used to
124
- // be read as "healthy" and admitted the agent. Silence is not evidence of auth, so this
125
- // fails closed: the agent stays unhealthy — and its issues stay queued — until the probe
126
- // succeeds. That is a deliberate trade against the incident, where admitting on a guess
127
- // cost 12 dead sessions and parked three issues on a human. Reported as runtime_auth so
128
- // the existing agents-page CLI status renders it, with a message that says plainly the
129
- // state is unconfirmed rather than asserting the agent is logged out.
130
- if (!status.ok) {
131
- const detail = status.output.trim().split("\n").slice(-1)[0] ?? "no output";
132
- issues.push({
133
- code: "runtime_auth",
134
- message: `Could not confirm Cursor auth — \`cursor-agent status\` failed (${detail})`,
135
- });
136
- }
137
- return issues;
237
+ // NOT-157: Cursor auth probe distinguishes hard (classified logged-out / keychain) from
238
+ // soft (timeout / unclassified probe flake). Soft path retries with backoff and holds a
239
+ // recent healthy result across a single post-sleep streak.
240
+ return cursorRuntimeIssues();
138
241
  }
139
242
  /**
140
243
  * Bedrock/Vertex installs authenticate through AWS/GCP credentials instead of a Claude
@@ -229,12 +332,17 @@ async function githubIssues() {
229
332
  async function runtimeIssues(runtime) {
230
333
  const capIssues = capHealthIssues(runtime);
231
334
  const cached = runtimeIssueCache.get(runtime);
232
- if (cached && Date.now() - cached.at < RUNTIME_CACHE_MS) {
335
+ const ttl = cached?.softProbeFailure ? SOFT_PROBE_CACHE_MS : RUNTIME_CACHE_MS;
336
+ if (cached && Date.now() - cached.at < ttl) {
233
337
  return [...capIssues, ...cached.issues];
234
338
  }
235
339
  const issues = await runtimeIssuesUncached(runtime);
236
340
  const nonCap = issues.filter((i) => i.code !== "usage_capped");
237
- runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap });
341
+ // Soft fail (published or grace-held) uses a short TTL so a wake retry can clear quickly;
342
+ // a sticky 60s cache of "Could not confirm" is what parked the queue after sleep (NOT-157).
343
+ const softProbeFailure = nonCap.some(isSoftCursorProbeIssue) ||
344
+ (runtime === "cursor_local" && cursorSoftFailStreak > 0);
345
+ runtimeIssueCache.set(runtime, { at: Date.now(), issues: nonCap, softProbeFailure });
238
346
  return [...capIssues, ...nonCap];
239
347
  }
240
348
  function agentSpecificIssues(agent, agentDeckOnline, mcpRegistered, deckAccessResult) {