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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
82 changes: 73 additions & 9 deletions companion/stream-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
}
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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));
}
Expand All @@ -144,18 +190,36 @@ 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;
// 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);
Expand All @@ -166,7 +230,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 }) {
Expand Down
2 changes: 1 addition & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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

Expand Down
2 changes: 1 addition & 1 deletion docs/REFERENCE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pid> /T /F` 终止脱离父进程的进程树。状态锁针对 Windows 目录与标记文件的重命名和删除瞬态错误(`EPERM`/`EBUSY`/`EACCES`)进行了自动重试。
Windows 为尽力支持,由 CI 的 `Tests (Windows)` 任务覆盖,尚未在真实的 Windows `agy` 安装上验证。子进程均以 `windowsHide: true` 启动,避免后台执行期间弹出控制台窗口。任务取消与进程清理通过 PowerShell(`Get-CimInstance Win32_Process`,`CreationDate` 使用往返精度)发现子孙进程,并逐个终止已确认身份的成员;组长进程使用 `taskkill /PID <pid> /F`,不再使用 `/T`。父子链接只有在子进程创建时间晚于父进程时才被采信:Windows 会在 `ParentProcessId` 中保留已退出父进程的 PID,该 PID 被复用后,一个无关的孤儿进程(通常是另一个任务的后台 worker)否则会被误认为子孙而被杀掉。状态锁针对 Windows 目录与标记文件的重命名和删除瞬态错误(`EPERM`/`EBUSY`/`EACCES`)进行了自动重试。

## 升级

Expand Down
22 changes: 22 additions & 0 deletions docs/releases/v0.7.2.md
Original file line number Diff line number Diff line change
@@ -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.
- [#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 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. 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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading