Skip to content

Commit b810dbd

Browse files
committed
fix(cli): settle goal pause suppression on command settle and announce resumed live goals
- /goal pause cleared selfInitiatedPauseGoalId only when the push handler observed the transition; a response-before-push ordering left the flag set and suppressed a later host-initiated pause notice. The success path now clears the flag and syncs the transition cache to the authoritative response, so the trailing push no longer duplicates the notice. - The attach-time goal notice ran before the driver adopted a resumed session, so resuming into a session with a live durable goal never announced the auto-continuing loop. switchSession now syncs the goal transition cache and emits the notice after the transcript replacement that would erase an adoption-time notice.
1 parent 36a9b7f commit b810dbd

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

packages/cli/src/__tests__/pi-tui-runner.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5005,6 +5005,99 @@ describe('Maka Pi TUI runner', () => {
50055005
]);
50065006
});
50075007

5008+
test('a settled /goal pause does not suppress a later host-initiated pause notice', async () => {
5009+
const terminal = new FakeTerminal(160, 24);
5010+
const driver = new SlashCommandDriver();
5011+
driver.goal = armedGoal;
5012+
// The host can answer the control RPC before the subscription push folds
5013+
// the transition; the suppression flag must settle with the command
5014+
// instead of lingering for the push handler.
5015+
driver.deferGoalControlPush = true;
5016+
const run = runMakaPiTui({
5017+
title: 'Maka',
5018+
driver,
5019+
cwd: '/repo',
5020+
model: 'claude-sonnet-4-5',
5021+
connectionSlug: 'claude-subscription',
5022+
permissionMode: 'ask',
5023+
terminal,
5024+
});
5025+
await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal 2/50'));
5026+
5027+
terminal.input('/goal pause');
5028+
terminal.input('\r');
5029+
await waitFor(() =>
5030+
plainTerminalOutput(terminal.output()).includes('Goal paused. /goal resume continues it'),
5031+
);
5032+
5033+
// The trailing push of the command's own pause folds onto the settled
5034+
// projection: no duplicate auto-pause notice.
5035+
driver.pushGoal({ ...armedGoal, status: 'paused', revision: 4, pausedAt: Date.now() });
5036+
await waitFor(() => plainTerminalOutput(terminal.output()).includes('goal paused 2/50'));
5037+
assert.equal(
5038+
plainTerminalOutput(terminal.output()).includes('Goal paused (2/50).'),
5039+
false,
5040+
);
5041+
5042+
terminal.input('/goal resume');
5043+
terminal.input('\r');
5044+
await waitFor(() => plainTerminalOutput(terminal.output()).includes('Goal resumed.'));
5045+
5046+
// A later host-initiated pause of the same goal (e.g. the Ctrl+C
5047+
// auto-pause) must announce itself.
5048+
driver.pushGoal({
5049+
...armedGoal,
5050+
status: 'paused',
5051+
revision: 6,
5052+
pausedAt: Date.now(),
5053+
lastReason: 'Goal-associated turn was aborted.',
5054+
});
5055+
await waitFor(() =>
5056+
plainTerminalOutput(terminal.output()).includes(
5057+
'Goal paused (2/50). Goal-associated turn was aborted.',
5058+
),
5059+
);
5060+
5061+
exitMaka(terminal);
5062+
await Promise.race([
5063+
run,
5064+
delay(CLOSE_BUDGET_MS).then(() => {
5065+
throw new Error('TUI did not close during test cleanup');
5066+
}),
5067+
]);
5068+
});
5069+
5070+
test('resuming into a session with a live goal announces the auto-continuing loop', async () => {
5071+
const terminal = new FakeTerminal(160, 24);
5072+
const driver = new SlashCommandDriver([fakeSessionSummary('session-2', '/repo')]);
5073+
// Before the switch, the driver has no attached session — the init-time
5074+
// check sees nothing; the notice must come from the switch seam.
5075+
driver.goal = null;
5076+
driver.goalsBySessionId.set('session-2', armedGoal);
5077+
const run = runMakaPiTui({
5078+
title: 'Maka',
5079+
driver,
5080+
cwd: '/repo',
5081+
model: 'claude-sonnet-4-5',
5082+
connectionSlug: 'claude-subscription',
5083+
permissionMode: 'ask',
5084+
terminal,
5085+
resumeSessionId: 'session-2',
5086+
});
5087+
5088+
await waitFor(() =>
5089+
plainTerminalOutput(terminal.output()).includes('Autonomous goal is running (2/50)'),
5090+
);
5091+
5092+
exitMaka(terminal);
5093+
await Promise.race([
5094+
run,
5095+
delay(CLOSE_BUDGET_MS).then(() => {
5096+
throw new Error('TUI did not close during test cleanup');
5097+
}),
5098+
]);
5099+
});
5100+
50085101
test('/goal control pre-validates impossible transitions', async () => {
50095102
const terminal = new FakeTerminal();
50105103
const driver = new SlashCommandDriver();
@@ -6264,6 +6357,13 @@ class SlashCommandDriver implements MakaSessionDriver {
62646357

62656358
/** Records control actions and applies them to the local goal like the host would. */
62666359
readonly controlledGoalActions: Array<'pause' | 'resume' | 'clear'> = [];
6360+
/**
6361+
* When true, controlGoal resolves without pushing the projection first —
6362+
* the response-before-push ordering a slow subscription stream can produce.
6363+
*/
6364+
deferGoalControlPush = false;
6365+
/** Per-session goal projections applied when switchSession adopts a session. */
6366+
readonly goalsBySessionId = new Map<string, GoalProjection | null>();
62676367

62686368
controlGoal(action: 'pause' | 'resume' | 'clear'): Promise<GoalProjection | null> {
62696369
this.controlledGoalActions.push(action);
@@ -6275,7 +6375,11 @@ class SlashCommandDriver implements MakaSessionDriver {
62756375
: action === 'pause'
62766376
? { ...goal, status: 'paused', revision: goal.revision + 1, pausedAt: Date.now() }
62776377
: { ...goal, status: 'active', revision: goal.revision + 1, pausedAt: null };
6278-
this.pushGoal(next);
6378+
if (this.deferGoalControlPush) {
6379+
this.goal = next;
6380+
} else {
6381+
this.pushGoal(next);
6382+
}
62796383
return Promise.resolve(next);
62806384
}
62816385

@@ -6381,6 +6485,9 @@ class SlashCommandDriver implements MakaSessionDriver {
63816485
const nextSummary = summary ?? fakeSessionSummary(sessionId);
63826486
this.orchestrationMode = nextSummary.orchestrationMode ?? 'default';
63836487
this.activeBoundaryDisplayMode = this.boundaryDisplayModeBySession.get(nextSummary.id);
6488+
if (this.goalsBySessionId.has(sessionId)) {
6489+
this.goal = this.goalsBySessionId.get(sessionId) ?? null;
6490+
}
63846491
return switchResult(nextSummary, [...(this.sessionMessages.get(nextSummary.id) ?? [])]);
63856492
}
63866493
async listRewindTargets(): Promise<RewindTarget[]> {

packages/cli/src/pi-tui-runner.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
368368
requestRender();
369369
});
370370
// Attaching to a session whose durable goal auto-continues after recovery
371-
// must never resume a token-burning loop silently.
371+
// must never resume a token-burning loop silently. This covers a driver
372+
// that is already attached at startup; a resumeSessionId attach happens
373+
// later, so switchSession repeats the check after adopting the session.
372374
if (
373375
currentGoal !== null &&
374376
(currentGoal.status === 'active' || currentGoal.status === 'waiting')
@@ -1423,6 +1425,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
14231425
relocateCwd === undefined ? undefined : { relocateCwd },
14241426
);
14251427
await applySwitchResult(result);
1428+
// Sync the transition cache to the adopted session's goal, then announce a
1429+
// live durable goal: the init-time check ran before the driver attached
1430+
// the resumed session, and the goal subscription only announces pause
1431+
// transitions. Emitting here — after the transcript replacement that
1432+
// would erase a notice from adoption time — keeps an auto-continuing
1433+
// token-burning loop from resuming silently.
1434+
currentGoal = input.driver.getGoal?.() ?? null;
1435+
if (
1436+
currentGoal !== null &&
1437+
(currentGoal.status === 'active' || currentGoal.status === 'waiting')
1438+
) {
1439+
state.entries.push({
1440+
kind: 'notice',
1441+
level: 'info',
1442+
text: goalAttachedNoticeText(currentGoal),
1443+
});
1444+
}
14261445
if (result.relocation?.changed) {
14271446
const warning =
14281447
result.relocation.oldCwdDirty === true
@@ -2518,7 +2537,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
25182537
notice(action === 'clear' ? 'Goal cleared.' : 'The goal no longer exists.');
25192538
return;
25202539
}
2540+
// Keep the transition cache on the authoritative response: a trailing push
2541+
// of this same transition then folds onto an identical previous state and
2542+
// is not mistaken for a fresh one.
2543+
currentGoal = result;
25212544
if (action === 'pause') {
2545+
// Settle the suppression flag: the command's own confirmation has told
2546+
// the user, and a lingering flag would suppress a later host-initiated
2547+
// pause of this goal (e.g. the Ctrl+C auto-pause).
2548+
selfInitiatedPauseGoalId = null;
25222549
notice('Goal paused. /goal resume continues it, /goal clear stops it.');
25232550
} else if (action === 'resume') {
25242551
notice('Goal resumed.');

0 commit comments

Comments
 (0)