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