Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
aa305e1
fix: coordinate worktree setup and archive
timigod Jul 31, 2026
88d58b2
fix: close worktree lifecycle races
timigod Jul 31, 2026
3852880
fix: bound worktree setup updates
timigod Jul 31, 2026
d8d104f
fix: make worktree cleanup incarnation-safe
timigod Jul 31, 2026
23492e2
fix: preserve cleanup incarnation on retry
timigod Jul 31, 2026
8f96a87
test: close worktree lifecycle gates
timigod Jul 31, 2026
1e7a87d
fix: make workspace lifecycle failures durable
timigod Aug 1, 2026
e2937e7
docs: declare v0.2.5 lifecycle patch
timigod Aug 1, 2026
bfdb6ed
docs: record lifecycle verification receipt
timigod Aug 1, 2026
e5e5d60
merge: converge lifecycle patch with upstream main
timigod Aug 1, 2026
832508f
docs: record current-main lifecycle convergence
timigod Aug 1, 2026
551bdd3
fix: fail unresolved workspace archives
timigod Aug 1, 2026
0858d4b
docs: close archive-response review gap
timigod Aug 1, 2026
d5743f9
fix: close workspace lifecycle ownership gaps
timigod Aug 1, 2026
d3530db
docs: record lifecycle ownership repair proof
timigod Aug 1, 2026
4864613
fix: keep auto-archive outcomes truthful
timigod Aug 1, 2026
bd90c39
docs: record truthful auto-archive proof
timigod Aug 1, 2026
2ee2896
fix: close archive target ownership set
timigod Aug 1, 2026
8834fd7
fix: preserve archive registry failures
timigod Aug 1, 2026
7e9b8bf
fix: keep automated cleanup retryable
timigod Aug 1, 2026
4a54864
docs: record complete lifecycle repair proof
timigod Aug 1, 2026
48b53ce
fix: retry pending workspace cleanup autonomously
timigod Aug 1, 2026
4cf635d
docs: record autonomous cleanup retry proof
timigod Aug 1, 2026
4bc441a
fix: keep cleanup retry off daemon readiness path
timigod Aug 1, 2026
2985ba3
docs: attest non-blocking cleanup retry
timigod Aug 1, 2026
5e17fcf
fix: fence and cancel cleanup retries
timigod Aug 1, 2026
322039e
docs: attest cleanup-only retry repair
timigod Aug 1, 2026
5c70d52
fix: serialize workspace creation with cleanup
timigod Aug 1, 2026
ad3728d
docs: attest cleanup creation exclusion
timigod Aug 1, 2026
759ed14
fix: key lifecycle locks by filesystem identity
timigod Aug 1, 2026
7fa508c
docs: attest filesystem-identity lifecycle locks
timigod Aug 1, 2026
260fa31
docs: record exact lifecycle test command
timigod Aug 1, 2026
bb956ee
fix(server): drain create lifecycle recovery on shutdown
timigod Aug 1, 2026
f128a34
fix(server): own create lifecycle through shutdown
timigod Aug 1, 2026
cf87f95
fix(server): make auto-archive shutdown durable
timigod Aug 1, 2026
4a43285
fix(server): recover interrupted auto-archive creation
timigod Aug 1, 2026
4aab0aa
Merge upstream main into review-v025-worktree-lifecycle-upstream
timigod Aug 1, 2026
2799e2e
fix(server): join canceled git process cleanup
timigod Aug 1, 2026
af896b2
Merge remote-tracking branch 'origin/main' into timigod/review-v025-w…
timigod Aug 1, 2026
eb3975b
test(server): keep git discovery on real clock
timigod Aug 1, 2026
dc1529c
Merge commit '9ddc3e0c4253b4cf3a1a7b864b7a92294cf2e8d4' into repair/p…
timigod Aug 1, 2026
64346de
fix(server): close create and initialization races
timigod Aug 1, 2026
8d5b386
Merge commit '74e4a9f90c181a31d266e48f6ba62717e443c596' into repair/p…
timigod Aug 1, 2026
976f172
fix(server): drain create ingress during shutdown
timigod Aug 1, 2026
fe2948a
fix(server): close lifecycle mutation gaps
timigod Aug 1, 2026
d891ae7
Merge remote-tracking branch 'origin/main' into repair/pr2719-init-cl…
timigod Aug 1, 2026
0a2282a
fix(server): gate native lifecycle tools on shutdown
timigod Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/protocol/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4735,6 +4735,8 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
payload: z.object({
success: z.boolean(),
removedAgents: z.array(z.string()).optional(),
removedDirectory: z.boolean().optional(),
cleanupPending: z.boolean().optional(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
Expand Down
45 changes: 24 additions & 21 deletions packages/server/src/server/agent/agent-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AgentLoaderManager = Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "getAgentInitializationState"
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
Expand Down Expand Up @@ -63,28 +64,26 @@ export async function ensureAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
await deps.agentManager.waitForAgentClose?.(agentId);
while (true) {
await deps.agentManager.waitForAgentClose?.(agentId);

const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return inflight.promise;
}

const existing = deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}

// A close may have started after the first barrier observed no in-flight
// work. Once the live lookup is empty, this second barrier closes that gap
// before storage-backed resume begins.
await deps.agentManager.waitForAgentClose?.(agentId);
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return inflight.promise;
}

const laterInflight = pendingAgentInitializations.get(agentId);
if (laterInflight) {
laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true;
return laterInflight.promise;
// The close barrier and the runtime lookup cannot be made atomic across an
// await. Re-read both pieces of state synchronously so a close that starts
// in that gap is joined on the next pass instead of returning its runtime.
const initializationState = deps.agentManager.getAgentInitializationState(agentId);
if (initializationState.closeInFlight) {
continue;
}
if (initializationState.agent) {
return initializationState.agent;
}
break;
}

const pendingOptions = {
Expand All @@ -109,7 +108,10 @@ export async function ensureAgentLoaded(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
{
...extractTimestamps(record),
autoArchiveObligation: record.autoArchiveObligation,
},
record.archivedAt ? { purpose: "history" } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
Expand All @@ -124,6 +126,7 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
autoArchiveObligation: record.autoArchiveObligation,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
Expand Down
54 changes: 54 additions & 0 deletions packages/server/src/server/agent/agent-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ class HeldReloadCloseClient extends TestAgentClient {
private readonly closeAllowed = deferred<void>();
originalSessionClosed = false;
replacementSessionClosed = false;
resumeCount = 0;

override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const signalCloseStarted = () => this.closeStarted.resolve();
Expand All @@ -268,6 +269,7 @@ class HeldReloadCloseClient extends TestAgentClient {
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeCount += 1;
const recordReplacementClosed = () => {
this.replacementSessionClosed = true;
};
Expand Down Expand Up @@ -7916,6 +7918,58 @@ test("load waits for an in-flight explicit close and creates one resumed runtime
}
});

test("load joins a close that starts after its barrier but before runtime lookup", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-start-gap-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldReloadCloseClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const agentId = "00000000-0000-4000-8000-000000000218";
let closeTask: Promise<void> | null = null;

try {
await manager.createAgent({ provider: "codex", cwd: workdir }, agentId, {
workspaceId: undefined,
});
const waitForAgentClose = manager.waitForAgentClose.bind(manager);
vi.spyOn(manager, "waitForAgentClose")
.mockImplementation(async (id) => waitForAgentClose(id))
.mockImplementationOnce(async () => {
queueMicrotask(() => {
closeTask = manager.closeAgent(agentId);
});
});

let loadSettled = false;
const load = ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
}).then((agent) => {
loadSettled = true;
return agent;
});

await client.waitForCloseToStart();
await Promise.resolve();
expect(loadSettled).toBe(false);
expect(client.resumeCount).toBe(0);

client.finishClosing();
const resumed = await load;
await closeTask;

expect(resumed).toMatchObject({ id: agentId, lifecycle: "idle" });
expect(client.originalSessionClosed).toBe(true);
expect(client.resumeCount).toBe(1);
} finally {
client.finishClosing();
await closeTask?.catch(() => undefined);
await manager.closeAgent(agentId).catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});

test("concurrent explicit closes tear down the runtime once", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-"));
const closeStarted = deferred<void>();
Expand Down
29 changes: 27 additions & 2 deletions packages/server/src/server/agent/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AutoArchiveObligation, StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
InMemoryAgentTimelineStore,
Expand Down Expand Up @@ -246,6 +246,7 @@ export interface CreateAgentOptions {
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
autoArchiveObligation?: AutoArchiveObligation;
}

export interface AgentManagerOptions {
Expand Down Expand Up @@ -1000,6 +1001,17 @@ export class AgentManager {
return agent ? { ...agent } : null;
}

getAgentInitializationState(id: string): {
agent: ManagedAgent | null;
closeInFlight: boolean;
} {
const closeInFlight = this.inFlightAgentCloses.has(id);
return {
agent: closeInFlight ? null : this.getAgent(id),
closeInFlight,
};
}

async waitForAgentClose(agentId: string): Promise<void> {
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
}
Expand Down Expand Up @@ -1052,6 +1064,10 @@ export class AgentManager {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
}

allocateAgentId(): string {
return validateAgentId(this.idFactory(), "allocateAgentId");
}

private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
Expand Down Expand Up @@ -1083,6 +1099,7 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
autoArchiveObligation: options.autoArchiveObligation,
});
}

