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
9 changes: 9 additions & 0 deletions plugins/Hylouis233/cli-agent-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,12 @@ capture truncation, and Codex prompt delimiters on Windows and POSIX.
## License

MIT. See LICENSE. Upstream credits: see NOTICE.

### Process-tracker regression coverage

The internal Linux tracker tests bind each newly visible child to its start identity while its
original parent can still be revalidated, before enumerating the parent's remaining tasks.
This preserves evidence for short-lived Git helpers without accepting unknown descendants or
children of a recycled parent PID. Pending children without verifiable identities still fail
closed. This test-path improvement does not enable Linux production delegation; the supported
kernel containment boundary remains Windows Job Objects.
20 changes: 20 additions & 0 deletions plugins/Hylouis233/cli-agent-bridge/process-tree.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,26 @@ async function linuxTrackedProcessSnapshot(
// Preserve evidence from every torn attempt. A later complete task
// pass cannot make a previously observed child safe to forget.
pendingChildren.add(childPid);
// Bind a newly observed child before reading more task files. A short
// Git helper and its parent can both disappear during those reads;
// deferring identity capture would lose evidence that is available now.
if (!treeState.knownStarts.has(childPid)) {
const child = await readLinuxStat(childPid, procRoot, fsOps);
if (child === null) return null;
if (child && child.parentPid === pid &&
/^\d+$/u.test(child.startIdentity) && /^\d+$/u.test(item.startIdentity) &&
BigInt(child.startIdentity) >= BigInt(item.startIdentity)) {
const anchor = await readLinuxStat(pid, procRoot, fsOps);
if (anchor === null) return null;
if (anchor?.startIdentity === item.startIdentity) {
treeState.knownPids.add(childPid);
treeState.knownStarts.set(childPid, child.startIdentity);
// Ownership is now bound to this immutable identity. Later
// reparenting must not turn it back into an unverified candidate.
enqueue(childPid);
}
}
}
}
}
try {
Expand Down
2 changes: 1 addition & 1 deletion plugins/Hylouis233/cli-agent-bridge/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1384,7 +1384,7 @@ export function backendGitProvenanceEnvironment(tracePath, baseEnvironment = pro
return env;
}

async function createBackendGitProvenance(baseEnvironment = process.env, options = {}) {
export async function createBackendGitProvenance(baseEnvironment = process.env, options = {}) {
let root = "";
let tracePath = "";
let handle = null;
Expand Down
58 changes: 58 additions & 0 deletions plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,3 +1058,61 @@ test("BSD process snapshots reject truncation and malformed records", async () =
runUtility: async () => ({ exitCode: 0, stdout: valid + "malformed\n" }),
}), null);
});

test("Linux binds a visible child before scanning its parent's remaining tasks", async () => {
let alive = true;
const fsOps = {
readdir: async target => target === "/fixture-proc/100/task"
? [100, 101].map(pid => ({ name: String(pid), isDirectory: () => true })) : [],
readFile: async target => {
if (target.endsWith("/100/stat") && alive) {
return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 });
}
if (target.endsWith("/200/stat") && alive) {
return procStatLine(200, { parent: 100, group: 100, startIdentity: 20 });
}
if (target.endsWith("/100/task/100/children")) return "200\n";
if (target.endsWith("/100/task/101/children")) {
alive = false; // Both short-lived processes exit during a later task read.
return "\n";
}
throw missingProcessError();
},
};
const state = {
knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]),
runMarker: "fixture-run", markerObservationGraceMs: 0,
};
await refreshProcessTree({ pid: 100 }, state, { platform: "linux", procRoot: "/fixture-proc", fsOps });
assert.equal(state.knownStarts.get(200), "20", "retain identity while the original parent is still observable");
assert.equal(state.processIdentityUncertain, undefined);
assert.equal(await isProcessTreeAlive({ pid: 100 }, state, {
platform: "linux", procRoot: "/fixture-proc", fsOps,
probeProcessGroup: () => { throw missingProcessError("ESRCH"); },
}), false);
});

