Skip to content

Commit fc561b3

Browse files
authored
fix(worker): close post-merge reliability gaps (#353)
* fix(worker): close post-merge reliability gaps * test(worker): stabilize sandbox and health fixtures
1 parent b0e8a3d commit fc561b3

9 files changed

Lines changed: 378 additions & 42 deletions

File tree

apps/worker/src/lib/reconcile.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,56 @@ describe("reconcileRuns owner-CAS recovery", () => {
399399
},
400400
);
401401

402+
it("evicts a terminal manual ticket when the capped AI snapshot omits it but Jira still reports AI", async () => {
403+
const manual = entry({ kind: "manual_ticket" });
404+
const runRegistry = registry([manual]);
405+
const tracker = issueTracker("AI");
406+
mockGetRun.mockReturnValue({ status: Promise.resolve("completed") });
407+
const onReleased = vi.fn();
408+
const { reconcileRuns } = await import("./reconcile.js");
409+
410+
await expect(
411+
reconcileRuns(
412+
new Set(),
413+
runRegistry,
414+
tracker,
415+
undefined,
416+
onReleased,
417+
undefined,
418+
mockDb,
419+
),
420+
).resolves.toEqual({ cancelled: 0, cleaned: 1 });
421+
expect(mockAssertActiveRunOwnerState).toHaveBeenCalledWith(
422+
mockDb,
423+
manual,
424+
"bound",
425+
);
426+
expect(tracker.moveTicket).toHaveBeenCalledTimes(1);
427+
expect(tracker.moveTicket).toHaveBeenCalledWith("PROJ-1", "Backlog");
428+
expect(runRegistry.release).toHaveBeenCalledTimes(1);
429+
expect(runRegistry.release).toHaveBeenCalledWith(
430+
manual.subjectKey,
431+
manual.ownerToken,
432+
manual.runId,
433+
);
434+
expect(onReleased).toHaveBeenCalledTimes(1);
435+
expect(onReleased).toHaveBeenCalledWith(manual.subjectKey);
436+
});
437+
438+
it("retains a manual claim omitted from the snapshot when Jira's live read is uncertain", async () => {
439+
const manual = entry({ kind: "manual_ticket" });
440+
const runRegistry = registry([manual]);
441+
const tracker = issueTracker();
442+
vi.mocked(tracker.fetchTicket).mockRejectedValue(new Error("Jira unavailable"));
443+
const { reconcileRuns } = await import("./reconcile.js");
444+
445+
await expect(
446+
reconcileRuns(new Set(), runRegistry, tracker, undefined, undefined, undefined, mockDb),
447+
).resolves.toEqual({ cancelled: 0, cleaned: 0 });
448+
expect(tracker.moveTicket).not.toHaveBeenCalled();
449+
expect(runRegistry.release).not.toHaveBeenCalled();
450+
});
451+
402452
it("retains a manual claim when stale-snapshot withdrawal cannot be confirmed", async () => {
403453
const manual = entry({ kind: "manual_ticket" });
404454
const runRegistry = registry([manual]);

apps/worker/src/lib/reconcile.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,21 @@ export async function reconcileRuns(
266266
continue;
267267
}
268268
const departure = await verifyTicketLeftAiColumn(ticketKey, issueTracker);
269-
if (!departure.left) continue;
269+
if (!departure.left) {
270+
// The Jira poll is capped, so a manual claim can be absent from its
271+
// snapshot even though the authoritative read still finds AI. Reuse the
272+
// owner-fenced terminal cleanup; an uncertain read retains the claim.
273+
if (entry.kind === "manual_ticket" && departure.trackerStatus !== null) {
274+
cleaned += await cleanFinishedManualTicket(
275+
boundEntry,
276+
runRegistry,
277+
issueTracker,
278+
onSubjectReleased,
279+
db,
280+
);
281+
}
282+
continue;
283+
}
270284
const reviewDestination =
271285
departure.trackerStatus !== null &&
272286
(await isAiReviewDestination({

apps/worker/src/mcp/run-diagnosis.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ import { diagnoseRun } from "./run-diagnosis.js";
44
import { WORKSPACE_GATE_NOT_RECORDED_MESSAGE } from "../workflow-definition/interpreter.js";
55

66
describe("diagnoseRun", () => {
7+
it("classifies the stable watchdog stalled-engine reason instead of unknown", () => {
8+
const result = diagnoseRun({
9+
status: "failed",
10+
error: {
11+
code: "AIW-DIAG-wrun_1-watchdog",
12+
message: 'Run engine stalled: step "collectPhase" has been running for 32 minutes',
13+
},
14+
steps: [],
15+
});
16+
expect(result.category).toBe("engine_stalled");
17+
expect(result.confidence).toBe("low");
18+
expect(result.evidenceRefs).toEqual(["AIW-DIAG-wrun_1-watchdog"]);
19+
expect(result.nextActions.join(" ")).toMatch(/watchdog|step|worker/i);
20+
});
21+
722
it("hands over the evidence it has even when no rule matched", () => {
823
// An unmatched run used to return evidenceRefs: [] unconditionally, so the
924
// diagnosis was strictly worse than its neighbours: runs.result showed a
@@ -392,6 +407,31 @@ describe("diagnoseRun", () => {
392407
});
393408
});
394409

410+
// Real production shape from wrun_01M13WTS2KV1ZX7ZKAFAHM5F7J: the provider
411+
// failure was already reduced to this stable, client-safe sentence, but the
412+
// diagnosis table had no matching rule and returned unknown. A provider
413+
// project spend limit is a dependency failure, not the workflow's own
414+
// budget_exhausted category, and needs billing guidance rather than a blind
415+
// retry/status-page suggestion.
416+
it("classifies the curated provider spend-limit message with billing remediation and preserves its diagnostic evidence", () => {
417+
const diagnosticId =
418+
"AIW-DIAG-wrun_01M13WTS2KV1ZX7ZKAFAHM5F7J-call_llm-1";
419+
const result = diagnoseRun({
420+
status: "failed",
421+
error: {
422+
code: diagnosticId,
423+
message:
424+
"The AI provider rejected the request: the account has reached its configured spend limit. Raise or remove the spend limit in the provider's billing settings, then rerun.",
425+
},
426+
steps: [],
427+
});
428+
expect(result.category).toBe("dependency_unavailable");
429+
expect(result.confidence).toBe("low");
430+
expect(result.evidenceRefs).toEqual([diagnosticId]);
431+
expect(result.nextActions.join(" ")).toMatch(/spend limit|billing/i);
432+
expect(result.nextActions.join(" ")).not.toMatch(/status page/i);
433+
});
434+
395435
// Real shape: PROVIDER_CAUSES rate-limit entry (workflow-definition/
396436
// failure-message.ts:96-99), reached the same way as the auth case above.
397437
it("classifies the curated AI-provider rate-limit message as dependency_unavailable, with low confidence", () => {

apps/worker/src/mcp/run-diagnosis.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type RunDiagnosisCategory =
4848
| "source_pull_request_moved"
4949
| "validation_failed"
5050
| "budget_exhausted"
51+
| "engine_stalled"
5152
| "engine_error"
5253
| "step_failed"
5354
| "unknown";
@@ -142,6 +143,10 @@ const NEXT_ACTIONS: Record<RunDiagnosisCategory, string[]> = {
142143
"The run stopped after exhausting its configured budget, not from a failure.",
143144
"Raise the workflow's budget limit or narrow the ticket's scope before retrying.",
144145
],
146+
engine_stalled: [
147+
"The watchdog marked this run failed after a workflow step stopped making progress.",
148+
"Inspect the named step and worker or sandbox health before retrying the run.",
149+
],
145150
engine_error: [
146151
"Check the workflow definition graph for an unresolvable trigger, node, or edge.",
147152
],
@@ -237,6 +242,10 @@ const SOURCE_PULL_REQUEST_MOVED_KEYWORDS = [
237242
// stopped by a budget check (workflows/agent.ts:2537-2543).
238243
const BUDGET_EXHAUSTED_PREFIX = "Run stopped on budget:";
239244

245+
// WATCHDOG_FAILURE_REASON_PREFIX (lib/telemetry/run-telemetry.ts:116), written
246+
// only by the engine-stall watchdog as a durable failed-run reason.
247+
const ENGINE_STALLED_PREFIX = "Run engine stalled:";
248+
240249
// fallbackTerminalError's "blocked" lead (lib/overview/sanitize-run-detail.ts:
241250
// 104-113): the observed face of three silent stop paths that record no
242251
// statusReason: markRunBlockedOnCancel and sweepOrphanedAwaitingRuns
@@ -265,6 +274,19 @@ const VALIDATION_FAILED_PREFIXES = [
265274
const DEPENDENCY_AUTH_PREFIX =
266275
"The AI provider rejected the credentials (authentication failed).";
267276

277+
// Curated provider sentence for an account/project spend-limit rejection
278+
// (workflow-definition/failure-message.ts:148-151). This is deliberately a
279+
// whole trusted lead rather than a raw `spend limit` search: runs.diagnose
280+
// receives the already-sanitized run reason, and only this code-owned sentence
281+
// is safe to route to billing remediation. It stays distinct from
282+
// `budget_exhausted`, which describes the workflow's own configured budget.
283+
const PROVIDER_SPEND_LIMIT_PREFIX =
284+
"The AI provider rejected the request: the account has reached its configured spend limit.";
285+
const PROVIDER_SPEND_LIMIT_ACTIONS = [
286+
"Raise or remove the provider project's configured spend limit in billing settings before retrying.",
287+
"Confirm the intended provider project/account is selected and that the new limit has propagated before rerunning.",
288+
] as const;
289+
268290
// The other PROVIDER_CAUSES sentences (workflow-definition/failure-message.ts:
269291
// 90-113): billing/credit, rate limit, model unavailable, and overloaded. Plus
270292
// SAFE_EXECUTION_ERROR_MESSAGES.provider (interpreter.ts:89), the uncurated
@@ -390,6 +412,15 @@ const RULES: readonly Rule[] = [
390412
return { confidence: "low", evidenceRefs: evidenceFrom(input) };
391413
},
392414
},
415+
{
416+
category: "engine_stalled",
417+
match: (input) => {
418+
if (input.status !== "failed") return null;
419+
const message = input.error?.message;
420+
if (!message || !message.startsWith(ENGINE_STALLED_PREFIX)) return null;
421+
return { confidence: "low", evidenceRefs: evidenceFrom(input) };
422+
},
423+
},
393424
{
394425
category: "cancelled",
395426
match: (input) => {
@@ -476,6 +507,22 @@ const RULES: readonly Rule[] = [
476507
return { confidence: "low", evidenceRefs: evidenceFrom(input) };
477508
},
478509
},
510+
{
511+
// This is a provider dependency failure, but the operator action is
512+
// billing remediation rather than a blind retry or a status-page check.
513+
// Keep it ahead of the generic dependency_unavailable rule below so the
514+
// stable curated spend-limit sentence gets its specific guidance.
515+
category: "dependency_unavailable",
516+
match: (input) => {
517+
const message = input.error?.message;
518+
if (!message || !message.startsWith(PROVIDER_SPEND_LIMIT_PREFIX)) return null;
519+
return {
520+
confidence: "low",
521+
evidenceRefs: evidenceFrom(input),
522+
nextActions: PROVIDER_SPEND_LIMIT_ACTIONS,
523+
};
524+
},
525+
},
479526
{
480527
category: "dependency_unavailable",
481528
match: (input) => {

0 commit comments

Comments
 (0)