Fix subagent announce failover race (always emit lifecycle end + treat timeout=0 as no-timeout) (#6621)

* Fix subagent announce race and timeout handling

Bug 1: Subagent announce fires before model failover retries finish
- Problem: CLI provider emitted lifecycle error on each attempt, causing
  subagent registry to prematurely call beginSubagentCleanup() and announce
  with incorrect status before failover retries completed
- Fix: Removed lifecycle error emission from CLI provider's attempt-level
  .catch() in agent-runner-execution.ts. Errors still propagate to
  runWithModelFallback for retry, but no intermediate lifecycle events
  are emitted. Only the final outcome (after all retries) emits lifecycle
  events.

Bug 2: Hard 600s per-prompt timeout ignores runTimeoutSeconds=0
- Problem: When runTimeoutSeconds=0 (meaning 'no timeout'), the code
  returned the default 600s timeout instead of respecting the 0 setting
- Fix: Modified resolveAgentTimeoutMs() to treat 0 as 'no timeout' and
  return a very large timeout value (30 days) instead of the default.
  This avoids setTimeout issues with Infinity while effectively providing
  unlimited time for long-running tasks.

* fix: emit lifecycle:error for CLI failures (#6621) (thanks @tyler6204)

* chore: satisfy format/lint gates (#6621) (thanks @tyler6204)

* fix: restore build after upstream type changes (#6621) (thanks @tyler6204)

* test: fix createSystemPromptOverride tests to match new return type (#6621) (thanks @tyler6204)
This commit is contained in:
Tyler Yust 2026-02-02 02:06:14 -08:00 committed by GitHub
parent d5f6caba3f
commit 8d2f98fb01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 81 additions and 42 deletions

View file

@ -2167,10 +2167,9 @@ async function processMessage(
sendBlueBubblesTyping(chatGuidForActions, true, { sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config, cfg: config,
accountId: account.accountId, accountId: account.accountId,
}) }).catch((err) => {
.catch((err) => { runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`); });
});
}, typingRestartDelayMs); }, typingRestartDelayMs);
}; };
try { try {

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -3,11 +3,13 @@
## 2026.2.1 ## 2026.2.1
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.31 ## 2026.1.31
### Changes ### Changes
- Version alignment with core OpenClaw release numbers. - Version alignment with core OpenClaw release numbers.
## 2026.1.30 ## 2026.1.30

View file

@ -15,12 +15,10 @@ import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js"; import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
const OAUTH_PROVIDER_IDS = new Set<OAuthProvider>( const OAUTH_PROVIDER_IDS = new Set<string>(getOAuthProviders().map((provider) => provider.id));
getOAuthProviders().map((provider) => provider.id),
);
const isOAuthProvider = (provider: string): provider is OAuthProvider => const isOAuthProvider = (provider: string): provider is OAuthProvider =>
OAUTH_PROVIDER_IDS.has(provider as OAuthProvider); OAUTH_PROVIDER_IDS.has(provider);
const resolveOAuthProvider = (provider: string): OAuthProvider | null => const resolveOAuthProvider = (provider: string): OAuthProvider | null =>
isOAuthProvider(provider) ? provider : null; isOAuthProvider(provider) ? provider : null;

View file

@ -99,12 +99,12 @@ const _readSessionMessages = async (sessionFile: string) => {
}; };
describe("createSystemPromptOverride", () => { describe("createSystemPromptOverride", () => {
it("returns the override prompt regardless of default prompt", () => { it("returns the override prompt trimmed", () => {
const override = createSystemPromptOverride("OVERRIDE"); const override = createSystemPromptOverride("OVERRIDE");
expect(override("DEFAULT")).toBe("OVERRIDE"); expect(override).toBe("OVERRIDE");
}); });
it("returns an empty string for blank overrides", () => { it("returns an empty string for blank overrides", () => {
const override = createSystemPromptOverride(" \n "); const override = createSystemPromptOverride(" \n ");
expect(override("DEFAULT")).toBe(""); expect(override).toBe("");
}); });
}); });

View file

@ -74,11 +74,8 @@ export function buildEmbeddedSystemPrompt(params: {
}); });
} }
export function createSystemPromptOverride( export function createSystemPromptOverride(systemPrompt: string): string {
systemPrompt: string, return systemPrompt.trim();
): (defaultPrompt?: string) => string {
const override = systemPrompt.trim();
return (_defaultPrompt?: string) => override;
} }
export function applySystemPromptOverrideToSession(session: AgentSession, override: string) { export function applySystemPromptOverrideToSession(session: AgentSession, override: string) {

View file

@ -40,9 +40,9 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
execute: async ( execute: async (
toolCallId, toolCallId,
params, params,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<unknown> | undefined, onUpdate: AgentToolUpdateCallback<unknown> | undefined,
_ctx, _ctx,
signal,
): Promise<AgentToolResult<unknown>> => { ): Promise<AgentToolResult<unknown>> => {
try { try {
return await tool.execute(toolCallId, params, signal, onUpdate); return await tool.execute(toolCallId, params, signal, onUpdate);
@ -91,9 +91,9 @@ export function toClientToolDefinitions(
execute: async ( execute: async (
toolCallId, toolCallId,
params, params,
_signal: AbortSignal | undefined,
_onUpdate: AgentToolUpdateCallback<unknown> | undefined, _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
_ctx, _ctx,
_signal,
): Promise<AgentToolResult<unknown>> => { ): Promise<AgentToolResult<unknown>> => {
const outcome = await runBeforeToolCallHook({ const outcome = await runBeforeToolCallHook({
toolName: func.name, toolName: func.name,

View file

@ -19,16 +19,25 @@ export function resolveAgentTimeoutMs(opts: {
}): number { }): number {
const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1); const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1);
const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000; const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000;
// Use a very large timeout value (30 days) to represent "no timeout"
// when explicitly set to 0. This avoids setTimeout issues with Infinity.
const NO_TIMEOUT_MS = 30 * 24 * 60 * 60 * 1000;
const overrideMs = normalizeNumber(opts.overrideMs); const overrideMs = normalizeNumber(opts.overrideMs);
if (overrideMs !== undefined) { if (overrideMs !== undefined) {
if (overrideMs <= 0) { if (overrideMs === 0) {
return NO_TIMEOUT_MS;
}
if (overrideMs < 0) {
return defaultMs; return defaultMs;
} }
return Math.max(overrideMs, minMs); return Math.max(overrideMs, minMs);
} }
const overrideSeconds = normalizeNumber(opts.overrideSeconds); const overrideSeconds = normalizeNumber(opts.overrideSeconds);
if (overrideSeconds !== undefined) { if (overrideSeconds !== undefined) {
if (overrideSeconds <= 0) { if (overrideSeconds === 0) {
return NO_TIMEOUT_MS;
}
if (overrideSeconds < 0) {
return defaultMs; return defaultMs;
} }
return Math.max(overrideSeconds * 1000, minMs); return Math.max(overrideSeconds * 1000, minMs);

View file

@ -172,24 +172,27 @@ export async function runAgentTurnWithFallback(params: {
}, },
}); });
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider); const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
return runCliAgent({ return (async () => {
sessionId: params.followupRun.run.sessionId, let lifecycleTerminalEmitted = false;
sessionKey: params.sessionKey, try {
sessionFile: params.followupRun.run.sessionFile, const result = await runCliAgent({
workspaceDir: params.followupRun.run.workspaceDir, sessionId: params.followupRun.run.sessionId,
config: params.followupRun.run.config, sessionKey: params.sessionKey,
prompt: params.commandBody, sessionFile: params.followupRun.run.sessionFile,
provider, workspaceDir: params.followupRun.run.workspaceDir,
model, config: params.followupRun.run.config,
thinkLevel: params.followupRun.run.thinkLevel, prompt: params.commandBody,
timeoutMs: params.followupRun.run.timeoutMs, provider,
runId, model,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt, thinkLevel: params.followupRun.run.thinkLevel,
ownerNumbers: params.followupRun.run.ownerNumbers, timeoutMs: params.followupRun.run.timeoutMs,
cliSessionId, runId,
images: params.opts?.images, extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
}) ownerNumbers: params.followupRun.run.ownerNumbers,
.then((result) => { cliSessionId,
images: params.opts?.images,
});
// CLI backends don't emit streaming assistant events, so we need to // CLI backends don't emit streaming assistant events, so we need to
// emit one with the final text so server-chat can populate its buffer // emit one with the final text so server-chat can populate its buffer
// and send the response to TUI/WebSocket clients. // and send the response to TUI/WebSocket clients.
@ -201,6 +204,7 @@ export async function runAgentTurnWithFallback(params: {
data: { text: cliText }, data: { text: cliText },
}); });
} }
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "lifecycle", stream: "lifecycle",
@ -210,9 +214,10 @@ export async function runAgentTurnWithFallback(params: {
endedAt: Date.now(), endedAt: Date.now(),
}, },
}); });
lifecycleTerminalEmitted = true;
return result; return result;
}) } catch (err) {
.catch((err) => {
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "lifecycle", stream: "lifecycle",
@ -220,11 +225,28 @@ export async function runAgentTurnWithFallback(params: {
phase: "error", phase: "error",
startedAt, startedAt,
endedAt: Date.now(), endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err), error: String(err),
}, },
}); });
lifecycleTerminalEmitted = true;
throw err; throw err;
}); } finally {
// Defensive backstop: never let a CLI run complete without a terminal
// lifecycle event, otherwise downstream consumers can hang.
if (!lifecycleTerminalEmitted) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: "CLI run completed without lifecycle terminal event",
},
});
}
}
})();
} }
const authProfileId = const authProfileId =
provider === params.followupRun.run.provider provider === params.followupRun.run.provider