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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.6.5 — candidate, pending approval

- Show readable, redacted provider errors when a background job's automatic follow-up fails,
preserving diagnostic audit detail and protection against replaying interrupted work.

Prepared 2026-11-06 from integrated beta development. This is not a publication date.
Requires Node.js 24.0.0 or newer and runtime image spec 1.6.3. See
[the candidate record](docs/releases/0.6.5.md) for scope, excluded unfinished work and
Expand Down
4 changes: 4 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# ChannelGate — Features

- **Readable background continuation errors.** Failed follow-ups show a bounded provider sentence
instead of a nested JSON response. Redacted audit diagnostics retain their detail; structured
engine failure facts and the guard against replaying interrupted continuations remain unchanged.

- **Continue after variable server approval in Slack.** Approving a hidden variable’s server automatically queues one continuation in the original thread as the original requester. It waits behind active work, honors queued Stop and current access, records usage and deduplicates repeated clicks. Queued wakeups survive restart; interrupted continuations are reported without replaying unknown actions. Denial and stale approvals do not continue.

The documentation handbook includes its branded favicon in standalone builds and website exports.
Expand Down
13 changes: 13 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# ChannelGate — Test Plan

## Readable background continuation errors (2026-11-06)

`node --test test/background-continuation-errors.test.js test/process-outcome.test.js`
executes BackgroundJobs through runMessage and the real Claude/Codex parsers, with a spawned
fixture CLI emitting each engine's nested provider-error event. Assert readable notices without
JSON, secret redaction in notices and audit, no partial secret at the 400-character boundary,
redaction after JSON escape decoding, preserved provider facts and diagnostic codes,
released runtime leases, and no second spawn when delivery resumes after the failed continuation.
The runtime backend and Slack delivery boundary are fixtures, not live provider/client acceptance.
Private REL-RUNTIME-07 remains the per-engine acceptance gate: reproduce the nested provider
failure in its isolated acceptance setup, inspect the actual continuation notice and durable
recovery, and keep deployed/native observation distinct from these automated results.

## Automatic continuation after variable server approval

