Skip to content

Commit f0c0a53

Browse files
Merge pull request #5 from dedsec-terminal/arena/01a08a52-transactional-antigravity-harn
Harden process supervision, lease durability, and MCP job handling
2 parents ae8f6e9 + 4692bfa commit f0c0a53

26 files changed

Lines changed: 682 additions & 262 deletions

‎.github/dependabot.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ updates:
44
directory: "/mcp"
55
schedule:
66
interval: monthly
7+
- package-ecosystem: github-actions
8+
directory: "/"
9+
schedule:
10+
interval: monthly

‎.github/workflows/ci.yml‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ concurrency:
1515

1616
jobs:
1717
ci:
18-
name: Windows CI
19-
runs-on: windows-latest
18+
name: CI (${{ matrix.os }})
19+
strategy:
20+
fail-fast: false
21+
matrix:
22+
os: [windows-latest, ubuntu-latest, macos-latest]
23+
runs-on: ${{ matrix.os }}
2024
defaults:
2125
run:
2226
working-directory: mcp
@@ -40,5 +44,8 @@ jobs:
4044
- name: Run build
4145
run: npm run build
4246

47+
- name: Check committed bundle matches source
48+
run: git diff --exit-code -- dist/server.cjs
49+
4350
- name: Run tests
4451
run: npm test

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Replace fabricated POSIX identities with Linux boot-scoped kernel start tokens and macOS process start/executable/argv probes; fail closed on query errors.
12+
- Parse DMTF timezone offsets as signed minutes; use shared POSIX process-group teardown for directly owned children.
13+
- Publish slot leases atomically, recover provably dead directory-lock owners, and report legacy locks/corrupt leases as blocked capacity.
14+
- Align slot defaults with the eight-worker cap; cache process probes per scan and use cheap Windows liveness checks during termination waits.
15+
- Require manifest hashes before patch application; preserve replacement common-directory lock owners on release.
16+
- Return structured job-action data and a clear no-active-worker cancellation result; derive storage health from corruption counts.
17+
- Bound output copying using chunk accumulation and a circular tail buffer; remove the redundant full health scan from async dispatch.
18+
- Use the Antigravity CLI's configured model by default; document agent/model and the currently unenforced retention policy.
19+
- Run CI on Windows, Linux and macOS, check committed bundle drift, and update GitHub Actions via Dependabot.
20+
- Preserve job evidence during retention cleanup when timestamps are missing or malformed.
21+
- Reject invalid cleanup clocks and negative or non-finite retention windows before scanning the ledger.
22+
- Add regression coverage for retention boundaries, future timestamps, creation-time fallback, and dry-run/destructive cleanup safety.
23+
1024
## [1.1.0] - 2026-09-10
1125

1226
### Added

‎CONTRIBUTING.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The repository coordinates atomic subagent execution through an MCP server inter
3434
### Focused Change Workflow
3535
1. **Targeted Scope**: Always scope changes to explicit, non-overlapping target files or module prefixes. Avoid large refactors or incidental formatting changes across unrelated files.
3636
2. **Worktree Isolation**: Never mutate shared state directly during exploratory or agent runs. The harness uses `isolation: "worktree"` for mutating delegations. Changes must be reviewed and explicitly applied.
37-
3. **Disjoint Concurrency**: When splitting tasks across parallel workers, ensure disjoint target paths with a maximum concurrency limit of 4 worker slots (`MAX_WORKER_SLOTS = 4`).
37+
3. **Disjoint Concurrency**: When splitting tasks across parallel workers, ensure disjoint target paths with a hard maximum of 8 worker slots, subject to adaptive CPU/memory admission (`MAX_WORKER_SLOTS = 8`).
3838
4. **Untrusted Worker Output**: In this architecture, worker execution claims are considered untrusted until parent verification executes automated checks.
3939

