Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 35 additions & 10 deletions src/tui/runner/exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"];
Expand All @@ -661,7 +660,7 @@ async function expectOpenTaskNudge(director: ChatDirector): Promise<void> {
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", [], {
Expand All @@ -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"] = {};
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions src/tui/runner/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunnerServices, "directorHolder" | "subAgentSessions">,
): void {
Expand Down
10 changes: 4 additions & 6 deletions src/tui/runner/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading