Skip to content
33 changes: 8 additions & 25 deletions src/evolution-loop.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
appendFile,
Expand Down Expand Up @@ -36,6 +35,10 @@ import {
projectRootFromAiRoot,
withFacultRootScope,
} from "./paths";
import {
processStartIdentity,
processStartIdentityMatches,
} from "./process-identity";
import { reconcileSources, reconciliationStatus } from "./reconciliation";
import { DEFAULT_SOURCE_FRESHNESS_THRESHOLD_HOURS } from "./reconciliation-config";
import type {
Expand Down Expand Up @@ -1502,29 +1505,6 @@ function renderReport(report: EvolutionLoopReport): string {
].join("\n");
}

function processStartIdentity(pid: number): string | undefined {
const result =
process.platform === "win32"
? spawnSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
],
{ encoding: "utf8" }
)
: spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
});
if (result.status !== 0 || typeof result.stdout !== "string") {
return undefined;
}
const startedAt = result.stdout.trim();
return startedAt ? `${process.platform}:${startedAt}` : undefined;
}

async function withLoopLock<T>(args: {
path: string;
leaseMinutes: number;
Expand Down Expand Up @@ -1615,7 +1595,10 @@ async function withLoopLock<T>(args: {
: undefined;
if (
!(recordedProcessStartedAt && observedProcessStartedAt) ||
recordedProcessStartedAt === observedProcessStartedAt
processStartIdentityMatches(
recordedProcessStartedAt,
observedProcessStartedAt
)
) {
throw new Error(
`A live evolution loop owner still holds ${args.path}. If process identity is unavailable and the lease is known to be abandoned, inspect the owner record and remove this one lock file explicitly.`
Expand Down
11 changes: 10 additions & 1 deletion src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,16 @@ function safeMachineStateDirExists(pathValue: string): boolean {
`Refusing unsafe machine-local project state directory: ${pathValue}`
);
}
return true;
const containsFile = (directory: string): boolean =>
readdirSync(directory, { withFileTypes: true }).some((entry) => {
const childPath = join(directory, entry.name);
const child = lstatSync(childPath);
if (child.isDirectory()) {
return containsFile(childPath);
}
return true;
});
return containsFile(pathValue);
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
Expand Down
62 changes: 62 additions & 0 deletions src/process-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect, test } from "bun:test";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import {
processStartIdentity,
processStartIdentityMatches,
} from "./process-identity";

test.skipIf(process.platform === "win32")(
"uses a timezone-invariant Unix process start identity",
async () => {
const fixture = await mkdtemp(join(tmpdir(), "fclt-process-identity-"));
const executableDir = join(fixture, "bin");
const psPath = join(executableDir, "ps");
await mkdir(executableDir);
await writeFile(
psPath,
`#!/bin/sh
case "$TZ" in
UTC) printf '%s\\n' 'Wed Jul 29 16:00:00 2026' ;;
America/Los_Angeles) printf '%s\\n' 'Wed Jul 29 09:00:00 2026' ;;
Asia/Tokyo) printf '%s\\n' 'Thu Jul 30 01:00:00 2026' ;;
*) printf '%s\\n' 'timezone was not fixed' ;;
esac
`,
"utf8"
);
await chmod(psPath, 0o755);
try {
const identityFor = (timezone: string): string | undefined =>
processStartIdentity(process.pid, {
environment: {
...process.env,
PATH: `${executableDir}${delimiter}${process.env.PATH ?? ""}`,
TZ: timezone,
},
});

const expected = `${process.platform}:stable-v1:Wed Jul 29 16:00:00 2026`;
expect(identityFor("America/Los_Angeles")).toBe(expected);
expect(identityFor("Asia/Tokyo")).toBe(expected);
expect(
processStartIdentityMatches(
`${process.platform}:Wed Jul 29 12:00:00 2026`,
expected
)
).toBe(true);
expect(
processStartIdentityMatches(
`${process.platform}:stable-v1:Wed Jul 29 15:59:59 2026`,
expected
)
).toBe(false);
expect(processStartIdentityMatches("original-process", expected)).toBe(
false
);
} finally {
await rm(fixture, { force: true, recursive: true });
}
}
);
53 changes: 53 additions & 0 deletions src/process-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { spawnSync } from "node:child_process";

const STABLE_IDENTITY_VERSION = "stable-v1";

export function processStartIdentity(
pid: number,
options: { environment?: NodeJS.ProcessEnv } = {}
): string | undefined {
const environment = options.environment ?? process.env;
const result =
process.platform === "win32"
? spawnSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
],
{ encoding: "utf8", env: environment }
)
: spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
encoding: "utf8",
env: {
...environment,
LC_ALL: "C",
TZ: "UTC",
},
});
if (result.status !== 0 || typeof result.stdout !== "string") {
Comment thread
roodboi marked this conversation as resolved.
return undefined;
}
const startedAt = result.stdout.trim();
return startedAt
? `${process.platform}:${STABLE_IDENTITY_VERSION}:${startedAt}`
: undefined;
}

export function processStartIdentityMatches(
recorded: string,
observed: string
): boolean {
if (recorded === observed) {
return true;
}
const stablePrefix = `${process.platform}:${STABLE_IDENTITY_VERSION}:`;
const legacyPrefix = `${process.platform}:`;
return (
observed.startsWith(stablePrefix) &&
recorded.startsWith(legacyPrefix) &&
!recorded.startsWith(stablePrefix)
);
}
Loading
Loading