4040
---
@@ -63,8 +63,8 @@ To verify MCP protocol communication independently:
6363
npm run test:protocol
6464
```
6565

66-
### Root Integration Tests
67-
From the workspace root directory:
66+
### Root Test Aliases
67+
These convenience entry points import the same suites already included in `npm test`; they are not additional, orphaned tests. From the workspace root directory:
6868
```bash
6969
node test/leases.test.mjs
7070
node test/ledger.test.mjs

‎README.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,12 @@ flowchart TD
116116
2. **Untrusted Worker Principle**: Worker claims are untrusted by default. The parent orchestrator inspects actual git/filesystem diffs and executes automated tests before changes are accepted.
117117
3. **Explicit Apply & Finalize**: Completed worktree changes are never merged automatically. The parent orchestrator reviews the patch hash, applies verified changes, and cleans up the worktree.
118118
4. **Path Traversal Protection**: Target paths are constrained to the workspace root; directory traversal (`..`) attempts outside the workspace are rejected.
119-
5. **Retention Policies**:
119+
5. **Retention and Cleanup**:
120120

121-
* **Worktrees**: Retained for 24 hours (1,440 minutes by default) to allow manual inspection and debugging before disposal.
122-
* **Evidence Ledger**: Job logs, terminal events, and hashes are kept for 14 days for forensic traceability.
121+
* **Worktrees**: Use explicit `agy_job finalize` after verification to remove a job's worktree. The `retentionMinutes` field (default 1,440) is reserved policy metadata, **not an automatic expiry timer**.
122+
* **Evidence Ledger**: Automatic ledger/outbox pruning is not currently wired into the runner or `reconcile`. The 14-day evidence constant is a policy target, not an enforced deletion schedule. Monitor disk usage and retain evidence needed for pending callbacks and review.
123+
* **Fail-safe Cleanup Library**: `collectEligibleJobs` is an explicit library operation (24-hour default window). It preserves active, unfinalized, callback-pending and corrupt jobs, and jobs with missing or malformed retention timestamps (`invalid_timestamp`). It rejects invalid clocks and negative/non-finite retention windows before scanning.
124+
* **Blocked Leases**: New owner-bearing slot locks recover when their owner is provably dead. Legacy nonce-only locks and corrupt leases are reported as blocked capacity and preserved. Verify no worker is still using the slot before manual repair; malformed data is not proof that a worker has stopped.
123125

124126
---
125127

@@ -240,6 +242,9 @@ Synchronously delegates a bounded task to Antigravity and waits for the result.
240242
* `timeoutSeconds` (number, default `300`): Execution timeout (1–1800s). Sizing target is under 25s.
241243
* `outputFormat` (`"text"` | `"json"`, default `"text"`): Desired output structure.
242244
* `resumeJobId` (string, optional): Resume a prior job attempt in its existing worktree.
245+
* `agent` (string, optional): Antigravity agent identifier, forwarded as `--agent`.
246+
* `model` (string, optional): Antigravity model identifier, forwarded as `--model`. When omitted, the harness uses the CLI's configured default rather than pinning a model.
247+
* `retentionMinutes` (integer, optional, 10–10080): Reserved policy metadata; currently does not schedule deletion. Use explicit `agy_job finalize` for worktree cleanup.
243248

244249
```json
245250
{
@@ -273,7 +278,7 @@ Dispatches a background worker task and returns an immediate acknowledgement. Wh
273278
```
274279

275280
### `agy_job`
276-
Inspects or manages the lifecycle of transactional jobs.
281+
Inspects or manages the lifecycle of transactional jobs. Successful responses include the parsed job-action result in MCP `structuredContent`, including `state` and `attempts[].artifactPaths` for status, or `jobs` for list. The JSON text response is retained for compatibility.
277282

278283
**Parameters**:
279284

‎mcp/dist/server.cjs‎

Lines changed: 29 additions & 29 deletions
Large diffs are not rendered by default.

