Skip to content

Commit aa874c4

Browse files
committed
fix(auto-reply): stamp activity per event and deprecate recovery exports
1 parent c40447e commit aa874c4

3 files changed

Lines changed: 104 additions & 27 deletions

File tree

src/auto-reply/reply/agent-runner-cli-dispatch.test.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,57 @@ describe("runCliAgentWithLifecycle", () => {
214214
});
215215

216216
// A run in a long pure-reasoning stretch must keep stamping activity, or
217-
// stale-takeover reclaims it while it is visibly thinking.
217+
// stale-takeover reclaims it while it is visibly thinking. Stamps are
218+
// per-event (delivery-independent), so dedupe downstream does not matter.
218219
expect(onReasoningProgress).toHaveBeenCalledTimes(2);
220+
expect(onActivity).toHaveBeenCalledTimes(3);
221+
});
222+
223+
it("keeps stamping onActivity when bridges are suppressed for silent runs", async () => {
224+
cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
225+
emitAgentEvent({
226+
runId: params.runId,
227+
stream: "thinking",
228+
data: { progressTokens: 50 },
229+
});
230+
emitAgentEvent({
231+
runId: params.runId,
232+
stream: "assistant",
233+
data: { text: "Silent answer", delta: "Silent answer" },
234+
});
235+
return { payloads: [], meta: { durationMs: 1 } };
236+
});
237+
const onActivity = vi.fn();
238+
const onAssistantText = vi.fn<(text: string) => Promise<void>>(async () => undefined);
239+
const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
240+
async () => undefined,
241+
);
242+
243+
await runCliAgentWithLifecycle({
244+
runId: "run-activity-suppressed",
245+
provider: "claude-cli",
246+
suppressAssistantBridge: true,
247+
onActivity,
248+
onAssistantText,
249+
onReasoningProgress,
250+
runParams: {
251+
sessionId: "session-1",
252+
sessionFile: "/tmp/session.jsonl",
253+
workspaceDir: "/tmp/workspace",
254+
prompt: "hello",
255+
provider: "claude-cli",
256+
model: "claude",
257+
thinkLevel: "high",
258+
timeoutMs: 1_000,
259+
runId: "run-activity-suppressed",
260+
},
261+
});
262+
263+
// silentExpected runs suppress deliveries, but their events are still real
264+
// liveness evidence — without these stamps a healthy silent run would be
265+
// reclaimed as run_stalled at the takeover window.
266+
expect(onAssistantText).not.toHaveBeenCalled();
267+
expect(onReasoningProgress).not.toHaveBeenCalled();
219268
expect(onActivity).toHaveBeenCalledTimes(2);
220269
});
221270

@@ -254,8 +303,7 @@ describe("runCliAgentWithLifecycle", () => {
254303
},
255304
});
256305

257-
// Reasoning text stamps even with no onReasoningText caller: the durable
258-
// reasoning deliver always runs, and thinking is activity evidence.
306+
// Every real event stamps, independent of which callbacks are registered.
259307
expect(onAssistantText).toHaveBeenCalledTimes(1);
260308
expect(onActivity).toHaveBeenCalledTimes(2);
261309
});

