diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b80ef8..52b2059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.2 — 2026-10-15 + +- Validate custom executor terminal results before releasing dependent tasks. +- Convert malformed results into fixed-code failures instead of an infinite scheduler loop. +- Add an isolated regression covering invalid states, exit codes and durations. + ## 0.1.1 — 2026-10-15 - Add a bounded repository policy check with value-free diagnostics. diff --git a/README.md b/README.md index 2a6ada2..4857439 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,13 @@ console.log(report.ok); for bounded output forwarding. Callbacks should not throw. Configuration errors throw `ConfigError`; command failures are returned in the report. +Custom integrations can inject `executor(task, options)`. It must resolve to a +terminal `status` (`passed`, `failed`, `timed-out`, `skipped` or `cancelled`), an +integer/null `exitCode` and a finite nonnegative `durationMs`. A passed result +requires exit code 0. Malformed results become a failed task with fixed reason +`executor-invalid-result`; descendants are skipped while independent tasks finish. +Rejected executors use `executor-error` without copying exception details. + ## Development and scope ```sh diff --git a/docs/reports.md b/docs/reports.md index 9fc5c7a..c1a2329 100644 --- a/docs/reports.md +++ b/docs/reports.md @@ -26,7 +26,9 @@ milliseconds are rounded, so very short operations can report zero. `exitCode` is null when no normal exit code is available or a task never started. Optional reasons include `spawn-error`, `signal`, `dependency`, -`interrupted`, and `executor-error`. A timeout/cancelled running task is +`interrupted`, `executor-error`, and `executor-invalid-result`. The last reason +means a custom executor returned a malformed or nonterminal result; the task +fails and its descendants are skipped. A timeout/cancelled running task is identified by its status; its exit code can vary by platform. Plan reports have `version: 1`, `action: "plan"`, effective `concurrency` and diff --git a/package-lock.json b/package-lock.json index a345370..fac0809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "task-relay", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "task-relay", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "bin": { "task-relay": "bin/task-relay.mjs" diff --git a/package.json b/package.json index 8e5cc59..3f0a2b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "task-relay", - "version": "0.1.1", + "version": "0.1.2", "description": "Run development tasks as a dependency graph with bounded parallelism, timeouts and clean JSON reports.", "private": true, "type": "module", diff --git a/src/schedule.mjs b/src/schedule.mjs index 68c1c32..da81437 100644 --- a/src/schedule.mjs +++ b/src/schedule.mjs @@ -1,6 +1,18 @@ import { performance } from 'node:perf_hooks'; import { executeTask } from './execute.mjs'; +const TERMINAL = new Set(['passed', 'failed', 'timed-out', 'skipped', 'cancelled']); +function executorResult(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || !TERMINAL.has(value.status) + || !(value.exitCode === null || Number.isInteger(value.exitCode)) + || typeof value.durationMs !== 'number' || !Number.isFinite(value.durationMs) || value.durationMs < 0 + || (value.status === 'passed' && value.exitCode !== 0)) { + return { status: 'failed', reason: 'executor-invalid-result', exitCode: null, durationMs: 0 }; + } + return value; +} + /** Schedule a validated plan. Executor injection supports deterministic integrations. */ export async function schedule(plan, { baseDir, concurrency = plan.concurrency, signal, onOutput, onEvent, executor = executeTask }) { const started = performance.now(); @@ -30,6 +42,7 @@ export async function schedule(plan, { baseDir, concurrency = plan.concurrency, results.set(task.name, { name: task.name, status: 'running' }); emit(results.get(task.name)); const promise = Promise.resolve().then(() => executor(task, { baseDir, signal, onOutput })) + .then(executorResult) .catch(() => ({ status: 'failed', reason: 'executor-error', exitCode: null, durationMs: 0 })) .then(result => { results.set(task.name, { ...result, name: task.name }); diff --git a/test/schedule.test.mjs b/test/schedule.test.mjs index 55c3039..c4e838a 100644 --- a/test/schedule.test.mjs +++ b/test/schedule.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createPlan } from '../src/index.mjs'; import { schedule } from '../src/schedule.mjs'; +import { spawnSync } from 'node:child_process'; const task = (needs = []) => ({ command: ['$node', '--version'], needs }); const plan = tasks => createPlan({ version: 1, tasks }); @@ -82,6 +83,44 @@ test('captures executor failure without exposing the thrown error text', async ( assert.equal(JSON.stringify(report).includes('PRIVATE_SENTINEL'), false); }); +test('malformed executor results fail the task and skip descendants without hanging', () => { + // Isolate the regression: an invalid status used to spin synchronously forever, + // preventing an in-process timeout or AbortSignal from firing. + const source = ` + import assert from 'node:assert/strict'; + import { createPlan } from './src/index.mjs'; + import { schedule } from './src/schedule.mjs'; + const invalid = [undefined, null, [], 'PRIVATE_RESULT', {}, + {status:'running',exitCode:0,durationMs:1}, + {status:'PRIVATE_STATUS',exitCode:0,durationMs:1}, + {status:'passed',exitCode:'PRIVATE_EXIT',durationMs:1}, + {status:'passed',exitCode:0,durationMs:Infinity}, + {status:'passed',exitCode:7,durationMs:1}]; + for (const value of invalid) { + const plan = createPlan({version:1,tasks:{ + source:{command:['$node','--version']}, + dependent:{command:['$node','--version'],needs:['source']}, + independent:{command:['$node','--version']} + }}); + const started = []; + const report = await schedule(plan,{baseDir:'.',executor:async task => { + started.push(task.name); + return task.name === 'source' ? value : {status:'passed',exitCode:0,durationMs:1}; + }}); + assert.equal(report.ok,false); + assert.deepEqual(started,['source','independent']); + assert.deepEqual(report.tasks.map(task=>task.status),['failed','skipped','passed']); + assert.equal(report.tasks[0].reason,'executor-invalid-result'); + assert.equal(JSON.stringify(report).includes('PRIVATE_'),false); + } + console.log('executor-results-checked'); + `; + const result = spawnSync(process.execPath,['--input-type=module','-e',source],{encoding:'utf8',timeout:5000,maxBuffer:4096,windowsHide:true}); + assert.equal(result.error,undefined,'executor validation must not hang'); + assert.equal(result.status,0,result.stderr); + assert.equal(result.stdout.trim(),'executor-results-checked'); +}); + test('report order is stable even when tasks finish out of order', async () => { const report = await schedule(plan({ slow: task(), fast: task() }), { baseDir: '.', executor: async task => { if (task.name === 'slow') await new Promise(resolve => setImmediate(resolve)); return passed(); },