Skip to content

Commit 9c66fbc

Browse files
committed
fix(prompts): forbid tool-less status turns and seed idle-with-fleet false
1 parent afa244c commit 9c66fbc

5 files changed

Lines changed: 45 additions & 19 deletions

File tree

‎packages/prompt-fragments/src/cards.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ ${dispatchRouteSection()}
1919
- Read only enough to route and write a useful brief. Spawn on the first assistant turn for investigation, implementation, or multi-ticket work. Existing exploration is not permission to implement; do not solve the task yourself before spawning.
2020
2121
# Rules
22+
- A reply with no tool calls ends the turn — never emit a standalone status line before spawning; call spawn_agent in the same turn.
2223
- Edit files with file tools only, never shell redirection or sed.
2324
- Match every requirement in the request; before finishing, re-read it and check each item.
2425
- Use manage_tasks for work of three or more steps.

‎src/prompts.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ test("dispatch card is spawn-first with no parallelization cap", () => {
2828
expect(prompt).toMatch(/first assistant turn/i);
2929
expect(prompt).toMatch(/same turn/i);
3030
expect(prompt).toMatch(/same live job/i);
31+
expect(prompt).toMatch(/reply with no tool calls ends the turn/i);
32+
expect(prompt).toMatch(/standalone status/i);
3133
expect(prompt).not.toMatch(/Latest operator turn is law/i);
3234
expect(prompt).not.toMatch(/Do not duplicate a live worker/i);
3335
expect(prompt).not.toMatch(/diagnostic fleet/i);

‎src/tui/runner/exit.test.ts‎

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -590,10 +590,9 @@ function rebuildManageTasksEvent(): ReactorInboundEvent {
590590
/**
591591
* The services surface every rebuild path touches: holder swap, fleet store,
592592
* workflow reattach, recorder reset, and a buildAgent that mints a fresh
593-
* director from the static `allowIdleWithFleet: true` seed — the same seed
594-
* the TUI session assembly uses (fleet lanes may appear mid-session). The
595-
* rotation-only stubs (buildSessionSources, hostHolder, the resetters) are
596-
* inert on interrupt/reload paths.
593+
* director from the static `allowIdleWithFleet: false` seed — the same seed
594+
* the TUI session assembly uses. The rotation-only stubs (buildSessionSources,
595+
* hostHolder, the resetters) are inert on interrupt/reload paths.
597596
*/
598597
function wireRebuildServices(
599598
services: RunnerServices,
@@ -639,7 +638,7 @@ function wireRebuildServices(
639638
services.hostHolder = {} as unknown as RunnerServices["hostHolder"];
640639
services.buildAgent = (async () => {
641640
directorHolder.instance = createChatDirector("base", [], {
642-
allowIdleWithFleet: true,
641+
allowIdleWithFleet: false,
643642
});
644643
return agent;
645644
}) as unknown as RunnerServices["buildAgent"];
@@ -661,7 +660,7 @@ async function expectOpenTaskNudge(director: ChatDirector): Promise<void> {
661660
expect(list.some((action) => action.type === "infer")).toBe(true);
662661
}
663662

664-
describe("rebuild re-syncs idle-with-fleet while drained", () => {
663+
describe("rebuild re-syncs idle-with-fleet", () => {
665664
test("seed then fleet-0 does not leave idle-with-fleet stuck true", async () => {
666665
const store = createSubAgentSessionStore();
667666
const director = createChatDirector("base", [], {
@@ -674,6 +673,36 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => {
674673
await expectOpenTaskNudge(director);
675674
});
676675

676+
test("resyncIdleWithFleetFlag promotes false seed to true when a fleet lane is running", async () => {
677+
const store = createSubAgentSessionStore();
678+
store.start({
679+
id: "lane-1",
680+
description: "work",
681+
agentId: "coder",
682+
brief: "implement",
683+
});
684+
const director = createChatDirector("base", [], {
685+
allowIdleWithFleet: false,
686+
});
687+
resyncIdleWithFleetFlag({
688+
directorHolder: { instance: director },
689+
subAgentSessions: store,
690+
});
691+
await director.decide(
692+
rebuildManageTasksEvent(),
693+
stubReactorState,
694+
stubReactorCapabilities,
695+
);
696+
const actions = await director.decide(
697+
stubTextTurnEvent(),
698+
stubReactorState,
699+
stubReactorCapabilities,
700+
);
701+
const list = Array.isArray(actions) ? actions : [actions];
702+
expect(list.some((action) => action.type === "infer")).toBe(false);
703+
expect(list.some((action) => action.type === "reply")).toBe(true);
704+
});
705+
677706
test("first assemble with a drained fleet does not leave idle-with-fleet stuck true", async () => {
678707
const store = createSubAgentSessionStore();
679708
const directorHolder: RunnerServices["directorHolder"] = {};
@@ -840,8 +869,6 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => {
840869
const directorHolder: RunnerServices["directorHolder"] = {};
841870
const agent = recordingAgent([]);
842871
const { state, services } = stubSendLifecycle(agent);
843-
// Every rebuild mints a fresh director from the static true seed (fleet
844-
// lanes may appear mid-session).
845872
wireRebuildServices(services, directorHolder, agent, store);
846873
const fleetEvents: unknown[] = [];
847874
services.emitter.on("event", (event: { type: string }) => {
@@ -852,13 +879,11 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => {
852879
expectOpenTaskNudge(
853880
defined(directorHolder.instance, "directorHolder.instance"),
854881
);
855-
// Drained fleet: the idle reload rebuilds onto the static true seed.
856882
state.pendingReload = true;
857883
defined(state.reloadIfIdle, "reloadIfIdle")();
858884
await services.sessionOps.awaitTail();
859885
expect(state.fatalBuildError).toBeNull();
860886
await nudges();
861-
// The interrupt rebuild inherits the same seed.
862887
defined(state.interrupt, "interrupt")();
863888
await services.sessionOps.awaitTail();
864889
expect(state.fatalBuildError).toBeNull();

‎src/tui/runner/exit.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ type AgentBuildReason =
152152
| "interrupt"
153153
| "session-rotation";
154154

155-
// Directors seed allowIdleWithFleet=true because fleet lanes may appear
156-
// mid-session. Rebuilds re-sync from the live count: the publisher only
157-
// fires on a count change, so a session that never sees 0→1→0 keeps the seed.
155+
// Rebuilds mint a fresh director from the static false seed while the
156+
// fleet-wake publisher only fires on count transitions, so a rebuild while
157+
// workers are already running would otherwise lose the live allowance.
158158
export function resyncIdleWithFleetFlag(
159159
services: Pick<RunnerServices, "directorHolder" | "subAgentSessions">,
160160
): void {

‎src/tui/runner/session.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -718,12 +718,10 @@ export async function assembleTUISession(
718718
computeAdvertised,
719719
inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000,
720720
totalTimeoutMs: config.totalTimeoutMs,
721-
// Seed the idle-with-fleet allowance (fleet lanes may appear mid-session;
722-
// the fleet-wake publisher keeps it live). No onTasksChange /
723-
// requestContinuation / getLiveFleetCount closures: task/tool updates and
724-
// compaction re-entry arrive as reactor events in the stream sink, and
725-
// the publisher drives the allowance via setAllowIdleWithFleet.
726-
allowIdleWithFleet: true,
721+
// Zero workers run at session assembly; the fleet-wake publisher only fires
722+
// on count transitions, so seeding true would suppress the open-task nudge
723+
// before any worker ever spawns.
724+
allowIdleWithFleet: false,
727725
clearDenials: () => permissionGate.clearDenials(),
728726
getProvider: () => state.config,
729727
directorHolder,

0 commit comments

Comments
 (0)