src/auto-reply/reply/agent-runner-cli-dispatch.ts

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -470,13 +470,10 @@ async function runCliAgentWithLifecycleInternal(
470470
...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
471471
});
472472
try {
473-
if (params.onFastModeAutoProgress) {
474-
params.onActivity?.();
475-
await params.onFastModeAutoProgress({
476-
text: summary,
477-
channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
478-
});
479-
}
473+
await params.onFastModeAutoProgress?.({
474+
text: summary,
475+
channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
476+
});
480477
} catch {
481478
// Progress hints are best-effort; a channel failure must not fail the agent turn.
482479
}
@@ -528,53 +525,54 @@ async function runCliAgentWithLifecycleInternal(
528525
},
529526
});
530527
}
531-
// One activity seam for every delivered CLI event: stamping per-callback at
532-
// call sites drifted (a queued-followup callback missed one), which lets
533-
// stale-takeover reclaim a run that is visibly producing output.
534-
const withActivity = <T>(
535-
deliver: ((payload: T) => Promise<void>) | undefined,
536-
): ((payload: T) => Promise<void>) | undefined =>
537-
deliver && params.onActivity
538-
? async (payload: T) => {
528+
// One delivery-independent activity seam for every CLI agent event.
529+
// Suppressed (silentExpected) runs still emit real events and must keep
530+
// stamping, or a healthy silent stream looks stale to the takeover window.
531+
const activityBridge = params.onActivity
532+
? createAgentEventBridge<Record<string, never>>({
533+
runId: params.runId,
534+
read: () => ({}),
535+
deliver: async () => {
539536
params.onActivity?.();
540-
await deliver(payload);
541-
}
542-
: deliver;
537+
},
538+
})
539+
: undefined;
543540
const assistantBridge = createAssistantTextBridge({
544541
runId: params.runId,
545542
suppressed: params.suppressAssistantBridge,
546-
deliver: withActivity(params.onAssistantText),
543+
deliver: params.onAssistantText,
547544
});
548545
let finalReasoningText: string | undefined;
549546
const reasoningBridge = createReasoningTextBridge({
550547
runId: params.runId,
551548
suppressed: params.suppressAssistantBridge,
552-
deliver: withActivity(async (payload: ReasoningTextPayload) => {
549+
deliver: async (payload: ReasoningTextPayload) => {
553550
finalReasoningText = normalizeOptionalString(payload.text);
554551
await params.onReasoningText?.(payload);
555-
}),
552+
},
556553
});
557554
const reasoningProgressBridge = createReasoningProgressBridge({
558555
runId: params.runId,
559556
suppressed: params.suppressAssistantBridge,
560-
deliver: withActivity(params.onReasoningProgress),
557+
deliver: params.onReasoningProgress,
561558
});
562559
const toolBridge = createToolEventBridge({
563560
runId: params.runId,
564561
suppressed: params.suppressAssistantBridge,
565-
deliver: withActivity(params.onToolEvent),
562+
deliver: params.onToolEvent,
566563
});
567564
const commentaryBridge = createCommentaryEventBridge({
568565
runId: params.runId,
569566
suppressed: params.suppressAssistantBridge,
570-
deliver: withActivity(params.onCommentaryText),
567+
deliver: params.onCommentaryText,
571568
});
572569
const toolBoundaryBridge = createToolBoundaryBridge({
573570
runId: params.runId,
574571
suppressed: params.suppressAssistantBridge,
575572
deliver: maybeAnnounceFastModeAutoOff,
576573
});
577574
const bridges = [
575+
activityBridge,
578576
assistantBridge,
579577
reasoningBridge,
580578
reasoningProgressBridge,

src/logging/diagnostic.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "./diagnostic-session-context.js";
4141
import {
4242
requestStuckSessionRecovery,
43+
requestStuckSessionRecoveryOutcome,
4344
resetDiagnosticSessionRecoveryCoordinatorForTest,
4445
type RecoverStuckSession,
4546
} from "./diagnostic-session-recovery-coordinator.js";
@@ -174,6 +175,36 @@ async function recoverStuckSession(
174175
});
175176
}
176177

178+
/**
179+
* @deprecated Unused by core since the dispatch-side recovery loop was removed
180+
* (#101910); reply admission owns stale-run reclaim now. Kept only because the
181+
* plugin SDK re-exports this module; scheduled for removal in the next SDK major.
182+
*/
183+
export function isStuckSessionRecoveryEnabled(config?: OpenClawConfig): boolean {
184+
return areDiagnosticsEnabledForProcess() && isDiagnosticsEnabled(config);
185+
}
186+
187+
/**
188+
* @deprecated Unused by core since the dispatch-side recovery loop was removed
189+
* (#101910); reply admission owns stale-run reclaim now. Kept only because the
190+
* plugin SDK re-exports this module; scheduled for removal in the next SDK major.
191+
*/
192+
export async function requestStuckDiagnosticSessionRecovery(
193+
params: StuckSessionRecoveryRequest,
194+
): Promise<StuckSessionRecoveryOutcome | undefined> {
195+
return requestStuckSessionRecoveryOutcome({
196+
recover: recoverStuckSession,
197+
classification: {
198+
eventType: "session.stalled",
199+
reason: "visible_reply_wait_timeout",
200+
classification: "stalled_agent_run",
201+
activeWorkKind: "embedded_run",
202+
recoveryEligible: false,
203+
},
204+
request: params,
205+
});
206+
}
207+
177208
function formatDiagnosticWorkLabel(
178209
state: {
179210
sessionId?: string;

0 commit comments

Comments
 (0)