Compare commits

..
Author SHA1 Message Date
Mohamed Boudra fd2e761608 test(server): write Git IO trace markers asynchronously 2026-10-26 18:05:32 +01:00
Mohamed Boudra 593a155562 fix(server): retain native aliases in worktree ownership 2026-10-26 18:05:32 +01:00
Mohamed Boudra 806edd800b fix(server): preserve Windows worktree path spelling 2026-10-26 18:05:32 +01:00
Mohamed Boudra 1a02425db4 fix(server): retain literal symlink targets in Git event identity
Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO.
2026-10-26 18:05:32 +01:00
Mohamed Boudra d78de43e4d fix(server): establish Git identities at observation setup
Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order.
2026-10-26 18:05:32 +01:00
Mohamed Boudra a5f246a18c test(server): verify Git trace records without filesystem probes 2026-10-26 18:05:32 +01:00
Mohamed Boudra 32356db309 fix(server): keep Git observation and worktree IO off the event loop
Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning.
2026-10-26 18:05:32 +01:00
33 changed files with 1363 additions and 1228 deletions
@@ -1,13 +1,9 @@
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import type { ClaudeRewindSdk } from "./rewind.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
@@ -36,10 +32,7 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
@@ -49,36 +42,10 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
rewindSdk,
});
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
return (() => {
const messages = [
{ type: "assistant", uuid: messageId, message: { content: text } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
}
function transcriptPath(): string {
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
}
@@ -98,286 +65,6 @@ describe("ClaudeAgentSession persisted history load", () => {
rmSync(tempRoot, { recursive: true, force: true });
});
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
const startedAt = performance.now();
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
const file = await open(transcriptPath(), "w");
const text = "x".repeat(16 * 1024);
let bytes = 0;
let count = 0;
try {
while (bytes < 32 * 1024 * 1024) {
const line =
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
const daemon = await createTestPaseoDaemon();
let lastTick = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
}, 2);
let loading = true;
let answeredDuringLoad = false;
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
expect(response.status).toBe(200);
await response.text();
answeredDuringLoad = loading;
return undefined;
});
let session: AgentSession | undefined;
try {
session = await resume(replyFactory("live after large resume"));
let index = 0;
for await (const event of session.streamHistory()) {
expect(event).toEqual({
type: "timeline",
provider: "claude",
timestamp: undefined,
item: { type: "assistant_message", text: `${index}:${text}` },
});
index++;
}
loading = false;
await health;
await new Promise((resolve) => setTimeout(resolve, 5));
console.info(
JSON.stringify({
bytes,
count,
longestGap,
answeredDuringLoad,
elapsedMs: performance.now() - startedAt,
}),
);
expect(index).toBe(count);
expect(longestGap).toBeLessThan(50);
expect(answeredDuringLoad).toBe(true);
const live = await collectSessionTurnEvents(session, "follow up");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
]);
} finally {
clearInterval(probe);
await session?.close();
await daemon.close();
}
}, 60_000);
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
const entries = [
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const forkId = "rebound-history";
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
writeFileSync(
forkFile,
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const forks: unknown[] = [];
const late = {
type: "assistant",
uuid: "retired-reply",
message: { content: "retired query" },
};
let pendingRead: ((value: unknown) => void) | undefined;
let releaseRetirement!: () => void;
const retirementGate = new Promise<void>((resolve) => {
releaseRetirement = resolve;
});
let retirementStarted!: () => void;
const retiring = new Promise<void>((resolve) => {
retirementStarted = resolve;
});
let firstQuery = true;
let retired = false;
const queryFactory = (() => {
if (!firstQuery) return replyFactory("live after rebind")({} as never);
firstQuery = false;
const messages = [
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
if (value) return { done: false, value };
if (retired) return { done: true, value: undefined };
return new Promise((resolve) => {
pendingRead = resolve;
});
},
close() {
retired = true;
pendingRead?.({ done: false, value: late });
},
async return() {
retirementStarted();
await retirementGate;
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession(sessionId, options) {
forks.push({ sessionId, ...options });
return { sessionId: forkId };
},
});
try {
const original = await Array.fromAsync(session.streamHistory());
expect(original).toHaveLength(4);
await collectSessionTurnEvents(session, "before rewind");
const emitted: AgentStreamEvent[] = [];
const subscription = session.subscribe((event) => emitted.push(event));
let rewindDone = false;
const rewind = (async () => {
await session.revertConversation!({ messageId: "u2" });
rewindDone = true;
})();
await retiring;
let historyDone = false;
const reading = (async () => {
const history = await Array.fromAsync(session.streamHistory());
historyDone = true;
return history;
})();
const readingAgain = Array.fromAsync(session.streamHistory());
await Promise.resolve();
expect(rewindDone).toBe(false);
expect(historyDone).toBe(false);
releaseRetirement();
await rewind;
subscription();
expect(retired).toBe(true);
expect(
emitted.filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
),
).toEqual([
{
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
},
},
]);
expect(forks).toEqual([
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
]);
expect(session.id).toBe(forkId);
const [history, secondRead] = await Promise.all([reading, readingAgain]);
expect(history).toEqual(original.slice(0, 2));
expect(secondRead).toEqual([]);
const live = await collectSessionTurnEvents(session, "continue the fork");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
]);
} finally {
releaseRetirement();
await session.close();
}
});
test("keeps history and live turns available after query retirement fails during rebind", async () => {
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(
transcriptPath(),
[
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
let firstQuery = true;
let finishRead: ((value: unknown) => void) | undefined;
const queryFactory = ((input) => {
const query = replyFactory(
firstQuery ? "before retirement" : "still available",
firstQuery ? "before-retirement" : "live-reply",
)(input);
if (!firstQuery) return query;
firstQuery = false;
let reads = 0;
return {
...query,
async next() {
if (++reads <= 2) return query.next();
return new Promise((resolve) => {
finishRead = resolve;
});
},
close() {
finishRead?.({ done: true, value: undefined });
throw new Error("retirement failed");
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession() {
return { sessionId: "failed-rebind" };
},
});
try {
await Array.fromAsync(session.streamHistory());
await collectSessionTurnEvents(session, "before rewind");
await expect(session.revertConversation!({ messageId: "u2" })).resolves.toBeUndefined();
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to rebind Claude session",
sessionId: "failed-rebind",
err: expect.objectContaining({ message: "retirement failed" }),
}),
);
expect(await Array.fromAsync(session.streamHistory())).toEqual([]);
const live = await collectSessionTurnEvents(session, "after failed rebind");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
} finally {
await session.close();
}
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
@@ -613,21 +300,21 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", async () => {
test("loads readable history when the temporary directory is unavailable", () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
withClaudeReplayHistory(file, (history) => {
expect([...history.parentEntries]).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
async (target) => {
(target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
@@ -647,16 +334,14 @@ describe("ClaudeAgentSession persisted history load", () => {
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
await withClaudeReplayHistory(parent, async (history) => {
withClaudeReplayHistory(parent, (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect([...history.parentEntries]).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
}
});
},
@@ -1,4 +1,3 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
@@ -83,7 +82,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1635,7 +1634,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
const session = new ClaudeAgentSession(claudeConfig, {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
@@ -1646,8 +1645,6 @@ export class ClaudeAgentClient implements AgentClient {
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
@@ -2332,9 +2329,6 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
private historyReady: Promise<void> | null = null;
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -2388,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.historySessionId = handle.sessionId;
this.loadPersistedHistory(handle.sessionId);
} else {
this.claudeSessionId = null;
this.persistence = null;
@@ -2468,7 +2462,6 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
@@ -2639,7 +2632,6 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
@@ -2651,9 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
let replayed = 0;
for (const entry of history) {
if (++replayed % 32 === 0) await setImmediate();
yield {
type: "timeline",
item: entry.item,
@@ -2971,7 +2961,6 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.session_close.start",
);
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
this.subscribers.clear();
@@ -3012,7 +3001,7 @@ class ClaudeAgentSession implements AgentSession {
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
// (metadata generator, branch-name generator) don't show up as resumable.
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
@@ -3056,7 +3045,6 @@ class ClaudeAgentSession implements AgentSession {
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
@@ -3066,15 +3054,16 @@ class ClaudeAgentSession implements AgentSession {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
setSessionId: (sessionId) => {
this.rebindConversationSession(sessionId);
},
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
@@ -3249,51 +3238,38 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
private rebindConversationSession(sessionId: string): Promise<void> {
const previousReady = this.ensureHistoryReady();
this.historyReady = (async () => {
await previousReady;
await this.retireQuery();
if (this.closed) return;
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historySessionId = sessionId;
await this.loadPersistedHistory(sessionId);
if (this.closed) return;
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
})().catch((error) => {
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
});
return this.historyReady;
private rebindConversationSession(sessionId: string): void {
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.loadPersistedHistory(sessionId);
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
}
private startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
@@ -3414,43 +3390,7 @@ class ClaudeAgentSession implements AgentSession {
return { kind: "fresh-session" };
}
private async retireQuery(): Promise<void> {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
await this.ensureHistoryReady();
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
@@ -3458,7 +3398,37 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.queryRestartNeeded && this.query) {
await this.retireQuery();
const oldQuery = this.query;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
@@ -4125,15 +4095,13 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
this.routeSdkMessageFromPump(message);
await this.routeSdkMessageFromPump(message);
return false;
} finally {
this.processingSdkMessages--;
@@ -4253,11 +4221,7 @@ class ClaudeAgentSession implements AgentSession {
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
}
private ownsQuery(query: Query): boolean {
return !this.closed && this.query === query;
}
private routeSdkMessageFromPump(message: SDKMessage): void {
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.consumeSessionStateChange(message)) {
return;
}
@@ -4291,7 +4255,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.parsed_event",
);
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
if (events.length === 0) {
return;
@@ -4326,11 +4290,11 @@ class ClaudeAgentSession implements AgentSession {
this.dispatchEvents(events);
}
private buildPumpedMessageEvents(
private async buildPumpedMessageEvents(
message: SDKMessage,
messageIdHint: string | null,
turnId: string | null,
): AgentStreamEvent[] {
): Promise<AgentStreamEvent[]> {
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
@@ -5308,31 +5272,22 @@ class ClaudeAgentSession implements AgentSession {
}
}
ensureHistoryReady(): Promise<void> {
if (!this.historyReady) {
this.historyReady = this.historySessionId
? this.loadPersistedHistory(this.historySessionId)
: Promise.resolve();
}
return this.historyReady;
}
private async loadPersistedHistory(sessionId: string): Promise<void> {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
withClaudeReplayHistory(historyPath, (history) => {
const replay = this.ingestPersistedSidechains(history);
this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
@@ -5344,12 +5299,12 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
private ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): Promise<void> {
): void {
const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) {
for (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
@@ -5359,33 +5314,27 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagents = [];
for (const subagent of history.subagents) {
subagents.push({
const subagentReplay = observeReplaySubagents({
subagents: history.subagents.map((subagent) => ({
...subagent,
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parentFacts: readClaudeReplayParentFacts(subagent.entries),
})),
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...(await observeReplayWorkflows({
...observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
}),
];
const restoredProviderSubagentIds = new Set(
observations
@@ -5472,10 +5421,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPath({
return claudeTranscriptPathSync({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
@@ -6242,13 +6191,13 @@ function labelReplayedSubagentCard(
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry>,
): ClaudeReplayParentFacts {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for await (const entry of entries) {
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -6780,7 +6729,7 @@ async function readClaudeRenamedSessionTitle(input: {
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6880,13 +6829,3 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
state: record.state as ClaudeCommandLifecycle["state"],
};
}
async function transcriptExists(file: string): Promise<boolean> {
try {
await fs.promises.access(file);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
@@ -1,5 +1,5 @@
import { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export async function claudeTranscriptPath(input: {
export function claudeTranscriptPathSync(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): Promise<string> {
}): string {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = await readdir(projectsRoot);
projectDirs = readdirSync(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,23 +58,13 @@ export async function claudeTranscriptPath(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (await exists(candidate)) {
if (existsSync(candidate)) {
return candidate;
}
}
return expected;
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch (error) {
if (isMissingDirectoryError(error)) return false;
throw error;
}
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void | Promise<void>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
await input.setSessionId(fork.sessionId);
input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void | Promise<void>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -45,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", async () => {
it("reads the real sidecar shape", () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -68,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", async () => {
it("keeps the good fields when one field has the wrong type", () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -79,14 +79,12 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("links a subagent to its Task call through meta.toolUseId", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -97,33 +95,31 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", async () => {
it("replays descendants beneath the sidechain that declared their Task", () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations).toContainEqual(
expect.objectContaining({
@@ -134,143 +130,89 @@ describe("observeReplaySubagents", () => {
);
});
it("keeps a subagent recorded before spawnDepth existed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("keeps a subagent recorded before spawnDepth existed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", async () => {
it("falls back to the scraped link when there is no meta file", () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
const [declared, ...rest] = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
}).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("drops an unlinkable subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", async () => {
it("drops a terminal subagent the parent has no Task call for", () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
it("drops a terminal skill-spawned subagent that has no link at all", () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", async () => {
const observations = (
await observeReplaySubagents({
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -279,81 +221,111 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
}).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations,
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations;
it("leaves a subagent with no recorded outcome running", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
it("reports a failed outcome as failed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
it("reports a failed outcome as failed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
}).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
it("carries entry timestamps onto timeline observations", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
})
).observations;
it("carries entry timestamps onto timeline observations", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
}).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -364,20 +336,18 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
it("reads the model and effort off the subagent's own transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("reads the model and effort off the subagent's own transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -386,55 +356,49 @@ describe("replay runtime", () => {
});
});
it("takes the last observed values when the model changes mid-transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("takes the last observed values when the model changes mid-transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
});
});
it("reports nothing when the transcript never names a model or effort", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("reports nothing when the transcript never names a model or effort", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
it("passes through a model the manifest does not know", async () => {
it("passes through a model the manifest does not know", () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -442,24 +406,20 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
async function subtitleOf(
entries: ClaudeReplayEntry[],
): Promise<SubagentObservation | undefined> {
return (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations.find((observation) => observation.kind === "subtitle");
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
return observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
// and must not be added in.
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -502,9 +462,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", async () => {
it("formats tokens for a child that only answered", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -519,12 +479,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -534,8 +494,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -546,9 +506,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -559,36 +519,34 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", async () => {
it("only reports counters the transcript actually shows", () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = await subtitleOf([
const observation = subtitleOf([
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
]);
expect(observation).toBeUndefined();
});
it("lands the complete provider-owned subtitle on the descriptor", async () => {
it("lands the complete provider-owned subtitle on the descriptor", () => {
const store = new ProviderSubagentStore();
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -601,7 +559,7 @@ describe("live and replay agree", () => {
// The property the design exists to guarantee: one session, two sources, one descriptor.
// This is inexpressible against the inference implementation, where the paths share no
// vocabulary and each derives identity and status its own way.
it("produces identical descriptor state from the wire and from disk", async () => {
it("produces identical descriptor state from the wire and from disk", () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -621,23 +579,21 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -250,13 +250,13 @@ function observeSubtitle(
};
}
async function observeSubagent(
function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
toolOwners: Map<string, string>,
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
): SubagentObservation[] {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
@@ -269,7 +269,7 @@ async function observeSubagent(
let childTerminalStatus: ProviderSubagentStatus | null = null;
// Consume each source once for presentation and timeline; keep no raw transcript records.
for await (const entry of subagent.entries) {
for (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
}
}
export async function observeReplaySubagents(input: {
export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
@@ -346,7 +346,7 @@ export async function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
for (const observation of await observeSubagent(
for (const observation of observeSubagent(
subagent,
parent,
input.convertEntry,
@@ -35,7 +35,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -62,8 +62,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -91,8 +91,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -114,7 +114,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", async () => {
it("replays the workflow result without exposing its script or child prompt", () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -128,7 +128,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -145,9 +145,9 @@ describe("Claude workflow replay", () => {
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
});
it("does not duplicate a result already present in the child transcript", async () => {
it("does not duplicate a result already present in the child transcript", () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -166,8 +166,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -179,9 +179,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", async () => {
it("drops a run that no parent Workflow tool call declared", () => {
expect(
await observeReplayWorkflows({
observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -45,17 +45,14 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
* row that can never receive another update.
*/
export async function observeReplayWorkflows(input: {
export function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -82,7 +79,7 @@ export async function observeReplayWorkflows(input: {
});
}
for (const observation of await observeWorkflowTimeline({
for (const observation of observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
@@ -118,17 +115,17 @@ export async function observeReplayWorkflows(input: {
return observations;
}
async function observeWorkflowTimeline(input: {
function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
finishedAt?: string;
}): Promise<SubagentObservation[]> {
}): SubagentObservation[] {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
for await (const entry of input.entries) {
for (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = input.convertEntry?.(entry) ?? [];
for (const item of items) {
@@ -153,13 +150,11 @@ async function observeWorkflowTimeline(input: {
return observations;
}
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -170,7 +165,7 @@ async function readWorkflowLinks(
}
}
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -221,11 +221,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
for await (const entry of entries) {
for (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
@@ -1,6 +1,5 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -25,11 +24,11 @@ interface RecordLocation {
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
async function* locatedRecords(
function* locatedRecords(
file: string,
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
fd: number,
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = fs.fstatSync(fd);
// Opening a directory succeeds on some platforms; it must never look like empty history.
if (stat.isDirectory()) {
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
@@ -44,7 +43,7 @@ async function* locatedRecords(
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
@@ -65,8 +64,6 @@ async function* locatedRecords(
if (entry) yield { entry, location };
start = end + 1;
}
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
await setImmediate();
if (start < count) {
pieces.push(buffer.subarray(start, count));
length += count - start;
@@ -88,25 +85,20 @@ async function* locatedRecords(
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
descriptors: ReadonlyMap<string, number>,
): Iterable<TranscriptEntry> {
return {
async *[Symbol.asyncIterator]() {
*[Symbol.iterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
function* readEntries(): Generator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
@@ -135,9 +127,9 @@ function parseRecord(line: string): TranscriptEntry | null {
}
}
async function readOptionalFile(file: string): Promise<string | null> {
function readOptionalFile(file: string): string | null {
try {
return await fs.readFile(file, "utf8");
return fs.readFileSync(file, "utf8");
} catch {
return null;
}
@@ -146,40 +138,31 @@ async function readOptionalFile(file: string): Promise<string | null> {
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
* by the replay ownership resolver; workflow children retain their separate run ownership. */
export interface ClaudeReplayHistory {
parentEntries: AsyncIterable<TranscriptEntry>;
parentEntries: Iterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: AsyncIterable<TranscriptEntry>;
entries: Iterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export async function withClaudeReplayHistory<T>(
export function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
consume: (history: ClaudeReplayHistory) => T,
): T {
const descriptors = new Map<string, number>();
try {
return await consume(await indexHistory(historyPath, descriptors));
return consume(indexHistory(historyPath, descriptors));
} finally {
await closeReplaySources(descriptors.values());
for (const fd of descriptors.values()) fs.closeSync(fd);
}
}
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
}
async function indexHistory(
historyPath: string,
descriptors: Map<string, FileHandle>,
): Promise<ClaudeReplayHistory> {
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
@@ -191,7 +174,8 @@ async function indexHistory(
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
for (const entry of await optionalDirectory(directory)) {
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
@@ -210,23 +194,23 @@ async function indexHistory(
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = await readOptionalFile(file);
const contents = readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
async function scan(
function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): Promise<void> {
const fd = await fs.open(file, "r");
): void {
const fd = fs.openSync(file, "r");
descriptors.set(file, fd);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
await scan(file, (entry, location) => {
scan(file, (entry, location) => {
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
const locations = locationsByAgentId.get(entry.agentId) ?? [];
@@ -234,11 +218,11 @@ async function indexHistory(
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
await scan(file, (entry, location) => {
scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
@@ -256,28 +240,21 @@ async function indexHistory(
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: await readWorkflows(sessionDirectory),
workflows: readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
if (fs.existsSync(workflowDirectory)) {
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
}
return workflows;
}
async function optionalDirectory(directory: string) {
try {
return await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
@@ -0,0 +1,106 @@
import { realpath } from "node:fs";
import { readlink } from "node:fs/promises";
import { promisify } from "node:util";
import pLimit from "p-limit";
import { dirname, resolve as resolvePath, sep } from "node:path";
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
const nativeRealpath = promisify(realpath.native);
const jsRealpath = promisify(realpath);
const identityIo = pLimit(2);
/** A subscription owns its lexical namespace, including the OS's real-root alias. */
export class GitObservationPaths {
private constructor(
private readonly root: string,
private readonly aliases: string[],
private readonly windows: boolean,
) {}
static async create(root: string): Promise<GitObservationPaths> {
const windows = looksLikeDefiniteWindowsPath(root);
const normalizePath = (value: string) =>
windows
? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase()
: normalizePathPreservingCase(value, false);
const aliases = new Set([normalizePath(root)]);
// Sequential: foreign paths must not fan out into the shared libuv pool.
for (const resolve of [nativeRealpath, jsRealpath]) {
try {
aliases.add(normalizePath(await identityIo(() => resolve(root))));
} catch {
/* Missing roots retain lexical identity. */
}
}
// A link may name /var/... while realpath returns /private/var/... on macOS.
// Retain literal targets too, including chains, without following links in callbacks.
let current = root;
const visited = new Set<string>();
for (let remaining = 40; remaining > 0; remaining--) {
const alias = normalizePath(current);
if (visited.has(alias)) break;
visited.add(alias);
aliases.add(alias);
try {
const target = await identityIo(() => readlink(current));
current = resolvePath(dirname(current), target);
} catch {
break;
}
}
return new GitObservationPaths(root, [...aliases], windows);
}
relative(candidate: string): string | null {
const normalized = this.normalizeEventPath(candidate);
const comparable = this.windows ? normalized.toLowerCase() : normalized;
for (const alias of this.aliases) {
if (comparable === alias) return "";
const prefix = alias.endsWith("/") ? alias : `${alias}/`;
if (comparable.startsWith(prefix)) {
// Slice the original spelling, preserving case in ref names on Windows.
return normalized.slice(prefix.length);
}
}
return null;
}
// Compile excluded subtrees in every established root namespace. The event callback
// only compares strings; neither an ignored root nor an event needs another realpath.
createEventFilter(excludedRoots: Iterable<string>): (candidate: string) => boolean {
const excluded: string[] = [];
for (const root of excludedRoots) {
const relative = this.relative(root);
if (relative === null) continue;
for (const alias of this.aliases) {
const subtree = relative ? `${alias}/${relative}` : alias;
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
}
}
const prefixes = excluded.map((root) => `${root}/`);
return (candidate) => {
const normalized = this.normalizeEventPath(candidate);
const comparable = this.windows ? normalized.toLowerCase() : normalized;
if (this.aliases.includes(comparable)) return false;
for (let i = 0; i < excluded.length; i++) {
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
}
return true;
};
}
private normalizeEventPath(candidate: string): string {
if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/");
if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/"))
return normalizePathPreservingCase(candidate, false);
return candidate;
}
matches = (candidate: string): boolean => this.relative(candidate) === "";
toWatchPath(candidate: string): string {
const relative = this.relative(candidate);
if (relative === null) return candidate;
return relative ? `${this.root}${sep}${relative}` : this.root;
}
}
@@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
);
expect(created.worktree.branchName).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "pending",
@@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
branchName: "renamed-from-agent-context",
});
expect(branchAfterFirst).toBe("renamed-from-agent-context");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
.toString()
.trim(),
).toBe("dazzling-yak");
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
@@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
createDeps(),
);
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
});
@@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority(
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
const createdWorktree = await createWorktreeCore(input, deps);
try {
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
targetWorktreePath: createdWorktree.worktree.worktreePath,
@@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: {
return { attempted: false, renamed: false, branchName: null };
}
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
let metadata: Awaited<ReturnType<typeof readPaseoWorktreeMetadata>>;
try {
metadata = readPaseoWorktreeMetadata(options.cwd);
metadata = await readPaseoWorktreeMetadata(options.cwd);
} catch {
return { attempted: false, renamed: false, branchName: null };
}
@@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: {
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
return { attempted: true, renamed: false, branchName: null };
}
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
const branchName = await options.generateBranchNameFromContext({
cwd: options.cwd,
@@ -264,15 +264,15 @@ async function findAvailableBranchName(options: {
return null;
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {
}): Promise<void> {
const { createdWorktree } = options;
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
return;
}
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
placeholderBranchName: createdWorktree.worktree.branchName,
});
}
@@ -9228,8 +9228,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" });
writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" });
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
placeholderBranchName: "placeholder-branch",
});
const workspaceCwd = path.join(repoDir, "packages", "app");
@@ -9288,6 +9288,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
firstAgentContext: { prompt: "Fix checkout title" },
});
await vi.runAllTimersAsync();
await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd]));
expect(generateCalls).toBe(1);
expect(stored.get(workspace.workspaceId)).toMatchObject({
@@ -9299,7 +9300,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async ()
.toString()
.trim(),
).toBe("placeholder-branch");
expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({
expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
@@ -347,7 +347,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) {
test("preserves ordinary checkout behavior when no separate base was recorded", async () => {
const fixture = await createBaseRecoveryFixture(null);
writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" });
await fixture.archiveAndRemove();
await fixture.service.restore(fixture.workspace.workspaceId);
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull();
@@ -430,7 +430,7 @@ test("restores the branch HEAD with a base name when the exact base is missing",
});
expect(existsSync(fixture.workspace.cwd)).toBe(true);
expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull();
const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd);
const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd);
expect(metadata).toMatchObject({ baseRefName: "main" });
expect(metadata?.baseRef).toBeUndefined();
expect(
@@ -450,7 +450,7 @@ test("restore fetches a deleted local branch from origin and retains its base",
await fixture.archiveAndRemove();
execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir });
await fixture.service.restore(fixture.workspace.workspaceId);
expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main");
expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main");
expect(
await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }),
).toMatchObject({
@@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) {
commitFile(cwd, "feature.txt", "feature change\n");
const head = runGit(cwd, "rev-parse", "HEAD");
await client.checkoutRefresh(cwd);
expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true });
const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" });
expect(before.error).toBeNull();
expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]);
@@ -526,3 +526,183 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu
rmSync(tempDir, { recursive: true, force: true });
}
}, 15_000);
test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const { fileURLToPath } = await import("node:url");
const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url));
const source = `
import assert from 'node:assert/strict';
import { write } from 'node:fs';
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setImmediate as tick } from 'node:timers/promises';
import { createHook } from 'node:async_hooks';
import pino from 'pino';
import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts';
import { runGitCommand } from './packages/server/src/utils/run-git-command.ts';
import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts';
const root = await mkdtemp(join(tmpdir(), 'git-async-contract-'));
const home = join(root, 'home');
const repo = join(root, 'repo');
const worktree = join(home, 'worktrees', 'project', 'topic');
const alias = join(root, 'alias');
const logger = pino({ level: 'trace' }, { write() {} });
// Async descriptor writes avoid Windows stderr's synchronous stream API.
const marker = (label) => new Promise((resolve, reject) => {
write(2, label + '\\n', (error) => error ? reject(error) : resolve());
});
let callback;
let notifications = 0;
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: {
subscribe: async (_root, onChange) => {
callback = onChange;
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
}
} });
try {
await mkdir(repo);
await runGitCommand(['init', '-b', 'main'], { cwd: repo });
await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo });
await mkdir(join(home, 'worktrees', 'project'), { recursive: true });
await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo });
await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' });
await writeFile(join(worktree, '.gitignore'), 'ignored/\\n');
await mkdir(join(worktree, 'ignored'));
await writeFile(join(worktree, 'ignored', 'noise'), 'noise');
await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir');
const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; });
const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') }));
// A shared CI runner can deschedule the child during a wall-clock sample.
// Keep the 20ms gate, but take three independent batches and require one
// responsive callback/timer pair. All batches must remain filesystem-free.
const samples = [];
for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) {
await tick();
let fsCalls = 0;
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
await marker('FILTER_START');
hook.enable();
const started = performance.now();
const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0));
callback(null, events);
const filterMs = performance.now() - started;
hook.disable();
const loopGapMs = await pulse;
await marker('FILTER_END');
assert.equal(fsCalls, 0, "filesystem calls");
assert.equal(notifications, 0, "ignored event notification");
samples.push({ filterMs, loopGapMs, fsCalls });
}
console.log(JSON.stringify({ ...samples[0], samples }));
assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples));
// Alias identity remains useful after deletion: no per-event realpath can recover this file.
callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]);
assert.equal(notifications, 1);
await marker('REFRESH_START');
const snapshot = await service.getSnapshot(worktree);
assert.equal(snapshot.git.isGit, true);
assert.equal(snapshot.git.isPaseoOwnedWorktree, true);
await runGitCommand(['status', '--porcelain'], { cwd: alias, logger });
await marker('REFRESH_END');
subscription.unsubscribe();
} finally {
await service.dispose();
await rm(root, { recursive: true, force: true });
}
`;
for (const tracing of [false, true]) {
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
...(tracing ? ["--trace-sync-io"] : []),
"--input-type=module",
"--eval",
source,
],
{
cwd: repositoryRoot,
env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" },
timeout: 30_000,
maxBuffer: 4 * 1024 * 1024,
},
).catch((error) => {
throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]"));
});
for (const phase of ["FILTER", "REFRESH"]) {
const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0];
expect(output, `${phase}: synchronous filesystem IO`).toBe("");
}
expect(JSON.parse(stdout.trim().split("\n")[0])).toMatchObject({ fsCalls: 0 });
}
}, 60_000);
test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => {
const { GitObservationPaths } = await import("./git-observation-paths.js");
const paths = await GitObservationPaths.create("C:\\paseo-observation-contract");
const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]);
expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe(
"refs/heads/MixedCase",
);
expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false);
expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true);
});
test("concurrent alias requests establish one working-tree subscription", async () => {
const { symlink } = await import("node:fs/promises");
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-")));
const repo = path.join(root, "repo");
const alias = path.join(root, "alias");
execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" });
await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir");
let subscriptions = 0;
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
deps: {
subscribe: async () => {
subscriptions++;
return { updateIgnore: async () => {}, unsubscribe: async () => {} };
},
},
});
try {
const watches = await Promise.all([
service.requestWorkingTreeWatch(repo, () => {}),
service.requestWorkingTreeWatch(alias, () => {}),
]);
expect(subscriptions).toBe(1);
for (const watch of watches) watch.unsubscribe();
} finally {
await service.dispose();
rmSync(root, { recursive: true, force: true });
}
});
test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => {
const { mkdir, symlink } = await import("node:fs/promises");
const { GitObservationPaths } = await import("./git-observation-paths.js");
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-")));
const repository = path.join(root, "repository");
const parentAlias = path.join(root, "parent-alias");
const workspaceAlias = path.join(root, "workspace-alias");
const literalTarget = path.join(parentAlias, "repository");
const linkType = process.platform === "win32" ? "junction" : "dir";
try {
await mkdir(repository);
await symlink(root, parentAlias, linkType);
await symlink(literalTarget, workspaceAlias, linkType);
const paths = await GitObservationPaths.create(workspaceAlias);
const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]);
for (const spelling of [workspaceAlias, literalTarget, repository]) {
expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt");
expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => {
]);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => {
expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]);
expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort());
});
first.unsubscribe();
@@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await vi.waitFor(() => {
expect(watcher.subscribe).toHaveBeenCalledWith(
GIT_DIR,
expect.any(Function),
expect.any(Object),
);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
@@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => {
[60_000, 6],
] as const) {
await vi.advanceTimersByTimeAsync(elapsedMs);
expect(pollCount()).toBe(expectedPolls);
await vi.waitFor(() => {
expect(pollCount()).toBe(expectedPolls);
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
}
isDirty = true;
await vi.advanceTimersByTimeAsync(60_000);
expect(pollCount()).toBe(7);
await vi.waitFor(() => expect(pollCount()).toBe(7));
await vi.advanceTimersByTimeAsync(5_000);
expect(pollCount()).toBe(8);
await vi.waitFor(() => expect(pollCount()).toBe(8));
subscription.unsubscribe();
service.dispose();
@@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => {
expect(watcher.subscribe).toHaveBeenCalledTimes(1);
});
await vi.advanceTimersByTimeAsync(35_000);
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
await vi.waitFor(() => {
expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0);
expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0);
});
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
openedSubscription.resolve({
@@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(subscribe).toHaveBeenCalledTimes(2);
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2));
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
@@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
service.dispose();
await service.dispose();
});
test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => {
@@ -42,18 +42,13 @@ import {
type ForgeResolver,
} from "../services/forge-resolver.js";
import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js";
import {
createRealpathAwarePathMatcher,
getRealpathAwareRelativePath,
isPathInsideRoot,
isRealpathInsideRoot,
looksLikeDefiniteWindowsPath,
} from "../utils/path.js";
import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js";
import {
createRunGitCommand,
runGitCommand,
type RunGitCommand,
} from "../utils/run-git-command.js";
import { GitObservationPaths } from "./git-observation-paths.js";
import { branchNameFromRef } from "../utils/worktree-metadata.js";
import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
@@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error {
}
interface WorkspaceGitTarget {
gitDirPaths: GitObservationPaths | null;
cwd: string;
listeners: Set<WorkspaceGitListener>;
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
@@ -443,6 +439,7 @@ interface WorkspaceGitTarget {
}
interface RepoGitTarget {
paths: GitObservationPaths;
repoGitRoot: string;
cwd: string;
workspaceKeys: Set<string>;
@@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh {
}
interface WorkingTreeWatchTarget {
paths: GitObservationPaths;
acceptsEvent: (candidate: string) => boolean;
cwd: string;
watchPath: string;
repoRoot: string | null;
@@ -1165,6 +1164,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async resolveWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null;
this.assertNotDisposed();
const targetCwd = repoRoot ?? cwd;
const existingTarget = this.workingTreeWatchTargets.get(targetCwd);
if (existingTarget) {
@@ -1179,7 +1180,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return target;
}
const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd;
const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd;
const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => {
if (this.workingTreeWatchSetups.get(targetCwd) === setup) {
this.workingTreeWatchSetups.delete(targetCwd);
@@ -1198,6 +1199,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
const target: WorkspaceGitTarget = {
gitDirPaths: null,
cwd,
listeners: new Set(),
workingTreeWatchTarget: null,
@@ -1299,6 +1301,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot);
const gitDir = facts.absoluteGitDir;
const gitDirPaths = target.gitDirPaths?.matches(gitDir)
? target.gitDirPaths
: await GitObservationPaths.create(gitDir);
if (!this.isActiveObservedWorkspaceTarget(target)) return;
target.gitDirPaths = gitDirPaths;
const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir));
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
@@ -1331,7 +1338,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const promise = this.deps
.getCheckoutSnapshotFacts(target.cwd, context)
.then((facts) => {
target.latestFacts = facts;
if (!target.closed) target.latestFacts = facts;
return facts;
})
.finally(() => {
@@ -1359,7 +1366,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const ignoredDirectories = repoRoot
? (await this.loadIgnoredDirs(watchPath)).ignored
: new Set<string>();
const paths = await GitObservationPaths.create(watchPath);
this.assertNotDisposed();
const target: WorkingTreeWatchTarget = {
paths,
acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]),
cwd,
watchPath,
repoRoot,
@@ -1523,15 +1534,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (watcherErrored) {
return;
}
events = this.relevantWorkingTreeEvents(target, events);
if (events.length === 0) return;
const discovered = this.noteWorkingTreeDirectories(target, events);
if (events.some((event) => basename(event.path) === ".gitignore")) {
void this.refreshWorkingTreeIgnoredDirectories(target);
} else if (discovered) {
this.scheduleWorkingTreeIgnoreRefresh(target);
}
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
return;
}
this.notifyWorkingTreeChanged(target, "working-tree-watch");
},
{ ignore },
@@ -1763,20 +1773,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
this.removeDeletedKnownDirectories(target, events);
const gitDir = join(target.watchPath, ".git");
// `matchesWatchPath` is realpath-aware because it compares an event-reported
// directory against `target.watchPath` itself — a config-time root that can
// legitimately differ from the watcher's reported form (e.g. a symlinked
// temp dir). It is now gated behind the plain Set lookup below, so it only
// runs for a directory this target has never seen before, not on every
// event.
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
// Event paths have already been mapped into this subscription's established namespace.
const matchesWatchPath = target.paths.matches;
// One new directory is enough: the scheduled Git query inventories every
// ignored directory that exists by then. Stop before a large event batch
// multiplies containment checks across all its new directories.
for (const event of events) {
const directory = dirname(event.path);
// Cheap Set lookup first — the steady-state case where the directory is
// already known must never reach the realpath-aware matcher below.
// already known do not need another root comparison.
if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) {
continue;
}
@@ -1955,28 +1960,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.pendingKnownDirectories.clear();
}
private hasRelevantWorkingTreeEvent(
private relevantWorkingTreeEvents(
target: WorkingTreeWatchTarget,
events: FileChange[],
): boolean {
const gitDir = join(target.watchPath, ".git");
const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath);
return events.some((event) => {
// Directory metadata changes at the watch root do not change Git state.
// FSEvents may emit these when every changed descendant was ignored.
if (matchesWatchPath(event.path)) {
return false;
): FileChange[] {
const relevant: FileChange[] = [];
// Watch backends may report the same path more than once in a batch.
const acceptedPaths = new Map<string, boolean>();
for (const event of events) {
let accepted = acceptedPaths.get(event.path);
if (accepted === undefined) {
accepted = target.acceptsEvent(event.path);
acceptedPaths.set(event.path, accepted);
}
if (isRealpathInsideRoot(gitDir, event.path)) {
return false;
}
for (const ignoredDirectory of target.ignoredDirectories) {
if (isRealpathInsideRoot(ignoredDirectory, event.path)) {
return false;
}
}
return true;
});
if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) });
}
return relevant;
}
private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise<void> {
@@ -2031,6 +2030,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) {
target.ignoredDirectories = ignoredDirectories;
target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target));
this.pruneKnownDirectories(target);
}
if (target.fallbackPolling) {
@@ -2152,7 +2152,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
const paths = await GitObservationPaths.create(repoGitRoot);
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return;
const establishedTarget = this.repoTargets.get(repoGitRoot);
if (establishedTarget) {
establishedTarget.workspaceKeys.add(workspaceTarget.cwd);
return;
}
const repoTarget: RepoGitTarget = {
paths,
repoGitRoot,
cwd: workspaceTarget.cwd,
workspaceKeys: new Set([workspaceTarget.cwd]),
@@ -2205,10 +2213,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: RepoGitTarget,
options?: { replaceFallback?: boolean },
): Promise<boolean> {
const paths = target.paths;
const ignore = getPrunedGitMetadataPaths("common").map((path) =>
join(target.repoGitRoot, path),
);
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
const matchesRepoGitRoot = paths.matches;
const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot);
let openedSubscription: FileObserverSubscription | null = null;
let watcherErrored = false;
@@ -2223,7 +2232,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const subscription = await this.subscribeWithDeadline(
target.repoGitRoot,
(error, events) => {
const liveEvents = canary.filterEvents(events);
const liveEvents = canary.filterEvents(
events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })),
);
if (error) {
if (watcherErrored) {
return;
@@ -2249,7 +2260,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const relevantEvents = liveEvents.filter(
(event) =>
!matchesRepoGitRoot(event.path) &&
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)),
);
if (relevantEvents.length > 0) {
const immediateEvents = target.fetchInFlight
@@ -2362,7 +2373,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
events: FileChange[],
): Map<string, RepoMetadataWorkspaceRefresh> | null {
const refreshes = new Map<string, RepoMetadataWorkspaceRefresh>();
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
const matchesRepoGitRoot = target.paths.matches;
for (const event of events) {
if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null;
@@ -2377,10 +2388,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): void {
const workspaceKeys = new Set<string>();
for (const event of events) {
const commonRelativePath = getRealpathAwareRelativePath(
target.repoGitRoot,
event.path,
)?.replaceAll("\\", "/");
const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/");
if (commonRelativePath === "config" || commonRelativePath === "info/exclude") {
for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey);
continue;
@@ -2390,8 +2398,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (
facts?.isGit &&
facts.absoluteGitDir &&
getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") ===
"config.worktree"
this.workspaceTargets
.get(workspaceKey)
?.gitDirPaths?.relative(event.path)
?.replaceAll("\\", "/") === "config.worktree"
) {
workspaceKeys.add(workspaceKey);
}
@@ -2407,7 +2417,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
const relativePath = target.paths.relative(event.path);
const effect = classifyGitMetadataPath("common", relativePath ?? "");
return (
(effect.kind === "ref" && effect.namespace === "remote") ||
@@ -2419,12 +2429,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private routeRepoMetadataEvent(
target: RepoGitTarget,
event: FileChange,
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
matchesRepoGitRoot: (candidate: string) => boolean,
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
): boolean {
if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true;
const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
const commonRelativePath = target.paths.relative(event.path);
const effect = classifyGitMetadataPath("common", commonRelativePath ?? "");
switch (effect.kind) {
case "ignore":
@@ -2476,7 +2486,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private routePrivateGitDirEvent(
target: RepoGitTarget,
event: FileChange,
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
matchesRepoGitRoot: (candidate: string) => boolean,
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
): boolean {
let matched = false;
@@ -2485,8 +2495,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) {
continue;
}
const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path);
if (relativePath === null) continue;
const relativePath = this.workspaceTargets
.get(workspaceKey)
?.gitDirPaths?.relative(event.path);
if (relativePath == null) continue;
matched = true;
const effect = classifyGitMetadataPath("worktree", relativePath);
if (effect.kind === "owner") {
@@ -2500,7 +2512,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private routeMainCheckoutMetadata(
target: RepoGitTarget,
matchesRepoGitRoot: ReturnType<typeof createRealpathAwarePathMatcher>,
matchesRepoGitRoot: (candidate: string) => boolean,
refreshBase: boolean,
refreshes: Map<string, RepoMetadataWorkspaceRefresh>,
): void {
@@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: {
continue;
}
try {
pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
} catch (error) {
options.logger.warn(
{ err: error, workspaceId: workspace.workspaceId },
@@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => {
const repoRoot = createTempDir("paseo-branch-config-");
const worktreeRoot = createTempDir("paseo-branch-worktree-");
mkdirSync(path.join(worktreeRoot, ".git"));
writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
placeholderBranchName: "dazzling-yak",
});
writeConfig(repoRoot, {
@@ -854,7 +854,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
second.worktree.worktreePath,
"remote.paseo-pr-14.url",
);
const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath);
const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath);
const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome });
expect(second.worktree.branchName).toBe("feature/gitlab-mr-1");
@@ -1113,7 +1113,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
createCoreDeps({ github }),
);
writeFileSync(
getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
await getPaseoWorktreeMetadataPath(second.worktree.worktreePath),
`${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`,
"utf8",
);
@@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => {
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
const { baseRef, commits } = await listCheckoutCommits({
cwd: worktreeDir,
+14 -12
View File
@@ -2859,7 +2859,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "old-change",
@@ -2899,7 +2899,7 @@ const x = 1;
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir });
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "placeholder",
@@ -2936,7 +2936,7 @@ const x = 1;
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "feature/pinned",
@@ -2975,7 +2975,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "old-change",
@@ -2997,7 +2997,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "old-change",
@@ -3062,7 +3062,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "old-change",
@@ -3114,7 +3114,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "feature/gitlab-mr",
@@ -3128,7 +3128,9 @@ const x = 1;
const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets });
await renameCurrentBranch(workspaceCwd, "feature/renamed");
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({
expect(
(await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget,
).toMatchObject({
localBranchName: "feature/renamed",
});
const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
@@ -3164,7 +3166,7 @@ const x = 1;
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
cwd: repoDir,
});
writePaseoWorktreeMetadata(workspaceDir, {
await writePaseoWorktreeMetadata(workspaceDir, {
baseRefName: "main",
changeRequestLookupTarget: {
headRef: "feature/placeholder",
@@ -3174,7 +3176,7 @@ const x = 1;
await renameCurrentBranch(workspaceDir, "feature/generated");
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({
headRef: "feature/generated",
localBranchName: "feature/generated",
});
@@ -3951,7 +3953,7 @@ const x = 1;
cwd: worktree.worktreePath,
});
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
rmSync(metadataPath, { force: true });
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
@@ -3970,7 +3972,7 @@ const x = 1;
paseoHome,
});
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath);
rmSync(metadataPath, { force: true });
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
+31 -9
View File
@@ -1,7 +1,15 @@
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises";
import {
open as openFile,
readFile,
stat as statFile,
mkdtemp,
copyFile,
realpath,
access,
rm,
} from "fs/promises";
import { tmpdir } from "node:os";
import { setImmediate } from "node:timers/promises";
import { TTLCache } from "@isaacs/ttlcache";
@@ -1104,7 +1112,7 @@ async function getMainRepoRootFromCommonDir(
if (!commonDir) {
throw new Error("Not in a git repository");
}
const normalized = realpathSync(commonDir);
const normalized = await realpath(commonDir);
if (basename(normalized) === ".git") {
return dirname(normalized);
@@ -1229,7 +1237,7 @@ export async function renameCurrentBranch(
const currentBranch = await getCurrentBranch(cwd);
if (currentBranch) {
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
}
return { previousBranch, currentBranch };
}
@@ -1274,8 +1282,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri
return metadata?.baseRef ?? metadata?.baseRefName ?? null;
}
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot));
async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise<string | null> {
return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot));
}
async function getStoredBaseRefForCwd(
@@ -1549,7 +1557,12 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
const rebaseMergePath = resolve(gitDir, "rebase-merge");
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
if (existsSync(mergeHeadPath)) {
if (
await access(mergeHeadPath).then(
() => true,
() => false,
)
) {
try {
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
} catch {
@@ -1557,7 +1570,16 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
}
}
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
if (
(await access(rebaseMergePath).then(
() => true,
() => false,
)) ||
(await access(rebaseApplyPath).then(
() => true,
() => false,
))
) {
try {
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
} catch {
@@ -2078,7 +2100,7 @@ export async function getCheckoutSnapshotFacts(
}
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
: null;
const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata);
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context));
+2 -2
View File
@@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean {
return getRealpathAwareRelativePath(root, candidate) !== null;
}
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
export function getRelativePathInsideRoot(root: string, candidate: string): string | null {
const compareAsWindows = shouldCompareAsWindows(root, candidate);
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
@@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s
return compareAsWindows ? normalized.toLowerCase() : normalized;
}
function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value;
const platformNormalized = platformPath.normalize(comparableValue);
@@ -507,7 +507,6 @@ describe("runGitCommand", () => {
command: "git",
args: ["status", "--short"],
cwd: process.cwd(),
cwdExists: true,
timeout: 30_000,
maxOutputBytes: 20 * 1024 * 1024,
acceptExitCodes: [0, 1],
@@ -520,7 +519,6 @@ describe("runGitCommand", () => {
command: "git",
args: ["status", "--short"],
cwd: process.cwd(),
cwdExists: true,
durationMs: expect.any(Number),
exitCode: 0,
signal: null,
@@ -530,6 +528,9 @@ describe("runGitCommand", () => {
}),
"Git command closed",
);
for (const [record] of trace.mock.calls) {
expect(record).not.toHaveProperty("cwdExists");
}
});
it("rejects non-zero exit codes that are not accepted and frees the slot", async () => {
@@ -1,5 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { existsSync } from "node:fs";
import type { Logger } from "pino";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import {
@@ -311,7 +310,6 @@ function executeGitCommand<Output>(
command: "git",
args,
cwd: options.cwd,
cwdExists: existsSync(options.cwd),
timeout,
maxOutputBytes,
acceptExitCodes,
+251 -149
View File
@@ -1,7 +1,12 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import pLimit from "p-limit";
import { isAbsolute, join, resolve } from "path";
import { z } from "zod";
const MAX_WORKTREE_METADATA_BYTES = 64 * 1024;
const ChangeRequestLookupTargetSchema = z.object({
headRef: z.string().min(1),
headRepositoryOwner: z.string().min(1).optional(),
@@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null {
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
}
export function rebindPaseoWorktreeChangeRequestHint(
export async function rebindPaseoWorktreeChangeRequestHint(
worktreeRoot: string,
previousBranch: string,
currentBranch: string,
): boolean {
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
if (!metadata || !target) {
return false;
}
): Promise<boolean> {
return mutateMetadata(worktreeRoot, async (root) => {
const metadata = await readMetadataFile(root);
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch);
if (!metadata || !target) {
return false;
}
writePaseoWorktreeMetadataFile(worktreeRoot, {
...metadata,
changeRequestLookupTarget: {
...target,
...(target.headRef === previousBranch &&
!target.headRepositoryOwner &&
target.changeRequestNumber === undefined
? { headRef: currentBranch }
: {}),
localBranchName: currentBranch,
},
await writePaseoWorktreeMetadataFile(root, {
...metadata,
changeRequestLookupTarget: {
...target,
...(target.headRef === previousBranch &&
!target.headRepositoryOwner &&
target.changeRequestNumber === undefined
? { headRef: currentBranch }
: {}),
localBranchName: currentBranch,
},
});
return true;
});
return true;
}
export function pinPaseoWorktreeBranchIdentityIfMissing(
export async function pinPaseoWorktreeBranchIdentityIfMissing(
worktreeRoot: string,
branch: string,
): boolean {
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
if (!metadata || metadata.changeRequestLookupTarget) {
return false;
}
const target = createPaseoWorktreeChangeRequestHint({
headRef: branch,
localBranchName: branch,
): Promise<boolean> {
return mutateMetadata(worktreeRoot, async (root) => {
const metadata = await readMetadataFile(root);
if (!metadata || metadata.changeRequestLookupTarget) {
return false;
}
const target = createPaseoWorktreeChangeRequestHint({
headRef: branch,
localBranchName: branch,
});
await writePaseoWorktreeMetadataFile(root, {
...metadata,
changeRequestLookupTarget: target,
});
return true;
});
writePaseoWorktreeMetadataFile(worktreeRoot, {
...metadata,
changeRequestLookupTarget: target,
});
return true;
}
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise<string> {
const gitPath = join(worktreeRoot, ".git");
if (!existsSync(gitPath)) {
throw new Error(`Not a git repository: ${worktreeRoot}`);
}
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
// In a normal checkout, `.git` is a directory.
let info;
try {
const gitFileContent = readFileSync(gitPath, "utf8");
const match = gitFileContent.match(/gitdir:\s*(.+)/);
if (match?.[1]) {
const raw = match[1].trim();
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
}
} catch {
// If `.git` is a directory, readFileSync will throw; fall through.
info = await stat(gitPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error });
throw error;
}
if (info.isDirectory()) return gitPath;
const gitFileContent = await readBoundedFile(gitPath);
const match = gitFileContent.match(/gitdir:\s*(.+)/);
if (match?.[1]) {
const raw = match[1].trim();
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
}
return gitPath;
}
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise<string> {
const gitDir = await getGitDirForWorktreeRoot(worktreeRoot);
return join(gitDir, "paseo", "worktree.json");
}
@@ -213,120 +221,166 @@ function assertValidBaseRef(value: string): void {
}
}
export function writePaseoWorktreeMetadata(
export async function writePaseoWorktreeMetadata(
worktreeRoot: string,
options: {
baseRefName: string;
baseRef?: string;
changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint;
},
): void {
const baseRefName = normalizeBaseRefName(options.baseRefName);
assertValidBaseRef(baseRefName);
const baseRef = options.baseRef?.trim();
if (baseRef) {
assertValidBaseRef(baseRef);
}
): Promise<void> {
return mutateMetadata(worktreeRoot, async (root) => {
const baseRefName = normalizeBaseRefName(options.baseRefName);
assertValidBaseRef(baseRefName);
const baseRef = options.baseRef?.trim();
if (baseRef) {
assertValidBaseRef(baseRef);
}
const metadata: PaseoWorktreeMetadata = {
version: 1,
baseRefName,
...(baseRef ? { baseRef } : {}),
...(options.changeRequestLookupTarget
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
: {}),
};
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
}
export function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number },
): void {
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
}
const next: PaseoWorktreeMetadata = {
...current,
version: 2,
runtime: {
worktreePort: options.worktreePort,
},
};
writePaseoWorktreeMetadataFile(worktreeRoot, next);
}
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
worktreeRoot: string,
options: { placeholderBranchName: string },
): void {
const placeholderBranchName = options.placeholderBranchName.trim();
if (!placeholderBranchName) {
throw new Error("Placeholder branch name is required");
}
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current) {
throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata");
}
writePaseoWorktreeMetadataFile(worktreeRoot, {
...current,
version: 2,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
},
const metadata: PaseoWorktreeMetadata = {
version: 1,
baseRefName,
...(baseRef ? { baseRef } : {}),
...(options.changeRequestLookupTarget
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
: {}),
};
await writePaseoWorktreeMetadataFile(root, metadata);
});
}
export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
export async function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number },
): Promise<void> {
return mutateMetadata(worktreeRoot, async (root) => {
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
}
const current = await readMetadataFile(root);
if (!current) {
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
}
const next: PaseoWorktreeMetadata = {
...current,
version: 2,
runtime: {
worktreePort: options.worktreePort,
},
};
await writePaseoWorktreeMetadataFile(root, next);
});
}
export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
worktreeRoot: string,
options: { placeholderBranchName: string },
): Promise<void> {
return mutateMetadata(worktreeRoot, async (root) => {
const placeholderBranchName = options.placeholderBranchName.trim();
if (!placeholderBranchName) {
throw new Error("Placeholder branch name is required");
}
const current = await readMetadataFile(root);
if (!current) {
throw new Error(
"Cannot persist first-agent branch auto-name metadata: missing base metadata",
);
}
await writePaseoWorktreeMetadataFile(root, {
...current,
version: 2,
firstAgentBranchAutoName: {
status: "pending",
placeholderBranchName,
},
});
});
}
export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
worktreeRoot: string,
options: { attemptedAt?: string } = {},
): PaseoWorktreeMetadata | null {
const current = readPaseoWorktreeMetadata(worktreeRoot);
if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") {
return current;
}
): Promise<PaseoWorktreeMetadata | null> {
return mutateMetadata(worktreeRoot, async (root) => {
const current = await readMetadataFile(root);
if (
!current ||
current.version !== 2 ||
current.firstAgentBranchAutoName?.status !== "pending"
) {
return current;
}
const next: PaseoWorktreeMetadata = {
...current,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
},
};
writePaseoWorktreeMetadataFile(worktreeRoot, next);
return next;
const next: PaseoWorktreeMetadata = {
...current,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
attemptedAt: options.attemptedAt ?? new Date().toISOString(),
},
};
await writePaseoWorktreeMetadataFile(root, next);
return next;
});
}
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
if (!existsSync(metadataPath)) {
return null;
}
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
return PaseoWorktreeMetadataSchema.parse(parsed);
export async function readPaseoWorktreeMetadata(
worktreeRoot: string,
): Promise<PaseoWorktreeMetadata | null> {
const key = await resolveMetadataIdentity(worktreeRoot);
await metadataMutations.get(key)?.catch(() => {});
return metadataIo(() => readMetadataFile(key));
}
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
async function readMetadataFile(worktreeRoot: string): Promise<PaseoWorktreeMetadata | null> {
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
try {
return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also
// caps a regular file that grows between stat and read; readFile's whole-file loop does not.
async function readBoundedFile(path: string): Promise<string> {
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
try {
const info = await file.stat();
const limit = MAX_WORKTREE_METADATA_BYTES;
if (!info.isFile() || info.size > limit)
throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
const buffer = Buffer.alloc(limit + 1);
let length = 0;
while (length < buffer.length) {
const { bytesRead } = await file.read(buffer, length, buffer.length - length);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`);
return buffer.toString("utf8", 0, length);
} finally {
await file.close();
}
}
export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise<string> {
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
if (!metadata) {
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
}
return metadata.baseRefName;
}
export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null {
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise<number | null> {
const metadata = await readPaseoWorktreeMetadata(worktreeRoot);
if (!metadata) {
return null;
}
@@ -336,13 +390,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul
return null;
}
function writePaseoWorktreeMetadataFile(
async function writePaseoWorktreeMetadataFile(
worktreeRoot: string,
metadata: PaseoWorktreeMetadata,
): void {
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
renameSync(tempPath, metadataPath);
): Promise<void> {
const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot);
const contents = `${JSON.stringify(metadata, null, 2)}\n`;
if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) {
throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`);
}
// Never recreate an archived Git directory while a queued mutation is pending.
try {
await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const tempPath = `${metadataPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, contents, "utf8");
await rename(tempPath, metadataPath);
}
const metadataIo = pLimit(2);
const metadataMutations = new Map<string, Promise<unknown>>();
// Only share in-flight identity resolution: a replaced symlink must not retain an old owner.
const metadataIdentities = new Map<string, Promise<string>>();
function resolveMetadataIdentity(worktreeRoot: string): Promise<string> {
const path = resolve(worktreeRoot);
const existing = metadataIdentities.get(path);
if (existing) return existing;
const identity = metadataIo(async () => {
try {
return await realpath(path);
} catch {
return path;
}
}).finally(() => {
if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path);
});
metadataIdentities.set(path, identity);
return identity;
}
async function mutateMetadata<T>(
worktreeRoot: string,
mutation: (root: string) => Promise<T>,
): Promise<T> {
const key = await resolveMetadataIdentity(worktreeRoot);
const previous = metadataMutations.get(key) ?? Promise.resolve();
const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key)));
metadataMutations.set(key, pending);
void pending
.finally(() => {
if (metadataMutations.get(key) === pending) metadataMutations.delete(key);
})
.catch(() => {});
return pending;
}
+121 -10
View File
@@ -21,7 +21,13 @@ import {
type WorktreeConfig,
} from "./worktree";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
import {
getPaseoWorktreeMetadataPath,
readPaseoWorktreeMetadata,
writePaseoWorktreeMetadata,
writePaseoWorktreeRuntimeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "./worktree-metadata.js";
import {
getCheckoutDiff,
getCheckoutStatus,
@@ -125,7 +131,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
expect(existsSync(result.worktreePath)).toBe(true);
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
expect(existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({
@@ -256,7 +262,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
cwd: result.worktreePath,
});
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({
version: 1,
@@ -319,7 +325,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({
version: 1,
@@ -370,7 +376,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
expect(existsSync(result.worktreePath)).toBe(true);
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
});
@@ -406,7 +412,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
expect(result.branchName).toBe("main-1");
expect(existsSync(result.worktreePath)).toBe(true);
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
expect(
(await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget,
).toEqual({
headRef: "main-1",
localBranchName: "main-1",
});
@@ -459,7 +467,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
.trim();
expect(currentBranch).toBe("user/feature");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ baseRefName: "main" });
});
@@ -566,7 +574,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
"from-origin\n",
);
expect(
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")),
JSON.parse(
readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"),
),
).toMatchObject({ baseRefName: "main" });
});
@@ -607,7 +617,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
// to the commit the worktree was cut from: "main" resolves local-first, which here is
// a different commit than the fork's upstream.
expect(
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" });
// An untouched child must report no work of its own. Comparing against the wrong base
@@ -694,7 +704,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
expect(
JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")),
).toMatchObject({
baseRefName: "release+hotfix",
baseRef: "refs/remotes/upstream/release+hotfix",
@@ -1617,3 +1627,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
});
describe.skipIf(isPlatform("win32"))("async worktree metadata", () => {
it("serializes metadata mutations per worktree while another worktree progresses", async () => {
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-"));
const first = join(root, "first");
const second = join(root, "second");
mkdirSync(join(first, ".git"), { recursive: true });
mkdirSync(join(second, ".git"), { recursive: true });
try {
await writePaseoWorktreeMetadata(first, { baseRefName: "main" });
await writePaseoWorktreeMetadata(second, { baseRefName: "release" });
const order: string[] = [];
const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() =>
order.push("first-runtime"),
);
const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() =>
order.push("second-runtime"),
);
const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, {
placeholderBranchName: "topic",
}).then(() => order.push("first-naming"));
// A read issued while writes are pending observes their completed snapshot.
const snapshot = readPaseoWorktreeMetadata(first);
await Promise.all([runtime, unrelated, naming]);
expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming"));
expect(await snapshot).toMatchObject({
baseRefName: "main",
runtime: { worktreePort: 4101 },
firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" },
});
expect(await readPaseoWorktreeMetadata(second)).toMatchObject({
baseRefName: "release",
runtime: { worktreePort: 4102 },
});
const alias = join(root, "first-alias");
symlinkSync(first, alias, "dir");
await Promise.all([
writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }),
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, {
placeholderBranchName: "renamed",
}),
]);
expect(await readPaseoWorktreeMetadata(first)).toMatchObject({
runtime: { worktreePort: 4103 },
firstAgentBranchAutoName: { placeholderBranchName: "renamed" },
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("a queued metadata mutation cannot recreate an archived Git directory", async () => {
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-"));
mkdirSync(join(root, ".git"));
try {
await writePaseoWorktreeMetadata(root, { baseRefName: "main" });
const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 });
// The mutation is queued in a microtask, so archive wins this ordering.
rmSync(join(root, ".git"), { recursive: true });
await expect(pending).rejects.toThrow("Not a git repository");
expect(existsSync(join(root, ".git"))).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("recognizes a removed worktree under a symlinked ownership root", async () => {
const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-"));
const actual = join(root, "actual");
const alias = join(root, "worktrees");
mkdirSync(actual);
symlinkSync(actual, alias, "dir");
try {
const cwd = join(alias, "project", "removed");
expect(
await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }),
).toMatchObject({ allowed: true, worktreePath: cwd });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("rejects oversized metadata and named pipes without reading them whole", async () => {
const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-"));
mkdirSync(join(root, ".git", "paseo"), { recursive: true });
try {
const path = await getPaseoWorktreeMetadataPath(root);
writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) }));
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
"Invalid Paseo worktree metadata file",
);
rmSync(path);
execFileSync("mkfifo", [path]);
await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow(
"Invalid Paseo worktree metadata file",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
+10 -1
View File
@@ -5,6 +5,7 @@ import {
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
runWorktreeTeardownCommands,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
@@ -171,7 +172,15 @@ describe("paseo worktree manager", () => {
expect(existsSync(created.worktreePath)).toBe(true);
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
expect(ownership.allowed).toBe(true);
expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath });
// Windows may report the same root through native 8.3 and JS long spellings.
const nativeWorktreePath = realpathSync.native(created.worktreePath);
await expect(
runWorktreeTeardownCommands({
worktreePath: created.worktreePath,
teardownCwd: nativeWorktreePath,
}),
).resolves.toEqual([]);
await expect(
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
).resolves.toMatchObject({
+74 -35
View File
@@ -1,11 +1,12 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs";
import { copyFile, rm, stat } from "fs/promises";
import { constants as fsConstants, realpath } from "fs";
import { copyFile, mkdir, rm, stat } from "fs/promises";
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import stripAnsi from "strip-ansi";
import pLimit from "p-limit";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
@@ -37,7 +38,12 @@ import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
import {
expandTilde,
getRealpathAwareRelativePath,
getRelativePathInsideRoot,
isPathInsideRoot,
} from "./path.js";
import { terminateWithTreeKill } from "./tree-kill.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -607,7 +613,7 @@ async function assertPortAvailable(port: number): Promise<void> {
async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(worktreePath);
const normalizedCommonDir = normalizePathForOwnership(commonDir);
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
// Normal repo/worktree: common dir is <repoRoot>/.git
if (basename(normalizedCommonDir) === ".git") {
return dirname(normalizedCommonDir);
@@ -623,12 +629,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
});
const topLevel = parseGitRevParsePath(stdout);
if (topLevel) {
return normalizePathForOwnership(topLevel);
return await normalizePathForOwnership(topLevel);
}
} catch {
// ignore
}
return normalizePathForOwnership(worktreePath);
return await normalizePathForOwnership(worktreePath);
}
}
@@ -711,12 +717,12 @@ export async function resolveWorktreeRuntimeEnv(options: {
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
if (worktreePort === null) {
worktreePort = await getAvailablePort();
const metadata = readPaseoWorktreeMetadata(options.worktreePath);
const metadata = await readPaseoWorktreeMetadata(options.worktreePath);
if (metadata) {
writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort });
}
} else {
await assertPortAvailable(worktreePort);
@@ -742,7 +748,7 @@ export async function runWorktreeTeardownCommands(options: {
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
const teardownCwd = options.teardownCwd ?? options.worktreePath;
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
@@ -754,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath);
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
createExternalProcessEnv(process.env, {
@@ -831,12 +837,12 @@ function deriveShortAlphanumericHash(value: string): string {
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(cwd);
const normalizedCommonDir = normalizePathForOwnership(commonDir);
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
const repoRoot =
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
return deriveShortAlphanumericHash(repoRoot);
} catch {
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd));
}
}
@@ -904,16 +910,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: {
return mappedCwd;
}
function normalizePathForOwnership(input: string): string {
// Keep the former realpathSync spelling on Windows: promises.realpath uses the
// native resolver and can return an 8.3 alias instead of the long path.
const resolveOwnershipPath = promisify(realpath);
const resolveNativeOwnershipPath = promisify(realpath.native);
const ownershipIdentityIo = pLimit(2);
async function normalizePathForOwnership(input: string): Promise<string> {
try {
return realpathSync(input);
return await ownershipIdentityIo(() => resolveOwnershipPath(input));
} catch {
return resolve(input);
}
}
function resolveRepoRootFromGitCommonDir(commonDir: string): string {
const normalizedCommonDir = normalizePathForOwnership(commonDir);
async function ownershipPathVariants(input: string): Promise<string[]> {
const variants = new Set([input, await normalizePathForOwnership(input)]);
try {
variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input)));
} catch {
// Missing descendants still match a lexical or established root spelling.
}
return [...variants];
}
// Keep lexical variants for missing paths: archive must still recognize a removed
// worktree below a symlinked worktrees root, whose full realpath no longer exists.
async function getOwnershipRelativePath(root: string, candidate: string): Promise<string | null> {
const roots = await ownershipPathVariants(root);
const candidates = await ownershipPathVariants(candidate);
for (const rootPath of roots) {
for (const candidatePath of candidates) {
const relative = getRelativePathInsideRoot(rootPath, candidatePath);
if (relative !== null) return relative;
}
}
return null;
}
async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise<string> {
const normalizedCommonDir = await normalizePathForOwnership(commonDir);
return basename(normalizedCommonDir) === ".git"
? dirname(normalizedCommonDir)
: normalizedCommonDir;
@@ -923,25 +959,25 @@ export async function isPaseoOwnedWorktreeCwd(
cwd: string,
options?: PaseoWorktreeOwnershipOptions,
): Promise<PaseoWorktreeOwnership> {
const resolvedCwd = normalizePathForOwnership(cwd);
const resolvedCwd = await normalizePathForOwnership(cwd);
// repoRoot is best-effort: git may be unreachable from the worktree (e.g. a
// previous archive attempt removed the admin dir before the working tree
// could be fully cleaned up). We still want to allow archiving in that case.
let repoRoot: string | undefined;
if (options?.knownGitCommonDir) {
repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir);
} else if (options?.knownGitCommonDir === undefined) {
try {
const gitCommonDir = await getGitCommonDir(cwd);
repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir);
} catch {
// ignore
}
}
const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options);
const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd);
const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd);
// Ownership is defined by the path living under <worktrees-root>/<hash>/<slug>[/...].
// The <hash>/<slug> prefix is Paseo-private — nothing else writes there — so the
@@ -1012,9 +1048,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
return entries;
}
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise<string> {
try {
const stats = statSync(worktreePath);
const stats = await stat(worktreePath);
const birthtimeMs = stats.birthtimeMs;
const createdAtMs =
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
@@ -1039,12 +1075,15 @@ export async function listPaseoWorktrees({
envOverlay: READ_ONLY_GIT_ENV,
});
return parseWorktreeList(stdout)
.map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) }))
.filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null)
.map((entry) =>
Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }),
);
const roots = await ownershipPathVariants(projectWorktreesRoot);
const worktrees: PaseoWorktreeInfo[] = [];
// Inspect foreign paths sequentially; a large worktree list must not consume the FS pool.
for (const entry of parseWorktreeList(stdout)) {
const path = await normalizePathForOwnership(entry.path);
if (!roots.some((root) => getRelativePathInsideRoot(root, path) !== null)) continue;
worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) });
}
return worktrees;
}
export interface DeletePaseoWorktreeOptions {
@@ -1083,7 +1122,7 @@ export async function deletePaseoWorktree({
}
const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!);
const resolvedRequested = normalizePathForOwnership(requestedPath);
const resolvedRequested = await normalizePathForOwnership(requestedPath);
const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, {
paseoHome,
worktreesRoot: worktreesBaseRoot,
@@ -1091,7 +1130,7 @@ export async function deletePaseoWorktree({
const resolvedWorktree =
ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested;
const relativeWorktreePath = getRealpathAwareRelativePath(
const relativeWorktreePath = await getOwnershipRelativePath(
resolvedWorktreesRoot,
resolvedWorktree,
);
@@ -1207,12 +1246,12 @@ export const createWorktree = async ({
}: CreateWorktreeOptions): Promise<CreatedWorktree> => {
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
mkdirSync(dirname(worktreePath), { recursive: true });
await mkdir(dirname(worktreePath), { recursive: true });
// Also handle worktree path collision
let finalWorktreePath = worktreePath;
let pathSuffix = 1;
while (existsSync(finalWorktreePath)) {
while (await pathExists(finalWorktreePath)) {
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
pathSuffix++;
}
@@ -1222,7 +1261,7 @@ export const createWorktree = async ({
cwd,
timeout: 120_000,
});
worktreePath = normalizePathForOwnership(finalWorktreePath);
worktreePath = await normalizePathForOwnership(finalWorktreePath);
if (sourcePlan.pushRemote) {
await configureWorktreePushRemote({
@@ -1239,7 +1278,7 @@ export const createWorktree = async ({
});
}
writePaseoWorktreeMetadata(worktreePath, {
await writePaseoWorktreeMetadata(worktreePath, {
baseRefName: sourcePlan.metadataBaseRefName,
...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}),
...(sourcePlan.changeRequestLookupTarget