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', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
status: 'success',
result: 'ok',
newSessionId: 'session-123',
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: 'ok',
output: { visibility: 'public', text: 'ok' },
phase: 'final',
});
return { status: 'success', result: 'ok', newSessionId: 'session-123' };
},
);
vi.mocked(buildRoomMemoryBriefing).mockResolvedValue(
'## Shared Room Memory\n- remembered context',
);
@@ -461,12 +467,15 @@ describe('runAgentForGroup room memory', () => {
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(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({
taskId: 'paired-task-1',
role: 'owner',
status: 'succeeded',
status: 'failed',
summary: 'ok',
});
});

View File

@@ -227,6 +227,7 @@ export async function runAgentForGroup(
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null;
let pairedExecutionCompleted = false;
let pairedSawOutput = false;
const shouldHandoffToCodex = (
reason: AgentTriggerReason,
@@ -708,6 +709,7 @@ export async function runAgentForGroup(
switch (outcome.type) {
case 'success':
pairedSawOutput = outcome.sawOutput;
return 'success';
case 'error':
return 'error';
@@ -998,21 +1000,35 @@ export async function runAgentForGroup(
}
pairedExecutionStatus = 'succeeded';
pairedSawOutput = primaryAttempt.sawOutput;
return 'success';
} finally {
if (pairedExecutionContext && !pairedExecutionCompleted) {
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({
taskId: pairedExecutionContext.task.id,
role: completedRole,
status: pairedExecutionStatus,
status: effectiveStatus,
summary: pairedExecutionSummary,
});
}
// After owner/reviewer completes, enqueue the next turn so
// 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);
}
}

View File

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

View File

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

View File

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

View File

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