Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/adapters/cli/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,10 @@ export function createGrokAdapter(pathOverride?: string): CliAdapter {
// Busy markers verified on 0.2.93: "⠧ Waiting for response…" spinner
// during the model phase, and the "Ctrl+c:cancel" shortcut-bar entry
// through model AND tool phases (idle bar shows Shift+Tab:mode /
// Ctrl+x:shortcuts only).
// Ctrl+x:shortcuts only). Kept for reattach / first-prompt-timeout
// probes — post-submit busy-absent probing is disabled for
// reliableTurnTerminal CLIs (worker) because these markers often lag
// several seconds after submit and were flipping card-off DONE early.
busyPattern: /Waiting for response|Ctrl\+c:\s*cancel/i,
// Routing/identity ride on --rules (injectsSessionContext); no inline hints.
systemHints: [],
Expand Down
17 changes: 12 additions & 5 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3125,12 +3125,18 @@ function setupWorkerHandlers(
source: 'screen_update',
content: msg.content,
});
// Usage ledger + turn reactions: idle/limited edges are turn
// boundaries. Append the token delta, and flip this turn's pending ✋
// reactions to ✅ (best-effort, never blocks the status pipeline).
// Usage ledger: any settle-to-idle/limited edge records the delta.
// Turn reactions are stricter — only flip ✋→✅ after a real busy
// period (working/analyzing). Cold-start starting→idle (or the first
// prompt-ready before the turn has gone working) must NOT DONE a
// message that is still about to be / just being processed. Grok
// card-off sessions hit this when the ready-gate settle fired idle
// ~seconds after GoGoGo while the CLI was still running the prompt.
if (ds.lastScreenStatus === 'idle' || ds.lastScreenStatus === 'limited') {
recordUsageForDaemonSession(ds);
void finishTurnReactions(ds);
if (prevStatus === 'working' || prevStatus === 'analyzing') {
void finishTurnReactions(ds);
}
}
if (
ds.lastScreenStatus === 'idle'
Expand Down Expand Up @@ -4082,7 +4088,8 @@ function shouldDropMismatchedHermesFinalOutput(
/**
* Turn-end half of the two-phase turn reactions (auto-on for card-off sessions,
* i.e. streaming card disabled). The 冲! "received" reactions are added per-message at the daemon
* acceptance point (`noteTurnReceived`); when the worker next returns to idle we
* acceptance point (`noteTurnReceived`); the screen_update handler calls this
* only on working|analyzing → idle|limited (not cold-start starting→idle), to
* flip every pending ✋ on this session to ✅ DONE and clear the list. When
* silentTurnReactions is enabled after a ✋ has already landed, we only remove
* that received reaction and do not add DONE. Binding the start to the message
Expand Down
50 changes: 50 additions & 0 deletions src/utils/pending-input-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,56 @@ export function resolveInitialPromptDelivery(opts: {
};
}

/**
* Whether this spawn baked a non-empty first prompt into argv (not the write
* queue). Shared base for both Grok pre-exec busy arming and the card-off
* "seed working before first idle" path for quiescence argv adapters.
*
* Riff has passesInitialPromptViaArgs=false → false (queue-after-spawn).
*/
export function shouldTrackArgvBakedFirstPrompt(opts: {
passesInitialPromptViaArgs: boolean;
preparedInitialPrompt?: string | null;
queuedInitialPrompt?: string | null;
}): boolean {
if (!opts.passesInitialPromptViaArgs) return false;
if (!opts.preparedInitialPrompt?.trim()) return false;
if (opts.queuedInitialPrompt) return false;
return true;
}

/**
* Whether markPromptReady must treat the first post-spawn ready as
* "pre-execution SessionStart" (report working, keep busy) rather than true
* end-of-turn idle.
*
* Strict conditions (PR #633 review):
* - adapter actually bakes the first prompt into argv (`passesInitialPromptViaArgs`)
* - an argv prompt exists and was NOT deferred to the write queue
* - SessionStart ready exists (`injectsReadyHook`) so first ready ≠ completion
* - turn terminal is authoritative (`reliableTurnTerminal`) so a later
* assistant_final/fireIdle will produce a real idle edge
*
* Riff (and any queue-after-spawn adapter) has passesInitialPromptViaArgs=false
* — must return false, or the first markPromptReady would clear isPromptReady
* and leave the post-spawn queue flush never firing.
* Gemini/Pi/MTR/OpenCode pass prompt via argv but use quiescence as the sole
* idle signal — first ready IS completion; must return false or they stick
* (use {@link shouldTrackArgvBakedFirstPrompt} + seed working→idle instead).
*/
export function shouldArmSpawnArgvInitialPromptBusy(opts: {
passesInitialPromptViaArgs: boolean;
preparedInitialPrompt?: string | null;
queuedInitialPrompt?: string | null;
injectsReadyHook: boolean;
reliableTurnTerminal: boolean;
}): boolean {
if (!shouldTrackArgvBakedFirstPrompt(opts)) return false;
if (!opts.injectsReadyHook) return false;
if (!opts.reliableTurnTerminal) return false;
return true;
}

/** Once either side of a queue boundary is durable, stop this batch and wait
* for the next reliable idle edge before writing the following turn. */
export function shouldStopPendingBatch(
Expand Down
110 changes: 100 additions & 10 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ import {
pendingInputMayFlush,
pendingInputAllowsTypeAhead,
resolveInitialPromptDelivery,
shouldArmSpawnArgvInitialPromptBusy,
shouldTrackArgvBakedFirstPrompt,
shouldDeferArgsBakedDurablePrompt,
shouldDeferInitialPromptForArgLimit,
shouldStopPendingBatch,
Expand Down Expand Up @@ -1095,6 +1097,19 @@ let lastSpawnEffectiveCliSessionId: string | undefined;
let lastSpawnDeferInitialPrompt = false;
let lastSpawnQueuedInitialPrompt: string | undefined;
let lastSpawnQueuedInitialPromptLogicalContent: string | undefined;
/**
* True only when {@link shouldArmSpawnArgvInitialPromptBusy} says so: argv-
* baked first prompt + SessionStart ready (Grok-class). First markPromptReady
* then reports working (not idle). Cleared on first consume. Must stay false
* for Riff/queue-after-spawn and for quiescence-only argv adapters.
*/
let spawnArgvInitialPromptBusy = false;
/**
* True when spawn baked first prompt into argv (any such adapter, incl. Pi /
* Gemini). Card-off reactions need a working→idle edge: Grok uses the busy
* arm above; quiescence argv adapters seed working then idle at first ready.
*/
let spawnArgvNeedsWorkingSeed = false;
let idleDetector: IdleDetector | null = null;
let isTmuxMode = false;
/** True once a crash diagnostic tmux shell (bmx-diag-<sid>) is live. */
Expand Down Expand Up @@ -5170,6 +5185,20 @@ function markPromptReadyFromPty(): void {
}
}

/** Push a coarse screen status to the daemon without waiting for the 2s sampler.
* Used so card-off reactions see working→idle even on short turns. */
function publishScreenStatus(status: 'working' | 'idle'): void {
if (!renderer) return;
const { content } = renderer.snapshot();
send({
type: 'screen_update',
content,
...usageLimitTracker.classify(content, status),
turnId: currentBotmuxTurnId,
dispatchAttempt: currentBotmuxDispatchAttempt,
});
}

function markPromptReady(): void {
if (isPromptReady) return; // guard against duplicate calls
if (cliRestartInProgress && !replacementSpawnInProgress) {
Expand Down Expand Up @@ -5269,14 +5298,43 @@ function markPromptReady(): void {
log('prompt-ready with no backend installed — deferring restart success receipt');
}
}
// Send immediate idle snapshot so Lark card reflects idle status.
// BUT: skip when messages are pending — flushPending() will immediately
// make the CLI busy, so the idle state is transient and shouldn't appear
// in the card. This avoids a false "就绪" flash on daemon restart
// (where the initial prompt is queued before the CLI becomes idle).
if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) {
const { content } = renderer.snapshot();
send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
// Send an immediate status snapshot so Lark card / card-off reactions track
// real work. Skip pure idle when:
// - messages are pending — flushPending() will immediately make the CLI
// busy (avoids a false "就绪" flash on daemon restart);
// - Grok-class argv+SessionStart arming: first ready is pre-execution, so
// park as working until assistant_final/fireIdle;
// - quiescence argv CLIs (Pi/Gemini/MTR/OpenCode): first ready IS turn end
// — seed working then idle so card-off gets working→idle (review P2).
const hasPendingWork =
pendingMessages.length > 0
|| pendingRawInputs.length > 0
|| pendingSessionRename !== null
|| isFlushing;
if (renderer && !hasPendingWork) {
if (spawnArgvInitialPromptBusy) {
spawnArgvInitialPromptBusy = false;
spawnArgvNeedsWorkingSeed = false;
// Stay non-ready so the next genuine end-of-turn idle is a real edge.
isPromptReady = false;
idleDetector?.reset();
publishScreenStatus('working');
log('Spawn argv initial prompt still in flight — reporting working (not idle) so turn reactions can settle later');
} else if (spawnArgvNeedsWorkingSeed) {
// First ready = true completion for quiescence argv adapters. Seed a
// working edge before idle so daemon finishTurnReactions (gated on
// working→idle) still flips card-off GoGoGo on cold-start one-shots.
spawnArgvNeedsWorkingSeed = false;
publishScreenStatus('working');
publishScreenStatus('idle');
log('Argv-baked first prompt completed — seeded working→idle for card-off reactions');
} else {
publishScreenStatus('idle');
}
} else if (hasPendingWork) {
// Queued path will flip busy via flushPending; drop argv seed flags.
spawnArgvInitialPromptBusy = false;
spawnArgvNeedsWorkingSeed = false;
}
// barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
// 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
Expand Down Expand Up @@ -5677,6 +5735,10 @@ async function flushPending(): Promise<void> {
if (isPromptReady) {
isPromptReady = false;
idleDetector?.reset();
// Immediate working for card-off reaction settle (PR #633 P2): the screen
// sampler is 2s — without this a short turn can complete before any
// working status is observed, leaving idle→idle and GoGoGo uncleared.
publishScreenStatus('working');
}

try {
Expand Down Expand Up @@ -5841,7 +5903,16 @@ async function flushPending(): Promise<void> {
} else {
result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId });
}
scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
// Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
// assistant_final → fireIdle. Their busyPattern is often missing for
// several seconds after submit (or never matches the current TUI chrome),
// so a post-submit "busy marker absent" probe falsely marks prompt ready
// and the card-off DONE reaction lands while the turn is still running
// (seen live on Grok: GoGoGo → +7s post-submit probe → DONE, then
// deferred recheck still saw active PTY output).
if (cliAdapter.reliableTurnTerminal !== true) {
scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
}
} catch (err: any) {
log(`writeInput threw: ${err?.message ?? err}`);
if (durableWrite && item.turnId) {
Expand Down Expand Up @@ -6002,7 +6073,11 @@ function sendToPty(
flushPending(); // fire-and-forget async; no-op if already flushing
} else {
if (!mergedQueued) log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
// Same false-idle trap as post-submit (see flushPending): do not let
// "busy marker absent" declare ready for transcript-backed CLIs.
if (cliAdapter?.reliableTurnTerminal !== true) {
scheduleBusyPatternIdleProbe(`${cliName()} queued-message`);
}
}
return true;
}
Expand Down Expand Up @@ -7120,6 +7195,21 @@ async function spawnCli(
preparedInitialPrompt = initialPromptDelivery.argvPrompt;
lastSpawnQueuedInitialPrompt = initialPromptDelivery.queuedContent;
lastSpawnQueuedInitialPromptLogicalContent = initialPromptDelivery.logicalContent;
// Argv-baked first prompt tracking (PR #633 P2 / second-round review):
// - needsWorkingSeed: any argv CLI (Pi/Gemini/MTR/OpenCode/Grok) so card-off
// reactions can form working→idle (seeded at first ready or Grok arm).
// - busy arm: only Grok-class SessionStart (first ready ≠ turn end).
const argvBakedOpts = {
passesInitialPromptViaArgs: cliAdapter.passesInitialPromptViaArgs === true,
preparedInitialPrompt,
queuedInitialPrompt: lastSpawnQueuedInitialPrompt,
};
spawnArgvNeedsWorkingSeed = shouldTrackArgvBakedFirstPrompt(argvBakedOpts);
spawnArgvInitialPromptBusy = shouldArmSpawnArgvInitialPromptBusy({
...argvBakedOpts,
injectsReadyHook: cliAdapter.injectsReadyHook === true,
reliableTurnTerminal: cliAdapter.reliableTurnTerminal === true,
});
if (deferInitialPrompt && preparedDeferredInput) {
piInitialPromptAdditionalArgs = [...(preparedDeferredInput.additionalArgs ?? [])];
piInitialPromptEnv = { ...(preparedDeferredInput.env ?? {}) };
Expand Down
75 changes: 75 additions & 0 deletions test/initial-prompt-arg-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,90 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { createPiAdapter } from '../src/adapters/cli/pi.js';
import { createGrokAdapter } from '../src/adapters/cli/grok.js';
import { createRiffAdapter } from '../src/adapters/cli/riff.js';
import { createGeminiAdapter } from '../src/adapters/cli/gemini.js';
import { shouldQueueInitialPrompt } from '../src/codex-rpc-lifecycle.js';
import {
resolveInitialPromptDelivery,
shouldArmSpawnArgvInitialPromptBusy,
shouldTrackArgvBakedFirstPrompt,
shouldDeferInitialPromptForArgLimit,
} from '../src/utils/pending-input-queue.js';
import { PI_INITIAL_PROMPT_COMMAND } from '../src/adapters/cli/pi-initial-prompt-extension.js';

process.env.BOTMUX_TIME_SCALE ??= '0.01';

describe('shouldArmSpawnArgvInitialPromptBusy (PR #633 CR)', () => {
it('arms only for Grok-class argv + SessionStart + reliable terminal', () => {
const grok = createGrokAdapter('/bin/grok');
expect(shouldArmSpawnArgvInitialPromptBusy({
passesInitialPromptViaArgs: grok.passesInitialPromptViaArgs === true,
preparedInitialPrompt: 'review this MR',
queuedInitialPrompt: undefined,
injectsReadyHook: grok.injectsReadyHook === true,
reliableTurnTerminal: grok.reliableTurnTerminal === true,
})).toBe(true);
});

it('does not arm for Riff (prompt is queue-after-spawn, not argv)', () => {
const riff = createRiffAdapter();
// Reviewer regression: preparedInitialPrompt non-empty alone must NOT arm —
// Riff ignores prompt in buildArgs and queues after spawnCli returns.
expect(riff.passesInitialPromptViaArgs).toBeFalsy();
expect(shouldArmSpawnArgvInitialPromptBusy({
passesInitialPromptViaArgs: riff.passesInitialPromptViaArgs === true,
preparedInitialPrompt: 'hello from feishu',
queuedInitialPrompt: undefined,
injectsReadyHook: riff.injectsReadyHook === true,
reliableTurnTerminal: riff.reliableTurnTerminal === true,
})).toBe(false);
});

it('does not arm for quiescence-only argv adapters (Pi / Gemini) but still tracks argv seed', () => {
for (const adapter of [createPiAdapter('/bin/pi'), createGeminiAdapter('/bin/gemini')]) {
expect(adapter.passesInitialPromptViaArgs).toBe(true);
expect(adapter.injectsReadyHook).toBeFalsy();
const base = {
passesInitialPromptViaArgs: adapter.passesInitialPromptViaArgs === true,
preparedInitialPrompt: 'do something',
queuedInitialPrompt: undefined as string | undefined,
};
// Track seed so markPromptReady can publish working→idle for card-off.
expect(shouldTrackArgvBakedFirstPrompt(base)).toBe(true);
// Must NOT hold busy across first ready (first ready IS turn end).
expect(shouldArmSpawnArgvInitialPromptBusy({
...base,
injectsReadyHook: adapter.injectsReadyHook === true,
reliableTurnTerminal: adapter.reliableTurnTerminal === true,
})).toBe(false);
}
});

it('does not arm when the first prompt was deferred to the write queue', () => {
expect(shouldArmSpawnArgvInitialPromptBusy({
passesInitialPromptViaArgs: true,
preparedInitialPrompt: 'argv-form',
queuedInitialPrompt: 'queued command',
injectsReadyHook: true,
reliableTurnTerminal: true,
})).toBe(false);
});

it('Riff post-spawn queue path: shouldQueueInitialPrompt is true when prompt exists', () => {
// Behavioral pin: riff does not bake prompt into argv, so the worker must
// queue + flush once after spawn (isPromptReady stays true for that flush).
const riff = createRiffAdapter();
expect(shouldQueueInitialPrompt({
hasPrompt: true,
rpcEngineActive: false,
queuePrompt: false,
passesInitialPromptViaArgs: riff.passesInitialPromptViaArgs === true,
deferInitialPrompt: false,
})).toBe(true);
});
});

describe('initial prompt argv byte-limit fallback', () => {
it('does not defer when the adapter does not pass initial prompts via args', () => {
expect(shouldDeferInitialPromptForArgLimit({
Expand Down
Loading