fix: remove silent run budget

This commit is contained in:
Eyejoker
2026-03-23 04:07:12 +09:00
parent 562067352a
commit 3435b99cfc
2 changed files with 30 additions and 144 deletions

View File

@@ -896,7 +896,7 @@ describe('createMessageRuntime', () => {
} }
}); });
it('retries a silent run internally before any visible output and succeeds in the same queue item', async () => { it('does not retry or emit a synthetic final when a run completes silently', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
const channel = makeChannel(chatJid); const channel = makeChannel(chatJid);
@@ -915,25 +915,11 @@ describe('createMessageRuntime', () => {
}, },
]); ]);
vi.mocked(agentRunner.runAgentProcess) vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({
.mockResolvedValueOnce({ status: 'success',
status: 'success', result: null,
result: null, newSessionId: 'session-silent-run',
newSessionId: 'session-silent-rollover', });
})
.mockImplementationOnce(async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: '두 번째 시도에서 성공했습니다.',
newSessionId: 'session-silent-rollover',
});
return {
status: 'success',
result: null,
newSessionId: 'session-silent-rollover',
};
});
const runtime = createMessageRuntime({ const runtime = createMessageRuntime({
assistantName: 'Andy', assistantName: 'Andy',
@@ -963,17 +949,14 @@ describe('createMessageRuntime', () => {
}); });
expect(result).toBe(true); expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(saveState).toHaveBeenCalled(); expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('1'); expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).not.toHaveBeenCalled();
chatJid,
'두 번째 시도에서 성공했습니다.',
);
expect(channel.sendAndTrack).not.toHaveBeenCalled(); expect(channel.sendAndTrack).not.toHaveBeenCalled();
}); });
it('retries a silent run with the original prompt still included after a queued follow-up arrives', async () => { it('does not emit a synthetic failure final when follow-up activity happens during a silent run', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
const channel = makeChannel(chatJid); const channel = makeChannel(chatJid);
@@ -1043,36 +1026,20 @@ describe('createMessageRuntime', () => {
}, },
); );
vi.mocked(agentRunner.runAgentProcess) vi.mocked(agentRunner.runAgentProcess).mockImplementationOnce(async () => {
.mockImplementationOnce(async () => { lastAgentTimestamps[chatJid] = '2';
lastAgentTimestamps[chatJid] = '2'; followUpPersisted = true;
followUpPersisted = true; activityTouch?.({
activityTouch?.({ source: 'follow-up',
source: 'follow-up', textLength: 9,
textLength: 9, filename: 'follow-up.json',
filename: 'follow-up.json',
});
return {
status: 'success',
result: null,
newSessionId: 'session-silent-follow-up',
};
})
.mockImplementationOnce(async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain('hello');
expect(input.prompt).toContain('follow-up');
await onOutput?.({
status: 'success',
phase: 'final',
result: '원래 요청과 follow-up을 같이 처리했습니다.',
newSessionId: 'session-silent-follow-up',
});
return {
status: 'success',
result: null,
newSessionId: 'session-silent-follow-up',
};
}); });
return {
status: 'success',
result: null,
newSessionId: 'session-silent-follow-up',
};
});
const runtime = createMessageRuntime({ const runtime = createMessageRuntime({
assistantName: 'Andy', assistantName: 'Andy',
@@ -1106,13 +1073,10 @@ describe('createMessageRuntime', () => {
}); });
expect(result).toBe(true); expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(saveState).toHaveBeenCalled(); expect(saveState).toHaveBeenCalled();
expect(lastAgentTimestamps[chatJid]).toBe('2'); expect(lastAgentTimestamps[chatJid]).toBe('2');
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).not.toHaveBeenCalled();
chatJid,
'원래 요청과 follow-up을 같이 처리했습니다.',
);
}); });
it('resets tracked progress after a final output that becomes empty after formatting', async () => { it('resets tracked progress after a final output that becomes empty after formatting', async () => {
@@ -1549,12 +1513,11 @@ describe('createMessageRuntime', () => {
} }
}); });
it('emits one visible failure final after silent rollovers exhaust the quiet budget', async () => { it('does not emit a visible failure final when a run stays silent', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
const channel = makeChannel(chatJid); const channel = makeChannel(chatJid);
const closeStdin = vi.fn();
const lastAgentTimestamps: Record<string, string> = {}; const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn(); const saveState = vi.fn();
@@ -1588,7 +1551,7 @@ describe('createMessageRuntime', () => {
channels: [channel], channels: [channel],
queue: { queue: {
registerProcess: vi.fn(), registerProcess: vi.fn(),
closeStdin, closeStdin: vi.fn(),
notifyIdle: vi.fn(), notifyIdle: vi.fn(),
} as any, } as any,
getRegisteredGroups: () => ({ [chatJid]: group }), getRegisteredGroups: () => ({ [chatJid]: group }),
@@ -1608,12 +1571,8 @@ describe('createMessageRuntime', () => {
}); });
expect(result).toBe(true); expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(3); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'요청을 완료하지 못했습니다. 다시 시도해 주세요.',
);
expect(lastAgentTimestamps[chatJid]).toBe('1'); expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(saveState).toHaveBeenCalled(); expect(saveState).toHaveBeenCalled();
} finally { } finally {

View File

@@ -565,18 +565,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const isMainGroup = group.isMain === true; const isMainGroup = group.isMain === true;
const isClaudeCodeAgent = const isClaudeCodeAgent =
(group.agentType || 'claude-code') === 'claude-code'; (group.agentType || 'claude-code') === 'claude-code';
const QUIET_RUN_ROLLOVER_MS = Math.min(deps.idleTimeout, 10_000);
const MAX_SILENT_ROLLOVERS = 2;
const MAX_TOTAL_SILENT_WAIT_MS =
QUIET_RUN_ROLLOVER_MS * (MAX_SILENT_ROLLOVERS + 1);
const FAILURE_FINAL_TEXT = const FAILURE_FINAL_TEXT =
'요청을 완료하지 못했습니다. 다시 시도해 주세요.'; '요청을 완료하지 못했습니다. 다시 시도해 주세요.';
const queueItemBaseCursor = normalizeStoredSeqCursor(
deps.getLastAgentTimestamps()[chatJid] || '0',
chatJid,
);
let silentRolloverCount = 0;
let totalSilentWaitMs = 0;
while (true) { while (true) {
const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0'; const sinceSeqCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
@@ -656,7 +646,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
const prompt = formatMessages(missedMessages, deps.timezone); const prompt = formatMessages(missedMessages, deps.timezone);
const previousCursor = deps.getLastAgentTimestamps()[chatJid] || '0';
const startSeq = missedMessages[0].seq ?? null; const startSeq = missedMessages[0].seq ?? null;
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null; const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
if (endSeq !== null) { if (endSeq !== null) {
@@ -670,8 +659,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
groupFolder: group.folder, groupFolder: group.folder,
runId, runId,
messageCount: missedMessages.length, messageCount: missedMessages.length,
silentRolloverCount,
totalSilentWaitMs,
}, },
'Dispatching queued messages to agent', 'Dispatching queued messages to agent',
); );
@@ -685,7 +672,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
let idleTimer: ReturnType<typeof setTimeout> | null = null; let idleTimer: ReturnType<typeof setTimeout> | null = null;
let hadError = false; let hadError = false;
let producedDeliverySucceeded = true; let producedDeliverySucceeded = true;
let quietStopReason: string | null = null;
let visiblePhase: VisiblePhase = 'silent'; let visiblePhase: VisiblePhase = 'silent';
let latestProgressText: string | null = null; let latestProgressText: string | null = null;
let latestProgressRendered: string | null = null; let latestProgressRendered: string | null = null;
@@ -697,7 +683,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
let poisonedSessionDetected = false; let poisonedSessionDetected = false;
let canPipeFollowUps = true; let canPipeFollowUps = true;
let pendingFollowUp: PendingFollowUp = null; let pendingFollowUp: PendingFollowUp = null;
const attemptStartedAt = Date.now();
const hasVisibleOutput = () => visiblePhase !== 'silent'; const hasVisibleOutput = () => visiblePhase !== 'silent';
const terminalObserved = () => visiblePhase === 'final'; const terminalObserved = () => visiblePhase === 'final';
@@ -935,10 +920,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
!hasVisibleOutput() && pendingFollowUp !hasVisibleOutput() && pendingFollowUp
? 'follow-up-no-output-preemption' ? 'follow-up-no-output-preemption'
: 'idle-timeout'; : 'idle-timeout';
quietStopReason ??=
!hasVisibleOutput() && pendingFollowUp
? 'follow-up-no-output-timeout'
: 'idle-timeout';
if (!hasVisibleOutput() && pendingFollowUp) { if (!hasVisibleOutput() && pendingFollowUp) {
logger.warn( logger.warn(
{ {
@@ -962,7 +943,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
deps.queue.closeStdin(chatJid, { runId, reason: closeReason }); deps.queue.closeStdin(chatJid, { runId, reason: closeReason });
}, },
hasVisibleOutput() ? deps.idleTimeout : QUIET_RUN_ROLLOVER_MS, deps.idleTimeout,
); );
}; };
@@ -1112,8 +1093,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
await finalizeProgressMessage(); await finalizeProgressMessage();
latestProgressTextForFinal = null; latestProgressTextForFinal = null;
} else { } else {
quietStopReason ??=
result.status === 'success' ? 'success-null-result' : 'error';
await finalizeProgressMessage(); await finalizeProgressMessage();
} }
@@ -1142,9 +1121,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (output === 'error') { if (output === 'error') {
hadError = true; hadError = true;
quietStopReason ??= 'error';
} else if (visiblePhase === 'silent') {
quietStopReason ??= 'success-null-result';
} }
const settledVisiblePhase = visiblePhase as VisiblePhase; const settledVisiblePhase = visiblePhase as VisiblePhase;
@@ -1169,64 +1145,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} else if ( } else if (
settledVisiblePhase === 'progress' && settledVisiblePhase === 'progress' &&
!terminalObserved() && !terminalObserved() &&
(hadError || quietStopReason === 'idle-timeout') hadError
) { ) {
await publishFailureFinal(); await publishFailureFinal();
} }
const pendingFollowUpSnapshot = pendingFollowUp as PendingFollowUp;
const quietWaitStartedAt =
pendingFollowUpSnapshot?.queuedAt ?? attemptStartedAt;
const quietWaitMs = Date.now() - quietWaitStartedAt;
clearProgressTicker(); clearProgressTicker();
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
deps.queue.setActivityTouch?.(chatJid, null); deps.queue.setActivityTouch?.(chatJid, null);
deps.queue.setFollowUpPipeAllowed?.(chatJid, null); deps.queue.setFollowUpPipeAllowed?.(chatJid, null);
clearPendingFollowUp(); clearPendingFollowUp();
if (settledVisiblePhase === 'silent') {
const nextTotalSilentWaitMs = totalSilentWaitMs + quietWaitMs;
const canRolloverAgain =
silentRolloverCount < MAX_SILENT_ROLLOVERS &&
nextTotalSilentWaitMs <= MAX_TOTAL_SILENT_WAIT_MS;
if (canRolloverAgain) {
deps.getLastAgentTimestamps()[chatJid] = queueItemBaseCursor;
deps.saveState();
silentRolloverCount++;
totalSilentWaitMs = nextTotalSilentWaitMs;
logger.warn(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
quietStopReason,
rollbackCursor: queueItemBaseCursor,
silentRolloverCount,
totalSilentWaitMs,
},
'Retrying silent run within the same queue item',
);
continue;
}
logger.warn(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
quietStopReason,
silentRolloverCount,
totalSilentWaitMs: nextTotalSilentWaitMs,
},
'Silent run budget exhausted, emitting failure final',
);
await publishFailureFinal();
}
if (!producedDeliverySucceeded) { if (!producedDeliverySucceeded) {
logger.warn( logger.warn(
{ chatJid, group: group.name, groupFolder: group.folder, runId }, { chatJid, group: group.name, groupFolder: group.folder, runId },
@@ -1242,8 +1171,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
groupFolder: group.folder, groupFolder: group.folder,
runId, runId,
visiblePhase: settledVisiblePhase, visiblePhase: settledVisiblePhase,
silentRolloverCount,
totalSilentWaitMs,
}, },
'Queued run completed successfully', 'Queued run completed successfully',
); );