Skip to content

Commit 39b63c7

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

5 files changed

Lines changed: 60 additions & 13 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 announcement before spawning; include \`spawn_agent\` (or the next tool call) in the same turn as your status line.
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ test("dispatch card is spawn-first with no parallelization cap", () => {
3434
expect(prompt).not.toMatch(/single explorer worker/i);
3535
});
3636

37+
test("dispatch card forbids tool-less status announcements before spawning", () => {
38+
const prompt = buildChatSystemPrompt();
39+
expect(prompt).toMatch(/reply with no tool calls ends the turn/i);
40+
expect(prompt).toMatch(
41+
/never emit a standalone status announcement before spawning/i,
42+
);
43+
});
44+
3745
const envBase: EnvironmentInfo = {
3846
cwd: "/tmp/proj",
3947
platform: "test",

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +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
593+
* director from the static `allowIdleWithFleet: false` seed — the same seed
594+
* the TUI session assembly uses (0 workers run at build time; `resyncIdleWithFleetFlag`
595+
* re-syncs from `liveFleetCount(...) > 0` across rebuilds). The
595596
* rotation-only stubs (buildSessionSources, hostHolder, the resetters) are
596597
* inert on interrupt/reload paths.
597598
*/
@@ -639,7 +640,7 @@ function wireRebuildServices(
639640
services.hostHolder = {} as unknown as RunnerServices["hostHolder"];
640641
services.buildAgent = (async () => {
641642
directorHolder.instance = createChatDirector("base", [], {
642-
allowIdleWithFleet: true,
643+
allowIdleWithFleet: false,
643644
});
644645
return agent;
645646
}) as unknown as RunnerServices["buildAgent"];
@@ -661,7 +662,7 @@ async function expectOpenTaskNudge(director: ChatDirector): Promise<void> {
661662
expect(list.some((action) => action.type === "infer")).toBe(true);
662663
}
663664

664-
describe("rebuild re-syncs idle-with-fleet while drained", () => {
665+
describe("rebuild re-syncs idle-with-fleet while drained or live", () => {
665666
test("seed then fleet-0 does not leave idle-with-fleet stuck true", async () => {
666667
const store = createSubAgentSessionStore();
667668
const director = createChatDirector("base", [], {
@@ -674,6 +675,43 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => {
674675
await expectOpenTaskNudge(director);
675676
});
676677

678+
test("false seed with no running lanes nudges open tasks without waiting for a resync", async () => {
679+
const director = createChatDirector("base", [], {
680+
allowIdleWithFleet: false,
681+
});
682+
await expectOpenTaskNudge(director);
683+
});
684+
685+
test("resyncIdleWithFleetFlag promotes false seed to true when a fleet lane is running", async () => {
686+
const store = createSubAgentSessionStore();
687+
store.start({
688+
id: "lane-1",
689+
description: "work",
690+
agentId: "coder",
691+
brief: "implement",
692+
});
693+
const director = createChatDirector("base", [], {
694+
allowIdleWithFleet: false,
695+
});
696+
resyncIdleWithFleetFlag({
697+
directorHolder: { instance: director },
698+
subAgentSessions: store,
699+
});
700+
await director.decide(
701+
rebuildManageTasksEvent(),
702+
stubReactorState,
703+
stubReactorCapabilities,
704+
);
705+
const actions = await director.decide(
706+
stubTextTurnEvent(),
707+
stubReactorState,
708+
stubReactorCapabilities,
709+
);
710+
const list = Array.isArray(actions) ? actions : [actions];
711+
expect(list.some((action) => action.type === "infer")).toBe(false);
712+
expect(list.some((action) => action.type === "reply")).toBe(true);
713+
});
714+
677715
test("first assemble with a drained fleet does not leave idle-with-fleet stuck true", async () => {
678716
const store = createSubAgentSessionStore();
679717
const directorHolder: RunnerServices["directorHolder"] = {};

‎src/tui/runner/exit.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ 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+
// Session assembly seeds allowIdleWithFleet=false because zero workers run at
156+
// startup. Mid-session rebuilds (interrupt, reloadIfIdle, rotation) mint a
157+
// fresh director from that static false seed while the publisher only fires on
158+
// fleet-count transitions, so rebuilds re-sync from liveFleetCount(...) > 0.
158159
export function resyncIdleWithFleetFlag(
159160
services: Pick<RunnerServices, "directorHolder" | "subAgentSessions">,
160161
): void {

‎src/tui/runner/session.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -718,12 +718,11 @@ 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+
// Seed false at construction because zero workers run at initial session
722+
// assembly; the fleet-wake publisher flips it true when running > 0 and
723+
// back to false when running === 0, while resyncIdleWithFleetFlag re-syncs
724+
// from liveFleetCount(...) > 0 across mid-session rebuilds.
725+
allowIdleWithFleet: false,
727726
clearDenials: () => permissionGate.clearDenials(),
728727
getProvider: () => state.config,
729728
directorHolder,

0 commit comments

Comments
 (0)