diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index 68a3f7e5a..db69ba7a8 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -590,10 +590,9 @@ function rebuildManageTasksEvent(): ReactorInboundEvent { /** * The services surface every rebuild path touches: holder swap, fleet store, * workflow reattach, recorder reset, and a buildAgent that mints a fresh - * director from the static `allowIdleWithFleet: true` seed — the same seed - * the TUI session assembly uses (fleet lanes may appear mid-session). The - * rotation-only stubs (buildSessionSources, hostHolder, the resetters) are - * inert on interrupt/reload paths. + * director from the static `allowIdleWithFleet: false` seed — the same seed + * the TUI session assembly uses. The rotation-only stubs (buildSessionSources, + * hostHolder, the resetters) are inert on interrupt/reload paths. */ function wireRebuildServices( services: RunnerServices, @@ -639,7 +638,7 @@ function wireRebuildServices( services.hostHolder = {} as unknown as RunnerServices["hostHolder"]; services.buildAgent = (async () => { directorHolder.instance = createChatDirector("base", [], { - allowIdleWithFleet: true, + allowIdleWithFleet: false, }); return agent; }) as unknown as RunnerServices["buildAgent"]; @@ -661,7 +660,7 @@ async function expectOpenTaskNudge(director: ChatDirector): Promise { expect(list.some((action) => action.type === "infer")).toBe(true); } -describe("rebuild re-syncs idle-with-fleet while drained", () => { +describe("rebuild re-syncs idle-with-fleet", () => { test("seed then fleet-0 does not leave idle-with-fleet stuck true", async () => { const store = createSubAgentSessionStore(); const director = createChatDirector("base", [], { @@ -674,6 +673,36 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => { await expectOpenTaskNudge(director); }); + test("resyncIdleWithFleetFlag promotes false seed to true when a fleet lane is running", async () => { + const store = createSubAgentSessionStore(); + store.start({ + id: "lane-1", + description: "work", + agentId: "coder", + brief: "implement", + }); + const director = createChatDirector("base", [], { + allowIdleWithFleet: false, + }); + resyncIdleWithFleetFlag({ + directorHolder: { instance: director }, + subAgentSessions: store, + }); + await director.decide( + rebuildManageTasksEvent(), + stubReactorState, + stubReactorCapabilities, + ); + const actions = await director.decide( + stubTextTurnEvent(), + stubReactorState, + stubReactorCapabilities, + ); + const list = Array.isArray(actions) ? actions : [actions]; + expect(list.some((action) => action.type === "infer")).toBe(false); + expect(list.some((action) => action.type === "reply")).toBe(true); + }); + test("first assemble with a drained fleet does not leave idle-with-fleet stuck true", async () => { const store = createSubAgentSessionStore(); const directorHolder: RunnerServices["directorHolder"] = {}; @@ -840,8 +869,6 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => { const directorHolder: RunnerServices["directorHolder"] = {}; const agent = recordingAgent([]); const { state, services } = stubSendLifecycle(agent); - // Every rebuild mints a fresh director from the static true seed (fleet - // lanes may appear mid-session). wireRebuildServices(services, directorHolder, agent, store); const fleetEvents: unknown[] = []; services.emitter.on("event", (event: { type: string }) => { @@ -852,13 +879,11 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => { expectOpenTaskNudge( defined(directorHolder.instance, "directorHolder.instance"), ); - // Drained fleet: the idle reload rebuilds onto the static true seed. state.pendingReload = true; defined(state.reloadIfIdle, "reloadIfIdle")(); await services.sessionOps.awaitTail(); expect(state.fatalBuildError).toBeNull(); await nudges(); - // The interrupt rebuild inherits the same seed. defined(state.interrupt, "interrupt")(); await services.sessionOps.awaitTail(); expect(state.fatalBuildError).toBeNull(); diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 191b0bf1e..d6e8b4934 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -152,9 +152,9 @@ type AgentBuildReason = | "interrupt" | "session-rotation"; -// Directors seed allowIdleWithFleet=true because fleet lanes may appear -// mid-session. Rebuilds re-sync from the live count: the publisher only -// fires on a count change, so a session that never sees 0→1→0 keeps the seed. +// Rebuilds mint a fresh director from the static false seed while the +// fleet-wake publisher only fires on count transitions, so a rebuild while +// workers are already running would otherwise lose the live allowance. export function resyncIdleWithFleetFlag( services: Pick, ): void { diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index eb16b04e8..ed9dc2123 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -718,12 +718,10 @@ export async function assembleTUISession( computeAdvertised, inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, totalTimeoutMs: config.totalTimeoutMs, - // Seed the idle-with-fleet allowance (fleet lanes may appear mid-session; - // the fleet-wake publisher keeps it live). No onTasksChange / - // requestContinuation / getLiveFleetCount closures: task/tool updates and - // compaction re-entry arrive as reactor events in the stream sink, and - // the publisher drives the allowance via setAllowIdleWithFleet. - allowIdleWithFleet: true, + // Zero workers run at session assembly; the fleet-wake publisher only fires + // on count transitions, so seeding true would suppress the open-task nudge + // before any worker ever spawns. + allowIdleWithFleet: false, clearDenials: () => permissionGate.clearDenials(), getProvider: () => state.config, directorHolder,