‎mcp/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"check": "tsc --noEmit",
3636
"prepack": "npm run build && node scripts/package-assets.mjs prepare",
3737
"postpack": "node scripts/package-assets.mjs clean",
38-
"test": "node test/system-capacity.mjs && node test/activity.mjs && node test/ledger-outbox.mjs && node test/process-leases.mjs && node test/contracts.mjs && node test/git-artifacts.mjs && node test/sparse-worktree.mjs && node test/runner-controller.mjs && node test/smoke.mjs --protocol-only && node test/deterministic.mjs",
38+
"test": "node test/reliability.mjs && node test/system-capacity.mjs && node test/activity.mjs && node test/ledger-outbox.mjs && node test/process-leases.mjs && node test/contracts.mjs && node test/git-artifacts.mjs && node test/sparse-worktree.mjs && node test/runner-controller.mjs && node test/smoke.mjs --protocol-only && node test/deterministic.mjs",
3939
"test:protocol": "node test/smoke.mjs --protocol-only",
4040
"test:live": "node test/smoke.mjs"
4141
},

‎mcp/src/index.ts‎

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
#!/usr/bin/env node
22

3-
import { spawn, spawnSync } from 'node:child_process';
3+
import { spawn } from 'node:child_process';
44
import { access, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
55
import { constants, existsSync } from 'node:fs';
66
import os from 'node:os';
77
import path from 'node:path';
88
import { randomUUID } from 'node:crypto';
99

10+
import { terminateOwnedProcessTree as terminateProcessTree } from '../../skills/delegate-to-antigravity/scripts/lib/owned-process.mjs';
11+
1012
import { McpServer } from '@modelcontextprotocol/server';
1113
import { serveStdio } from '@modelcontextprotocol/server/stdio';
1214
import * as z from 'zod/v4';
@@ -68,43 +70,29 @@ async function runnerAvailable(): Promise<boolean> {
6870
}
6971
}
7072

71-
function terminateProcessTree(child: ReturnType<typeof spawn>): void {
72-
if (!child.pid) return;
73-
if (process.platform === 'win32') {
74-
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
75-
windowsHide: true,
76-
stdio: 'ignore',
77-
});
78-
} else {
79-
child.kill('SIGTERM');
80-
}
81-
}
8273

