ocuclaw 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/relay-core.js +75 -0
- package/dist/runtime/relay-service.js +32 -0
- package/dist/runtime/relay-worker-supervisor.js +8 -0
- package/dist/runtime/relay-worker-transport.js +10 -1
- package/dist/tools/glasses-ui-cron.js +50 -0
- package/dist/tools/glasses-ui-paint-floor.js +23 -1
- package/dist/tools/glasses-ui-surfaces.js +361 -34
- package/dist/tools/glasses-ui-tool.js +594 -86
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- package/dist/version.js +2 -2
- package/package.json +2 -2
- package/skills/glasses-ui/SKILL.md +19 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Main-side latch over the worker's send-buffer high-water count (roadmap
|
|
2
|
+
// step 4a). The worker transport counts app clients whose ws.bufferedAmount
|
|
3
|
+
// exceeds the high-water mark every health heartbeat and posts the count to
|
|
4
|
+
// main; this latch converts those reports into the boolean the glasses-ui
|
|
5
|
+
// paint-floor shed consumes (relay-service.isGlassesSendBufferOverHighWater).
|
|
6
|
+
//
|
|
7
|
+
// LEAF MODULE: must not import other relay runtime modules (the CJS emitter
|
|
8
|
+
// resolves bidirectional imports to {} mid-cycle — see
|
|
9
|
+
// memory/ocuclaw-cjs-emitter-import-cycle).
|
|
10
|
+
//
|
|
11
|
+
// Semantics:
|
|
12
|
+
// - latch on any report with sendBufferHighWaterClients >= 1
|
|
13
|
+
// - hysteresis: a 0-report clears only after recoveredHoldMs has elapsed
|
|
14
|
+
// since the last >=1 report (pressure flaps at paint cadence otherwise)
|
|
15
|
+
// - decay: with no fresh report inside staleMs the latch fails OPEN (false)
|
|
16
|
+
// — a dead or wedged worker must never freeze glasses paints forever
|
|
17
|
+
// - a report from a new workerEpoch discards prior state (worker restart)
|
|
18
|
+
// - false->true / true->false transitions emit debug events so the 4b
|
|
19
|
+
// hardware validation can observe the shed firing
|
|
20
|
+
|
|
21
|
+
const DEFAULT_RECOVERED_HOLD_MS = 3_000;
|
|
22
|
+
const DEFAULT_STALE_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
export function createGlassesBackpressureLatch(options = {}) {
|
|
25
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
26
|
+
const recoveredHoldMs = Number.isFinite(options.recoveredHoldMs)
|
|
27
|
+
? options.recoveredHoldMs
|
|
28
|
+
: DEFAULT_RECOVERED_HOLD_MS;
|
|
29
|
+
const staleMs = Number.isFinite(options.staleMs) ? options.staleMs : DEFAULT_STALE_MS;
|
|
30
|
+
const emitDebug = typeof options.emitDebug === "function" ? options.emitDebug : () => {};
|
|
31
|
+
|
|
32
|
+
let latched = false;
|
|
33
|
+
let latchedAtMs = null;
|
|
34
|
+
let lastOverAtMs = null;
|
|
35
|
+
let lastReportAtMs = null;
|
|
36
|
+
let workerEpoch = null;
|
|
37
|
+
|
|
38
|
+
function clearState() {
|
|
39
|
+
latched = false;
|
|
40
|
+
latchedAtMs = null;
|
|
41
|
+
lastOverAtMs = null;
|
|
42
|
+
lastReportAtMs = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emitTransition(nextLatched, reason, atMs) {
|
|
46
|
+
if (nextLatched === latched) return;
|
|
47
|
+
if (nextLatched) {
|
|
48
|
+
latchedAtMs = atMs;
|
|
49
|
+
emitDebug("glasses_backpressure_latched", "warn", { reason });
|
|
50
|
+
} else {
|
|
51
|
+
emitDebug("glasses_backpressure_cleared", "info", {
|
|
52
|
+
reason,
|
|
53
|
+
latchedForMs: latchedAtMs === null ? null : Math.max(0, atMs - latchedAtMs),
|
|
54
|
+
});
|
|
55
|
+
latchedAtMs = null;
|
|
56
|
+
}
|
|
57
|
+
latched = nextLatched;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-evaluate decay/hysteresis clears against the clock. */
|
|
61
|
+
function evaluate(atMs) {
|
|
62
|
+
if (!latched) return;
|
|
63
|
+
if (lastReportAtMs !== null && atMs - lastReportAtMs > staleMs) {
|
|
64
|
+
emitTransition(false, "stale_reports", atMs);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
lastOverAtMs !== null &&
|
|
69
|
+
atMs - lastOverAtMs >= recoveredHoldMs &&
|
|
70
|
+
lastReportAtMs !== null &&
|
|
71
|
+
lastReportAtMs > lastOverAtMs
|
|
72
|
+
) {
|
|
73
|
+
// The newest report said 0 and the hold window since the last >=1
|
|
74
|
+
// report has fully elapsed.
|
|
75
|
+
emitTransition(false, "recovered", atMs);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function report(params) {
|
|
80
|
+
const atMs = now();
|
|
81
|
+
const count =
|
|
82
|
+
params && Number.isFinite(params.sendBufferHighWaterClients)
|
|
83
|
+
? params.sendBufferHighWaterClients
|
|
84
|
+
: null;
|
|
85
|
+
if (count === null) return;
|
|
86
|
+
const epoch = params && Number.isFinite(params.workerEpoch) ? params.workerEpoch : null;
|
|
87
|
+
if (epoch !== null && workerEpoch !== null && epoch !== workerEpoch) {
|
|
88
|
+
// Worker restarted: prior pressure belongs to dead sockets.
|
|
89
|
+
clearState();
|
|
90
|
+
}
|
|
91
|
+
if (epoch !== null) workerEpoch = epoch;
|
|
92
|
+
lastReportAtMs = atMs;
|
|
93
|
+
if (count >= 1) {
|
|
94
|
+
lastOverAtMs = atMs;
|
|
95
|
+
emitTransition(true, "over_high_water", atMs);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
evaluate(atMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isOverHighWater() {
|
|
102
|
+
evaluate(now());
|
|
103
|
+
return latched;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reset(reason) {
|
|
107
|
+
const atMs = now();
|
|
108
|
+
emitTransition(false, typeof reason === "string" ? reason : "reset", atMs);
|
|
109
|
+
clearState();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { report, isOverHighWater, reset };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default { createGlassesBackpressureLatch };
|
|
@@ -18,9 +18,11 @@ import { createEvenAiRunWaiter } from "../even-ai/even-ai-run-waiter.js";
|
|
|
18
18
|
import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js";
|
|
19
19
|
import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
|
|
20
20
|
import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
|
|
21
|
+
import { createAgentTurnTracker } from "../tools/glasses-ui-wake.js";
|
|
21
22
|
import { createDownstreamHandler } from "./downstream-handler.js";
|
|
22
23
|
import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
|
|
23
24
|
import { createRelayHealthMonitor } from "./relay-health-monitor.js";
|
|
25
|
+
import { createGlassesBackpressureLatch } from "./glasses-backpressure-latch.js";
|
|
24
26
|
import { createRelayOperationRegistry } from "./relay-operation-registry.js";
|
|
25
27
|
import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
|
|
26
28
|
import {
|
|
@@ -29,6 +31,9 @@ import {
|
|
|
29
31
|
} from "./session-service.js";
|
|
30
32
|
import { createUpstreamRuntime } from "./upstream-runtime.js";
|
|
31
33
|
|
|
34
|
+
const GLASSES_UI_MARKERS = new Set(["listening", "parked", "inflight"]);
|
|
35
|
+
export function sanitizeGlassesMarker(v) { return GLASSES_UI_MARKERS.has(v) ? v : undefined; }
|
|
36
|
+
|
|
32
37
|
const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
|
|
33
38
|
const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
|
|
34
39
|
const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
|
|
@@ -342,6 +347,12 @@ function createRelay(opts) {
|
|
|
342
347
|
const activityStatusAdapter = createActivityStatusAdapter(
|
|
343
348
|
opts.activityStatusAdapter,
|
|
344
349
|
);
|
|
350
|
+
// Per-session "agent turn in flight or imminent" signal (roadmap 6f):
|
|
351
|
+
// marked busy on every dispatched send (voice/user/wake), refreshed by the
|
|
352
|
+
// gateway activity stream, idled on end-phase activity, decay-bounded
|
|
353
|
+
// (fail open). The glasses-ui wake controller consults it so a wake never
|
|
354
|
+
// races a genuine turn (voice absorbs wake, §2.6c).
|
|
355
|
+
const agentTurnTracker = createAgentTurnTracker();
|
|
345
356
|
const sharedHttpServer = opts.httpServer || null;
|
|
346
357
|
|
|
347
358
|
// --- Cached state ---
|
|
@@ -1262,6 +1273,10 @@ function createRelay(opts) {
|
|
|
1262
1273
|
const runId = activity && activity.runId ? activity.runId : null;
|
|
1263
1274
|
const origin = activity && activity.origin ? activity.origin : null;
|
|
1264
1275
|
const phase = activity && activity.phase ? activity.phase : null;
|
|
1276
|
+
agentTurnTracker.onActivity(
|
|
1277
|
+
(activity && activity.sessionKey) || sessionService.ensureSessionKey(),
|
|
1278
|
+
phase,
|
|
1279
|
+
);
|
|
1265
1280
|
|
|
1266
1281
|
emitDebug(
|
|
1267
1282
|
"app.timeline",
|
|
@@ -1348,6 +1363,7 @@ function createRelay(opts) {
|
|
|
1348
1363
|
surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
|
|
1349
1364
|
depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
|
|
1350
1365
|
spec: params && params.spec ? params.spec : null,
|
|
1366
|
+
marker: sanitizeGlassesMarker(params && params.marker),
|
|
1351
1367
|
};
|
|
1352
1368
|
server.broadcast(JSON.stringify(payload));
|
|
1353
1369
|
emitDebug(
|
|
@@ -1382,6 +1398,7 @@ function createRelay(opts) {
|
|
|
1382
1398
|
})
|
|
1383
1399
|
.filter((i) => i !== null);
|
|
1384
1400
|
}
|
|
1401
|
+
const m = sanitizeGlassesMarker(patch.marker); if (m) cleanPatch.marker = m;
|
|
1385
1402
|
const payload = {
|
|
1386
1403
|
type: "glasses_ui_surface_update",
|
|
1387
1404
|
sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
|
|
@@ -1542,6 +1559,9 @@ function createRelay(opts) {
|
|
|
1542
1559
|
);
|
|
1543
1560
|
|
|
1544
1561
|
return maybeSeedOcuClawSessionConfig(resolvedSessionKey).then(() => {
|
|
1562
|
+
// A genuine user/voice send is starting an agent turn — mark the
|
|
1563
|
+
// session busy so a racing glasses wake is absorbed (§2.6c).
|
|
1564
|
+
agentTurnTracker.markBusy(resolvedSessionKey);
|
|
1545
1565
|
// Dispatch upstream first so local transcript work cannot delay first
|
|
1546
1566
|
// model tokens on large histories.
|
|
1547
1567
|
const upstreamPromise = gatewayBridge.sendMessage(
|
|
@@ -2676,6 +2696,14 @@ function createRelay(opts) {
|
|
|
2676
2696
|
|
|
2677
2697
|
const pluginVersionService = createPluginVersionService();
|
|
2678
2698
|
|
|
2699
|
+
// Roadmap 4a: latch the worker's per-heartbeat send-buffer pressure counts
|
|
2700
|
+
// into the boolean the glasses-ui paint-floor shed queries
|
|
2701
|
+
// (isGlassesSendBufferOverHighWater on the relay API / relay-service facade).
|
|
2702
|
+
const glassesBackpressureLatch = createGlassesBackpressureLatch({
|
|
2703
|
+
emitDebug: (event, severity, data) =>
|
|
2704
|
+
emitDebug("relay.health", event, severity, null, () => data || {}),
|
|
2705
|
+
});
|
|
2706
|
+
|
|
2679
2707
|
server = createRelayWorkerSupervisor({
|
|
2680
2708
|
pluginId: "ocuclaw",
|
|
2681
2709
|
getPluginVersion: () => pluginVersionService.getPluginVersion(),
|
|
@@ -2686,6 +2714,7 @@ function createRelay(opts) {
|
|
|
2686
2714
|
host: opts.host,
|
|
2687
2715
|
port: opts.port,
|
|
2688
2716
|
token: opts.token,
|
|
2717
|
+
onWorkerBackpressure: (message) => glassesBackpressureLatch.report(message),
|
|
2689
2718
|
externalDebugToolsEnabled,
|
|
2690
2719
|
evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
|
|
2691
2720
|
evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
|
|
@@ -3331,6 +3360,48 @@ function createRelay(opts) {
|
|
|
3331
3360
|
sendGlassesUiSurfaceUpdate(params);
|
|
3332
3361
|
},
|
|
3333
3362
|
|
|
3363
|
+
/**
|
|
3364
|
+
* Tap-to-wake lane (roadmap 6f): ONE agent turn for a parked glasses
|
|
3365
|
+
* gesture, dispatched through the same gateway client the voice send
|
|
3366
|
+
* uses. The MESSAGE is built (and sanitized) by the glasses-ui wake
|
|
3367
|
+
* controller — refs-only with non-wearer provenance framing; this method
|
|
3368
|
+
* is a dumb transport and deliberately does NOT touch the local
|
|
3369
|
+
* conversation state (a wake is not a wearer utterance — no synthetic
|
|
3370
|
+
* user message without provenance, §2.6).
|
|
3371
|
+
*/
|
|
3372
|
+
dispatchGlassesWake(params) {
|
|
3373
|
+
const sessionKey =
|
|
3374
|
+
params && typeof params.sessionKey === "string" && params.sessionKey
|
|
3375
|
+
? params.sessionKey
|
|
3376
|
+
: sessionService.ensureSessionKey();
|
|
3377
|
+
const message = params && typeof params.message === "string" ? params.message : "";
|
|
3378
|
+
if (!message) {
|
|
3379
|
+
return Promise.reject(new Error("dispatchGlassesWake requires a message"));
|
|
3380
|
+
}
|
|
3381
|
+
const idempotencyKey =
|
|
3382
|
+
params && typeof params.idempotencyKey === "string" && params.idempotencyKey
|
|
3383
|
+
? params.idempotencyKey
|
|
3384
|
+
: null;
|
|
3385
|
+
agentTurnTracker.markBusy(sessionKey);
|
|
3386
|
+
emitDebug(
|
|
3387
|
+
"relay.protocol",
|
|
3388
|
+
"glasses_wake_dispatch",
|
|
3389
|
+
"info",
|
|
3390
|
+
{ sessionKey },
|
|
3391
|
+
() => ({
|
|
3392
|
+
idempotencyKey,
|
|
3393
|
+
messageChars: message.length,
|
|
3394
|
+
}),
|
|
3395
|
+
);
|
|
3396
|
+
const requestParams = { message, sessionKey };
|
|
3397
|
+
if (idempotencyKey) requestParams.idempotencyKey = idempotencyKey;
|
|
3398
|
+
return gatewayBridge.request("agent", requestParams, { expectFinal: false });
|
|
3399
|
+
},
|
|
3400
|
+
|
|
3401
|
+
isAgentTurnBusy(sessionKey) {
|
|
3402
|
+
return agentTurnTracker.isBusy(sessionKey);
|
|
3403
|
+
},
|
|
3404
|
+
|
|
3334
3405
|
onGlassesUiResult(handler) {
|
|
3335
3406
|
return onGlassesUiResult(handler);
|
|
3336
3407
|
},
|
|
@@ -3351,6 +3422,10 @@ function createRelay(opts) {
|
|
|
3351
3422
|
return server ? server.getConnectedAppCount() > 0 : false;
|
|
3352
3423
|
},
|
|
3353
3424
|
|
|
3425
|
+
isGlassesSendBufferOverHighWater() {
|
|
3426
|
+
return glassesBackpressureLatch.isOverHighWater();
|
|
3427
|
+
},
|
|
3428
|
+
|
|
3354
3429
|
onAppClientDisconnect(handler) {
|
|
3355
3430
|
return onAppClientDisconnect(handler);
|
|
3356
3431
|
},
|
|
@@ -447,6 +447,38 @@ export function createOcuClawRelayService(opts = {}) {
|
|
|
447
447
|
}
|
|
448
448
|
return false;
|
|
449
449
|
},
|
|
450
|
+
isGlassesSendBufferOverHighWater() {
|
|
451
|
+
// Roadmap 4a: glasses-ui paint-floor shed signal (glasses-ui-tool.ts
|
|
452
|
+
// already consults this; false = no shedding, the safe default).
|
|
453
|
+
const liveRelay = resolveLiveRelay();
|
|
454
|
+
if (liveRelay && typeof liveRelay.isGlassesSendBufferOverHighWater === "function") {
|
|
455
|
+
return liveRelay.isGlassesSendBufferOverHighWater();
|
|
456
|
+
}
|
|
457
|
+
return false;
|
|
458
|
+
},
|
|
459
|
+
// Tap-to-wake lane + busy signal (roadmap 6f). registerGlassesUiTool
|
|
460
|
+
// probes these at plugin register() time — BEFORE start() — and wires
|
|
461
|
+
// dispatchWake to null when the probe fails, so they must exist on the
|
|
462
|
+
// facade unconditionally and resolve the live (possibly shared) relay at
|
|
463
|
+
// call time, like every other passthrough above.
|
|
464
|
+
dispatchGlassesWake(params) {
|
|
465
|
+
const liveRelay = resolveLiveRelay();
|
|
466
|
+
if (!liveRelay || typeof liveRelay.dispatchGlassesWake !== "function") {
|
|
467
|
+
// The wake controller retries once, then parks the wake in its
|
|
468
|
+
// durable outbox with a warn lifecycle — never silent after the ✓-ack.
|
|
469
|
+
return Promise.reject(new Error("ocuclaw relay not started"));
|
|
470
|
+
}
|
|
471
|
+
return liveRelay.dispatchGlassesWake(params);
|
|
472
|
+
},
|
|
473
|
+
isAgentTurnBusy(sessionKey) {
|
|
474
|
+
const liveRelay = resolveLiveRelay();
|
|
475
|
+
if (liveRelay && typeof liveRelay.isAgentTurnBusy === "function") {
|
|
476
|
+
return liveRelay.isAgentTurnBusy(sessionKey);
|
|
477
|
+
}
|
|
478
|
+
// Idle, not busy: a missing relay must surface through the dispatch
|
|
479
|
+
// path's failure handling, never suppress wakes as phantom-busy.
|
|
480
|
+
return false;
|
|
481
|
+
},
|
|
450
482
|
start,
|
|
451
483
|
stop,
|
|
452
484
|
};
|
|
@@ -777,6 +777,14 @@ export function createRelayWorkerSupervisor(options = {}) {
|
|
|
777
777
|
postMainFrame("unicast", frame, pending.requesterClientId);
|
|
778
778
|
return;
|
|
779
779
|
}
|
|
780
|
+
if (message.kind === "worker.backpressure") {
|
|
781
|
+
// Roadmap 4a: heartbeat-cadence send-buffer pressure counts from the
|
|
782
|
+
// worker transport; relay-core latches them for the glasses paint shed.
|
|
783
|
+
if (typeof options.onWorkerBackpressure === "function") {
|
|
784
|
+
options.onWorkerBackpressure(message);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
780
788
|
if (message.kind === "debug" && typeof options.emitDebug === "function") {
|
|
781
789
|
options.emitDebug(
|
|
782
790
|
message.category || "relay.worker.health",
|
|
@@ -997,8 +997,17 @@ export function createRelayWorkerTransport(options = {}) {
|
|
|
997
997
|
}, Math.max(10, Math.min(1000, manifest.queue.messageSendTtlMs)));
|
|
998
998
|
healthTimer = setInterval(() => {
|
|
999
999
|
health.updateLoopLagP95Ms(sampleLoopLagP95Ms());
|
|
1000
|
-
|
|
1000
|
+
const sendBufferHighWaterClients = countSendBufferHighWaterClients();
|
|
1001
|
+
health.updateSendBufferHighWaterClients(sendBufferHighWaterClients);
|
|
1001
1002
|
health.sample();
|
|
1003
|
+
// Roadmap 4a: main holds the glasses paint-floor shed latch; the ws
|
|
1004
|
+
// sockets (and their bufferedAmount) live here in the worker, so the
|
|
1005
|
+
// count crosses on every heartbeat (the latch fail-opens on staleness).
|
|
1006
|
+
postToMain({
|
|
1007
|
+
kind: "worker.backpressure",
|
|
1008
|
+
workerEpoch: manifest.workerEpoch,
|
|
1009
|
+
sendBufferHighWaterClients,
|
|
1010
|
+
});
|
|
1002
1011
|
}, manifest.health.heartbeatIntervalMs);
|
|
1003
1012
|
postToMain({ kind: "worker.ready", workerEpoch: manifest.workerEpoch, address: address() });
|
|
1004
1013
|
}
|
|
@@ -311,6 +311,12 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
311
311
|
resolved: false,
|
|
312
312
|
nextTickTimer: null,
|
|
313
313
|
maxDurationTimer: null,
|
|
314
|
+
// Pause-aware duration accounting (roadmap 6e): the cap measures
|
|
315
|
+
// ACTIVE time only. remainingMs is banked on pause and re-armed on
|
|
316
|
+
// resume; armedAtMs is monotonic (never wall-clock — the 2026-06-01
|
|
317
|
+
// freeze came from mixing clocks).
|
|
318
|
+
maxDurationRemainingMs: params.refresh.maxDurationMs,
|
|
319
|
+
maxDurationArmedAtMs: null,
|
|
314
320
|
isSmokeTest: false,
|
|
315
321
|
lastTickAt: null,
|
|
316
322
|
generationToken: 0,
|
|
@@ -319,7 +325,14 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
319
325
|
};
|
|
320
326
|
active.set(state.surfaceId, state);
|
|
321
327
|
// Arm the duration cap.
|
|
328
|
+
state.maxDurationArmedAtMs = monotonicNowMs();
|
|
322
329
|
state.maxDurationTimer = setTimeoutFn(() => {
|
|
330
|
+
// Cron-death observability (Wave-1 e2e leg-4 gap): the cap firing was
|
|
331
|
+
// invisible — bounded tick-silence watches were the only evidence.
|
|
332
|
+
emitLifecycle("cron_max_duration_reached", "debug", {
|
|
333
|
+
surfaceId: state.surfaceId,
|
|
334
|
+
sessionKey: state.sessionKey,
|
|
335
|
+
});
|
|
323
336
|
resolveAndClean(state, { result: "timeout" });
|
|
324
337
|
}, params.refresh.maxDurationMs);
|
|
325
338
|
// start() is sync; runSmokeTest is async. Catch any rejection so it
|
|
@@ -380,6 +393,20 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
380
393
|
clearTimeoutFn(state.nextTickTimer);
|
|
381
394
|
state.nextTickTimer = null;
|
|
382
395
|
}
|
|
396
|
+
// Pause-aware duration cap (roadmap 6e): stop the wall-clock burn and
|
|
397
|
+
// bank the remaining ACTIVE budget. Pre-6e the cap kept running while
|
|
398
|
+
// the parent hid under a pushed child — hardware-observed 2026-06-11
|
|
399
|
+
// as cron_resume {found:false} → Back landed on a frozen list. Guarded
|
|
400
|
+
// on the timer handle so a double-pause cannot double-deduct.
|
|
401
|
+
if (state.maxDurationTimer) {
|
|
402
|
+
clearTimeoutFn(state.maxDurationTimer);
|
|
403
|
+
state.maxDurationTimer = null;
|
|
404
|
+
state.maxDurationRemainingMs = Math.max(
|
|
405
|
+
0,
|
|
406
|
+
state.maxDurationRemainingMs - (monotonicNowMs() - state.maxDurationArmedAtMs),
|
|
407
|
+
);
|
|
408
|
+
state.maxDurationArmedAtMs = null;
|
|
409
|
+
}
|
|
383
410
|
state.paused = true;
|
|
384
411
|
// Bump so an in-flight tick (started before pause) is discarded when it
|
|
385
412
|
// resolves — it must not patch the now-hidden parent screen.
|
|
@@ -398,6 +425,29 @@ export function createGlassesUiCronEngine(deps) {
|
|
|
398
425
|
});
|
|
399
426
|
return false;
|
|
400
427
|
}
|
|
428
|
+
// Re-arm the duration cap with the banked ACTIVE remainder (roadmap
|
|
429
|
+
// 6e). An exhausted budget resolves terminal timeout here instead of
|
|
430
|
+
// re-arming a dead cron — honest death beats a zombie surface.
|
|
431
|
+
if (!state.maxDurationTimer) {
|
|
432
|
+
if (state.maxDurationRemainingMs <= 0) {
|
|
433
|
+
emitLifecycle("cron_resume", "debug", {
|
|
434
|
+
surfaceId,
|
|
435
|
+
found: true,
|
|
436
|
+
resolved: false,
|
|
437
|
+
branch: "max_duration_exhausted",
|
|
438
|
+
});
|
|
439
|
+
resolveAndClean(state, { result: "timeout" });
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
state.maxDurationArmedAtMs = monotonicNowMs();
|
|
443
|
+
state.maxDurationTimer = setTimeoutFn(() => {
|
|
444
|
+
emitLifecycle("cron_max_duration_reached", "debug", {
|
|
445
|
+
surfaceId: state.surfaceId,
|
|
446
|
+
sessionKey: state.sessionKey,
|
|
447
|
+
});
|
|
448
|
+
resolveAndClean(state, { result: "timeout" });
|
|
449
|
+
}, state.maxDurationRemainingMs);
|
|
450
|
+
}
|
|
401
451
|
state.paused = false;
|
|
402
452
|
if (state.nextTickTimer) {
|
|
403
453
|
clearTimeoutFn(state.nextTickTimer);
|
|
@@ -35,6 +35,14 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
35
35
|
return !!(p && p.__render === true);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function markerOf(p) {
|
|
39
|
+
if (!p) return undefined;
|
|
40
|
+
return p.__marker !== undefined ? p.__marker : p.marker;
|
|
41
|
+
}
|
|
42
|
+
function isMarkerOnly(p) {
|
|
43
|
+
return !!p && !isRenderSentinel(p) && Object.keys(p).length === 1 && p.marker !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
function mergePatch(base, incoming) {
|
|
39
47
|
// A render sentinel ({ __render, __depth, __spec }, Task 13) and a field
|
|
40
48
|
// patch ({ body }/{ items }/{ title }) must NEVER shallow-merge — a merged
|
|
@@ -43,8 +51,22 @@ export function createPaintFloorCoalescer(deps) {
|
|
|
43
51
|
// earlier one wholesale (a render replaces a queued field patch; a field
|
|
44
52
|
// patch after a queued render replaces that render). Same-kind writes
|
|
45
53
|
// merge last-write-wins per field as before.
|
|
54
|
+
//
|
|
55
|
+
// Marker-only field patch must NEVER drop a queued render (7a): merge the
|
|
56
|
+
// marker onto the render sentinel instead of superseding it wholesale.
|
|
57
|
+
if (isMarkerOnly(incoming) && isRenderSentinel(base)) {
|
|
58
|
+
return { ...base, __marker: incoming.marker };
|
|
59
|
+
}
|
|
46
60
|
if (isRenderSentinel(base) !== isRenderSentinel(incoming)) {
|
|
47
|
-
|
|
61
|
+
// Different write kinds: later supersedes wholesale (a render replaces a
|
|
62
|
+
// queued field patch; a content field patch replaces a queued render).
|
|
63
|
+
// Marker is sticky — carry it forward if the survivor didn't set one.
|
|
64
|
+
const next = incoming && typeof incoming === "object" ? { ...incoming } : {};
|
|
65
|
+
if (markerOf(next) === undefined) {
|
|
66
|
+
const m = markerOf(base);
|
|
67
|
+
if (m !== undefined) { if (isRenderSentinel(next)) next.__marker = m; else next.marker = m; }
|
|
68
|
+
}
|
|
69
|
+
return next;
|
|
48
70
|
}
|
|
49
71
|
const merged = base ? { ...base } : {};
|
|
50
72
|
if (incoming && typeof incoming === "object") {
|