Skip to content

Commit b22c36f

Browse files
steipeteqingminglonganyechsnottymasatohoshino
authored
fix: land ten small reliability fixes (#100399)
* fix(cron): reject sub-millisecond durations * fix(skill-workshop): preserve proposal terminal newline Preserve proposal_content exactly at the agent tool boundary and make renderProposalMarkdown defensively emit a terminal newline. Add focused regressions for the tool write path and markdown renderer. * fix(skill-workshop): reject blank raw proposal content * fix: treat empty-string optional integer tool params as unset Optional positive-integer tool params (e.g. Telegram replyTo/threadId) threw ToolInputError when a tool-calling model populated them with an empty-string or whitespace-only default. Those defaults carry no value, so readPositiveIntegerParam/readNonNegativeIntegerParam now treat a blank string as unset (undefined) instead of throwing, while still rejecting genuinely invalid present values (0, "42.5", "-3"). This prevents silent message-delivery failures when models emit empty routing-param defaults. Adds unit tests covering blank vs invalid. * fix(gateway): log start session persistence failures The gateway session-lifecycle "start" event persistence (persistGatewaySessionLifecycleEvent, which surfaces write failures via requireWriteSuccess) was fired as void ...catch(() => undefined), swallowing the rejection with zero logging. A failed start-marker write silently dropped the run's start record from restart-recovery accounting, with no operator-visible trace. The sibling terminal-phase catch already logs this since #97839; the start path was the unfixed sibling. Mirror that fix: log the swallowed start-phase persistence failure with the same redacted message shape via formatForLog, keeping the fire-and-forget semantics unchanged. Adds a focused regression test asserting the log fires on start-persist rejection. * fix(memory): report close-time pending work failures * fix(shared): return "" from sliceUtf16Safe when end <= start, matching native .slice sliceUtf16Safe silently swapped reversed bounds (to < from) instead of returning "" like String.prototype.slice, creating a subtle footgun for callers with dynamic start/end pairs. Caller scan across src/, extensions/, and packages/ confirmed no production code relies on the old swap behavior — all callers use (text, 0, N) or (text, -N) forms only. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(plugins): require plugin manifest in npm verifier * fix(android): propagate CancellationException in CameraHandler catch blocks Catch (err: Throwable) swallows kotlinx.coroutines.CancellationException, breaking structured concurrency when the coroutine scope is cancelled during camera operations (handleList/handleSnap/handleClip). Add CancellationException rethrow before each Throwable catch to match the existing pattern used in GatewaySession and TalkModeManager. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * fix(android): propagate CancellationException past GatewaySession invoke boundary * chore: sync native i18n inventory after gateway session line shift * fix(agents): prevent native hook relay bridge race condition on renew and registration Remove synchronous bridge record write after server.listen() that races before the TCP server binds, and guard renew handler with server.listening check to prevent stale relay registrations. Closes #98650 * fix: harden small reliability fixes Co-authored-by: cxbAsDev <[email protected]> * docs(agents): explain buffered LSP spawn failures * docs(agents): clarify LSP spawn timeout invariant --------- Co-authored-by: qingminlong <[email protected]> Co-authored-by: anyech <[email protected]> Co-authored-by: snotty <[email protected]> Co-authored-by: masatohoshino <[email protected]> Co-authored-by: lin-hongkuan <[email protected]> Co-authored-by: simon-w <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: 宇宙熊Yzx <[email protected]> Co-authored-by: xialonglee <[email protected]> Co-authored-by: nankingjing <[email protected]> Co-authored-by: cxbAsDev <[email protected]>
1 parent c757675 commit b22c36f

25 files changed

Lines changed: 359 additions & 33 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ Docs: https://docs.openclaw.ai
1313
### Fixes
1414

1515
- **iOS Voice Wake cleanup:** avoid initializing the microphone audio pipeline while disabling inactive Voice Wake, preventing simulator launch aborts and unnecessary audio setup.
16+
- **Cron duration validation:** reject positive durations that truncate below one millisecond instead of silently scheduling a zero-duration interval. (#100311) Thanks @qingminglong.
17+
- **Skill workshop proposals:** preserve the terminal newline in generated proposal Markdown while still rejecting blank raw content. (#100293) Thanks @anyech.
18+
- **Agent tool inputs and LSP startup:** treat blank optional integer arguments as absent, and fail embedded LSP startup immediately when its child process cannot spawn. (#100273, #99922) Thanks @snotty and @cxbAsDev.
19+
- **Gateway and memory diagnostics:** report failed start-session persistence and close-time memory work instead of silently discarding those failures. (#100313, #100308) Thanks @masatohoshino and @lin-hongkuan.
20+
- **Unicode and plugin package verification:** match native slice semantics for reversed UTF-16 bounds, and reject published plugin packages that omit `openclaw.plugin.json`. (#100014, #99904) Thanks @Simon-XYDT and @849261680.
21+
- **Android invoke cancellation:** preserve coroutine cancellation through camera handlers and the Gateway invoke boundary so cancelled work cannot emit a stale result. (#99916) Thanks @xialonglee.
22+
- **Codex native hook relay diagnostics:** avoid bridge registry writes before the local relay server begins listening. (#100300) Thanks @nankingjing.
1623
- **Agent stop recovery:** prevent late-aborting prompts from reacquiring orphaned session locks after teardown, so `/stop` leaves the conversation ready for the next turn.
1724
- **Message delivery status:** report failed and partially failed best-effort channel delivery instead of returning a success-shaped message-tool result. (#99928) Thanks @masatohoshino.
1825
- **WhatsApp credential recovery:** restore malformed primary auth state from a valid backup during startup. (#99070) Thanks @LeonidasLux.

apps/.i18n/native-source.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,15 +371,15 @@
371371
},
372372
{
373373
"kind": "conditional-branch",
374-
"line": 1096,
374+
"line": 1098,
375375
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
376376
"source": "Connecting…",
377377
"surface": "android",
378378
"id": "native.android.1cbaba9cbffb2f97"
379379
},
380380
{
381381
"kind": "conditional-branch",
382-
"line": 1096,
382+
"line": 1098,
383383
"path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
384384
"source": "Reconnecting…",
385385
"surface": "android",

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,8 @@ class GatewaySession(
10151015
try {
10161016
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
10171017
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
1018+
} catch (err: CancellationException) {
1019+
throw err
10181020
} catch (err: Throwable) {
10191021
invokeErrorFromThrowable(err)
10201022
}

apps/android/app/src/main/java/ai/openclaw/app/node/CameraHandler.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ai.openclaw.app.gateway.GatewaySession
66
import android.content.Context
77
import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.CancellationException
910
import kotlinx.coroutines.withContext
1011
import kotlinx.serialization.json.Json
1112
import kotlinx.serialization.json.JsonPrimitive
@@ -55,6 +56,8 @@ class CameraHandler(
5556
)
5657
}.toString()
5758
GatewaySession.InvokeResult.ok(payload)
59+
} catch (err: CancellationException) {
60+
throw err
5861
} catch (err: Throwable) {
5962
val (code, message) = invokeErrorFromThrowable(err)
6063
GatewaySession.InvokeResult.error(code = code, message = message)
@@ -83,6 +86,8 @@ class CameraHandler(
8386
val r = camera.snap(paramsJson)
8487
camLog("success, payload size=${r.payloadJson.length}")
8588
r
89+
} catch (err: CancellationException) {
90+
throw err
8691
} catch (err: Throwable) {
8792
camLog("inner error: ${err::class.java.simpleName}: ${err.message}")
8893
camLog("stack: ${err.stackTraceToString().take(2000)}")
@@ -93,6 +98,8 @@ class CameraHandler(
9398
camLog("returning result")
9499
showCameraHud("Photo captured", CameraHudKind.Success, 1600)
95100
return GatewaySession.InvokeResult.ok(res.payloadJson)
101+
} catch (err: CancellationException) {
102+
throw err
96103
} catch (err: Throwable) {
97104
camLog("outer error: ${err::class.java.simpleName}: ${err.message}")
98105
camLog("stack: ${err.stackTraceToString().take(2000)}")
@@ -123,6 +130,8 @@ class CameraHandler(
123130
val r = camera.clip(paramsJson)
124131
clipLog("success, file size=${r.file.length()}")
125132
r
133+
} catch (err: CancellationException) {
134+
throw err
126135
} catch (err: Throwable) {
127136
clipLog("inner error: ${err::class.java.simpleName}: ${err.message}")
128137
clipLog("stack: ${err.stackTraceToString().take(2000)}")
@@ -157,6 +166,8 @@ class CameraHandler(
157166
return GatewaySession.InvokeResult.ok(
158167
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""",
159168
)
169+
} catch (err: CancellationException) {
170+
throw err
160171
} catch (err: Throwable) {
161172
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
162173
clipLog("stack: ${err.stackTraceToString().take(2000)}")

apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,47 @@ class GatewaySessionInvokeTest {
763763
)
764764
}
765765

766+
@Test
767+
fun nodeInvokeRequest_doesNotSendResultAfterCancellation() =
768+
runBlocking {
769+
val json = testJson()
770+
val connected = CompletableDeferred<Unit>()
771+
val invokeStarted = CompletableDeferred<Unit>()
772+
val invokeResult = CompletableDeferred<Unit>()
773+
val lastDisconnect = AtomicReference("")
774+
val serverWebSocket = AtomicReference<WebSocket?>(null)
775+
val server =
776+
startGatewayServer(json) { webSocket, id, method, _ ->
777+
serverWebSocket.set(webSocket)
778+
when (method) {
779+
"connect" -> {
780+
webSocket.send(connectResponseFrame(id))
781+
webSocket.send(
782+
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-cancelled","nodeId":"node-1","command":"camera.snap","timeoutMs":5000}}""",
783+
)
784+
}
785+
"node.invoke.result" -> invokeResult.complete(Unit)
786+
}
787+
}
788+
val harness =
789+
createNodeHarness(connected = connected, lastDisconnect = lastDisconnect) {
790+
invokeStarted.complete(Unit)
791+
throw CancellationException("cancelled")
792+
}
793+
794+
try {
795+
connectNodeSession(harness.session, server.port)
796+
awaitConnectedOrThrow(connected, lastDisconnect, server)
797+
withTimeout(TEST_TIMEOUT_MS) { invokeStarted.await() }
798+
799+
assertNull(withTimeoutOrNull(250) { invokeResult.await() })
800+
} finally {
801+
serverWebSocket.get()?.close(1000, "done")
802+
delay(100)
803+
shutdownHarness(harness, server)
804+
}
805+
}
806+
766807
@Test
767808
fun sendNodeEventDetailed_sendsPresenceAlivePayloadAndReturnsStructuredResponse() =
768809
runBlocking {

extensions/memory-core/src/memory/manager-async-state.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ export async function startAsyncSearchSync(params: {
1919
export async function awaitPendingManagerWork(params: {
2020
pendingSync?: Promise<void> | null;
2121
pendingProviderInit?: Promise<void> | null;
22+
onError?: (err: unknown) => void;
2223
}): Promise<void> {
2324
if (params.pendingSync) {
2425
try {
2526
await params.pendingSync;
26-
} catch {}
27+
} catch (err: unknown) {
28+
params.onError?.(err);
29+
}
2730
}
2831
if (params.pendingProviderInit) {
2932
try {
3033
await params.pendingProviderInit;
31-
} catch {}
34+
} catch (err: unknown) {
35+
params.onError?.(err);
36+
}
3237
}
3338
}

extensions/memory-core/src/memory/manager.async-search.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,42 @@ describe("memory search async sync", () => {
7272
await closePromise;
7373
});
7474

75+
it("reports pending sync failures during close", async () => {
76+
const onError = vi.fn();
77+
const syncError = new Error("sync failed");
78+
79+
await awaitPendingManagerWork({
80+
pendingSync: Promise.reject(syncError),
81+
onError,
82+
});
83+
84+
expect(onError).toHaveBeenCalledWith(syncError);
85+
});
86+
87+
it("reports pending provider initialization failures during close", async () => {
88+
const onError = vi.fn();
89+
const providerError = new Error("provider init failed");
90+
91+
await awaitPendingManagerWork({
92+
pendingProviderInit: Promise.reject(providerError),
93+
onError,
94+
});
95+
96+
expect(onError).toHaveBeenCalledWith(providerError);
97+
});
98+
99+
it("does not report errors for completed pending close work", async () => {
100+
const onError = vi.fn();
101+
102+
await awaitPendingManagerWork({
103+
pendingSync: Promise.resolve(),
104+
pendingProviderInit: Promise.resolve(),
105+
onError,
106+
});
107+
108+
expect(onError).not.toHaveBeenCalled();
109+
});
110+
75111
it("skips background search sync when search-triggered sync is disabled", async () => {
76112
const syncMock = vi.fn(async () => {});
77113
await startAsyncSearchSync({

extensions/memory-core/src/memory/manager.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,14 +1364,23 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
13641364
}
13651365
}
13661366
};
1367+
const reportPendingWorkError = (err: unknown) => {
1368+
log.warn(`memory close: pending manager work failed: ${formatErrorMessage(err)}`);
1369+
};
13671370
const awaitCurrentSync = async () => {
13681371
const pendingSync = this.syncing;
13691372
if (!pendingSync) {
13701373
return;
13711374
}
1372-
await awaitPendingManagerWork({ pendingSync });
1375+
await awaitPendingManagerWork({
1376+
pendingSync,
1377+
onError: reportPendingWorkError,
1378+
});
13731379
};
1374-
await awaitPendingManagerWork({ pendingProviderInit });
1380+
await awaitPendingManagerWork({
1381+
pendingProviderInit,
1382+
onError: reportPendingWorkError,
1383+
});
13751384
rememberCurrentProvider();
13761385
try {
13771386
await awaitCurrentSync();

packages/normalization-core/src/utf16-slice.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe("sliceUtf16Safe", () => {
2323
expect(sliceUtf16Safe("hello", 0, 10)).toBe("hello");
2424
});
2525

26-
it("swaps start and end when start > end", () => {
27-
expect(sliceUtf16Safe("hello", 3, 1)).toBe("el");
26+
it("returns empty when start > end, matching String.prototype.slice", () => {
27+
expect(sliceUtf16Safe("hello", 3, 1)).toBe("");
2828
});
2929

3030
it("preserves emoji with surrogate pairs", () => {

packages/normalization-core/src/utf16-slice.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,8 @@ export function sliceUtf16Safe(input: string, start: number, end?: number): stri
1919
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
2020
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
2121

22-
if (to < from) {
23-
const tmp = from;
24-
from = to;
25-
to = tmp;
22+
if (to <= from) {
23+
return "";
2624
}
2725

2826
if (from > 0 && from < len) {

0 commit comments

Comments
 (0)