8374
function runProcess(command: string, args: string[], cwd: string, timeoutMs: number): Promise<RunResult> {
8475
return new Promise((resolve, reject) => {
8576
const child = spawn(command, args, {
8677
cwd,
8778
env: process.env,
8879
windowsHide: true,
80+
detached: process.platform !== 'win32',
8981
stdio: ['ignore', 'pipe', 'pipe'],
9082
});
91-
let stdout: Buffer<ArrayBufferLike> = Buffer.alloc(0);
92-
let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0);
83+
const stdout: Buffer<ArrayBufferLike>[] = [];
84+
const stderr: Buffer<ArrayBufferLike>[] = [];
85+
let stdoutBytes = 0;
86+
let stderrBytes = 0;
9387
let truncated = false;
9488
let timedOut = false;
9589
let settled = false;
9690

97-
const append = (
98-
current: Buffer<ArrayBufferLike>,
99-
chunk: Buffer<ArrayBufferLike>,
100-
): Buffer<ArrayBufferLike> => {
101-
if (current.length >= MAX_OUTPUT_BYTES) {
102-
truncated = true;
103-
return current;
104-
}
105-
const remaining = MAX_OUTPUT_BYTES - current.length;
106-
if (chunk.length > remaining) truncated = true;
107-
return Buffer.concat([current, chunk.subarray(0, remaining)]);
91+
const append = (chunks: Buffer<ArrayBufferLike>[], length: number, chunk: Buffer<ArrayBufferLike>): number => {
92+
const kept = Math.min(chunk.length, MAX_OUTPUT_BYTES - length);
93+
if (kept < chunk.length) truncated = true;
94+
if (kept > 0) chunks.push(Buffer.from(chunk.subarray(0, kept)));
95+
return length + kept;
10896
};
10997

11098
let timer: NodeJS.Timeout;
@@ -115,8 +103,8 @@ function runProcess(command: string, args: string[], cwd: string, timeoutMs: num
115103
callback();
116104
};
117105

118-
child.stdout.on('data', (chunk: Buffer<ArrayBufferLike>) => { stdout = append(stdout, chunk); });
119-
child.stderr.on('data', (chunk: Buffer<ArrayBufferLike>) => { stderr = append(stderr, chunk); });
106+
child.stdout.on('data', (chunk: Buffer<ArrayBufferLike>) => { stdoutBytes = append(stdout, stdoutBytes, chunk); });
107+
child.stderr.on('data', (chunk: Buffer<ArrayBufferLike>) => { stderrBytes = append(stderr, stderrBytes, chunk); });
120108
child.on('error', (error) => finish(() => reject(error)));
121109

122110
timer = setTimeout(() => {
@@ -126,8 +114,8 @@ function runProcess(command: string, args: string[], cwd: string, timeoutMs: num
126114

127115
child.on('close', (exitCode) => finish(() => resolve({
128116
exitCode,
129-
stdout: stdout.toString('utf8'),
130-
stderr: stderr.toString('utf8'),
117+
stdout: Buffer.concat(stdout, stdoutBytes).toString('utf8'),
118+
stderr: Buffer.concat(stderr, stderrBytes).toString('utf8'),
131119
timedOut,
132120
truncated,
133121
})));
@@ -213,7 +201,7 @@ const delegateSchema = {
213201
),
214202
isolation: z.enum(['worktree', 'shared']).optional(),
215203
resumeJobId: z.string().min(1).max(200).optional(),
216-
retentionMinutes: z.number().int().min(10).max(10080).optional(),
204+
retentionMinutes: z.number().int().min(10).max(10080).optional().describe('Reserved retention-policy metadata; automatic pruning is not implemented. Use explicit finalize for worktree cleanup.'),
217205
subagents: z.number().int().min(0).max(8).optional().describe(
218206
'Optional subagents limit (0..8). Omission uses controller auto policy.',
219207
),
@@ -358,14 +346,8 @@ function createServer(): McpServer {
358346
let resolvedCwd: string;
359347
try {
360348
resolvedCwd = await validateWorkspace(cwd);
361-
const check = await runnerCheck();
362-
if (check.exitCode !== 0 || check.timedOut) {
363-
return failure(check.stderr.trim() || 'Antigravity callback preflight failed');
364-
}
365-
const readiness = JSON.parse(check.stdout) as { codexCallbackAvailable?: boolean };
366-
if (!readiness.codexCallbackAvailable) {
367-
return failure('Codex CLI was not found; asynchronous callback dispatch is unavailable.');
368-
}
349+
// The async runner checks both agy and callback availability before
350+
// persisting or spawning. Avoid a redundant full ledger/CPU health scan.
369351
} catch (error) {
370352
return failure(error instanceof Error ? error.message : String(error));
371353
}
@@ -419,7 +401,13 @@ function createServer(): McpServer {
419401
const payload = JSON.stringify({ ...(jobArgs ?? {}), ...(jobId ? { jobId } : {}) });
420402
const result = await runProcess(process.execPath, [runnerPath(), '--job-action', action, '--job-args-json', payload], pluginRoot(), 30_000);
421403
const text = result.stdout.trim() || result.stderr.trim() || '(runner returned no output)';
422-
return { content: [{ type: 'text', text }], structuredContent: { action, jobId, exitCode: result.exitCode, artifactPaths: parseAgyMeta(result.stdout).artifactPaths ?? [] }, isError: result.exitCode !== 0 || result.timedOut };
404+
if (result.exitCode !== 0 || result.timedOut) return failure(text);
405+
if (result.truncated) return failure('Job response exceeded the output limit; use a more specific job query.');
406+
let data: unknown;
407+
try { data = JSON.parse(result.stdout); }
408+
catch { return failure('Job action returned malformed JSON'); }
409+
if (!data || typeof data !== 'object' || Array.isArray(data)) return failure('Job action returned an invalid result');
410+
return { content: [{ type: 'text', text }], structuredContent: { ...data, action, jobId, exitCode: result.exitCode } };
423411
} catch (error) { return failure(`Failed to manage Antigravity job: ${error instanceof Error ? error.message : String(error)}`); }
424412
});
425413

‎mcp/test/contracts.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,8 @@ test("Isolation matrix and constants", () => {
315315
assert.deepEqual(VALID_ISOLATION_MODES, ["shared", "worktree"]);
316316
assert.equal(DEFAULT_RETENTION_MINUTES, 1440);
317317
assert.equal(EVIDENCE_RETENTION_DAYS, 14);
318-
assert.equal(MAX_WORKER_SLOTS, 4);
319-
assert.equal(MAX_CONCURRENT_SLOTS, 4);
318+
assert.equal(MAX_WORKER_SLOTS, 8);
319+
assert.equal(MAX_CONCURRENT_SLOTS, 8);
320320

321321
// Matrix cell 1: sync plan => default shared, explicit shared allowed, explicit worktree allowed
322322
assert.equal(getDefaultIsolation({ mode: "plan", isAsync: false }), "shared");

‎mcp/test/git-artifacts.mjs‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ describe("Evidence Capture & Apply Roundtrip", () => {
403403
targetRepoPath: repoDir,
404404
patch: evidence.patch,
405405
manifest: evidence.manifest,
406+
manifestHash: evidence.manifestHash,
406407
});
407408

408409
assert.equal(applyRes.blocked, true);
@@ -432,12 +433,17 @@ describe("Evidence Capture & Apply Roundtrip", () => {
432433
repoRoot: repoDir,
433434
});
434435

436+
const missingHash = await applyPatch({ targetRepoPath: repoDir, patch: evidence.patch, manifest: evidence.manifest });
437+
assert.deepEqual(missingHash.reasons, ["MANIFEST_HASH_REQUIRED"]);
438+
assert.equal(missingHash.applied, false);
439+
435440
// 1. Base SHA mismatch: canonical repo has a new commit
436441
await commitFiles(repoDir, { "unrelated.txt": "advance head\n" });
437442
const resBaseMismatch = await applyPatch({
438443
targetRepoPath: repoDir,
439444
patch: evidence.patch,
440445
manifest: evidence.manifest,
446+
manifestHash: evidence.manifestHash,
441447
});
442448
assert.equal(resBaseMismatch.blocked, true);
443449
assert.ok(resBaseMismatch.reasons.includes("BASE_SHA_MISMATCH"));
@@ -451,6 +457,7 @@ describe("Evidence Capture & Apply Roundtrip", () => {
451457
targetRepoPath: repoDir,
452458
patch: corruptedPatch,
453459
manifest: evidence.manifest,
460+
manifestHash: evidence.manifestHash,
454461
});
455462
assert.equal(resPatchMismatch.blocked, true);
456463
assert.ok(resPatchMismatch.reasons.includes("PATCH_HASH_MISMATCH"));
@@ -521,6 +528,7 @@ describe("Evidence Capture & Apply Roundtrip", () => {
521528
targetRepoPath: repoDir,
522529
patch: evidence.patch,
523530
manifest: evidence.manifest,
531+
manifestHash: evidence.manifestHash,
524532
});
525533

526534
assert.equal(resConflict.blocked, true);
@@ -578,15 +586,11 @@ describe("Evidence Capture & Apply Roundtrip", () => {
578586
// Try finalize while file is open
579587
const firstFinalize = await finalizeWorktree(worktreePath, {
580588
repoRoot: repoDir,
581-
...(process.platform === "win32"
582-
? { removeRunner: async () => ({ status: 1, stderr: "EBUSY: file is used by another process", stdout: "" }) }
583-
: {}),
589+
removeRunner: async () => ({ status: 1, stderr: "EBUSY: file is used by another process", stdout: "" }),
584590
});
585591

586-
if (process.platform === "win32") {
587-
assert.equal(firstFinalize.status, "pending_prune");
588-
assert.ok(firstFinalize.error);
589-
}
592+
assert.equal(firstFinalize.status, "pending_prune");
593+
assert.ok(firstFinalize.error);
590594

591595
// Close the open handle
592596
await handle.close();

0 commit comments

Comments
 (0)