From fa0522eed1edd0ebd8bacaf01b2feab9e3b25295 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 20:48:43 +0800 Subject: [PATCH 1/6] fix(windows): stop killing orphan workers behind reused parent PIDs On Windows a detached worker keeps its exited dispatcher's PID in ParentProcessId. Once that PID is reused by another job's agy process, both tree walks used at cleanup (tree() in stream-worker and taskkill /T) took the worker for a descendant and terminated it with no chance to write its status file, so wait reported "crashed" with an empty log. Every Windows CI run since process-tree cleanup landed (#18) had a roughly even chance of losing one worker to this; which test broke was whichever job happened to hold the reused PID. - tree() follows a parent link only when the child was created after its parent; a row born before its recorded parent is an orphan behind a reused PID. PowerShell now reports CreationDate in round-trip ("o") format so the comparison has 100 ns precision instead of one second. - taskkill runs without /T on Windows; descendants are terminated one by one after passing the identity and birth-order checks. - stopExecution never signals its own process and accepts the table source so a test can inject the one thing no kernel lets it choose: a stale parent link over real processes. - Regression test with real processes and real signals, plus unit tests for parseBorn, tree() and the taskkill arguments. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- companion/stream-worker.mjs | 66 +++++++++++++++++++--- docs/REFERENCE.md | 2 +- docs/REFERENCE.zh-CN.md | 2 +- tests/windows.test.mjs | 109 +++++++++++++++++++++++++++++++++++- 4 files changed, 165 insertions(+), 14 deletions(-) diff --git a/companion/stream-worker.mjs b/companion/stream-worker.mjs index 017f12d..22a487b 100644 --- a/companion/stream-worker.mjs +++ b/companion/stream-worker.mjs @@ -13,7 +13,11 @@ export function signalGroup(pid, signal, runner = spawnSync, platform = process. if (!Number.isInteger(pid) || pid <= 1) return; if (platform === 'win32') { try { - const res = runner('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); + // Never /T here: taskkill's own tree walk follows stale ParentProcessId + // links (a dead parent's PID reused by this root) into unrelated orphans. + // Descendants are terminated one by one by stopExecution after each has + // passed the identity and birth-order checks in tree(). + const res = runner('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true, stdio: 'ignore' }); if (res?.error || (res?.status != null && res.status !== 0)) { try { process.kill(pid); } catch { /* already exited */ } } @@ -69,8 +73,11 @@ export function windowsProcessTable(runner = spawnSync) { '-NoProfile', '-NonInteractive', '-Command', - // -Property limits the WMI fetch to the three columns we parse. - 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | Select-Object ProcessId,ParentProcessId,CreationDate', + // -Property limits the WMI fetch to the three columns we parse. The + // round-trip ("o") format keeps the 100 ns precision of CreationDate; + // the default locale rendering is second-granular, too coarse to order a + // process against one that reused its parent's PID in the same second. + "Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | Select-Object ProcessId,ParentProcessId,@{Name='CreationDate';Expression={if ($_.CreationDate) { $_.CreationDate.ToString('o') } else { '' }}}", // A cold PowerShell start plus the CIM query can take several seconds on // a busy host; a timeout here would make cleanup skip the tree entirely. ], { encoding: 'utf8', timeout: 15000, windowsHide: true }); @@ -127,12 +134,51 @@ export function processTable() { return match ? [{ pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3]), born: match[4].trim() }] : []; }); } -function tree(pid, rows = processTable()) { +/** Birth stamp as 100 ns ticks since the epoch (BigInt, so PowerShell's + * seven fractional digits survive), or null when the format is unknown. + * Accepts ISO-8601 (PowerShell "o"), WMIC (yyyyMMddHHmmss.ffffff+ZZZ) and + * any legacy rendering Date.parse understands. */ +export function parseBorn(born) { + if (typeof born !== 'string' || !born || born === 'unknown') return null; + const iso = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,7}))?(Z|[+-]\d{2}:\d{2})?$/.exec(born); + if (iso) { + const seconds = BigInt(Date.UTC(+iso[1], +iso[2] - 1, +iso[3], +iso[4], +iso[5], +iso[6])) / 1000n; + const ticks = BigInt((iso[7] || '').padEnd(7, '0')); + const zoneMinutes = iso[8] && iso[8] !== 'Z' ? (iso[8].startsWith('-') ? -1n : 1n) * (BigInt(iso[8].slice(1, 3)) * 60n + BigInt(iso[8].slice(4, 6))) : 0n; + return (seconds - zoneMinutes * 60n) * 10_000_000n + ticks; + } + const wmic = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-]\d{3})$/.exec(born); + if (wmic) { + const seconds = BigInt(Date.UTC(+wmic[1], +wmic[2] - 1, +wmic[3], +wmic[4], +wmic[5], +wmic[6])) / 1000n; + return (seconds - BigInt(wmic[8]) * 60n) * 10_000_000n + BigInt(wmic[7]) * 10n; + } + const legacy = Date.parse(born); + return Number.isNaN(legacy) ? null : BigInt(legacy) * 10_000n; +} + +/** A process cannot be older than its parent. A row whose recorded parent was + * born after it is an orphan whose dead parent's PID has been reused: Windows + * keeps the stale ParentProcessId, and the reuser is not its ancestor. When + * either stamp is unreadable the edge is kept; POSIX reparents orphans to + * init, so the stale-link case does not arise there. */ +export function bornAfterParent(child, parent) { + const c = parseBorn(child?.born), p = parseBorn(parent?.born); + if (c === null || p === null) return true; + return c >= p; +} + +export function tree(pid, rows = processTable()) { if (!rows || !pid) return []; - const found = new Set([pid]); + const root = rows.find((row) => row.pid === pid); + if (!root) return []; + const found = new Map([[pid, root]]); for (let changed = true; changed;) { changed = false; - for (const row of rows) if (found.has(row.parent) && !found.has(row.pid)) { found.add(row.pid); changed = true; } + for (const row of rows) { + if (found.has(row.pid) || !found.has(row.parent)) continue; + if (!bornAfterParent(row, found.get(row.parent))) continue; + found.set(row.pid, row); changed = true; + } } return rows.filter((row) => row.pid !== pid && found.has(row.pid)); } @@ -144,15 +190,17 @@ export function processIdentity(pid) { } const matches = (rows, identity) => !!identity && !!rows?.some((row) => row.pid === identity.pid && row.born === identity.born); -export async function stopExecution(root, known = []) { +export async function stopExecution(root, known = [], table = processTable) { if (!root) return; - const current = processTable(); + const current = table(); if (!current) return; const children = new Map(known.filter((old) => matches(current, old)).map((row) => [row.pid, row])); if (matches(current, root)) { children.set(root.pid, root); for (const row of tree(root.pid, current)) children.set(row.pid, row); } + // This process is never a member of the execution group it is stopping. + children.delete(process.pid); // Nothing of ours is left: skip the grace wait and the second table query. if (children.size === 0) return; const signal = (rows, kind) => { @@ -166,7 +214,7 @@ export async function stopExecution(root, known = []) { }; signal(current, 'SIGTERM'); await new Promise((resolve) => setTimeout(resolve, 500)); - signal(processTable(), 'SIGKILL'); + signal(table(), 'SIGKILL'); } export async function runStreaming({ binary, args, job, budget, signal, update, conversation }) { diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index eb1e9fd..4f3bd76 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -253,7 +253,7 @@ If the installed `agy` CLI does not support Gemini 3.8 Flash, the companion fail ## Windows support -Windows is supported on a best-effort basis and exercised by the `Tests (Windows)` CI job; it has not yet been validated against a real Windows `agy` installation. Subprocesses are spawned with `windowsHide: true` so no console windows appear during background execution. Job cancellation and process cleanup discover descendant processes via PowerShell (`Get-CimInstance Win32_Process`) and terminate detached process trees using `taskkill /PID /T /F`. State locking retries transient Windows errors (`EPERM`/`EBUSY`/`EACCES`) when renaming or unlinking lock directories and marker files. +Windows is supported on a best-effort basis and exercised by the `Tests (Windows)` CI job; it has not yet been validated against a real Windows `agy` installation. Subprocesses are spawned with `windowsHide: true` so no console windows appear during background execution. Job cancellation and process cleanup discover descendant processes via PowerShell (`Get-CimInstance Win32_Process`, with `CreationDate` in round-trip precision) and terminate each identified member individually; the leader falls to `taskkill /PID /F`, never `/T`. A parent link is only followed when the child was created after its parent: Windows keeps a dead parent's PID in `ParentProcessId`, so once that PID is reused an unrelated orphan (typically another job's detached worker) would otherwise look like a descendant and be killed. State locking retries transient Windows errors (`EPERM`/`EBUSY`/`EACCES`) when renaming or unlinking lock directories and marker files. ## Upgrading diff --git a/docs/REFERENCE.zh-CN.md b/docs/REFERENCE.zh-CN.md index c28bb43..5197982 100644 --- a/docs/REFERENCE.zh-CN.md +++ b/docs/REFERENCE.zh-CN.md @@ -343,7 +343,7 @@ AGY 会读取工作区中的 `AGENTS.md`、`GEMINI.md` 和 `.agents/rules/*.md` ## Windows 支持 -Windows 为尽力支持,由 CI 的 `Tests (Windows)` 任务覆盖,尚未在真实的 Windows `agy` 安装上验证。子进程均以 `windowsHide: true` 启动,避免后台执行期间弹出控制台窗口。任务取消与进程清理通过 PowerShell(`Get-CimInstance Win32_Process`)发现子孙进程,并使用 `taskkill /PID /T /F` 终止脱离父进程的进程树。状态锁针对 Windows 目录与标记文件的重命名和删除瞬态错误(`EPERM`/`EBUSY`/`EACCES`)进行了自动重试。 +Windows 为尽力支持,由 CI 的 `Tests (Windows)` 任务覆盖,尚未在真实的 Windows `agy` 安装上验证。子进程均以 `windowsHide: true` 启动,避免后台执行期间弹出控制台窗口。任务取消与进程清理通过 PowerShell(`Get-CimInstance Win32_Process`,`CreationDate` 使用往返精度)发现子孙进程,并逐个终止已确认身份的成员;组长进程使用 `taskkill /PID /F`,不再使用 `/T`。父子链接只有在子进程创建时间晚于父进程时才被采信:Windows 会在 `ParentProcessId` 中保留已退出父进程的 PID,该 PID 被复用后,一个无关的孤儿进程(通常是另一个任务的后台 worker)否则会被误认为子孙而被杀掉。状态锁针对 Windows 目录与标记文件的重命名和删除瞬态错误(`EPERM`/`EBUSY`/`EACCES`)进行了自动重试。 ## 升级 diff --git a/tests/windows.test.mjs b/tests/windows.test.mjs index 2da45c8..1c98a08 100644 --- a/tests/windows.test.mjs +++ b/tests/windows.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { sandbox, run, jobIdOf } from './helpers.mjs'; import { @@ -10,8 +10,17 @@ import { windowsProcessTable, terminateProcessGroup, signalGroup, + parseBorn, + bornAfterParent, + tree, + processTable, + processIdentity, + stopExecution, } from '../companion/stream-worker.mjs'; +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } }; + const HERE = path.dirname(fileURLToPath(import.meta.url)); test('every spawn and spawnSync in companion/*.mjs passes windowsHide: true', () => { @@ -152,7 +161,7 @@ test('windowsProcessTable: returns null when both powershell and wmic fail', () assert.equal(result, null); }); -test('terminateProcessGroup: on win32 invokes taskkill /PID /T /F with windowsHide', () => { +test('terminateProcessGroup: on win32 invokes taskkill /PID /F (never /T) with windowsHide', () => { const calls = []; const fakeRunner = (cmd, args, opts) => { calls.push({ cmd, args, opts }); @@ -162,7 +171,9 @@ test('terminateProcessGroup: on win32 invokes taskkill /PID /T /F with win terminateProcessGroup(4321, 'SIGTERM', fakeRunner, 'win32'); assert.equal(calls.length, 1); assert.equal(calls[0].cmd, 'taskkill'); - assert.deepEqual(calls[0].args, ['/PID', '4321', '/T', '/F']); + // /T would let taskkill walk stale ParentProcessId links into unrelated + // orphans (a reused PID); descendants are killed individually instead. + assert.deepEqual(calls[0].args, ['/PID', '4321', '/F']); assert.equal(calls[0].opts.windowsHide, true); assert.equal(calls[0].opts.stdio, 'ignore'); }); @@ -193,3 +204,95 @@ test('terminateProcessGroup / signalGroup: on POSIX uses negative PID for group process.kill = origKill; } }); + +test('parseWindowsProcessTable: keeps the round-trip CreationDate and tolerates a missing one', () => { + const sample = ` +ProcessId ParentProcessId CreationDate +--------- --------------- ------------ + 0 0 + 4 0 2026-09-13T11:00:00.0000000+00:00 + 3616 2468 2026-09-13T11:33:32.5460000+00:00 +`; + const rows = parseWindowsProcessTable(sample); + assert.deepEqual(rows, [ + { pid: 0, parent: 0, group: 0, born: 'unknown' }, + { pid: 4, parent: 0, group: 4, born: '2026-09-13T11:00:00.0000000+00:00' }, + { pid: 3616, parent: 2468, group: 3616, born: '2026-09-13T11:33:32.5460000+00:00' }, + ]); +}); + +test('windowsProcessTable: asks PowerShell for CreationDate in round-trip (100 ns) precision', () => { + const calls = []; + windowsProcessTable((cmd, args) => { calls.push({ cmd, args }); return { status: 0, stdout: 'ProcessId ParentProcessId CreationDate\n' }; }); + assert.equal(calls[0].cmd, 'powershell'); + const command = calls[0].args.at(-1); + assert.match(command, /Get-CimInstance Win32_Process/); + assert.match(command, /CreationDate\.ToString\('o'\)/, 'default locale formatting is second-granular'); +}); + +test('parseBorn: orders ISO-8601, WMIC and legacy stamps on one 100 ns scale', () => { + const utc = parseBorn('2026-09-13T11:33:32.5460000+00:00'); + assert.equal(utc, 17892992125460000n); + assert.equal(parseBorn('2026-09-13T19:33:32.5460000+08:00'), utc, 'zone offsets are normalized'); + assert.equal(parseBorn('2026-09-13T11:33:32.5460000Z'), utc); + assert.equal(parseBorn('2026-09-13T11:33:32.5460001+00:00'), utc + 1n, 'the seventh fractional digit survives'); + assert.equal(parseBorn('20260913113332.546000+000'), utc, 'WMIC format'); + assert.equal(parseBorn('20260913193332.546000+480'), utc, 'WMIC zone offset in minutes'); + assert.equal(parseBorn('9/13/2026 11:33:32 AM'), BigInt(Date.parse('9/13/2026 11:33:32 AM')) * 10_000n, 'legacy locale rendering'); + assert.equal(parseBorn('unknown'), null); + assert.equal(parseBorn(''), null); + assert.equal(parseBorn(undefined), null); + assert.equal(bornAfterParent({ born: 'unknown' }, { born: utc.toString() }), true, 'unreadable stamps keep the edge'); +}); + +test('tree: a row born before its recorded parent is an orphan behind a reused PID, not a descendant', () => { + // The recycler R received the PID of a dispatch process that had already + // exited; the orphan worker W still records that PID as its parent. + const R = { pid: 100, parent: 1, group: 100, born: '2026-09-13T11:33:34.0000000+00:00' }; + const W = { pid: 200, parent: 100, group: 200, born: '2026-09-13T11:33:33.9999999+00:00' }; + const WC = { pid: 500, parent: 200, group: 500, born: '2026-09-13T11:33:36.0000000+00:00' }; + const C = { pid: 300, parent: 100, group: 300, born: '2026-09-13T11:33:34.0000000+00:00' }; + const GC = { pid: 400, parent: 300, group: 400, born: '2026-09-13T11:33:35.0000000+00:00' }; + assert.deepEqual(tree(100, [R, W, WC, C, GC]).map((row) => row.pid), [300, 400]); + assert.deepEqual(tree(100, [R, W, WC, C, GC].map((row) => ({ ...row, born: 'unknown' }))).map((row) => row.pid), [200, 500, 300, 400], + 'without birth stamps every link is trusted, as before'); + assert.deepEqual(tree(999, [R, C]), [], 'an absent root has no tree'); +}); + +test('stopExecution: a real orphan whose parent PID a live root reuses survives the root\'s cleanup', { timeout: 60000 }, async (t) => { + // Real processes and real signals; the only synthetic element is one + // ParentProcessId in the table, because no kernel lets a test choose which + // PID it hands out next (and POSIX reparents orphans to init anyway). + const orphan = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore', windowsHide: true }); + orphan.unref(); + t.after(() => { try { orphan.kill('SIGKILL'); } catch {} }); + let orphanIdentity = null; + for (let i = 0; i < 100 && !orphanIdentity; i++) { orphanIdentity = processIdentity(orphan.pid); if (!orphanIdentity) await pause(100); } + assert.ok(orphanIdentity, 'orphan must be visible in the process table'); + // POSIX ps reports birth at second granularity; make the root strictly younger. + await pause(1100); + const root = spawn(process.execPath, ['-e', + "require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true }); setInterval(() => {}, 1000)"], + { stdio: 'ignore', windowsHide: true }); + t.after(() => { try { root.kill('SIGKILL'); } catch {} }); + let rootIdentity = null, child = null; + for (let i = 0; i < 100 && !(rootIdentity && child); i++) { + const rows = processTable(); + rootIdentity ||= rows?.find((row) => row.pid === root.pid) || null; + child = rows?.find((row) => row.parent === root.pid) || null; + if (!(rootIdentity && child)) await pause(100); + } + assert.ok(rootIdentity && child, 'root and its real child must be visible in the process table'); + assert.ok(bornAfterParent(child, rootIdentity), 'a real child is born after its parent'); + t.after(() => { try { process.kill(child.pid, 'SIGKILL'); } catch {} }); + // The stale link: the orphan's parent PID is the root's PID, but the orphan + // was born earlier, so the root cannot be its parent. + const staleLink = (rows) => rows?.map((row) => row.pid === orphan.pid ? { ...row, parent: root.pid } : row) ?? null; + const table = staleLink(processTable()); + assert.deepEqual(tree(root.pid, table).map((row) => row.pid), [child.pid], 'the orphan is not a descendant; the real child is'); + await stopExecution(rootIdentity, [], () => staleLink(processTable())); + for (let i = 0; i < 50 && (alive(root.pid) || alive(child.pid)); i++) await pause(100); + assert.equal(alive(root.pid), false, 'the root is stopped'); + assert.equal(alive(child.pid), false, 'the real descendant is stopped'); + assert.equal(alive(orphan.pid), true, 'the unrelated orphan survives'); +}); From f2400eeb99c70d768709b130195f71895e5406da Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 20:55:16 +0800 Subject: [PATCH 2/6] test(windows): check tree membership, not equality, under conhost helpers Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- tests/windows.test.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/windows.test.mjs b/tests/windows.test.mjs index 1c98a08..47ea07e 100644 --- a/tests/windows.test.mjs +++ b/tests/windows.test.mjs @@ -288,8 +288,11 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives // The stale link: the orphan's parent PID is the root's PID, but the orphan // was born earlier, so the root cannot be its parent. const staleLink = (rows) => rows?.map((row) => row.pid === orphan.pid ? { ...row, parent: root.pid } : row) ?? null; - const table = staleLink(processTable()); - assert.deepEqual(tree(root.pid, table).map((row) => row.pid), [child.pid], 'the orphan is not a descendant; the real child is'); + // Windows adds conhost.exe and similar helpers under the root, so the tree + // is checked for membership, not equality. + const members = tree(root.pid, staleLink(processTable())).map((row) => row.pid); + assert.ok(members.includes(child.pid), `the real child ${child.pid} is a descendant: ${members}`); + assert.ok(!members.includes(orphan.pid), `the orphan ${orphan.pid} is not a descendant: ${members}`); await stopExecution(rootIdentity, [], () => staleLink(processTable())); for (let i = 0; i < 50 && (alive(root.pid) || alive(child.pid)); i++) await pause(100); assert.equal(alive(root.pid), false, 'the root is stopped'); From 344bcf111c8b7a04eec4c68d73337edb5bdf34e0 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 21:50:14 +0800 Subject: [PATCH 3/6] fix(windows): adopt late descendants, accept 0.7.1 identities, harden tests Follow-ups from the second agy review of the diff: - stopExecution adopts members that appear between its two snapshots (a tool started during the grace period) from a still-live, identity-matched member under the same birth-order rule as tree(). Without /T this window was otherwise left to leak. - cancel compares worker identities with sameBirth(), which matches a 0.7.1 locale-rendered stamp against the round-trip table at the coarser precision, so a job that spans the upgrade can still be canceled. - Tests: the real-process test registers child cleanup before polling and polls at most 30 times (each table query costs seconds on Windows); the unreadable-stamp assertion now pairs an unreadable child with a readable parent; a POSIX real-process test covers late-child adoption. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- companion/agy-companion.mjs | 6 ++--- companion/stream-worker.mjs | 27 ++++++++++++++++++++ tests/windows.test.mjs | 51 ++++++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/companion/agy-companion.mjs b/companion/agy-companion.mjs index edea016..a0b1b87 100644 --- a/companion/agy-companion.mjs +++ b/companion/agy-companion.mjs @@ -93,7 +93,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import { boundSnapshot, excerpt } from './observation.mjs'; -import { atomicJSON, runStreaming, processIdentity } from './stream-worker.mjs'; +import { atomicJSON, runStreaming, processIdentity, sameBirth } from './stream-worker.mjs'; import { withStateLock, replaceFile, readTextRetry } from './state-lock.mjs'; const SELF = fileURLToPath(import.meta.url); @@ -1602,7 +1602,7 @@ async function cmdCancel(opts) { if (!job.spec_file) die('this legacy job has no cancellation request channel; cannot safely signal an unverified stored PID'); const identity = job.worker_identity; const current = identity ? processIdentity(job.pid) : null; - if (identity && (identity.pid !== job.pid || (current && identity.born !== current.born))) { + if (identity && (identity.pid !== job.pid || (current && !sameBirth(identity.born, current.born)))) { die('worker identity no longer matches this job; refusing to signal a reused or unrelated PID'); } // Keep running visible until the worker stores the cancellation report. @@ -1618,7 +1618,7 @@ async function cmdCancel(opts) { // On Windows process.kill() is TerminateProcess: the worker would die without // running its cleanup and orphan the agy tree, so rely on the marker alone there. const current = job.worker_identity && process.platform !== 'win32' ? processIdentity(job.pid) : null; - if (current && current.pid === job.worker_identity.pid && current.born === job.worker_identity.born) { + if (current && current.pid === job.worker_identity.pid && sameBirth(current.born, job.worker_identity.born)) { try { process.kill(current.pid, 'SIGTERM'); } catch {} } const deadline = Date.now() + 10000; diff --git a/companion/stream-worker.mjs b/companion/stream-worker.mjs index 22a487b..9908430 100644 --- a/companion/stream-worker.mjs +++ b/companion/stream-worker.mjs @@ -167,6 +167,17 @@ export function bornAfterParent(child, parent) { return c >= p; } +/** Identity stamps recorded by an earlier release may use a coarser + * rendering (locale time, one second) than the current table. Compare at + * the coarser precision so an upgrade does not orphan a running job. */ +export function sameBirth(a, b) { + if (a === b) return true; + const pa = parseBorn(a), pb = parseBorn(b); + if (pa === null || pb === null) return false; + const coarse = (stamp) => !/\.\d+/.test(stamp); + return coarse(a) || coarse(b) ? pa / 10_000_000n === pb / 10_000_000n : pa === pb; +} + export function tree(pid, rows = processTable()) { if (!rows || !pid) return []; const root = rows.find((row) => row.pid === pid); @@ -203,7 +214,23 @@ export async function stopExecution(root, known = [], table = processTable) { children.delete(process.pid); // Nothing of ours is left: skip the grace wait and the second table query. if (children.size === 0) return; + // A member spawned after the last snapshot (a tool started during the grace + // period) is adopted from a still-live, identity-matched member under the + // same birth-order rule as tree(); a dead member's PID proves nothing. + const adopt = (rows) => { + if (!rows) return; + for (let changed = true; changed;) { + changed = false; + for (const row of rows) { + if (children.has(row.pid) || row.pid === process.pid) continue; + const parent = children.get(row.parent); + if (!parent || !matches(rows, parent) || !bornAfterParent(row, parent)) continue; + children.set(row.pid, row); changed = true; + } + } + }; const signal = (rows, kind) => { + adopt(rows); // A surviving, identified member proves this is still our execution group. const reusedLeader = rows?.some((row) => row.pid === root.pid && row.born !== root.born); const ownedMember = rows?.some((row) => row.group === root.pid && children.get(row.pid)?.born === row.born); diff --git a/tests/windows.test.mjs b/tests/windows.test.mjs index 47ea07e..d891a34 100644 --- a/tests/windows.test.mjs +++ b/tests/windows.test.mjs @@ -12,6 +12,7 @@ import { signalGroup, parseBorn, bornAfterParent, + sameBirth, tree, processTable, processIdentity, @@ -242,7 +243,23 @@ test('parseBorn: orders ISO-8601, WMIC and legacy stamps on one 100 ns scale', ( assert.equal(parseBorn('unknown'), null); assert.equal(parseBorn(''), null); assert.equal(parseBorn(undefined), null); - assert.equal(bornAfterParent({ born: 'unknown' }, { born: utc.toString() }), true, 'unreadable stamps keep the edge'); + assert.equal(bornAfterParent({ born: 'unknown' }, { born: '2026-09-13T11:33:32.5460000+00:00' }), true, 'an unreadable child stamp keeps the edge'); + assert.equal(bornAfterParent({ born: '2026-09-13T11:33:32.5460000+00:00' }, { born: 'unknown' }), true, 'an unreadable parent stamp keeps the edge'); +}); + +test('sameBirth: an identity recorded in the 0.7.1 locale rendering still matches the round-trip table', () => { + const iso = '2026-09-13T11:33:32.5460000+00:00'; + // 0.7.1 stored PowerShell's default rendering: local wall-clock time, no + // zone, one-second precision. It is read back on the same machine. + const at = new Date(Date.UTC(2026, 8, 13, 11, 33, 32)); + const legacy = `${at.getMonth() + 1}/${at.getDate()}/${at.getFullYear()} ${at.getHours() % 12 || 12}:${String(at.getMinutes()).padStart(2, '0')}:${String(at.getSeconds()).padStart(2, '0')} ${at.getHours() < 12 ? 'AM' : 'PM'}`; + assert.equal(sameBirth(iso, iso), true); + assert.equal(sameBirth(legacy, iso), true, `${legacy} names the same second as ${iso}`); + assert.equal(sameBirth(iso, legacy), true); + assert.equal(sameBirth(legacy, '2026-09-13T11:33:33.0000000+00:00'), false, 'a different second is a different process'); + assert.equal(sameBirth(iso, '2026-09-13T11:33:32.5460001+00:00'), false, 'two precise stamps must match exactly'); + assert.equal(sameBirth('unknown', iso), false); + assert.equal(sameBirth('unknown', 'unknown'), true, 'identical strings always match, as before'); }); test('tree: a row born before its recorded parent is an orphan behind a reused PID, not a descendant', () => { @@ -266,8 +283,10 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives const orphan = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore', windowsHide: true }); orphan.unref(); t.after(() => { try { orphan.kill('SIGKILL'); } catch {} }); + // Each processTable() call costs 1-3 s on a Windows runner; 30 tries stay + // inside the test timeout. let orphanIdentity = null; - for (let i = 0; i < 100 && !orphanIdentity; i++) { orphanIdentity = processIdentity(orphan.pid); if (!orphanIdentity) await pause(100); } + for (let i = 0; i < 30 && !orphanIdentity; i++) { orphanIdentity = processIdentity(orphan.pid); if (!orphanIdentity) await pause(100); } assert.ok(orphanIdentity, 'orphan must be visible in the process table'); // POSIX ps reports birth at second granularity; make the root strictly younger. await pause(1100); @@ -276,7 +295,8 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives { stdio: 'ignore', windowsHide: true }); t.after(() => { try { root.kill('SIGKILL'); } catch {} }); let rootIdentity = null, child = null; - for (let i = 0; i < 100 && !(rootIdentity && child); i++) { + t.after(() => { if (child) try { process.kill(child.pid, 'SIGKILL'); } catch {} }); + for (let i = 0; i < 30 && !(rootIdentity && child); i++) { const rows = processTable(); rootIdentity ||= rows?.find((row) => row.pid === root.pid) || null; child = rows?.find((row) => row.parent === root.pid) || null; @@ -284,7 +304,6 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives } assert.ok(rootIdentity && child, 'root and its real child must be visible in the process table'); assert.ok(bornAfterParent(child, rootIdentity), 'a real child is born after its parent'); - t.after(() => { try { process.kill(child.pid, 'SIGKILL'); } catch {} }); // The stale link: the orphan's parent PID is the root's PID, but the orphan // was born earlier, so the root cannot be its parent. const staleLink = (rows) => rows?.map((row) => row.pid === orphan.pid ? { ...row, parent: root.pid } : row) ?? null; @@ -299,3 +318,27 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives assert.equal(alive(child.pid), false, 'the real descendant is stopped'); assert.equal(alive(orphan.pid), true, 'the unrelated orphan survives'); }); + +test('stopExecution: a descendant spawned during the grace period is adopted from its live parent and stopped', { skip: process.platform === 'win32' && 'SIGTERM is TerminateProcess on Windows; the root cannot react to it', timeout: 60000 }, async (t) => { + const sb = sandbox('adopt-late-child'); + const pidFile = path.join(sb.root, 'late-child.pid'); + // The root ignores SIGTERM and only then spawns a child: it exists in the + // second snapshot but not in the one cleanup started from. + const root = spawn(process.execPath, ['-e', ` + process.on('SIGTERM', () => { + const child = require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true }); + require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)); + }); + setInterval(() => {}, 1000);`], { stdio: 'ignore', windowsHide: true }); + t.after(() => { try { root.kill('SIGKILL'); } catch {} }); + t.after(() => { try { process.kill(Number(fs.readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch {} }); + let rootIdentity = null; + for (let i = 0; i < 30 && !rootIdentity; i++) { rootIdentity = processIdentity(root.pid); if (!rootIdentity) await pause(100); } + assert.ok(rootIdentity, 'root must be visible in the process table'); + await stopExecution(rootIdentity); + assert.ok(fs.existsSync(pidFile), 'the root received SIGTERM and spawned its late child'); + const late = Number(fs.readFileSync(pidFile, 'utf8')); + for (let i = 0; i < 50 && (alive(root.pid) || alive(late)); i++) await pause(100); + assert.equal(alive(root.pid), false, 'the root is stopped'); + assert.equal(alive(late), false, 'the late child is adopted and stopped'); +}); From 451cd6e6c5b4d49abcfdd54ba9608958539e1140 Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 21:50:14 +0800 Subject: [PATCH 4/6] chore(release): 0.7.2 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- docs/releases/v0.7.2.md | 22 ++++++++++++++++++++++ package.json | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.7.2.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2973f97..b0f4b74 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agy", - "version": "0.7.1", + "version": "0.7.2", "description": "Delegate work to Google's Antigravity CLI (agy) with fast Gemini access - five personas: staffer (general), researcher, reviewer (code and plans), implementer, ask.", "author": { "name": "Keli Wen", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index caddfba..ad466dc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agy", - "version": "0.7.1", + "version": "0.7.2", "description": "Delegate work to Google's Antigravity CLI (agy) with fast Gemini access - five personas: staffer (general), researcher, reviewer (code and plans), implementer, ask.", "author": { "name": "Keli Wen", diff --git a/docs/releases/v0.7.2.md b/docs/releases/v0.7.2.md new file mode 100644 index 0000000..f3711d3 --- /dev/null +++ b/docs/releases/v0.7.2.md @@ -0,0 +1,22 @@ +# agy-staff 0.7.2 — Windows: workers survive other jobs' cleanup + +## Changes since 0.7.1 + +0.7.1 made the companion run on Windows. This hotfix closes a process-cleanup defect it introduced that could terminate a running background job. Behaviour on macOS and Linux is unchanged; users there need not upgrade. + +- [#23](https://github.com/keli-wen/agy-staff/pull/23): a detached worker on Windows keeps its exited dispatcher's PID in `ParentProcessId`. Windows reuses PIDs quickly, so when another job's agy process (or, for a single job, its own agy or one of agy's tool processes) received that PID, cleanup at that job's end took the worker for a descendant and terminated it. The worker died silently, its result was never stored, and `wait` reported `crashed` with a diagnostic hint about sandbox contexts that pointed users at the wrong problem. Two independent tree walks did this: `tree()` in the stream worker and `taskkill /T`. Now `tree()` follows a parent link only when the child was created after its parent (a process cannot be older than its parent; a row born before its recorded parent is an orphan behind a reused PID), PowerShell reports `CreationDate` in round-trip format so that comparison has 100 ns precision instead of one second, `taskkill` runs without `/T` and descendants are terminated individually after passing the identity and birth-order checks, and cleanup never signals its own process. Members that appear between the two cleanup snapshots (a tool agy starts during the grace period) are adopted from a still-live, identity-matched member under the same birth-order rule, closing a gap `/T` had only partly covered. A worker identity recorded by 0.7.1 in the locale rendering still matches the new table, so a job that spans the upgrade can be canceled. +- [#22](https://github.com/keli-wen/agy-staff/pull/22): the PowerShell process-table query fetches only the three columns it parses, cleanup skips the grace wait and second query when nothing of the job's tree is left, and the test helpers allow a cold Windows runner more time to start a worker. + +Package, Claude Code and Codex manifests are aligned at 0.7.2. Canonical skills and generated Pi skills are unchanged. No CLI flags, exit codes or output formats changed. + +## Validation + +The 0.7.1 release note reported the Windows suite at 0 failures. That was the fifth run of that pull request; the four before it had failed, each on a different test. Measured over the runs since the Windows job was added, the suite passed about half the time, and both pushes to `master` after 0.7.1 failed. Every failure had the same signature described above; the affected test was whichever job happened to hold the reused PID. + +With this fix the Windows suite passed 4 of 4 runs on the final commit of #23 (194 tests, about 4 minutes each), and the pre-existing tests passed in all 8 runs of the branch. The offline suite passed 194/194 on macOS and 8 of 8 Ubuntu CI runs. A regression test drives real processes and real signals through `stopExecution`: a real detached orphan, a real root with a real child, and one synthetic `ParentProcessId` in the table, since no kernel lets a test choose the next PID it hands out. With the birth-order check disabled the test fails. Unit tests cover birth-stamp parsing (ISO-8601, WMIC and legacy renderings), stale-link pruning in `tree()` and the `taskkill` arguments. + +The diagnosis and the fix design were reviewed independently by an agy review job, which confirmed the cause and identified the `taskkill /T` path; a second review of the final diff found no blocker and produced the two follow-ups above. + +## Upgrading + +Windows users of 0.7.1 should upgrade. Claude Code and Codex install a copy, so pull the new version in (see [upgrading](../REFERENCE.md#upgrading)) and restart the harness. A job started under 0.7.1 and still running during the upgrade keeps working and can be canceled; its worker runs the old code until it finishes. diff --git a/package.json b/package.json index b7f279a..e28baf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agy-staff", - "version": "0.7.1", + "version": "0.7.2", "description": "Delegate work to Google's Antigravity CLI (agy) with fast Gemini access.", "keywords": [ "pi-package" From 823882e793e73fd233e901a7e58e6da80486b0bf Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 21:57:45 +0800 Subject: [PATCH 5/6] test(windows): wait for the late-child root to install its SIGTERM handler On a fast Linux runner ps saw the root before Node had registered the handler, so SIGTERM took its default action and no late child appeared. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- tests/windows.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/windows.test.mjs b/tests/windows.test.mjs index d891a34..595eb5c 100644 --- a/tests/windows.test.mjs +++ b/tests/windows.test.mjs @@ -322,16 +322,22 @@ test('stopExecution: a real orphan whose parent PID a live root reuses survives test('stopExecution: a descendant spawned during the grace period is adopted from its live parent and stopped', { skip: process.platform === 'win32' && 'SIGTERM is TerminateProcess on Windows; the root cannot react to it', timeout: 60000 }, async (t) => { const sb = sandbox('adopt-late-child'); const pidFile = path.join(sb.root, 'late-child.pid'); + const readyFile = path.join(sb.root, 'ready'); // The root ignores SIGTERM and only then spawns a child: it exists in the - // second snapshot but not in the one cleanup started from. + // second snapshot but not in the one cleanup started from. It announces + // readiness after installing the handler; before that SIGTERM would still + // take the default action and end the root (seen on a fast Linux runner). const root = spawn(process.execPath, ['-e', ` process.on('SIGTERM', () => { const child = require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true }); require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)); }); + require('fs').writeFileSync(${JSON.stringify(readyFile)}, 'ready'); setInterval(() => {}, 1000);`], { stdio: 'ignore', windowsHide: true }); t.after(() => { try { root.kill('SIGKILL'); } catch {} }); t.after(() => { try { process.kill(Number(fs.readFileSync(pidFile, 'utf8')), 'SIGKILL'); } catch {} }); + for (let i = 0; i < 100 && !fs.existsSync(readyFile); i++) await pause(50); + assert.ok(fs.existsSync(readyFile), 'the root installed its SIGTERM handler'); let rootIdentity = null; for (let i = 0; i < 30 && !rootIdentity; i++) { rootIdentity = processIdentity(root.pid); if (!rootIdentity) await pause(100); } assert.ok(rootIdentity, 'root must be visible in the process table'); From edaf893f2d3a6f7c608d45716f414dc06a51a95e Mon Sep 17 00:00:00 2001 From: pkuwkl Date: Sun, 13 Sep 2026 23:05:07 +0800 Subject: [PATCH 6/6] fix(windows): drop the 0.7.1 identity compatibility shim Worker identities are compared as the exact strings the table produced, as before. A job started by 0.7.1 and still running through the upgrade is the only case affected; the release note says to let jobs finish first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RKgBCYMWQru5SawpfCLxFY --- companion/agy-companion.mjs | 6 +++--- companion/stream-worker.mjs | 11 ----------- docs/releases/v0.7.2.md | 6 +++--- tests/windows.test.mjs | 16 ---------------- 4 files changed, 6 insertions(+), 33 deletions(-) diff --git a/companion/agy-companion.mjs b/companion/agy-companion.mjs index a0b1b87..edea016 100644 --- a/companion/agy-companion.mjs +++ b/companion/agy-companion.mjs @@ -93,7 +93,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; import { boundSnapshot, excerpt } from './observation.mjs'; -import { atomicJSON, runStreaming, processIdentity, sameBirth } from './stream-worker.mjs'; +import { atomicJSON, runStreaming, processIdentity } from './stream-worker.mjs'; import { withStateLock, replaceFile, readTextRetry } from './state-lock.mjs'; const SELF = fileURLToPath(import.meta.url); @@ -1602,7 +1602,7 @@ async function cmdCancel(opts) { if (!job.spec_file) die('this legacy job has no cancellation request channel; cannot safely signal an unverified stored PID'); const identity = job.worker_identity; const current = identity ? processIdentity(job.pid) : null; - if (identity && (identity.pid !== job.pid || (current && !sameBirth(identity.born, current.born)))) { + if (identity && (identity.pid !== job.pid || (current && identity.born !== current.born))) { die('worker identity no longer matches this job; refusing to signal a reused or unrelated PID'); } // Keep running visible until the worker stores the cancellation report. @@ -1618,7 +1618,7 @@ async function cmdCancel(opts) { // On Windows process.kill() is TerminateProcess: the worker would die without // running its cleanup and orphan the agy tree, so rely on the marker alone there. const current = job.worker_identity && process.platform !== 'win32' ? processIdentity(job.pid) : null; - if (current && current.pid === job.worker_identity.pid && sameBirth(current.born, job.worker_identity.born)) { + if (current && current.pid === job.worker_identity.pid && current.born === job.worker_identity.born) { try { process.kill(current.pid, 'SIGTERM'); } catch {} } const deadline = Date.now() + 10000; diff --git a/companion/stream-worker.mjs b/companion/stream-worker.mjs index 9908430..7f5524c 100644 --- a/companion/stream-worker.mjs +++ b/companion/stream-worker.mjs @@ -167,17 +167,6 @@ export function bornAfterParent(child, parent) { return c >= p; } -/** Identity stamps recorded by an earlier release may use a coarser - * rendering (locale time, one second) than the current table. Compare at - * the coarser precision so an upgrade does not orphan a running job. */ -export function sameBirth(a, b) { - if (a === b) return true; - const pa = parseBorn(a), pb = parseBorn(b); - if (pa === null || pb === null) return false; - const coarse = (stamp) => !/\.\d+/.test(stamp); - return coarse(a) || coarse(b) ? pa / 10_000_000n === pb / 10_000_000n : pa === pb; -} - export function tree(pid, rows = processTable()) { if (!rows || !pid) return []; const root = rows.find((row) => row.pid === pid); diff --git a/docs/releases/v0.7.2.md b/docs/releases/v0.7.2.md index f3711d3..f66d0bf 100644 --- a/docs/releases/v0.7.2.md +++ b/docs/releases/v0.7.2.md @@ -4,7 +4,7 @@ 0.7.1 made the companion run on Windows. This hotfix closes a process-cleanup defect it introduced that could terminate a running background job. Behaviour on macOS and Linux is unchanged; users there need not upgrade. -- [#23](https://github.com/keli-wen/agy-staff/pull/23): a detached worker on Windows keeps its exited dispatcher's PID in `ParentProcessId`. Windows reuses PIDs quickly, so when another job's agy process (or, for a single job, its own agy or one of agy's tool processes) received that PID, cleanup at that job's end took the worker for a descendant and terminated it. The worker died silently, its result was never stored, and `wait` reported `crashed` with a diagnostic hint about sandbox contexts that pointed users at the wrong problem. Two independent tree walks did this: `tree()` in the stream worker and `taskkill /T`. Now `tree()` follows a parent link only when the child was created after its parent (a process cannot be older than its parent; a row born before its recorded parent is an orphan behind a reused PID), PowerShell reports `CreationDate` in round-trip format so that comparison has 100 ns precision instead of one second, `taskkill` runs without `/T` and descendants are terminated individually after passing the identity and birth-order checks, and cleanup never signals its own process. Members that appear between the two cleanup snapshots (a tool agy starts during the grace period) are adopted from a still-live, identity-matched member under the same birth-order rule, closing a gap `/T` had only partly covered. A worker identity recorded by 0.7.1 in the locale rendering still matches the new table, so a job that spans the upgrade can be canceled. +- [#23](https://github.com/keli-wen/agy-staff/pull/23): a detached worker on Windows keeps its exited dispatcher's PID in `ParentProcessId`. Windows reuses PIDs quickly, so when another job's agy process (or, for a single job, its own agy or one of agy's tool processes) received that PID, cleanup at that job's end took the worker for a descendant and terminated it. The worker died silently, its result was never stored, and `wait` reported `crashed` with a diagnostic hint about sandbox contexts that pointed users at the wrong problem. Two independent tree walks did this: `tree()` in the stream worker and `taskkill /T`. Now `tree()` follows a parent link only when the child was created after its parent (a process cannot be older than its parent; a row born before its recorded parent is an orphan behind a reused PID), PowerShell reports `CreationDate` in round-trip format so that comparison has 100 ns precision instead of one second, `taskkill` runs without `/T` and descendants are terminated individually after passing the identity and birth-order checks, and cleanup never signals its own process. Members that appear between the two cleanup snapshots (a tool agy starts during the grace period) are adopted from a still-live, identity-matched member under the same birth-order rule, closing a gap `/T` had only partly covered. - [#22](https://github.com/keli-wen/agy-staff/pull/22): the PowerShell process-table query fetches only the three columns it parses, cleanup skips the grace wait and second query when nothing of the job's tree is left, and the test helpers allow a cold Windows runner more time to start a worker. Package, Claude Code and Codex manifests are aligned at 0.7.2. Canonical skills and generated Pi skills are unchanged. No CLI flags, exit codes or output formats changed. @@ -15,8 +15,8 @@ The 0.7.1 release note reported the Windows suite at 0 failures. That was the fi With this fix the Windows suite passed 4 of 4 runs on the final commit of #23 (194 tests, about 4 minutes each), and the pre-existing tests passed in all 8 runs of the branch. The offline suite passed 194/194 on macOS and 8 of 8 Ubuntu CI runs. A regression test drives real processes and real signals through `stopExecution`: a real detached orphan, a real root with a real child, and one synthetic `ParentProcessId` in the table, since no kernel lets a test choose the next PID it hands out. With the birth-order check disabled the test fails. Unit tests cover birth-stamp parsing (ISO-8601, WMIC and legacy renderings), stale-link pruning in `tree()` and the `taskkill` arguments. -The diagnosis and the fix design were reviewed independently by an agy review job, which confirmed the cause and identified the `taskkill /T` path; a second review of the final diff found no blocker and produced the two follow-ups above. +The diagnosis and the fix design were reviewed independently by an agy review job, which confirmed the cause and identified the `taskkill /T` path; a second review of the final diff found no blocker and produced the adoption follow-up above. ## Upgrading -Windows users of 0.7.1 should upgrade. Claude Code and Codex install a copy, so pull the new version in (see [upgrading](../REFERENCE.md#upgrading)) and restart the harness. A job started under 0.7.1 and still running during the upgrade keeps working and can be canceled; its worker runs the old code until it finishes. +Windows users of 0.7.1 should upgrade. Claude Code and Codex install a copy, so pull the new version in (see [upgrading](../REFERENCE.md#upgrading)) and restart the harness. Let running jobs finish before upgrading on Windows: a job started by 0.7.1 recorded its worker identity in the old timestamp rendering, and `cancel` in 0.7.2 refuses to signal a worker whose identity it cannot match. diff --git a/tests/windows.test.mjs b/tests/windows.test.mjs index 595eb5c..ccc3177 100644 --- a/tests/windows.test.mjs +++ b/tests/windows.test.mjs @@ -12,7 +12,6 @@ import { signalGroup, parseBorn, bornAfterParent, - sameBirth, tree, processTable, processIdentity, @@ -247,21 +246,6 @@ test('parseBorn: orders ISO-8601, WMIC and legacy stamps on one 100 ns scale', ( assert.equal(bornAfterParent({ born: '2026-09-13T11:33:32.5460000+00:00' }, { born: 'unknown' }), true, 'an unreadable parent stamp keeps the edge'); }); -test('sameBirth: an identity recorded in the 0.7.1 locale rendering still matches the round-trip table', () => { - const iso = '2026-09-13T11:33:32.5460000+00:00'; - // 0.7.1 stored PowerShell's default rendering: local wall-clock time, no - // zone, one-second precision. It is read back on the same machine. - const at = new Date(Date.UTC(2026, 8, 13, 11, 33, 32)); - const legacy = `${at.getMonth() + 1}/${at.getDate()}/${at.getFullYear()} ${at.getHours() % 12 || 12}:${String(at.getMinutes()).padStart(2, '0')}:${String(at.getSeconds()).padStart(2, '0')} ${at.getHours() < 12 ? 'AM' : 'PM'}`; - assert.equal(sameBirth(iso, iso), true); - assert.equal(sameBirth(legacy, iso), true, `${legacy} names the same second as ${iso}`); - assert.equal(sameBirth(iso, legacy), true); - assert.equal(sameBirth(legacy, '2026-09-13T11:33:33.0000000+00:00'), false, 'a different second is a different process'); - assert.equal(sameBirth(iso, '2026-09-13T11:33:32.5460001+00:00'), false, 'two precise stamps must match exactly'); - assert.equal(sameBirth('unknown', iso), false); - assert.equal(sameBirth('unknown', 'unknown'), true, 'identical strings always match, as before'); -}); - test('tree: a row born before its recorded parent is an orphan behind a reused PID, not a descendant', () => { // The recycler R received the PID of a dispatch process that had already // exited; the orphan worker W still records that PID as its parent.