mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon): name the ACP stage-timeout verdict instead of writing prose
The ACP stage watchdog is the daemon's own verdict — nothing upstream
reports anything, the daemon decides a stage is over and kills the child.
It shipped that verdict as a bare `{ message: 'ACP <stage> timed out after
<n>ms' }`, so the only thing that could recover "this run timed out"
downstream was a regex over that English sentence (`isTimeoutText`). Any
path that rewrites, wraps, localizes or drops an ACP error message re-filed
a watchdog kill as `process_exit / exit_code` — `retryable: false`,
`user_action: 'none'` — which is the generic card with no Retry, for a
failure whose entire remedy is a retry.
The watchdog now stamps `retryable: true` and `details.kind = 'timeout'`
(the same discriminator `acp_child_exit` / `acp_no_visible_output` /
`amr_model` already carry), and the classifier reads that marker rather
than re-deriving the verdict from the sentence. The sentence itself is
unchanged: it is what the details drawer shows and what telemetry reads.
Deliberately narrow — only the watchdog writes the marker, and only the
timeout branch reads it. Agent-supplied JSON-RPC data lands under
`error.data`, never `error.details`, so an agent cannot forge the verdict;
`empty_output` still resolves first, so a run that stalled AND produced
nothing still says it produced nothing.
`acp-stall-last-progress-age` re-anchors its non-vacuity guard on the
child's own exit status (143 = its SIGTERM handler ran) instead of on the
derived `AGENT_EXIT_143` code, which never described this failure.
* fix(daemon): recognise opencode's compaction-continuation stream EOF
vela 0.0.35 (#1847) moved the compaction continuation onto its own request
and worded its EOF differently: `opencode compaction continuation ended
before prompt completion`. That phrase matched nothing in the repository,
so the same bridge-level stream EOF the resume-failure table already
handles — one phase later — could not reach the re-seed path at all and
ended the conversation on a hard failure.
Added to `AMR_OPENCODE_EVENT_STREAM_RESUME_FAILURE_PATTERNS` in the same
shape as its siblings (un-anchored, case-insensitive), so a compaction that
dies mid-continuation clears the stale handle and re-runs the turn with a
fresh session — one slightly slower cold turn instead of a dead end.
The gate stays narrow: `resolveAgentResumeFailurePolicy` only fires when a
stored handle was actually being continued (`isResuming` + a resume session
id), and `run.resumeAutoReseeded` allows the recovery once. A create turn
carrying the same phrase is untouched.
* fix(daemon): keep an upstream that says "retryable" retryable
Real corpus (2026-09-08, runs 423140d1 and e9bea966): opencode reported a
closed upstream socket AFTER exhausting its own three attempts and said so
in machine-readable form — `"isRetryable": true`. The daemon recorded
`upstream_unavailable / stream_disconnected` with `retryable: false`, the
opposite of what upstream stated.
Where the word was dropped: opencode's field is `isRetryable`, and vela
hands the whole `session.error` envelope over as a JSON string inside the
ACP error message rather than as `error.data`. `rpcErrorRetryable` read
only `data.retryable`; the classifier's `latestRetryable` reads
`error.data.isRetryable` and never saw it either. With no verdict from
either reader, `fail()` fell back to its own `retryable: false` default,
and that fabricated verdict became the run's.
The cost is not the error card (`stream_disconnected` still draws a Retry)
but the daemon's own recovery: `isResumableFailure` returns false the
moment `retryable` is false, so `run.resumable` never gets set and the
Continue affordance disappears, and `decidePostToolResumeRecovery` requires
`retryable === true`, so the silent post-tool continuation never fires.
Both readers now accept the `isRetryable` spelling. Only `true` is read: an
explicit upstream "no" is already handled by the branches that can disprove
it (a 4xx re-fails identically and routes to `upstream_client_error`), and
letting a coarse SDK flag force `false` is drift the classifier refuses by
name. `request_too_large` still wins over it.
Verified against the frozen ACP landing table: no row moves. Removing
`fail()`'s `?? false` default instead would have moved 17 of them,
including turning a 429 rate limit into a retry — left alone deliberately.
* fix(daemon): read a structural isRetryable only when it says true
`rpcErrorRetryable` accepted the vendor `isRetryable` field in both
directions, so an adapter that forwards opencode's `error.data` object
structurally (rather than embedded in the message string, which is the
shape the corpus happened to carry) could hand the bridge a `false`.
`session.ts` composes the two readers with `??`, so that `false` did not
merely record an upstream "no": it short-circuited
`inferRpcErrorRetryable`, which for the same payload in string form
returns `undefined` and lets a transport blip be read as retryable.
`fail()` then stamped `retryable: false`, the classifier adopted it as
`retryableHint`, and `isResumableFailure` withdrew Continue — the exact
failure this branch exists to remove, reachable through the other shape.
Read only `true`, matching the rule `inferRpcErrorRetryable` already
applies to the string form. ACP's own `retryable` keeps both values: it
is a deliberate protocol-level statement, not a status-code-derived SDK
flag riding along inside a vendor payload.
Red spec covers the structural shape the previous negative test did not:
parity between the two shapes on the real 404 `error.data`, and a
structural `isRetryable:false` on the row-B1 stream-idle-timeout blip
that must not overrule the classifier. Both go red with this change
backed out. The frozen ACP landing table is untouched — 47 rows, 17
moved-code rows, 196 assertions, zero lines changed.
* fix(daemon): namespace the ACP watchdog verdict so agent data cannot claim it
`error.details` is not a daemon-private slot. `fail()` copies an agent's
JSON-RPC `error.data` into it verbatim (`fail(rpcErr, { details })`), so an
adapter sending `{"error":{"data":{"kind":"timeout"}}}` reached
`hasDaemonTimeoutVerdict` as a stage-watchdog kill: `failure_category:
'timeout'`, `terminal_trigger: 'acp_stage_timeout'`, and precedence over
the forced-signal guard — with the watchdog never having fired.
Rename the marker to `acp_stage_timeout` and match that exact value, so no
upstream payload collides with it by accident. Both docblocks now describe
the shared slot accurately, including the residual: this is collision
resistance, not authentication. An adapter that deliberately sent
`kind: 'acp_stage_timeout'` would still be believed, and closing that needs
a daemon-only field agent payload is never copied into — noted at `fail()`,
not done here.
The previous anti-forgery test asserted this against `error.data`, a shape
the session bridge never emits, so it passed without exercising the copy.
Replaced with one that drives the real bridge: the agent answers
`session/prompt` with a JSON-RPC error carrying `error.data`, and the frame
the bridge actually emits is what gets classified. It also pins the copy
itself, so a future change that stops forwarding agent data is visible.
Added the matching positive control through the same real bridge.
Backing both source lines out turns 4 tests red, in both directions.
Frozen ACP landing table untouched: 47 rows, 17-entry moved list, 196/196,
zero lines changed.
321 lines
15 KiB
TypeScript
321 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
inferRpcErrorRetryable,
|
|
rpcErrorRetryable,
|
|
} from '../src/agent-protocol/acp/rpc.js';
|
|
import {
|
|
classifyRunFailure,
|
|
isResumableFailure,
|
|
} from '../src/run-failure-classification.js';
|
|
|
|
/**
|
|
* Red spec: when opencode says a failure IS retryable, we must not record the
|
|
* opposite.
|
|
*
|
|
* Real corpus (user diagnostics bundle, 2026-09-08, two independent runs —
|
|
* `423140d1` 03:27 and `e9bea966` 04:03): opencode reported a closed upstream
|
|
* socket AFTER exhausting its own three attempts, and said so in machine
|
|
* readable form — `"isRetryable": true`. What the daemon persisted on the `end`
|
|
* event was `upstream_unavailable / stream_disconnected` with
|
|
* `retryable: false`, the opposite of what upstream stated.
|
|
*
|
|
* Why that costs the user something even though the error CARD for
|
|
* `stream_disconnected` still draws a Retry button: `retryable` is what the
|
|
* daemon's own recovery reads. `isResumableFailure` returns false the instant
|
|
* it is false, so `run.resumable` never gets set and the Continue affordance
|
|
* disappears; `decidePostToolResumeRecovery` requires `retryable === true`, so
|
|
* the silent post-tool continuation never fires either. The most recoverable
|
|
* failure there is got the treatment reserved for dead ends.
|
|
*
|
|
* Where the word was dropped: opencode's field is `isRetryable`, and vela hands
|
|
* the whole `session.error` envelope over as a JSON STRING inside the ACP error
|
|
* message rather than as `error.data`. `rpcErrorRetryable` read only
|
|
* `data.retryable`; the classifier's `latestRetryable` reads
|
|
* `error.data.isRetryable` and so never saw it either. With no verdict from
|
|
* either reader, `fail()` fell back to its own default — `retryable: false` —
|
|
* and that fabricated verdict is what the whole chain then believed.
|
|
*/
|
|
|
|
/**
|
|
* Verbatim from the diagnostics bundle: opencode's `session.error` event as
|
|
* vela forwards it, with the `json-rpc id N: ` prefix `rpcErrorMessage` adds.
|
|
*/
|
|
const RAW_SOCKET_CLOSED =
|
|
'json-rpc id 4: opencode event stream: {"properties":{"error":{"name":"APIError",'
|
|
+ '"data":{"isRetryable":true,"message":"Cannot connect to API: The socket connection '
|
|
+ 'was closed unexpectedly. For more information, pass `verbose: true` in the second '
|
|
+ 'argument to fetch()","metadata":{"retryAttempts":"2","totalAttempts":"3",'
|
|
+ '"retryExhausted":"true","url":"https://amr-link.open-design.ai/v1/chat/completions"}}}},'
|
|
+ '"type":"session.error"}';
|
|
|
|
/**
|
|
* The ACP bridge's retryability decision for a JSON-RPC error, exactly as
|
|
* `session.ts` makes it: the structured field first, then the message/details
|
|
* reader, and `undefined` when neither reached a verdict.
|
|
*/
|
|
function bridgeRetryable(message: string, data: unknown): boolean | undefined {
|
|
return rpcErrorRetryable(data) ?? inferRpcErrorRetryable(message, data);
|
|
}
|
|
|
|
/**
|
|
* The classifier as it sees a real ACP fatal: the `runtime_close` diagnostic
|
|
* plus the error event `fail()` emitted, whose `retryable` becomes the hint.
|
|
*/
|
|
function classify(message: string, retryable: boolean | undefined) {
|
|
// `fail()` stamps `options.retryable ?? false` on a frame that carries
|
|
// details, so an undecided bridge still publishes `false`. Reproduced here
|
|
// rather than assumed: it is the step that turned "we do not know" into "no".
|
|
const stamped = retryable ?? false;
|
|
return classifyRunFailure({
|
|
result: 'failed',
|
|
status: { status: 'failed', error: message, errorCode: null },
|
|
agentId: 'amr',
|
|
events: [
|
|
{ event: 'diagnostic', data: { type: 'runtime_close', rpc_close_reason: 'fatal_rpc_error' } },
|
|
{
|
|
event: 'error',
|
|
data: {
|
|
message,
|
|
error: { code: 'AGENT_EXECUTION_FAILED', message, retryable: stamped },
|
|
},
|
|
},
|
|
],
|
|
} as Parameters<typeof classifyRunFailure>[0]);
|
|
}
|
|
|
|
describe('an upstream that says "retryable" is recorded as retryable', () => {
|
|
it('reads opencode\'s isRetryable out of the frame vela actually sends', () => {
|
|
// Proof the fixture can see the defect: the word IS in the payload. If a
|
|
// future rewording moves it, this line goes red before the verdict does.
|
|
expect(RAW_SOCKET_CLOSED).toContain('"isRetryable":true');
|
|
expect(bridgeRetryable(RAW_SOCKET_CLOSED, undefined)).toBe(true);
|
|
});
|
|
|
|
it('also reads it when vela forwards the field as structured error data', () => {
|
|
// The other shape the same statement arrives in. `rpcErrorRetryable` knew
|
|
// only the `retryable` spelling, so this half was dropped at the bridge.
|
|
expect(bridgeRetryable('json-rpc id 4: upstream closed the connection', {
|
|
isRetryable: true,
|
|
message: 'The socket connection was closed unexpectedly',
|
|
})).toBe(true);
|
|
});
|
|
|
|
it('lands the run as a retryable stream disconnect, and keeps Continue alive', () => {
|
|
const failure = classify(
|
|
RAW_SOCKET_CLOSED,
|
|
bridgeRetryable(RAW_SOCKET_CLOSED, undefined),
|
|
);
|
|
|
|
expect(failure?.failure_category).toBe('upstream_unavailable');
|
|
expect(failure?.failure_detail).toBe('stream_disconnected');
|
|
expect(failure?.retryable).toBe(true);
|
|
expect(failure?.user_action).toBe('retry');
|
|
// The affordance the user actually lost: `run.resumable` is gated on this.
|
|
expect(isResumableFailure(failure)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('the passthrough does not hand out retries nobody granted', () => {
|
|
it('leaves an upstream that says NOT retryable alone', () => {
|
|
// Same envelope, opposite word. Only `true` is read, and a 4xx is routed to
|
|
// `upstream_client_error` regardless — a request the provider rejected on
|
|
// shape re-fails identically.
|
|
const raw =
|
|
'json-rpc id 4: opencode event stream: opencode session error: '
|
|
+ '{"error":{"name":"APIError","data":{"message":"Not Found","statusCode":404,'
|
|
+ '"isRetryable":false,"responseBody":"<html><head><title>404 Not Found</title></head>"}}}';
|
|
expect(bridgeRetryable(raw, undefined)).toBeUndefined();
|
|
const failure = classify(raw, bridgeRetryable(raw, undefined));
|
|
expect(failure?.failure_detail).toBe('upstream_client_error');
|
|
expect(failure?.retryable).toBe(false);
|
|
expect(failure?.user_action).toBe('none');
|
|
});
|
|
|
|
it('does not turn a rate limit into a retry', () => {
|
|
// The class this must never leak into: a 429 that says nothing about its
|
|
// own retryability keeps the verdict it has.
|
|
const raw = 'json-rpc id 4: rate limit exceeded';
|
|
expect(bridgeRetryable(raw, undefined)).toBeUndefined();
|
|
const failure = classify(raw, bridgeRetryable(raw, undefined));
|
|
expect(failure?.failure_category).toBe('rate_limit');
|
|
expect(failure?.retryable).toBe(false);
|
|
expect(failure?.user_action).toBe('none');
|
|
});
|
|
|
|
it('does not resurrect a spent balance or a suspended account', () => {
|
|
for (const raw of [
|
|
'json-rpc id 4: insufficient balance',
|
|
'json-rpc id 4: your account has been suspended due to a risk-control review',
|
|
]) {
|
|
const failure = classify(raw, bridgeRetryable(raw, undefined));
|
|
expect(failure?.retryable).toBe(false);
|
|
expect(failure?.user_action).not.toBe('retry');
|
|
}
|
|
});
|
|
|
|
it('still refuses a request that is too large, even if upstream says retry', () => {
|
|
// Precedence check: `request_too_large` is decided BEFORE the upstream's
|
|
// own word, because the identical payload deterministically re-fails.
|
|
const raw =
|
|
'json-rpc id 4: [code=request_too_large] request body exceeds configured limit'
|
|
+ ' {"isRetryable":true}';
|
|
expect(bridgeRetryable(raw, undefined)).toBe(false);
|
|
});
|
|
|
|
it('leaves provider_routing_error retryable, as production already reports it', () => {
|
|
// The control from the same diagnostics bundle: run `43eed13f` (03:29)
|
|
// classified `upstream_unavailable / provider_routing_error` with
|
|
// `retryable: true`. Not every upstream failure was being judged dead, and
|
|
// this one must stay exactly where it is.
|
|
const raw = 'json-rpc id 4: AMR model catalog is temporarily unavailable. Please retry.';
|
|
const failure = classify(raw, true);
|
|
expect(failure?.failure_detail).toBe('provider_routing_error');
|
|
expect(failure?.retryable).toBe(true);
|
|
expect(failure?.user_action).toBe('retry');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The rule above ("only `true` is read") is stated for the message-string
|
|
* reader, and the negative anchors that police it all feed the string form —
|
|
* where `inferRpcErrorRetryable` matches `"isRetryable":true` and returns
|
|
* `undefined` for anything else, so an upstream "no" is correctly left
|
|
* undecided.
|
|
*
|
|
* The STRUCTURED reader is the same statement arriving as ACP `error.data`
|
|
* instead, which is the shape an adapter produces when it forwards opencode's
|
|
* `error.data` object through rather than re-serialising it into the sentence
|
|
* (`apps/web/src/providers/daemon.ts` already reads `isRetryable` off exactly
|
|
* that object). Nothing above exercises it with a `false`, and the two readers
|
|
* are only equivalent if they agree on that value: `session.ts` composes them
|
|
* with `??`, so a structural `false` short-circuits the message reader
|
|
* entirely and becomes the frame's verdict.
|
|
*/
|
|
describe('a structured isRetryable is read under the same rule as the string form', () => {
|
|
/**
|
|
* Verbatim `error.data` from the real APIError envelope already frozen as row
|
|
* B5 of the ACP landing table (`acp-service-failure.test.ts`) — the same
|
|
* bytes the string-form anchor above parses, lifted out of the sentence.
|
|
*/
|
|
const APIERROR_404_DATA = {
|
|
message: 'Not Found',
|
|
statusCode: 404,
|
|
isRetryable: false,
|
|
responseBody: '<html><head><title>404 Not Found</title></head>',
|
|
};
|
|
|
|
const APIERROR_404_STRING =
|
|
'json-rpc id 4: opencode event stream: opencode session error: '
|
|
+ '{"error":{"name":"APIError","data":{"message":"Not Found","statusCode":404,'
|
|
+ '"isRetryable":false,"responseBody":"<html><head><title>404 Not Found</title></head>"}}}';
|
|
|
|
it('reaches the same verdict for the same payload in either shape', () => {
|
|
// Proof the fixture can see the defect: the word IS in both shapes.
|
|
expect(APIERROR_404_DATA.isRetryable).toBe(false);
|
|
expect(APIERROR_404_STRING).toContain('"isRetryable":false');
|
|
|
|
const asMessage = bridgeRetryable(APIERROR_404_STRING, undefined);
|
|
const asData = bridgeRetryable('json-rpc id 4: Not Found', APIERROR_404_DATA);
|
|
expect(asMessage).toBeUndefined();
|
|
expect(asData).toBe(asMessage);
|
|
});
|
|
|
|
/**
|
|
* The consequence, and the reason this is not a style point. `[code=upstream_error]
|
|
* stream idle timeout` is the transport blip already frozen as row B1 with
|
|
* `retryable: true` / `user_action: 'retry'` — the daemon reads that text and
|
|
* knows it is retryable. Hand it the SDK's coarse `false` structurally and,
|
|
* because `??` stops at a boolean, the message reader that would have said
|
|
* `true` never runs: `fail()` stamps `false`, the classifier adopts it as
|
|
* `retryableHint`, and `isResumableFailure` withdraws Continue — the exact
|
|
* failure mode this PR exists to remove, reintroduced through the other door.
|
|
*/
|
|
const SDK_NO_ON_A_TRANSPORT_BLIP = {
|
|
isRetryable: false,
|
|
message: '[code=upstream_error] stream idle timeout: no data received within configured window',
|
|
};
|
|
|
|
/**
|
|
* Row B1's text verbatim — the sentence the daemon shows and the only thing
|
|
* the classifier reads. It rides along with the structured `data` above
|
|
* because vela forwards the whole `session.error` envelope in the message
|
|
* regardless; the structural copy is the addition under test.
|
|
*/
|
|
const TRANSPORT_BLIP_MESSAGE =
|
|
'json-rpc id 4: opencode event stream: {"type":"session.error","properties":{"error":'
|
|
+ '{"data":{"message":"\\"[code=upstream_error] stream idle timeout: no data received '
|
|
+ 'within configured window\\""}}}}';
|
|
|
|
/**
|
|
* The full chain as `session.ts` runs it: the composed bridge verdict, then
|
|
* `fail(message, { details, …retryable })` — which stamps
|
|
* `options.retryable ?? false` and carries `details` on the frame — then the
|
|
* classifier reading that frame back.
|
|
*/
|
|
function classifyStructured(message: string, data: unknown) {
|
|
const retryable = bridgeRetryable(message, data);
|
|
return classifyRunFailure({
|
|
result: 'failed',
|
|
status: { status: 'failed', error: message, errorCode: null },
|
|
agentId: 'amr',
|
|
events: [
|
|
{ event: 'diagnostic', data: { type: 'runtime_close', rpc_close_reason: 'fatal_rpc_error' } },
|
|
{
|
|
event: 'error',
|
|
data: {
|
|
message,
|
|
error: {
|
|
code: 'AGENT_EXECUTION_FAILED',
|
|
message,
|
|
retryable: retryable ?? false,
|
|
details: data,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
} as Parameters<typeof classifyRunFailure>[0]);
|
|
}
|
|
|
|
it('does not let a structural no override the classifier on a transport blip', () => {
|
|
// Control: the identical payload with the flag only in the message is
|
|
// already retryable today. The structural copy must not change that answer.
|
|
expect(bridgeRetryable(TRANSPORT_BLIP_MESSAGE, undefined)).toBe(true);
|
|
expect(bridgeRetryable(TRANSPORT_BLIP_MESSAGE, SDK_NO_ON_A_TRANSPORT_BLIP)).not.toBe(false);
|
|
|
|
const failure = classifyStructured(TRANSPORT_BLIP_MESSAGE, SDK_NO_ON_A_TRANSPORT_BLIP);
|
|
expect(failure?.failure_category).toBe('upstream_unavailable');
|
|
expect(failure?.failure_detail).toBe('stream_disconnected');
|
|
expect(failure?.retryable).toBe(true);
|
|
expect(failure?.user_action).toBe('retry');
|
|
// The affordance row B1 already promises this failure, and the one a
|
|
// structural `false` silently took away.
|
|
expect(isResumableFailure(failure)).toBe(true);
|
|
});
|
|
|
|
it('still hands out no retry the structural payload did not earn', () => {
|
|
// The other half of the rule: declining to read `false` must not be read as
|
|
// reading `true`. The adapter shape here is the fullest one — the envelope
|
|
// kept in the sentence AND its `data` forwarded structurally — so the text
|
|
// evidence row B5 was frozen on is present, and `upstream_client_error` is
|
|
// reached from it rather than from any hint. `fail()` still stamps `false`
|
|
// on an undecided frame, so nothing about this row moves.
|
|
const failure = classifyStructured(APIERROR_404_STRING, APIERROR_404_DATA);
|
|
expect(bridgeRetryable(APIERROR_404_STRING, APIERROR_404_DATA)).not.toBe(true);
|
|
expect(failure?.failure_detail).toBe('upstream_client_error');
|
|
expect(failure?.retryable).toBe(false);
|
|
expect(failure?.user_action).toBe('none');
|
|
expect(isResumableFailure(failure)).toBe(false);
|
|
});
|
|
|
|
it('keeps reading a structural yes, which is the direction this PR added', () => {
|
|
// Guard against "fixing" the above by deleting the branch: the accepted
|
|
// direction must survive.
|
|
expect(rpcErrorRetryable({ isRetryable: true, message: 'socket closed' })).toBe(true);
|
|
// And the ACP-native `retryable` field is a protocol-level statement, not a
|
|
// coarse SDK flag — it is out of scope here and keeps both of its values.
|
|
expect(rpcErrorRetryable({ retryable: false })).toBe(false);
|
|
expect(rpcErrorRetryable({ retryable: true })).toBe(true);
|
|
});
|
|
});
|