fix: /stop leaves stale paired task, sawOutput guard, progress text overwrite

- /stop now resets the active paired task to completed so the next
  user message routes to the owner instead of the stuck reviewer
- Owner completion with sawOutput=false (e.g. interrupted by /stop)
  no longer auto-triggers the reviewer — treated as interrupted
- Clear pendingProgressText when intermediate updates arrive on an
  existing progress message, preventing flushPendingProgress from
  overwriting latestProgressText with stale buffered content
This commit is contained in:
Eyejoker
2026-03-31 02:54:04 +09:00
parent 33575c84f1
commit dddf18428e
6 changed files with 38 additions and 11 deletions

View File

@@ -181,11 +181,17 @@ function makeDeps() {
describe('runAgentForGroup room memory', () => { describe('runAgentForGroup room memory', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({ vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success', status: 'success',
result: 'ok', result: 'ok',
newSessionId: 'session-123', output: { visibility: 'public', text: 'ok' },
phase: 'final',
}); });
return { status: 'success', result: 'ok', newSessionId: 'session-123' };
},
);
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue( vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(
'## Shared Room Memory\n- remembered context', '## Shared Room Memory\n- remembered context',
); );
@@ -461,12 +467,15 @@ describe('runAgentForGroup room memory', () => {
EJCLAW_PAIRED_ROLE: 'owner', EJCLAW_PAIRED_ROLE: 'owner',
}), }),
); );
// Owner produced no visible output (mock doesn't go through streamed
// evaluator) → treated as interrupted, status is 'failed' to prevent
// auto-triggering the reviewer.
expect( expect(
pairedExecutionContext.completePairedExecutionContext, pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({ ).toHaveBeenCalledWith({
taskId: 'paired-task-1', taskId: 'paired-task-1',
role: 'owner', role: 'owner',
status: 'succeeded', status: 'failed',
summary: 'ok', summary: 'ok',
}); });
}); });

View File

@@ -227,6 +227,7 @@ export async function runAgentForGroup(
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedExecutionCompleted = false; let pairedExecutionCompleted = false;
let pairedSawOutput = false;
const shouldHandoffToCodex = ( const shouldHandoffToCodex = (
reason: AgentTriggerReason, reason: AgentTriggerReason,
@@ -708,6 +709,7 @@ export async function runAgentForGroup(
switch (outcome.type) { switch (outcome.type) {
case 'success': case 'success':
pairedSawOutput = outcome.sawOutput;
return 'success'; return 'success';
case 'error': case 'error':
return 'error'; return 'error';
@@ -998,21 +1000,35 @@ export async function runAgentForGroup(
} }
pairedExecutionStatus = 'succeeded'; pairedExecutionStatus = 'succeeded';
pairedSawOutput = primaryAttempt.sawOutput;
return 'success'; return 'success';
} finally { } finally {
if (pairedExecutionContext && !pairedExecutionCompleted) { if (pairedExecutionContext && !pairedExecutionCompleted) {
const completedRole = roomRoleContext?.role ?? 'owner'; const completedRole = roomRoleContext?.role ?? 'owner';
// Owner was interrupted without producing output (e.g. /stop) —
// treat as failed so reviewer is not auto-triggered.
const effectiveStatus =
completedRole === 'owner' &&
pairedExecutionStatus === 'succeeded' &&
!pairedSawOutput
? 'failed'
: pairedExecutionStatus;
completePairedExecutionContext({ completePairedExecutionContext({
taskId: pairedExecutionContext.task.id, taskId: pairedExecutionContext.task.id,
role: completedRole, role: completedRole,
status: pairedExecutionStatus, status: effectiveStatus,
summary: pairedExecutionSummary, summary: pairedExecutionSummary,
}); });
} }
// After owner/reviewer completes, enqueue the next turn so // After owner/reviewer completes, enqueue the next turn so
// the message loop picks it up without waiting for a new message. // the message loop picks it up without waiting for a new message.
if (pairedExecutionContext && pairedExecutionStatus === 'succeeded') { // Skip if owner produced no output — likely interrupted by /stop.
if (
pairedExecutionContext &&
pairedExecutionStatus === 'succeeded' &&
pairedSawOutput
) {
deps.queue.enqueueMessageCheck(chatJid); deps.queue.enqueueMessageCheck(chatJid);
} }
} }

View File

@@ -147,6 +147,7 @@ export class MessageTurnController {
this.previousProgressText = this.latestProgressText; this.previousProgressText = this.latestProgressText;
this.latestProgressText = text; this.latestProgressText = text;
this.latestProgressTextForFinal = text; this.latestProgressTextForFinal = text;
this.pendingProgressText = null; // discard stale buffer
this.toolActivities = []; this.toolActivities = [];
void this.syncTrackedProgressMessage(); void this.syncTrackedProgressMessage();
} else { } else {

View File

@@ -41,7 +41,7 @@ describe('runClaudeRotationLoop', () => {
{ runId: 'rotate-org-access' }, { runId: 'rotate-org-access' },
); );
expect(outcome).toEqual({ type: 'success' }); expect(outcome).toEqual({ type: 'success', sawOutput: true });
expect(rotateToken).toHaveBeenCalledTimes(1); expect(rotateToken).toHaveBeenCalledTimes(1);
expect(markTokenHealthy).toHaveBeenCalledTimes(1); expect(markTokenHealthy).toHaveBeenCalledTimes(1);
}); });

View File

@@ -37,7 +37,7 @@ export interface RotationAttemptResult {
} }
export type RotationOutcome = export type RotationOutcome =
| { type: 'success' } | { type: 'success'; sawOutput: boolean }
| { type: 'error'; trigger?: TriggerInfo }; | { type: 'error'; trigger?: TriggerInfo };
// ── Shared rotation loop ───────────────────────────────────────── // ── Shared rotation loop ─────────────────────────────────────────
@@ -161,7 +161,7 @@ export async function runClaudeRotationLoop(
// ── Success ── // ── Success ──
markTokenHealthy(); markTokenHealthy();
clearGlobalFailover(); clearGlobalFailover();
return { type: 'success' }; return { type: 'success', sawOutput: attempt.sawOutput };
} }
// ── All tokens exhausted ── // ── All tokens exhausted ──

View File

@@ -166,6 +166,7 @@ export async function handleSessionCommand(opts: {
if (command === '/stop') { if (command === '/stop') {
const killed = deps.killProcess(); const killed = deps.killProcess();
deps.resetPairedTask?.();
deps.advanceCursor(cmdMsg.timestamp); deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage( await deps.sendMessage(
killed ? 'Agent stopped.' : 'No agent is currently running in this room.', killed ? 'Agent stopped.' : 'No agent is currently running in this room.',