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:
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user