Expand All @@ -1107,6 +1124,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
autoArchiveObligation?: AutoArchiveObligation;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
Expand All @@ -1126,6 +1144,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
autoArchiveObligation?: AutoArchiveObligation;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
Expand Down Expand Up @@ -2801,6 +2820,7 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
autoArchiveObligation?: AutoArchiveObligation;
},
): Promise<ManagedAgent> {
let registered = false;
Expand Down Expand Up @@ -2841,6 +2861,7 @@ export class AgentManager {
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
autoArchiveObligation: options?.autoArchiveObligation,
});
this.assertAgentRegistrationActive(managed);
if (!options?.publishWhenReady) {
Expand Down Expand Up @@ -3191,7 +3212,11 @@ export class AgentManager {

private async persistSnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
options?: {
title?: string | null;
internal?: boolean;
autoArchiveObligation?: AutoArchiveObligation;
},
): Promise<void> {
if (!this.registry) {
return;
Expand Down
108 changes: 108 additions & 0 deletions packages/server/src/server/agent/agent-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,114 @@ describe("AgentStorage", () => {
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
});

test("persists auto-archive obligations across restart", async () => {
await storage.applySnapshot(createManagedAgent({ id: "agent-auto-archive" }), {
autoArchiveObligation: {
phase: "armed",
target: { kind: "workspace", workspaceId: "ws-auto-archive" },
},
});

const reloaded = new AgentStorage(storagePath, logger);
await expect(reloaded.get("agent-auto-archive")).resolves.toMatchObject({
autoArchiveObligation: {
phase: "armed",
target: { kind: "workspace", workspaceId: "ws-auto-archive" },
},
});
});

test("persists pending creation intent separately from visible agents", async () => {
await storage.beginPendingAgentCreation("agent-pending-create");
await storage.planPendingAgentCreationWorktree(
"agent-pending-create",
"/tmp/paseo/worktrees/project/feature-2",
{
worktreeIncarnationId: "4d2ce498-4c27-4ea2-8ed3-46720de7194e",
metadataBaseRefName: "main",
},
);
await expect(storage.listPendingAgentCreations()).resolves.toMatchObject([
{
cleanupTarget: {
kind: "worktree",
targetPath: "/tmp/paseo/worktrees/project/feature-2",
worktreeIncarnationId: "4d2ce498-4c27-4ea2-8ed3-46720de7194e",
directoryIdentity: null,
},
},
]);
await storage.identifyPendingAgentCreationWorktree(
"agent-pending-create",
"/tmp/paseo/worktrees/project/feature-2",
{
worktreeIncarnationId: "4d2ce498-4c27-4ea2-8ed3-46720de7194e",
directoryIdentity: { device: "7", inode: "42" },
metadataBaseRefName: "main",
},
);

const reloaded = new AgentStorage(storagePath, logger);
await expect(reloaded.list()).resolves.toEqual([]);
await expect(reloaded.listPendingAgentCreations()).resolves.toEqual([
{
agentId: "agent-pending-create",
createdAt: expect.any(String),
ownerKind: "agent",
cleanupTarget: {
kind: "worktree",
targetPath: "/tmp/paseo/worktrees/project/feature-2",
worktreeIncarnationId: "4d2ce498-4c27-4ea2-8ed3-46720de7194e",
directoryIdentity: { device: "7", inode: "42" },
metadataBaseRefName: "main",
},
},
]);

await reloaded.removePendingAgentCreation("agent-pending-create");
await expect(reloaded.listPendingAgentCreations()).resolves.toEqual([]);
});

test.each(["armed", "pending"] as const)(
"explicit undefined preserves a persisted %s auto-archive obligation",
async (phase) => {
const agent = createManagedAgent({ id: `agent-auto-archive-${phase}` });
await storage.applySnapshot(agent, {
autoArchiveObligation: { phase, target: { kind: "agent" } },
});

await storage.applySnapshot(
{ ...agent, updatedAt: new Date("2025-02-01") },
{
autoArchiveObligation: undefined,
},
);

const reloaded = new AgentStorage(storagePath, logger);
await expect(reloaded.get(agent.id)).resolves.toMatchObject({
autoArchiveObligation: { phase, target: { kind: "agent" } },
});
},
);

test("serialized snapshots cannot overwrite a pending auto-archive obligation", async () => {
const agent = createManagedAgent({ id: "agent-auto-archive-race" });
await storage.applySnapshot(agent, {
autoArchiveObligation: { phase: "armed", target: { kind: "agent" } },
});

const pending = storage.update(agent.id, (record) => ({
...record,
autoArchiveObligation: { phase: "pending", target: { kind: "agent" } },
}));
const snapshot = storage.applySnapshot({ ...agent, updatedAt: new Date("2025-02-01") });
await Promise.all([pending, snapshot]);

await expect(storage.get(agent.id)).resolves.toMatchObject({
autoArchiveObligation: { phase: "pending", target: { kind: "agent" } },
});
});

test("applySnapshot stores and reloads featureValues when present", async () => {
await storage.applySnapshot(
createManagedAgent({
Expand Down
Loading