Automated: `node --test test/secret-host-continuation.test.js test/egress-secret-approval.test.js test/durable-approvals.test.js test/approval-links.test.js`.
Expand Down
9 changes: 7 additions & 2 deletions src/gateway/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { buildShellEnv, serviceSecretValues } from "../engines/child-env.js";
import { resolveRuntime } from "../runtimes/resolve.js";
import { newRunId, runtimeSupports } from "../runtimes/contract.js";
import { killGroup, killTree, processStartTime } from "../util/proc.js";
import { describeProcessOutcome } from "../util/process-outcome.js";
import { describeProcessOutcome, plainFailureText } from "../util/process-outcome.js";
import { appendTail } from "../util/tail.js";
import { effectiveWorkDir } from "./folders.js";
import { gatewayRoot } from "../config/paths.js";
Expand Down Expand Up @@ -907,8 +907,13 @@ export class BackgroundJobs {
// Boot recovery redelivers it (up to MAX_DELIVERY_ATTEMPTS).
const failure = redactSecretValues(err.message, outputSecrets);
await logEvent("bg_continue_error", { id: rec.id, error: failure });
// Decode before the final redaction, and redact before bounding: truncation must never
// leave a partial secret that the full-value redactor can no longer recognize.
const noticeFailure = redactSecretValues(
plainFailureText(failure, Number.MAX_SAFE_INTEGER) || "The continuation failed.", outputSecrets,
).slice(0, 400);
try {
await postNotice(client, { conversationId: rec.channelId, threadKey: rec.threadKey, text: `⚠️ ${what} *${rec.label}* ${status}, but I couldn't continue: ${failure}` });
await postNotice(client, { conversationId: rec.channelId, threadKey: rec.threadKey, text: `⚠️ ${what} *${rec.label}* ${status}, but I couldn't continue: ${noticeFailure}` });
} catch {
/* ignore */
}
Expand Down
106 changes: 106 additions & 0 deletions test/background-continuation-errors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { writeFileSync } from "node:fs";
import { ensureTestEnv } from "./helpers.js";

const scratch = ensureTestEnv();
process.env.SESSION_KEEPALIVE = "0";
process.env.PATH = path.join(import.meta.dirname, "fixtures") + path.delimiter + process.env.PATH;

const { useFakeRuntime, createFakeRuntimeBackend } = await import("./runtime-fake.js");
const backend = createFakeRuntimeBackend();
const spawn = backend.spawn.bind(backend);
const cli = path.join(scratch, "provider-error.mjs");
const canary = "synthetic-continuation-secret";
writeFileSync(cli, `
const padding = process.argv[2] === "claude" ? 390 - "Claude provider rejected the request: ".length : 390;
const body = process.argv[3] === "long"
? "x".repeat(padding) + "${canary}"
: "The selected model is unavailable. ${canary}";
let message = JSON.stringify({ error: {
message: body, code: "fixture_model_unavailable"
} });
if (process.argv[3] === "escaped") message = message.replace("synthetic-", "\\\\u0073ynthetic-");
const event = process.argv[2] === "codex"
? { type: "turn.failed", error: { message } }
: { type: "assistant", error: "invalid_request_error", message: {
role: "assistant", content: [{ type: "text", text: message }]
} };
console.log(JSON.stringify(event));
process.exitCode = 1;
`);
let engine, scenario;
backend.spawn = (target, spec) => spawn(target, {
...spec, cmd: process.execPath, args: [cli, engine, scenario],
});
await useFakeRuntime(backend);

const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js");
const { saveSettings } = await import("../src/config/settings.js");
const { runMessage } = await import("../src/gateway/run.js");
const { BackgroundJobs } = await import("../src/gateway/background.js");
const { readEvents } = await import("../src/util/logger.js");
const { getDb } = await import("../src/db/index.js");

for (const selected of ["claude", "codex"]) {
for (const variant of ["ordinary", "long", "escaped"]) {
test(`${selected} ${variant} CLI continuation failures stay readable, redacted and unreplayed`, async () => {
engine = selected;
scenario = variant;
saveSettings({ engine, engineFallback: false, engineEnabled: { claude: true, codex: true }, composioMode: "personal" });
await setUser("U_CONTINUATION", { name: "Fixture admin", isAdmin: true, approved: true });
const channel = `D_CONTINUATION_${engine.toUpperCase()}`;
const entry = await upsertChannelEntry(channel, { name: `continuation-${engine}`, type: "im", isDM: true });
await saveChannelMeta(entry.slug, { channelId: channel, type: "im", isDM: true, template: "custom", engine, cleanMode: true, allowNetwork: false });
const posts = [], errors = [];
const client = { chat: { postMessage: async payload => {
posts.push(payload);
return { ok: true, ts: "1900000000.000001" };
} } };
const jobs = new BackgroundJobs({
slack: { snapshot: () => ({ connected: true }), getClient: () => client },
runner: async args => {
try { return await runMessage({ ...args, preferCold: true }); }
catch (error) { errors.push(error); throw error; }
},
});
const rec = {
id: `continuation-${engine}`, kind: "shell", channelId: channel, slug: entry.slug,
authorId: "U_CONTINUATION", threadKey: "1900000000.000002", label: "fixture job",
command: "true", startedAt: Date.now() - 1000, pid: null, maxMs: 60000,
logFile: "", tail: "harmless output", timedOut: false, secretValues: [canary],
};
jobs.jobs.set(rec.id, rec);
try {
const before = backend.calls.spawn.length;
// Real engine parser and runMessage; only runtime execution and transport are fixtures.
await jobs._finish(rec, { code: 0 });
assert.equal(backend.calls.spawn.length, before + 1);
assert.equal(errors.length, 1);
assert.equal(errors[0].details.engine, engine);
assert.equal(errors[0].details.providerError, true);
assert.ok(errors[0].details.providerCode);
assert.equal(errors[0].details.exitCode, 1);
const notice = posts.find(post => post.text.includes("couldn't continue"))?.text;
if (variant !== "long") assert.match(notice, /The selected model is unavailable\. \[REDACTED\]/);
assert.doesNotMatch(notice, /\{\s*"error"|fixture_model_unavailable/);
assert.ok(!notice.includes("synthetic-"), "no secret prefix survives truncation or JSON decoding");
assert.ok(notice.split("couldn't continue: ")[1].length <= 400);
const audit = readEvents({ limit: 200 }).find(row => row.event === "bg_continue_error" && row.id === rec.id);
assert.ok(audit);
assert.ok(!audit.error.includes(canary));
if (engine === "claude") assert.match(audit.error, /fixture_model_unavailable/, "redacted diagnostic detail is preserved");
await jobs._deliver(rec);
assert.equal(backend.calls.spawn.length, before + 1, "unknown continuation effects are never replayed");
assert.ok(posts.some(post => /continuation was interrupted/.test(post.text)));
assert.equal(jobs.jobs.has(rec.id), false);
assert.equal(getDb().prepare("SELECT id FROM bg_jobs WHERE id = ?").get(rec.id), undefined);
assert.ok(backend.calls.leases.every(lease => lease.released));
} finally {
jobs.jobs.clear();
getDb().prepare("DELETE FROM bg_jobs WHERE id = ?").run(rec.id);
}
});
}
}
Loading