fix(skills): record timeouts as timeout and clear the race timer (#16216)
Some checks failed
Release-Green (continuous) / Baseline headroom (push) Has been cancelled
opencode-plugin CI / Test v2 (Node 22) (push) Has been cancelled
opencode-plugin CI / Test v2 (Node 24) (push) Has been cancelled
Publish to Docker Hub / Resolve Docker release metadata (push) Has been cancelled
Publish to Docker Hub / omni-build runner offline alert (push) Has been cancelled
Release-Green (continuous) / Validate active release branch (push) Has been cancelled
Release-Green (continuous) / Validate main branch (push) Has been cancelled
Release-Green (continuous) / Bank ratchet shrinks (push) Has been cancelled
Quality Gates / Change Classification (push) Has been cancelled
opencode-plugin CI / Test (Node 24) (push) Has been cancelled
CI / Change Classification (push) Has been cancelled
CI / PR Test Policy (push) Has been cancelled
Lock released branch / lock-branch (push) Has been cancelled
Lock released branch / guard-no-push-after-release (push) Has been cancelled
opencode-plugin CI / Test (Node 22) (push) Has been cancelled
OpenSSF Scorecard / Scorecard analysis (push) Has been cancelled
Quality Gates / Merge integrity (changelog + generated skills) (push) Has been cancelled
Release acceptance / Release acceptance (push) Has been cancelled
CI / Protocol Clients E2E (live server, advisory) (push) Has been cancelled
opencode-plugin CI / Build (push) Has been cancelled
CI / Gate / CI (push) Has been cancelled
CI / CI Dashboard (push) Has been cancelled
Publish to Docker Hub / Preflight — amd64 runner availability (push) Has been cancelled
Publish to Docker Hub / Build Docker (linux/amd64) (push) Has been cancelled
Publish to Docker Hub / Build Docker (linux/arm64) (push) Has been cancelled
Publish to Docker Hub / Publish multi-arch manifests (push) Has been cancelled
Quality Gates / Docs Gates (fast-path) (push) Has been cancelled
Quality Gates / Fast Quality Gates (push) Has been cancelled
Quality Gates / Vitest (fast-path) (push) Has been cancelled
Quality Gates / No new ESLint warnings (push) Has been cancelled
CI / i18n Glossary (zh-CN, ko) (push) Has been cancelled
CI / i18n Validation (all languages) (push) Has been cancelled
CI / Security Tests (push) Has been cancelled
CI / Ecosystem E2E (live server) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Quality Ratchet (push) Has been cancelled
CI / Quality Gates (Extended) (push) Has been cancelled
CI / Docs Sync (Strict) (push) Has been cancelled
CI / Docs Lint (prose — advisory) (push) Has been cancelled
CI / i18n UI Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Package Artifact (push) Has been cancelled
CI / Electron Package Smoke (ubuntu-latest) (push) Has been cancelled
CI / Electron Package Smoke (windows-latest) (push) Has been cancelled
CI / Unit Tests (1/8) (push) Has been cancelled
CI / Unit Tests (2/8) (push) Has been cancelled
CI / Unit Tests (3/8) (push) Has been cancelled
CI / Unit Tests (4/8) (push) Has been cancelled
CI / Unit Tests (5/8) (push) Has been cancelled
CI / Unit Tests (6/8) (push) Has been cancelled
CI / Unit Tests (7/8) (push) Has been cancelled
CI / Unit Tests (8/8) (push) Has been cancelled
CI / Bun SQLite Compatibility (push) Has been cancelled
CI / Bun SQLite Compatibility-1 (push) Has been cancelled
CI / Vitest (MCP / autoCombo / UI components) (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / SonarQube (push) Has been cancelled
CI / PR Coverage Comment (push) Has been cancelled
CI / E2E Tests (1/9) (push) Has been cancelled
CI / E2E Tests (2/9) (push) Has been cancelled
CI / E2E Tests (3/9) (push) Has been cancelled
CI / E2E Tests (4/9) (push) Has been cancelled
CI / E2E Tests (5/9) (push) Has been cancelled
CI / E2E Tests (6/9) (push) Has been cancelled
CI / E2E Tests (7/9) (push) Has been cancelled
CI / E2E Tests (8/9) (push) Has been cancelled
CI / E2E Tests (9/9) (push) Has been cancelled
CI / Integration Tests (1/2) (push) Has been cancelled
CI / Integration Tests (2/2) (push) Has been cancelled
Quality Gates / Unit Tests fast-path (1/4) (push) Has been cancelled
Quality Gates / Unit Tests fast-path (2/4) (push) Has been cancelled
Quality Gates / Unit Tests fast-path (3/4) (push) Has been cancelled
Quality Gates / Unit Tests fast-path (4/4) (push) Has been cancelled
Quality Gates / Gate / Quality (push) Has been cancelled

Thanks @Louayzouaoui1. Wave 27 boarded this pull request cleanly on release/v3.8.52 at f8d11c9326. The focused unit tests for the files it touches passed on that combined tree (116 passed in the second run, plus the auto-combo weight tests and the skills characterization tests). Changelog integrity passed. The only file-size finding is executeTargetAttempt.ts, already over its frozen ceiling on the release tip and unchanged by this diff.
⚠️ base-red inherited: #15306
Closes #16187
This commit is contained in:
Louayzouaoui1 2026-10-11 04:40:28 +01:00 • committed by GitHub
parent 02a1204f14
commit 7f3cbb046b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 81 additions and 15 deletions

View file

@ -0,0 +1 @@
- **fix(skills):** a skill that outlives the executor timeout is now recorded as `timeout` instead of `error`, so execution history and analytics can tell timeouts from handler failures. The race timer is also cleared once the handler settles; it used to stay pending for the full timeout (30 s by default) after every execution.

View file

@ -13,6 +13,13 @@ import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("SKILLS_EXECUTOR");
class SkillTimeoutError extends Error {
constructor() {
super("Skill execution timed out");
this.name = "SkillTimeoutError";
}
}
function toSafeSkillErrorMessage(value: unknown): string {
try {
const raw = value instanceof Error ? value.message : value;
@ -286,12 +293,15 @@ class SkillExecutor {
}
private async executeWithTimeout<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("Skill execution timed out")), this.timeout)
),
]);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new SkillTimeoutError()), this.timeout);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
/**
@ -343,7 +353,7 @@ class SkillExecutor {
}
} catch (err) {
errorMessage = toSafeSkillErrorMessage(err);
status = SkillStatus.ERROR;
status = err instanceof SkillTimeoutError ? SkillStatus.TIMEOUT : SkillStatus.ERROR;
}
return { output, errorMessage, status };

View file

@ -31,8 +31,7 @@ function uniqueName(prefix: string) {
return `${prefix}-${randomUUID().slice(0, 8)}`;
}
// Short executor timeout so the timeout case runs fast. NOTE: executeWithTimeout never
// clears its timer, so every execution leaves a pending timer of this length behind.
// Short executor timeout so the timeout case runs fast.
skillExecutor.setTimeout(200);
skillExecutor.registerHandler("char-echo", async (input) => ({ echoed: String(input.text) }));
@ -323,7 +322,7 @@ test("execute: a failure-shaped output and a throwing handler both resolve with
assert.equal(hardRun.output, null);
});
test("characterization: a timed-out skill is currently recorded as status error, never timeout", async () => {
test("a timed-out skill is recorded as status timeout", async () => {
const name = uniqueName("char-slow");
await skillRegistry.register({
name,
@ -332,9 +331,8 @@ test("characterization: a timed-out skill is currently recorded as status error,
apiKeyId: "char-slow",
});
const run = await skillExecutor.execute(name, {}, { apiKeyId: "char-slow" });
// SkillStatus.TIMEOUT exists and the DB CHECK allows 'timeout', but the executor maps a
// timeout to ERROR with this message.
assert.equal(run.status, SkillStatus.ERROR);
// A timeout is recorded as SkillStatus.TIMEOUT (#16187), not as a generic handler error.
assert.equal(run.status, SkillStatus.TIMEOUT);
assert.equal(run.errorMessage, "Skill execution timed out");
});

View file

@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import "../_setup/isolateDataDir.ts";
process.env.DISABLE_SQLITE_AUTO_BACKUP ||= "true";
const core = await import("../../src/lib/db/core.ts");
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
const { SkillStatus } = await import("../../src/lib/skills/types.ts");
const SCHEMA = { input: {}, output: {} };
const API_KEY_ID = "executor-timeout";
skillExecutor.registerHandler("timeout-fast", async () => ({ ok: true }));
skillExecutor.registerHandler("timeout-slow", () => new Promise(() => {}));
test.after(() => {
core.resetDbInstance();
});
function pendingTimers(): number {
return process.getActiveResourcesInfo().filter((name) => name === "Timeout").length;
}
async function registerSkill(handler: string) {
const name = `${handler}-${randomUUID().slice(0, 8)}`;
await skillRegistry.register({ name, schema: SCHEMA, handler, apiKeyId: API_KEY_ID });
return name;
}
test("a skill that outlives the executor timeout is recorded as timeout and persisted that way", async () => {
skillExecutor.setTimeout(100);
const name = await registerSkill("timeout-slow");
const run = await skillExecutor.execute(name, {}, { apiKeyId: API_KEY_ID });
assert.equal(run.status, SkillStatus.TIMEOUT);
assert.equal(run.errorMessage, "Skill execution timed out");
const row = core
.getDbInstance()
.prepare("SELECT status FROM skill_executions WHERE id = ?")
.get(run.id) as { status: string };
assert.equal(row.status, "timeout");
});
test("a handler that settles in time leaves no pending timeout timer behind", async () => {
skillExecutor.setTimeout(30_000);
const name = await registerSkill("timeout-fast");
const before = pendingTimers();
const run = await skillExecutor.execute(name, {}, { apiKeyId: API_KEY_ID });
assert.equal(run.status, SkillStatus.SUCCESS);
assert.ok(
pendingTimers() <= before,
"the 30s race timer must be cleared once the handler settles"
);
});

View file

@ -242,7 +242,7 @@ test("skillExecutor records disabled skills and missing skills as direct failure
assert.equal(skillExecutor.listExecutions("key-a").length, 0);
});
test("skillExecutor turns handler errors and timeouts into error executions", async () => {
test("skillExecutor records handler errors as error and timeouts as timeout", async () => {
await registerEchoSkill();
skillExecutor.registerHandler("echo-handler", async () => {
@ -289,7 +289,7 @@ test("skillExecutor turns handler errors and timeouts into error executions", as
);
assert.equal(skillExecutor["maxRetries"], 7);
assert.equal(timedOut.status, "error");
assert.equal(timedOut.status, "timeout");
assert.equal(timedOut.output, null);
assert.match(timedOut.errorMessage, /timed out/i);
});