Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion packages/aft-bridge/src/__tests__/error-contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
/// <reference path="../bun-test.d.ts" />

import { describe, expect, test } from "bun:test";
import { SubcError } from "@cortexkit/subc-client";
import { BridgeTransportTimeoutError, isBridgeTransportTimeout } from "../bridge.js";
import { adaptToolError, BASH_TRANSPORT_DISPOSITION } from "../error-contract.js";
import {
adaptToolError,
BASH_TRANSPORT_DISPOSITION,
SUBC_MODULE_RESTART_DISPOSITION,
} from "../error-contract.js";

/** Shaped exactly as subc-client raises it: bare SubcError, no code. */
function routeGoodbyeError(): SubcError {
return new SubcError("route closed by subc (GOODBYE)");
}

describe("adaptToolError", () => {
test("adds bash transport disposition guidance while preserving the error", () => {
Expand All @@ -29,4 +39,52 @@ describe("adaptToolError", () => {
expect(original.message).toBe("read transport timed out");
expect(original.message).not.toContain(BASH_TRANSPORT_DISPOSITION);
});

test("a route GOODBYE reports an UNKNOWN outcome, never a failure", () => {
const original = routeGoodbyeError();

const adapted = adaptToolError("write", original);

expect(adapted).toBe(original);
expect(original.message).toContain(SUBC_MODULE_RESTART_DISPOSITION);
// The wording is the safety property: an operator or agent that reads
// "failed" re-runs the call, which double-applies a mutation that may
// already have landed before the daemon dropped the reply.
expect(original.message).toContain("UNKNOWN");
expect(original.message).toContain("never blind-retry a mutation");
expect(original.message).not.toContain("Re-run the command.");
});

test("a GOODBYE'd bash call gets the unknown-outcome text, not the re-run text", () => {
// BASH_TRANSPORT_DISPOSITION asserts no task was created and says to re-run.
// That is true for a not-sent transport failure and FALSE for a GOODBYE,
// where the command may already have executed.
const original = routeGoodbyeError();

adaptToolError("bash", original);

expect(original.message).toContain(SUBC_MODULE_RESTART_DISPOSITION);
expect(original.message).not.toContain(BASH_TRANSPORT_DISPOSITION);
});

test("disposition is appended once when the error passes through twice", () => {
const original = routeGoodbyeError();

adaptToolError("read", original);
adaptToolError("read", original);

const occurrences = original.message.split(SUBC_MODULE_RESTART_DISPOSITION).length - 1;
expect(occurrences).toBe(1);
});

test("a coded SubcError is left alone — only the bare GOODBYE shape matches", () => {
// module_reloading is proven-not-forwarded and retryable; it must not be
// dressed up as an unknown outcome.
const coded = new SubcError("route closed by subc (GOODBYE)", "module_reloading");

const adapted = adaptToolError("write", coded);

expect(adapted).toBe(coded);
expect(coded.message).not.toContain(SUBC_MODULE_RESTART_DISPOSITION);
});
});
49 changes: 47 additions & 2 deletions packages/aft-bridge/src/error-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
* rewrite the contract-owned message while doing so.
*/

import { isConsumerReconnectTransient, StaleRouteHandleError } from "@cortexkit/subc-client";
import {
isConsumerReconnectTransient,
StaleRouteHandleError,
SubcError,
} from "@cortexkit/subc-client";

import { isBridgeTransportTimeout } from "./bridge.js";
import { SubcRootGenerationExpiredError, SubcRootReapedError } from "./subc-transport.js";
Expand Down Expand Up @@ -52,6 +56,35 @@ export function toolErrorFromResponse(
export const BASH_TRANSPORT_DISPOSITION =
"The transport to the AFT daemon was interrupted; no background task was created for this command and no task ID exists. Re-run the command. Do not poll bash_status for it.";

/**
* Agent-facing guidance for a call the daemon GOODBYE'd mid-flight.
*
* Deliberately does NOT say the call failed. The daemon emits route GOODBYEs
* after its drain wait regardless of whether that drain completed, so a call
* in flight at GOODBYE was admitted BEFORE the gate closed and may already
* have run to completion with only its reply lost. "Failed" reads as an
* invitation to re-run, which double-applies a mutation that already landed.
*/
export const SUBC_MODULE_RESTART_DISPOSITION =
"The AFT daemon module restarted while this call was in flight, so its outcome is UNKNOWN: it may or may not have executed. Verify actual state before re-running, and never blind-retry a mutation.";

/**
* A route GOODBYE delivered against an in-flight request.
*
* COUPLING: subc-client raises this as a bare `SubcError` carrying no code
* (client.ts, the `FrameType.Goodbye` branch), so the message literal is the
* only discriminator available. Asked upstream for a stable `code` on that
* error; until it exists this match is the seam and will fail open (no
* disposition appended) rather than misclassify.
*/
function isRouteGoodbyeError(error: unknown): boolean {
return (
error instanceof SubcError &&
error.code === undefined &&
error.message.includes("route closed by subc")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The GOODBYE detection rests on a fragile substring match: error.message.includes("route closed by subc") combined with error.code === undefined. This is the exact coupling the PR flags as a known limitation, and the downside of a false negative is material — the UNKNOWN/verify-before-rerun guidance would simply not be appended, recreating the blind re-run of a possibly-landed mutation that this change exists to prevent. Since the literal is the only discriminator until upstream adds a code, consider extracting it to a single named constant (shared by the matcher and the test helper) and adding a comment marking it as a temporary upstream-coupling to revisit, so a future wire-message change fails loudly in tests rather than silently. Note the check is a partial includes, so it also cannot distinguish a route GOODBYE from any other SubcError that happens to contain the phrase.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/aft-bridge/src/error-contract.ts, line 84:

<comment>The GOODBYE detection rests on a fragile substring match: `error.message.includes("route closed by subc")` combined with `error.code === undefined`. This is the exact coupling the PR flags as a known limitation, and the downside of a false negative is material — the UNKNOWN/verify-before-rerun guidance would simply not be appended, recreating the blind re-run of a possibly-landed mutation that this change exists to prevent. Since the literal is the only discriminator until upstream adds a code, consider extracting it to a single named constant (shared by the matcher and the test helper) and adding a comment marking it as a temporary upstream-coupling to revisit, so a future wire-message change fails loudly in tests rather than silently. Note the check is a partial `includes`, so it also cannot distinguish a route GOODBYE from any other SubcError that happens to contain the phrase.</comment>

<file context>
@@ -52,6 +56,35 @@ export function toolErrorFromResponse(
+  return (
+    error instanceof SubcError &&
+    error.code === undefined &&
+    error.message.includes("route closed by subc")
+  );
+}
</file context>

);
}

function isTransportClassError(error: unknown): boolean {
return (
isBridgeTransportTimeout(error) ||
Expand All @@ -67,8 +100,20 @@ function isTransportClassError(error: unknown): boolean {
* object, class, code, or retry behavior. Other commands retain their errors.
*/
export function adaptToolError(command: string, error: unknown): unknown {
if (command !== "bash" || !isTransportClassError(error)) return error;
if (!(error instanceof Error)) return error;

// Checked before the bash branch, and applied to every command: a GOODBYE'd
// call has an unknown outcome, so BASH_TRANSPORT_DISPOSITION's "no task was
// created, re-run the command" would be actively wrong here.
if (isRouteGoodbyeError(error)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new GOODBYE branch appends the disposition message to the error for every command, but function docstring still claims 'Other commands retain their errors' and the file header says hosts must not rewrite the contract-owned message. That contract text is now stale: a non-bash GOODBYE error is no longer returned unchanged. The behavior is intentional per the PR, so this is mainly a documentation/contract-consistency concern — update the docstring to call out the GOODBYE exception so future readers don't assume non-bash errors are always passed through untouched.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/aft-bridge/src/error-contract.ts, line 108:

<comment>The new GOODBYE branch appends the disposition message to the error for every command, but function docstring still claims 'Other commands retain their errors' and the file header says hosts must not rewrite the contract-owned message. That contract text is now stale: a non-bash GOODBYE error is no longer returned unchanged. The behavior is intentional per the PR, so this is mainly a documentation/contract-consistency concern — update the docstring to call out the GOODBYE exception so future readers don't assume non-bash errors are always passed through untouched.</comment>

<file context>
@@ -67,8 +100,20 @@ function isTransportClassError(error: unknown): boolean {
+  // Checked before the bash branch, and applied to every command: a GOODBYE'd
+  // call has an unknown outcome, so BASH_TRANSPORT_DISPOSITION's "no task was
+  // created, re-run the command" would be actively wrong here.
+  if (isRouteGoodbyeError(error)) {
+    if (error.message.includes(SUBC_MODULE_RESTART_DISPOSITION)) return error;
+    error.message = error.message
</file context>

if (error.message.includes(SUBC_MODULE_RESTART_DISPOSITION)) return error;
error.message = error.message
? `${error.message} ${SUBC_MODULE_RESTART_DISPOSITION}`
: SUBC_MODULE_RESTART_DISPOSITION;
return error;
}

if (command !== "bash" || !isTransportClassError(error)) return error;
if (error.message.includes(BASH_TRANSPORT_DISPOSITION)) return error;
error.message = error.message
? `${error.message} ${BASH_TRANSPORT_DISPOSITION}`
Expand Down
Loading