Skip to content

Commit db9c460

Browse files
claude[bot]claude
andauthored
test(service-automation): pin that a node failing mid-resume strands the run (#13899)
A measurement instrument, not a repair. The reported strand came from the approvals reject door, whose error prose is the only place the word "stranded" appears. This pins the same outcome through the GENERIC resume door with no approvals involvement: `resumeInternal` calls `forgetSuspendedRun(run, 'resumed')` before `traverseNext`, so a downstream node that throws throws with the pause already consumed. Characterization assertions: the suspension is gone, the run is recorded `failed`, a second resume answers RUN_NOT_FOUND, and cancelRun is a no-op. Two reverse controls keep those from being constants -- a resume refused before the consumption point (INVALID_SIGNAL) leaves the pause intact and resumable, and a clean resume also ends unsuspended, so the strand is the FAILED status rather than the missing pause. A repair should turn the first test red; that is why it is pinned now. Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f63965 commit db9c460

1 file changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach } from 'vitest';
4+
import { AutomationEngine } from './engine.js';
5+
import type { NodeExecutor } from './engine.js';
6+
import { defineActionDescriptor } from '@objectstack/spec/automation';
7+
8+
/**
9+
* MEASUREMENT INSTRUMENT (#13807 step 1) — is the "stranded run" reported by
10+
* the approvals reject door a property of `plugin-approvals`, or of
11+
* `resumeInternal` itself?
12+
*
13+
* The reported call was `POST /api/v1/approvals/requests/{id}/reject`, which
14+
* answered 500 with:
15+
*
16+
* ...run 'run_...' could not be resumed and is now stranded: resume of run
17+
* '...' failed: Node 'mark_rejected' failed:
18+
* update_record(crm_leave_request) failed: Record ... not found
19+
*
20+
* Nothing in THIS file touches approvals. The flow below is a plain pausing
21+
* node with `resumeAuthority: 'any'` continued through the generic
22+
* `engine.resume()` door — the same door `POST /:name/runs/:runId/resume`
23+
* serves. If the strand reproduces here, the strand is the engine's, and the
24+
* word "stranded" is only the approvals-side error prose wrapped around it.
25+
*
26+
* The mechanism these tests pin is the ORDERING inside `resumeInternal`:
27+
* `forgetSuspendedRun(run, 'resumed')` consumes the suspension BEFORE
28+
* `traverseNext` runs any downstream node. So a downstream node that throws
29+
* throws with the pause already gone — there is nothing left to resume, and no
30+
* engine verb puts it back.
31+
*
32+
* These are CHARACTERIZATION assertions: they describe what the engine does
33+
* today, including the part that is the defect. A repair for #13807 SHOULD
34+
* turn them red; that is the point of pinning them now, so the repair has to
35+
* state which of these facts it changed.
36+
*/
37+
38+
function silentLogger() {
39+
return {
40+
info() {}, warn() {}, error() {}, debug() {},
41+
child() { return silentLogger(); },
42+
} as any;
43+
}
44+
45+
/**
46+
* A pausing node open to the generic resume route. `resumeAuthority: 'any'` is
47+
* the deliberate opposite of the `approval` node's `resumeAuthority: 'service'`
48+
* — it is what makes this fixture a NON-approvals reproduction rather than a
49+
* re-run of the approvals path under another name.
50+
*/
51+
const openPauser: NodeExecutor = {
52+
type: 'pauser',
53+
descriptor: defineActionDescriptor({
54+
type: 'pauser', version: '1.0.0', name: 'pauser',
55+
supportsPause: true, resumeAuthority: 'any',
56+
}),
57+
async execute() {
58+
return { success: true, suspend: true, correlation: 'test:hold' };
59+
},
60+
};
61+
62+
/**
63+
* Stands in for `mark_rejected`: a downstream write-back node whose target row
64+
* was deleted while the run was parked. The message shape mirrors the report so
65+
* the reproduction is legible next to it.
66+
*/
67+
const deletedRowWriter: NodeExecutor = {
68+
type: 'write_back',
69+
async execute() {
70+
throw new Error('update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found in crm_leave_request');
71+
},
72+
};
73+
74+
/** The control's downstream node: the same position, but it succeeds. */
75+
const healthyWriter: NodeExecutor = {
76+
type: 'write_back_ok',
77+
async execute() { return { success: true }; },
78+
};
79+
80+
const flowWith = (writerType: string) => ({
81+
name: 'writeback_flow',
82+
label: 'Write-back Flow',
83+
type: 'autolaunched',
84+
nodes: [
85+
{ id: 'start', type: 'start', label: 'Start' },
86+
{ id: 'hold', type: 'pauser', label: 'Hold' },
87+
{ id: 'mark_rejected', type: writerType, label: 'Mark rejected' },
88+
{ id: 'end', type: 'end', label: 'End' },
89+
],
90+
edges: [
91+
{ id: 'e1', source: 'start', target: 'hold' },
92+
{ id: 'e2', source: 'hold', target: 'mark_rejected' },
93+
{ id: 'e3', source: 'mark_rejected', target: 'end' },
94+
],
95+
});
96+
97+
describe('#13807 step 1 — a node failing mid-resume strands the run, with no approvals in sight', () => {
98+
let engine: AutomationEngine;
99+
100+
beforeEach(() => {
101+
engine = new AutomationEngine(silentLogger());
102+
engine.registerNodeExecutor(openPauser);
103+
engine.registerNodeExecutor(deletedRowWriter);
104+
engine.registerNodeExecutor(healthyWriter);
105+
});
106+
107+
it('consumes the suspension and leaves the run unrecoverable through EVERY engine verb', async () => {
108+
engine.registerFlow('writeback_flow', flowWith('write_back'));
109+
110+
const paused = await engine.execute('writeback_flow');
111+
expect(paused.status).toBe('paused');
112+
const runId = paused.runId!;
113+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
114+
115+
// The resume that reproduces the report: the downstream node throws.
116+
const failed = await engine.resume(runId);
117+
expect(failed.success).toBe(false);
118+
expect(failed.error).toContain('not found in crm_leave_request');
119+
120+
// 1. The suspension is GONE — consumed before the node ever ran.
121+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
122+
123+
// 2. The run is recorded terminal-failed, not paused.
124+
expect((await engine.getRun(runId))?.status).toBe('failed');
125+
126+
// 3. Re-resuming is refused: there is no pause left to continue.
127+
const retry = await engine.resume(runId);
128+
expect(retry.success).toBe(false);
129+
expect(retry.code).toBe('RUN_NOT_FOUND');
130+
131+
// 4. Cancelling is a no-op too — `cancelRun` needs a suspended run to
132+
// consume, so it cannot even tidy the run away.
133+
expect(await engine.cancelRun(runId, 'operator cleanup')).toBe(false);
134+
});
135+
136+
/**
137+
* REVERSE CONTROL for assertion 1. "The suspension is gone" is only a reading
138+
* if the same assertions can SEE a suspension that survived. A resume refused
139+
* BEFORE the consumption point (`INVALID_SIGNAL`, raised while folding the
140+
* signal) is the engine's own example of that: the pause stays live and the
141+
* legitimate continuation still lands.
142+
*/
143+
it('CONTROL — a resume refused before the consumption point leaves the pause intact and resumable', async () => {
144+
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));
145+
146+
const paused = await engine.execute('writeback_flow');
147+
const runId = paused.runId!;
148+
149+
const refused = await engine.resume(runId, { variables: { $internal: 1 } } as any);
150+
expect(refused.success).toBe(false);
151+
expect(refused.code).toBe('INVALID_SIGNAL');
152+
153+
// The same probes that read `false` above read `true` here — so they are
154+
// measuring the suspension, not returning a constant.
155+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
156+
157+
const ok = await engine.resume(runId);
158+
expect(ok.success).toBe(true);
159+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
160+
expect((await engine.getRun(runId))?.status).toBe('completed');
161+
});
162+
163+
/**
164+
* REVERSE CONTROL for assertions 2-4. A run that resumed cleanly also ends
165+
* with no suspension — so "no suspension" alone does not identify the strand.
166+
* What separates them is the terminal status, and that a completed run is a
167+
* finished one rather than a run with work left that nothing can reach.
168+
*/
169+
it('CONTROL — a clean resume also ends unsuspended, so the strand is the FAILED status, not the missing pause', async () => {
170+
engine.registerFlow('writeback_flow', flowWith('write_back_ok'));
171+
172+
const paused = await engine.execute('writeback_flow');
173+
const runId = paused.runId!;
174+
175+
expect((await engine.resume(runId)).success).toBe(true);
176+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
177+
expect((await engine.getRun(runId))?.status).toBe('completed');
178+
expect((await engine.resume(runId)).code).toBe('RUN_NOT_FOUND');
179+
});
180+
});

0 commit comments

Comments
 (0)