Files
msd-core/tests/worktree-worker-log.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

203 lines
9.8 KiB
JavaScript

'use strict';
/**
* #4624 — `worktree worker-record` / `worker-status` / `worker-complete`: the
* durable per-worker lifecycle record for the orchestrator-worktree backend.
*
* WHY: the dispatch fragment spawned external executors behind a bare shell
* `wait`. When the orchestrator's turn ended before a worker finished, no
* launch identity, result location, or terminal outcome was persisted, so a
* resumed session re-derived everything from manual PID/log discovery and
* could re-dispatch a recorded plan (#4624). These tests pin the state
* machine that closes that gap:
*
* record (running) ──▶ status (pidAlive/summaryExists/needsReconciliation)
* ▲ │
* │ re-record only after ▼
* └── terminal ───── worker-complete (idempotent)
*
* Determinism: `pidAlive` is injected everywhere (no live-PID probing), the
* summary file is real fs, and the CLI end-to-end case uses a self-PID or a
* max-int32 pid (ESRCH on every platform) so no assertion depends on the
* runner's process table.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const ROOT = path.join(__dirname, '..');
const MSD_TOOLS = path.join(ROOT, 'msd-core', 'bin', 'msd-tools.cjs');
const {
cmdWorktreeWorkerRecord,
cmdWorktreeWorkerStatus,
cmdWorktreeWorkerComplete,
} = require('../msd-core/bin/lib/worktree-safety.cjs');
describe('#4624 worktree worker lifecycle records', () => {
let dir;
let wtPath;
let summaryPath;
beforeEach(() => {
dir = createTempDir('rwt-4624');
wtPath = path.join(dir, 'agent-p3-1700000000');
summaryPath = path.join(dir, '3-SUMMARY.md');
});
afterEach(() => {
cleanup(dir);
});
function run(cmd, args, deps = {}) {
let exitCode = 0;
let stdout = '';
let stderr = '';
const scoped = {
write: (s) => { stdout += s; },
writeErr: (s) => { stderr += s; },
...deps,
};
// The cmd functions set process.exitCode directly; capture it without
// leaking into the runner's own exit status.
const prior = process.exitCode;
process.exitCode = 0;
if (cmd === 'record') cmdWorktreeWorkerRecord(dir, args, scoped);
else if (cmd === 'status') cmdWorktreeWorkerStatus(dir, args, scoped);
else if (cmd === 'complete') cmdWorktreeWorkerComplete(dir, args, scoped);
exitCode = process.exitCode || 0;
process.exitCode = prior;
return { exitCode, stdout, stderr };
}
const dead = () => false;
test('record persists a running launch record beside the worktree with an absolute summary path', () => {
const r = run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3',
'--summary-path', summaryPath, '--log-file', `${wtPath}.worker.log`]);
assert.equal(r.exitCode, 0);
const out = JSON.parse(r.stdout);
assert.equal(out.ok, true);
assert.equal(out.record.state, 'running');
assert.equal(out.record.agentId, 'agent-p3-1700000000');
assert.equal(out.record.summaryPath, summaryPath, 'summary path must resolve to absolute (resume reads it from any cwd)');
const onDisk = JSON.parse(require('node:fs').readFileSync(`${wtPath}.worker.json`, 'utf8'));
assert.equal(onDisk.state, 'running');
assert.equal(onDisk.pid, 4242);
});
test('record refuses a second dispatch while a running record exists (duplicate-dispatch guard)', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
const r = run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
assert.equal(r.exitCode, 1, 're-dispatching a recorded running worker must fail closed');
const out = JSON.parse(r.stdout);
assert.equal(out.ok, false);
assert.equal(out.reason, 'already_running');
assert.match(r.stderr, /never re-dispatch a recorded plan/);
});
test('record after a terminal record is allowed (fresh lifecycle at the same path)', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
run('complete', ['--path', wtPath, '--exit-code', '0']);
const r = run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
assert.equal(r.exitCode, 0);
const out = JSON.parse(r.stdout);
assert.equal(out.ok, true);
assert.equal(out.record.state, 'running');
});
test('status composes the recovery view: dead pid + running record = needsReconciliation', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
const r = run('status', ['--path', wtPath], { isPidAlive: dead });
const out = JSON.parse(r.stdout);
assert.equal(out.found, true);
assert.equal(out.workers[0].state, 'running');
assert.equal(out.workers[0].pidAlive, false);
assert.equal(out.workers[0].summaryExists, false, 'no SUMMARY written yet — nothing artifact-complete to reconcile');
assert.equal(out.workers[0].needsReconciliation, true);
});
test('status surfaces artifact-completion independently of the process result', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
require('node:fs').writeFileSync(summaryPath, '# SUMMARY\n');
const r = run('status', ['--path', wtPath], { isPidAlive: dead });
const out = JSON.parse(r.stdout);
assert.equal(out.workers[0].summaryExists, true, 'the exit-after-artifact-completion shape the issue describes');
assert.equal(out.workers[0].needsReconciliation, true);
});
test('status --root scans every record and flags torn records as reconciliation candidates', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
require('node:fs').writeFileSync(path.join(dir, 'torn.worker.json'), '{ truncated');
const r = run('status', ['--root', dir]);
const out = JSON.parse(r.stdout);
assert.equal(out.workers.length, 2);
const torn = out.workers.find((w) => w.state === 'unreadable');
assert.ok(torn, 'a torn record must surface, never silently vanish');
assert.equal(torn.needsReconciliation, true);
});
test('status with no record reports found:false (unrecorded worktree)', () => {
const r = run('status', ['--path', wtPath]);
const out = JSON.parse(r.stdout);
assert.equal(out.found, false);
assert.deepEqual(out.workers, []);
});
test('complete is idempotent: a resumed session re-marking a terminal worker still succeeds', () => {
run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3', '--summary-path', summaryPath]);
const first = run('complete', ['--path', wtPath, '--exit-code', '1', '--note', 'blocked: missing toolchain']);
assert.equal(first.exitCode, 0);
const again = run('complete', ['--path', wtPath, '--exit-code', '1', '--note', 'blocked: missing toolchain']);
assert.equal(again.exitCode, 0);
const out = JSON.parse(again.stdout);
assert.equal(out.alreadyComplete, true);
const view = JSON.parse(run('status', ['--path', wtPath], { isPidAlive: dead }).stdout);
assert.equal(view.workers[0].state, 'complete');
assert.equal(view.workers[0].exitCode, 1);
assert.equal(view.workers[0].needsReconciliation, false, 'a terminal record never needs reconciliation');
});
test('complete without a record fails with no_record (never fabricates state)', () => {
const r = run('complete', ['--path', wtPath, '--exit-code', '0']);
assert.equal(r.exitCode, 1);
assert.equal(JSON.parse(r.stdout).reason, 'no_record');
});
test('usage errors: missing/invalid --pid, and status requires exactly one of --path/--root', () => {
assert.equal(run('record', ['--path', wtPath, '--plan', '3', '--summary-path', summaryPath]).exitCode, 2);
assert.equal(run('record', ['--path', wtPath, '--pid', 'not-a-pid', '--plan', '3', '--summary-path', summaryPath]).exitCode, 2);
assert.equal(run('record', ['--path', wtPath, '--pid', '4242', '--plan', '3']).exitCode, 2, '--summary-path is required — an omitted path would make summaryExists vacuously true');
assert.equal(run('status', ['--path', wtPath, '--root', dir]).exitCode, 2);
assert.equal(run('status', []).exitCode, 2);
});
test('end-to-end through the msd-tools CLI router: record → status(dead pid) → complete', () => {
const record = runNode([MSD_TOOLS, 'query', 'worktree', 'worker-record',
'--path', wtPath, '--pid', String(process.pid), '--plan', '3', '--summary-path', summaryPath],
{ timeoutMs: PROBE_TIMEOUT_MS });
assert.equal(record.exitCode, 0, `record failed: ${record.stderr}`);
const deadPid = 2147483646; // max int32 — ESRCH on every platform, never a real pid
runNode([MSD_TOOLS, 'query', 'worktree', 'worker-record',
'--path', path.join(dir, 'agent-p4-1700000001'), '--pid', String(deadPid),
'--plan', '4', '--summary-path', summaryPath], { timeoutMs: PROBE_TIMEOUT_MS });
const status = runNode([MSD_TOOLS, 'query', 'worktree', 'worker-status', '--root', dir],
{ timeoutMs: PROBE_TIMEOUT_MS });
assert.equal(status.exitCode, 0);
const workers = JSON.parse(status.stdout).workers;
const self = workers.find((w) => w.pid === process.pid);
const gone = workers.find((w) => w.pid === deadPid);
assert.equal(self.needsReconciliation, false, 'self pid is alive');
assert.equal(gone.needsReconciliation, true, 'dead-pid worker needs reconciliation');
const complete = runNode([MSD_TOOLS, 'query', 'worktree', 'worker-complete',
'--path', wtPath, '--exit-code', '0'], { timeoutMs: PROBE_TIMEOUT_MS });
assert.equal(complete.exitCode, 0);
});
});