test("Linux early child binding rejects a parent whose PID identity changed", async () => {
let reads = 0;
const fsOps = {
readdir: async target => target === "/fixture-proc/100/task"
? [{ name: "100", isDirectory: () => true }] : [],
readFile: async target => {
if (target.endsWith("/100/stat")) {
return procStatLine(100, { parent: 1, group: 100, startIdentity: ++reads === 1 ? 10 : 30 });
}
if (target.endsWith("/200/stat")) {
return procStatLine(200, { parent: 100, group: 100, startIdentity: 40 });
}
if (target.endsWith("/100/task/100/children")) return "200\n";
throw missingProcessError();
},
};
const state = {
knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), runMarker: "fixture-run",
};
await refreshProcessTree({ pid: 100 }, state, { platform: "linux", procRoot: "/fixture-proc", fsOps });
assert.equal(state.knownPids.has(200), false);
assert.equal(state.knownStarts.get(200), undefined);
assert.equal(state.processIdentityUncertain, true);
});
66 changes: 59 additions & 7 deletions plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import {
backendConfigurationControlEnvironment, backendConfigurationReaderEnvironment,
backendEntryFromProbe, backendGitProvenanceEnvironment,
closestExistingBase, committedDelta,
closestExistingBase, committedDelta, createBackendGitProvenance,
gitCommonDirectory, gitWorktreeRoot, loadBackends, markWorkspaceQuarantined,
populateCommitishCache, readBackendGitProvenance, readBoundedRegularFile,
readRepositoryLockActivity, readWorkspaceQuarantine, repositoryLockKey,
Expand Down Expand Up @@ -1778,7 +1778,7 @@ test("cancellation requires a fresh workspace_status to reveal earlier edits", a
assert.ok(statusOut.git.changedFiles.includes(changedFile), JSON.stringify(statusOut.git));
});

test("provenance setup obeys cancellation and deadline before worker launch", async (context) => {
test("pre-launch setup obeys cancellation and the overall deadline", async (context) => {
const setupRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-setup-test-"));
const startedFile = path.join(setupRoot, "started.txt");
context.after(() => rm(setupRoot, { recursive: true, force: true }));
Expand All @@ -1804,14 +1804,61 @@ test("provenance setup obeys cancellation and deadline before worker launch", as
name: "expired-trace-setup", eventFile,
}, { timeoutMs: 5_000 }), 51_006);
assert.equal(timed.result.structuredContent.timedOut, true, JSON.stringify(timed));
assert.match(timed.result.structuredContent.error, /preparing Git provenance/iu);
// The absolute request budget includes preflight. A slow runner can spend
// it in an earlier stage; the phase-specific deadline is exercised below.
assert.match(timed.result.structuredContent.error, /worker never started/iu);
assert.deepEqual(await events(eventFile), [], "trace setup interruption must precede worker launch");
for (const traceRoot of (await readFile(startedFile, "utf8")).trim().split(/\r?\n/u)) {
await assert.rejects(access(traceRoot), /ENOENT/u,
"interrupted provenance setup must close its handle and remove its private root");
}
});

test("deadline during open provenance setup removes its private trace", async (context) => {
const setupRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-deadline-"));
const startedFile = path.join(setupRoot, "started.txt");
const settings = {
NODE_ENV: "test",
CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_DELAY_MS: "60000",
CLI_AGENT_BRIDGE_TEST_TRACE_CREATION_STARTED_FILE: startedFile,
};
const saved = Object.fromEntries(Object.keys(settings).map(key => [key, process.env[key]]));
Object.assign(process.env, settings);
context.after(async () => {
context.mock.timers.reset();
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
await rm(setupRoot, { recursive: true, force: true });
});
// Keep real filesystem I/O; advance only the deadline after the trace handle
// is open, so machine-dependent Git startup cannot select the wrong phase.
context.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.now() });
const rejected = assert.rejects(
createBackendGitProvenance({}, { deadline: Date.now() + 5000 }),
{ message: "delegation deadline exceeded" },
);
const waitForIO = async predicate => {
const deadline = performance.now() + 5000;
while (!await predicate()) {
assert.ok(performance.now() < deadline, "filesystem barrier was not reached");
await new Promise(resolve => setImmediate(resolve));
}
};
let traceRoot;
await waitForIO(async () => {
try { traceRoot = (await readFile(startedFile, "utf8")).trim(); return !!traceRoot; }
catch (error) { if (error.code === "ENOENT") return false; throw error; }
});
await access(path.join(traceRoot, "git.trace"));
context.mock.timers.tick(5000);
await rejected;
await waitForIO(async () => {
try { await access(traceRoot); return false; }
catch (error) { if (error.code === "ENOENT") return true; throw error; }
});
});

