Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5f89fb0
perf(server): bind listener before startup recovery and stop per-task…
ibetitsmike Sep 2, 2026
314818f
fix(server): harden background startup recovery against early clients
ibetitsmike Sep 2, 2026
6aa18ab
fix(server): make startup recovery respect concurrent client state ch…
ibetitsmike Sep 2, 2026
12549db
fix(server): close remaining post-listen recovery races with task_sto…
ibetitsmike Sep 2, 2026
f095888
fix(analytics): wait for the worker to close DuckDB before process exit
ibetitsmike Sep 2, 2026
02cfc0a
fix: wait for the analytics worker's real exit; keep a concurrently i…
ibetitsmike Sep 2, 2026
b460a3e
fix(server): make post-listen recovery safe against workflow resumes …
ibetitsmike Sep 2, 2026
3439514
fix(server): key the resume guard by run and confirm cleanup eligibil…
ibetitsmike Sep 2, 2026
b14171c
fix(server): decide reported-task cleanup under the lifecycle lock; k…
ibetitsmike Sep 2, 2026
2df37ac
fix(server): cancel startup recovery on dispose; recheck descendants …
ibetitsmike Sep 2, 2026
63036b7
fix(server): recovery cancellation returns early; recovery sends yiel…
ibetitsmike Sep 2, 2026
7489062
fix(server): confirm reported-task cleanup inside remove()'s lock hol…
ibetitsmike Sep 2, 2026
68075f8
Merge origin/main into mike/server-bind-before-recovery
ibetitsmike Sep 2, 2026
dae08c6
refactor(server): run task recovery before the listener binds; keep o…
ibetitsmike Sep 2, 2026
98ea055
Finalize pending best-of parent partials before the listener opens
ibetitsmike Sep 2, 2026
f8be932
Close the remaining post-listen housekeeping races
ibetitsmike Sep 2, 2026
be815d6
Make best-of partial finalization a compare-and-set
ibetitsmike Sep 2, 2026
cd178f5
Stop startup chat recovery on shutdown; follow the live parent in cle…
ibetitsmike Sep 2, 2026
c81a8dc
Serialize commitPartial with the partial compare-and-set
ibetitsmike Sep 3, 2026
1cb415c
Keep beginShutdown ahead of the recovery-session sweep
ibetitsmike Sep 3, 2026
fdce45f
Make commitPartial one cross-process transaction
ibetitsmike Sep 3, 2026
2169beb
Re-check archived state before startup recovery dispatches
ibetitsmike Sep 3, 2026
3a74713
Stop internal dispatch on archive during backoff and on shutdown
ibetitsmike Sep 3, 2026
5cebf00
Latch sessions against dispatch before the housekeeping join
ibetitsmike Sep 3, 2026
08ca3a8
Merge remote-tracking branch 'origin/main' into mike/server-bind-befo…
ibetitsmike Sep 3, 2026
1d4d77e
Honor the shutdown latch at every pre-stream checkpoint
ibetitsmike Sep 3, 2026
433e2bd
Defer best-of finalization while a sibling executes again
ibetitsmike Sep 3, 2026
b795316
Close the remaining dispatch windows around removal, shutdown, and be…
ibetitsmike Sep 3, 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
29 changes: 20 additions & 9 deletions src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ async function main(): Promise<void> {
launchProjectPath = null;

// Keepalive interval to prevent premature process exit during async initialization.
// During startup, taskService.initialize() may resume running tasks by calling
// sendMessage(), which spawns background AI streams. Between the completion of
// serviceContainer.initialize() and the HTTP server starting to listen, there can
// be a brief moment where no ref'd handles exist, causing Node to exit with code 0.
// This interval ensures the event loop stays alive until the server is listening.
// During startup, initializeCore() may resume running tasks by calling sendMessage(),
// which spawns background AI streams. Between its completion and the HTTP server
// starting to listen, there can be a brief moment where no ref'd handles exist,
// causing Node to exit with code 0. This interval ensures the event loop stays alive
// until the server is listening.
const startupKeepalive = setInterval(() => {
// Intentionally empty - keeps event loop alive during startup
}, 1000);
Expand All @@ -116,9 +116,9 @@ async function main(): Promise<void> {
}

// Early lockfile check: detect an existing server BEFORE initializing services.
// serviceContainer.initialize() resumes queued/running tasks (via TaskService),
// so we must fail fast here to avoid orphaned side effects when another server
// already holds the lock. ServerService.startServer() re-checks as defense-in-depth.
// initializeCore() resumes queued/running tasks (via TaskService), so we must fail fast
// here to avoid orphaned side effects when another server already holds the lock.
// ServerService.startServer() re-checks as defense-in-depth.
const xumHome = getXumHome();
const earlyLockfile = new ServerLockfile(xumHome);
const existing = await earlyLockfile.read();
Expand All @@ -133,7 +133,10 @@ async function main(): Promise<void> {
const serviceContainer = new ServiceContainer(stores);
// Headless server has no interactive host-key dialog
setOpenSSHHostKeyPolicyMode("headless-fallback");
await serviceContainer.initialize();
// Core init (including agent-task recovery, which must finish before any client can act on
// tasks) gates the listener; the housekeeping that scales with the number of workspaces runs
// in the background once the server is accepting connections.
await serviceContainer.initializeCore();
serviceContainer.windowService.setMainWindow(mockWindow);

if (ADD_PROJECT_PATH) {
Expand Down Expand Up @@ -163,6 +166,14 @@ async function main(): Promise<void> {
allowHttpOrigin: ALLOW_HTTP_ORIGIN,
});

// Housekeeping is best-effort background work; a failure must not take the listening server
// down. serviceContainer.dispose() cancels it at its next task/step boundary and waits
// (bounded) for the step in flight, so shutdown during housekeeping never starts periodic
// services against disposed dependencies.
void serviceContainer.runStartupHousekeeping().catch((error: unknown) => {
log.error("[startup] Background startup housekeeping failed", { error });
});
Comment thread
ibetitsmike marked this conversation as resolved.

// Server is now listening - clear the startup keepalive since httpServer keeps the loop alive
clearInterval(startupKeepalive);

Expand Down
7 changes: 7 additions & 0 deletions src/constants/startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Startup passes that take longer than this are logged at warn level so slow deployments
* (thousands of workspaces, large session dirs) surface in logs without enabling debug output.
* Chosen well above a healthy cold start (seconds) but below the point where a health check
* would already have flagged the process.
*/
export const SLOW_STARTUP_WARN_THRESHOLD_MS = 30_000;
8 changes: 8 additions & 0 deletions src/constants/terminationTimeouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ export const APP_RUNTIME_DISPOSE_TIMEOUT_MS = 2 * 1000;
* bound this fits inside the same 5 s quit budgets.
*/
export const APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS = 2 * 1000;

/**
* Bounds how long `ServiceContainer.dispose()` waits for in-flight background
* startup housekeeping (server mode) to reach its cancellation checkpoint before
* tearing down the services it uses. Sized to leave the two bounds above room
* inside the same 5 s quit budgets.
*/
export const STARTUP_HOUSEKEEPING_JOIN_TIMEOUT_MS = 500;
42 changes: 40 additions & 2 deletions src/node/services/agentSession.continueMessageAgentId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ describe("AgentSession continue-message agentId fallback", () => {
historyCleanup = undefined;
});

const createSession = async (messages: MuxMessage[] = []) => {
const createSession = async (messages: MuxMessage[] = [], config = createConfig()) => {
const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;
for (const message of messages) {
Expand All @@ -159,7 +159,7 @@ describe("AgentSession continue-message agentId fallback", () => {

const session = new AgentSession({
workspaceId: "ws",
config: createConfig(),
config,
historyService,
aiService: createAiService(),
initStateManager: createInitStateManager(),
Expand Down Expand Up @@ -289,6 +289,44 @@ describe("AgentSession continue-message agentId fallback", () => {
expect(dispatchedOptions?.strictAgentResolution).toBe(true);
});

test("dispatchPendingFollowUp leaves the follow-up pending when the workspace is archived on disk", async () => {
const archivedConfig = {
...createConfig(),
loadConfigOrDefault: () => ({
projects: new Map([
[
"/tmp",
{
workspaces: [{ id: "ws", path: "/tmp/ws", archivedAt: "2026-01-01T00:00:00.000Z" }],
},
],
]),
}),
} as unknown as Config;
const { historyService, internals } = await createSession(
[
compactionSummaryMessage("summary-archived", {
text: "resume after compaction",
model: "openai:gpt-4o",
agentId: "exec",
}),
],
archivedConfig
);
internals.sendMessage = mock(() => Promise.resolve({ success: true as const }));

const dispatched = await internals.dispatchPendingFollowUp();

expect(dispatched).toBe(false);
expect(internals.sendMessage).not.toHaveBeenCalled();
// Still pending for the next startup after an unarchive.
const lastMessages = await historyService.getLastMessages("ws", 1);
expect(lastMessages.success && lastMessages.data[0]?.metadata?.muxMetadata).toMatchObject({
type: "compaction-summary",
pendingFollowUp: { text: "resume after compaction" },
});
});

test("dispatchPendingFollowUp skips idle-only follow-ups when queued user input exists", async () => {
const { session, historyService, internals } = await createSession([
compactionSummaryMessage("summary-idle-only", idleFollowUp()),
Expand Down
161 changes: 160 additions & 1 deletion src/node/services/agentSession.startupAutoRetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ interface SessionBundle {
cleanup: () => Promise<void>;
}

async function createSessionBundle(workspaceId: string): Promise<SessionBundle> {
async function createSessionBundle(
workspaceId: string,
aiServiceOverrides?: Partial<AgentSessionAIService>
): Promise<SessionBundle> {
const workspaceMetadata: WorkspaceMetadata = {
id: workspaceId,
name: workspaceId,
Expand All @@ -69,6 +72,7 @@ async function createSessionBundle(workspaceId: string): Promise<SessionBundle>
workspaceId,
aiServiceOverrides: {
getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))),
...aiServiceOverrides,
},
initStateManagerOverrides: {
replayInit: mock(() => Promise.resolve()),
Expand Down Expand Up @@ -141,6 +145,161 @@ describe("AgentSession startup auto-retry recovery", () => {
session.dispose();
});

test("startup auto-retry does not dispatch once the workspace is archived on disk", async () => {
const workspaceId = "startup-retry-archived";
const { session, config, historyService, events, cleanup } =
await createSessionBundle(workspaceId);
cleanups.push(cleanup);

const appendResult = await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-1", "user", "Interrupted before the archive", {
timestamp: Date.now(),
})
);
expect(appendResult.success).toBe(true);

// The archive lands while the check is still reading history (a regular session the client
// created is not disposed by archive, so only the durable state can stop the dispatch).
const getLastMessages = historyService.getLastMessages.bind(historyService);
spyOn(historyService, "getLastMessages").mockImplementation(async (id, count) => {
const result = await getLastMessages(id, count);
await config.editConfig((cfg) => {
cfg.projects.set("/tmp/project", {
workspaces: [
{
id: workspaceId,
path: `/tmp/project/${workspaceId}`,
name: workspaceId,
archivedAt: new Date().toISOString(),
},
],
});
return cfg;
});
return result;
});

const privateSession = session as unknown as {
startupAutoRetryCheckPromise: Promise<void> | null;
startupAutoRetryCheckScheduled: boolean;
};
session.ensureStartupAutoRetryCheck();
await privateSession.startupAutoRetryCheckPromise;

expect(events.some((event) => event.type === "auto-retry-scheduled")).toBe(false);
// Completed rather than deferred: nothing reruns the check for an archived workspace.
expect(privateSession.startupAutoRetryCheckScheduled).toBe(true);

session.dispose();
});

test("auto-retry abandons instead of resuming once the workspace is archived on disk", async () => {
const workspaceId = "startup-retry-archived-before-timer";
const { session, config, events, cleanup } = await createSessionBundle(workspaceId);
cleanups.push(cleanup);

const privateSession = session as unknown as RetryableSessionForTests;
privateSession.lastAutoRetryResumeRequest = {
options: { model: "anthropic:claude-sonnet-4-5", agentId: "exec" },
};
const resumeStreamMock = mock((_options: SendMessageOptions) =>
Promise.resolve({ success: true as const, data: { started: true } })
);
privateSession.resumeStream = resumeStreamMock;

// The archive lands during the backoff countdown; only the durable state can stop the timer.
await config.editConfig((cfg) => {
cfg.projects.set("/tmp/project", {
workspaces: [
{
id: workspaceId,
path: `/tmp/project/${workspaceId}`,
name: workspaceId,
archivedAt: new Date().toISOString(),
},
],
});
return cfg;
});

await privateSession.retryActiveStream();

expect(resumeStreamMock).not.toHaveBeenCalled();
const abandonedReasons = events
.filter(
(event): event is Extract<WorkspaceChatMessage, { type: "auto-retry-abandoned" }> =>
event.type === "auto-retry-abandoned"
)
.map((event) => event.reason);
expect(abandonedReasons).toEqual(["workspace_archived"]);

session.dispose();
});

test("beginShutdown cancels the pending retry and stops re-arming or streaming", async () => {
const workspaceId = "startup-retry-shutdown";
const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle("assistant-1"))));
const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId, {
streamMessage: streamMessage as unknown as AgentSessionAIService["streamMessage"],
});
cleanups.push(cleanup);

const privateSession = session as unknown as {
handleStreamFailureForAutoRetry: (error: { type: string; message?: string }) => Promise<void>;
};
await privateSession.handleStreamFailureForAutoRetry({ type: "unknown", message: "boom" });
expect(session.shouldRetainAfterStartupRecovery()).toBe(true);
const scheduledBefore = events.filter((event) => event.type === "auto-retry-scheduled").length;
expect(scheduledBefore).toBe(1);

session.beginShutdown();

expect(session.shouldRetainAfterStartupRecovery()).toBe(false);
await privateSession.handleStreamFailureForAutoRetry({ type: "unknown", message: "boom" });
expect(events.filter((event) => event.type === "auto-retry-scheduled").length).toBe(
scheduledBefore
);
// Not disposed, but sends are refused before any row lands: a follow-up row persisted here
// would read as a dispatched turn on the next startup while its stream never ran.
const sendResult = await session.sendMessage("hello", {
model: "anthropic:claude-sonnet-4-5",
agentId: "exec",
});
expect(sendResult.success).toBe(false);
expect(streamMessage).not.toHaveBeenCalled();
const history = await historyService.getHistoryFromLatestBoundary(workspaceId);
expect(history.success ? history.data : ["unexpected"]).toHaveLength(0);

session.dispose();
});

test("beginShutdown during pre-stream awaits stops the stream before the provider", async () => {
const workspaceId = "startup-retry-shutdown-mid-prepare";
const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle("assistant-1"))));
const { session, historyService, cleanup } = await createSessionBundle(workspaceId, {
streamMessage: streamMessage as unknown as AgentSessionAIService["streamMessage"],
});
cleanups.push(cleanup);

// Shutdown lands while the stream start is already past its entry check, awaiting disk I/O.
const commitPartial = historyService.commitPartial.bind(historyService);
spyOn(historyService, "commitPartial").mockImplementation(async (id) => {
const result = await commitPartial(id);
session.beginShutdown();
return result;
});

const sendResult = await session.sendMessage("hello", {
model: "anthropic:claude-sonnet-4-5",
agentId: "exec",
});
expect(sendResult.success).toBe(true);
expect(streamMessage).not.toHaveBeenCalled();

session.dispose();
});

test("visible completed subagent report cards do not schedule startup auto-retry", async () => {
const workspaceId = "startup-retry-subagent-report-card";
const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId);
Expand Down
Loading
Loading