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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/schedule.mjs
Original file line number Diff line number Diff line change
@@ -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 };
Comment thread
Dcastroro marked this conversation as resolved.
}
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();
Expand Down Expand Up @@ -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 });
Expand Down
39 changes: 39 additions & 0 deletions test/schedule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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(); },
Expand Down
Loading