test("shutdown waits for a late provenance-setup cleanup", async (context) => {
const setupRoot = await mkdtemp(path.join(os.tmpdir(), "cli-agent-trace-shutdown-test-"));
const startedFile = path.join(setupRoot, "started.txt");
Expand Down Expand Up @@ -2467,12 +2514,17 @@ test("canonical worktree locking serializes independent server processes", async
try {
await secondClient.initialize();
const eventFile = path.join(tempRoot, "cross-process-events.jsonl");
let firstResult;
const first = client.request("tools/call", taskArguments(workspace, {
name: "first-server", eventFile, delayMs: 800, writeFile: "first-server.txt",
}));
await waitFor(async () => (await events(eventFile)).some(
(item) => item.name === "first-server" && item.event === "start",
));
})).then((response) => { firstResult = response; return response; });
await waitFor(async () => {
if (firstResult) assert.equal(firstResult.result?.structuredContent?.ok, true,
"first server failed before its start event: " + JSON.stringify(firstResult));
return (await events(eventFile)).some(
(item) => item.name === "first-server" && item.event === "start",
);
});
const second = secondClient.request("tools/call", taskArguments(workspace, {
name: "second-server", eventFile, delayMs: 10, writeFile: "second-server.txt",
}, { allowDirty: true }));
Expand Down
20 changes: 12 additions & 8 deletions plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -558,16 +558,18 @@ test("post-CAS cancellation reconciles the committed owner before returning", as
} else process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE = saved.release;
});
const cancel = cancellationToken();
const acquisition = tryAcquireGitWorkspaceLock({
// Observe the expected rejection before releasing the CAS barrier: the
// operation can reject before the asynchronous release-file write resolves.
const acquisitionRejected = assert.rejects(tryAcquireGitWorkspaceLock({
cwd: repo, key, cancel, heartbeatMs: 60_000,
});
}), WorkspaceLockCancelledError);
await waitForFile(startedFile);
assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u,
"the real acquisition CAS must commit before cancellation");
await writeFile(blocker, "intentional compensating-delete failure\n");
cancel.cancel();
await writeFile(releaseFile, "release\n");
await assert.rejects(acquisition, WorkspaceLockCancelledError);
await acquisitionRejected;
assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u,
"a failed exact delete must leave a recoverable owner ref");
await rm(blocker, { force: true });
Expand Down Expand Up @@ -604,14 +606,14 @@ test("post-CAS deadline reconciliation deletes the exact committed owner", async
});
const passive = { cancelled: false, promise: new Promise(() => {}), subscribe: () => () => {} };
const deadline = Date.now() + 3_000;
const acquisition = tryAcquireGitWorkspaceLock({
const acquisitionRejected = assert.rejects(tryAcquireGitWorkspaceLock({
cwd: repo, key, cancel: passive, deadline, heartbeatMs: 60_000,
});
}), WorkspaceLockDeadlineError);
await waitForFile(startedFile);
assert.match(await git(repo, ["rev-parse", ref]), /^[0-9a-f]{40,64}$/u);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now() + 20)));
await writeFile(releaseFile, "release\n");
await assert.rejects(acquisition, WorkspaceLockDeadlineError);
await acquisitionRejected;
await assert.rejects(
execFileAsync("git", ["rev-parse", "--verify", ref], { cwd: repo }), /Command failed/u,
);
Expand Down Expand Up @@ -654,13 +656,15 @@ test("interrupted state CAS cleanup covers both exact commit outcomes", async (c
}
});
const cancel = cancellationToken();
const update = result.lease.markWorkerStarting({ cancel });
const updateRejected = assert.rejects(
result.lease.markWorkerStarting({ cancel }), WorkspaceLockCancelledError,
);
await waitForFile(startedFile);
const candidateOid = await git(repo, ["rev-parse", ref]);
assert.notEqual(candidateOid, previousOid, "the real state CAS must commit its candidate OID");
cancel.cancel();
await writeFile(releaseFile, "release\n");
await assert.rejects(update, WorkspaceLockCancelledError);
await updateRejected;
delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_STARTED_FILE;
delete process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_CAS_RESULT_RELEASE_FILE;
process.env.CLI_AGENT_BRIDGE_TEST_WORKSPACE_RELEASE_READBACK_FAILURES = "1";
Expand Down
Loading