create-walle 0.10.0 → 0.10.1
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/README.md +84 -41
- package/lib/runtime-closure-policy.json +2 -2
- package/package.json +10 -4
- package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
- package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
- package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
- package/template/coding-task-manager/lib/client-diag-handler.js +20 -0
- package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
- package/template/coding-task-manager/lib/codex-local-links.js +267 -0
- package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
- package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
- package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
- package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
- package/template/coding-task-manager/lib/feature-manifest.js +0 -2
- package/template/coding-task-manager/lib/harness-history-transition.js +38 -11
- package/template/coding-task-manager/lib/headless-term-service.js +5 -6
- package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
- package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
- package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
- package/template/coding-task-manager/lib/lead-model-switch.js +8 -0
- package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
- package/template/coding-task-manager/lib/login-tripwire.js +17 -0
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +4 -0
- package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
- package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
- package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
- package/template/coding-task-manager/lib/native-history-control.js +60 -12
- package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
- package/template/coding-task-manager/lib/raw-history-block.js +3 -1
- package/template/coding-task-manager/lib/read-pool-client.js +31 -22
- package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
- package/template/coding-task-manager/lib/remote-command-runtime.js +5 -4
- package/template/coding-task-manager/lib/root-prompt-admission.js +10 -9
- package/template/coding-task-manager/lib/root-prompt-flow.js +8 -5
- package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
- package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
- package/template/coding-task-manager/lib/session-create/provider-process.js +4 -2
- package/template/coding-task-manager/lib/session-lifecycle.js +2 -50
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +59 -3
- package/template/coding-task-manager/lib/session-resource-sampler.js +48 -0
- package/template/coding-task-manager/lib/session-status.js +2 -2
- package/template/coding-task-manager/lib/typing-interaction-trace.js +44 -2
- package/template/coding-task-manager/lib/typing-worker-stages.js +148 -1
- package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
- package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
- package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
- package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
- package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
- package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
- package/template/coding-task-manager/lib/ws/input.js +1 -0
- package/template/coding-task-manager/protocol/message-table.json +0 -10
- package/template/coding-task-manager/protocol/messages.js +0 -10
- package/template/coding-task-manager/protocol/ws-schema.json +0 -20
- package/template/coding-task-manager/protocol/ws-schema.md +0 -2
- package/template/coding-task-manager/public/css/app.css +0 -1
- package/template/coding-task-manager/public/index.html +3 -17
- package/template/coding-task-manager/public/js/client-health-core.js +81 -0
- package/template/coding-task-manager/public/js/client-health-main.js +61 -0
- package/template/coding-task-manager/public/js/prompt-submission-client.js +5 -1
- package/template/coding-task-manager/public/js/protocol.js +0 -10
- package/template/coding-task-manager/public/js/row-join.js +14 -1
- package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
- package/template/coding-task-manager/public/js/terminal-history-viewport.js +0 -2
- package/template/coding-task-manager/server.js +62 -186
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/wall-e/coding/host-capabilities.js +2 -2
- package/template/wall-e/fly.toml +1 -1
- package/template/wall-e/tools/local-tools.js +6 -1
- package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
|
@@ -46,6 +46,13 @@ function startServerMemorySamplerRuntime(options = {}) {
|
|
|
46
46
|
// WP-0b (§5 D-2): () => [{ owner, memory }] over the read-pool / db-owner worker clients.
|
|
47
47
|
// Forwarded verbatim; absent -> the sampler's own () => [] default keeps workers[] empty.
|
|
48
48
|
workersSource,
|
|
49
|
+
// WP-A P-2 (freeze-probes): () => the work in flight at sample time (read-pool ops, db-owner
|
|
50
|
+
// pending ops, recent main-loop spans). Forwarded verbatim; absent -> the sampler's () => null
|
|
51
|
+
// default keeps the row's `inFlight` null.
|
|
52
|
+
inFlightSource,
|
|
53
|
+
// WP-A P-3 (freeze-probes): { sampleFootprintRegions(pid) } for the main pid -- the categorized
|
|
54
|
+
// `/usr/bin/footprint` read. Forwarded verbatim; absent -> main.regions stays null.
|
|
55
|
+
regionSampler,
|
|
49
56
|
samplerOptions,
|
|
50
57
|
} = options;
|
|
51
58
|
// WP-4 (design doc §5 D-5, §8 WP-4): reuse the SAME 'mirrors-report' IPC round trip
|
|
@@ -62,6 +69,9 @@ function startServerMemorySamplerRuntime(options = {}) {
|
|
|
62
69
|
mirrorsSampler,
|
|
63
70
|
// Explicit workersSource wins; otherwise a workersSource carried in samplerOptions (test seam).
|
|
64
71
|
workersSource: typeof workersSource === 'function' ? workersSource : samplerOptions?.workersSource,
|
|
72
|
+
// WP-A P-2/P-3: forwarded the same way, so server.js's one call site wires all three probes.
|
|
73
|
+
inFlightSource: typeof inFlightSource === 'function' ? inFlightSource : samplerOptions?.inFlightSource,
|
|
74
|
+
regionSampler: regionSampler || samplerOptions?.regionSampler || null,
|
|
65
75
|
listProcesses: () => serverProcessList({ sessionHostManager, desktopIndex, errorSink }),
|
|
66
76
|
buildLedgerInput: () => ({
|
|
67
77
|
processMemory: process.memoryUsage(),
|
|
@@ -135,6 +135,15 @@ function createServerMemorySampler(options = {}) {
|
|
|
135
135
|
// liveDecoders } }] in raw bytes -- read from the read-pool/db-owner worker clients. Omitted ->
|
|
136
136
|
// workers[] is empty and workersTotal sums are 0 (never fabricated from nothing).
|
|
137
137
|
workersSource = () => [],
|
|
138
|
+
// WP-A P-2 (freeze-probes): () => any JSON-safe value describing the work in flight when the row
|
|
139
|
+
// was taken (read-pool ops, db-owner pending ops, recent main-loop spans). Stored verbatim on the
|
|
140
|
+
// row as `inFlight`; a throw or an absent source yields null ("unknown"), never a lost row.
|
|
141
|
+
inFlightSource = () => null,
|
|
142
|
+
// WP-A P-3 (freeze-probes): optional { sampleFootprintRegions(pid): Promise<{ totalMB, top }|null> }
|
|
143
|
+
// -- the CATEGORIZED `/usr/bin/footprint` read (lib/session-resource-sampler.js), a fresh spawn per
|
|
144
|
+
// call rather than the TTL-cached sampleFootprints() summary, so the region that just grew is
|
|
145
|
+
// named. Omitted -> main.regions stays null, never fabricated.
|
|
146
|
+
regionSampler = null,
|
|
138
147
|
now = () => Date.now(),
|
|
139
148
|
// Injectable so a test isn't forced to monkey-patch the process-global console (which
|
|
140
149
|
// would race other concurrently-running tests in the same file).
|
|
@@ -199,6 +208,17 @@ function createServerMemorySampler(options = {}) {
|
|
|
199
208
|
};
|
|
200
209
|
}
|
|
201
210
|
|
|
211
|
+
// WP-A P-2: the in-flight snapshot for this row. A broken/absent source reports null rather than
|
|
212
|
+
// failing the tick (same disposition as readWorkerMemory above).
|
|
213
|
+
function readInFlight() {
|
|
214
|
+
try {
|
|
215
|
+
return (typeof inFlightSource === 'function' ? inFlightSource() : null) ?? null;
|
|
216
|
+
} catch {
|
|
217
|
+
/* expected: the read-pool/db-owner clients can be mid-restart; "unknown in flight" beats a lost row */
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
202
222
|
async function sampleProcessesRss(pids) {
|
|
203
223
|
if (!pids.length) return new Map();
|
|
204
224
|
const psCommand = platform === 'darwin' ? '/bin/ps' : 'ps';
|
|
@@ -261,6 +281,18 @@ function createServerMemorySampler(options = {}) {
|
|
|
261
281
|
const mu = process.memoryUsage();
|
|
262
282
|
const mainProc = procs.find((p) => p.role === 'main') || null;
|
|
263
283
|
const poolProc = procs.find((p) => p.role === 'session-host-pool') || null;
|
|
284
|
+
// WP-A P-3: the MAIN process' categorized footprint regions -- the only read that can NAME the
|
|
285
|
+
// category behind a step (the 4<->9 GB swing is entirely "Malloc Large"). `null` (never
|
|
286
|
+
// fabricated) when unwired, when the main pid is unknown, or when the read failed.
|
|
287
|
+
let regions = null;
|
|
288
|
+
if (regionSampler && typeof regionSampler.sampleFootprintRegions === 'function' && mainProc && mainProc.pid) {
|
|
289
|
+
try {
|
|
290
|
+
regions = (await regionSampler.sampleFootprintRegions(mainProc.pid)) ?? null;
|
|
291
|
+
} catch {
|
|
292
|
+
/* expected: /usr/bin/footprint can be missing, denied or killed; the row must still record */
|
|
293
|
+
regions = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
264
296
|
const main = {
|
|
265
297
|
heapUsedMB: roundMB(mu.heapUsed),
|
|
266
298
|
externalMB: roundMB(mu.external),
|
|
@@ -273,6 +305,8 @@ function createServerMemorySampler(options = {}) {
|
|
|
273
305
|
footprintMB: mainProc ? mainProc.physFootprintMB : null,
|
|
274
306
|
// WP-0b (§5 D-2): main-thread's OWN live zstd contexts (workers report theirs separately).
|
|
275
307
|
liveDecoders: liveDecoderCount(),
|
|
308
|
+
// WP-A P-3: categorized footprint regions of this same pid (null when unwired/unsampled).
|
|
309
|
+
regions,
|
|
276
310
|
};
|
|
277
311
|
// WP-0b (§5 D-2, §9 S-9): per-worker heap/external/arrayBuffers + live decoder count, summed
|
|
278
312
|
// into workersTotal so the row explains what the main isolate's process.memoryUsage() cannot.
|
|
@@ -338,7 +372,10 @@ function createServerMemorySampler(options = {}) {
|
|
|
338
372
|
}
|
|
339
373
|
const ledger = compactLedgerSubset(fullLedger);
|
|
340
374
|
|
|
341
|
-
|
|
375
|
+
// WP-A P-2: the work in flight at sample time, so a step in rss/footprint can be read against
|
|
376
|
+
// what was running when it happened (`null` = unknown/not wired).
|
|
377
|
+
const inFlight = readInFlight();
|
|
378
|
+
const sample = { schemaVersion: SCHEMA_VERSION, reason, procs, main, poolHost, ledger, workers, workersTotal, inFlight };
|
|
342
379
|
if (typeof record === 'function') {
|
|
343
380
|
try { record('server-memory', sample); } catch { /* expected: a broken diagnostic sink must not break the sampler loop itself */ }
|
|
344
381
|
}
|
|
@@ -363,13 +400,15 @@ function createServerMemorySampler(options = {}) {
|
|
|
363
400
|
// WP-0b (§5 D-2, §9 S-9): run an extra sample whenever main RSS stepped >= minStepMB since the
|
|
364
401
|
// last recorded row. Never throws; returns the sample or null when skipped (no prior sample,
|
|
365
402
|
// caller rss unavailable, below threshold, an unrepresentable prior rss, or a sample in flight).
|
|
403
|
+
// WP-A P-3: the default threshold is 768 MB, not 2048 -- the live 2026-10-21 measurement showed the
|
|
404
|
+
// server's real steps are ~970 MB of "Malloc Large", which the old 2048 default silently skipped.
|
|
366
405
|
async function sampleIfStep(options = {}) {
|
|
367
406
|
try {
|
|
368
407
|
const opts = options && typeof options === 'object' ? options : {};
|
|
369
408
|
const target = Number(opts.rssMB);
|
|
370
409
|
if (!Number.isFinite(target)) return null;
|
|
371
410
|
if (!_lastMain || !Number.isFinite(Number(_lastMain.rssMB))) return null;
|
|
372
|
-
const threshold = Number.isFinite(Number(opts.minStepMB)) ? Number(opts.minStepMB) :
|
|
411
|
+
const threshold = Number.isFinite(Number(opts.minStepMB)) ? Number(opts.minStepMB) : 768;
|
|
373
412
|
if (Math.abs(target - Number(_lastMain.rssMB)) < threshold) return null;
|
|
374
413
|
return await sampleOnce({ reason: 'rss-step', rssMB: target });
|
|
375
414
|
} catch {
|
|
@@ -76,7 +76,7 @@ function applyModelExecutionRecipeToSpawnEnv(env, recipe, expected = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function modelExecutionRecipeForSpawn({ sessionId, processGeneration, agent, launchPlan, leadRoute, transition,
|
|
79
|
-
sourceEnvironmentReference, providerThreadId: explicitProviderThreadId }) {
|
|
79
|
+
sourceEnvironmentReference, providerThreadId: explicitProviderThreadId, nativeConfigReceipt }) {
|
|
80
80
|
const routeId = leadRoute ? String(leadRoute.routeId || leadRoute.alias || '') : null;
|
|
81
81
|
const profile = leadRoute?.profile || leadRoute?.preparedPlan?.profile || null;
|
|
82
82
|
const ownership = leadRoute?.ownership || leadRoute?.preparedPlan?.ownership || null;
|
|
@@ -93,6 +93,8 @@ function modelExecutionRecipeForSpawn({ sessionId, processGeneration, agent, lau
|
|
|
93
93
|
factsRevision: routeId ? (profile.factsRevision || ownership?.factsRevision || null) : null,
|
|
94
94
|
transitionId: transition?.transitionId || leadRoute?.transitionId || ownership?.transitionId || null,
|
|
95
95
|
...(sourceEnvironmentReference ? { sourceEnvironmentReference } : {}),
|
|
96
|
+
...(nativeConfigReceipt && ['present', 'missing'].includes(nativeConfigReceipt.state)
|
|
97
|
+
? { nativeConfigReceipt: { state: nativeConfigReceipt.state, sha256: nativeConfigReceipt.sha256 ?? null } } : {}),
|
|
96
98
|
};
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -890,7 +892,7 @@ function createProviderSession(state) {
|
|
|
890
892
|
const modelProcessGeneration = capturedModelProcessGeneration;
|
|
891
893
|
const executionRecipe = modelExecutionRecipeForSpawn({ sessionId: id, processGeneration: modelProcessGeneration,
|
|
892
894
|
agent: createAgentType, launchPlan: preparedLaunchPlan, leadRoute, transition: msg._leadModelTransition || null,
|
|
893
|
-
sourceEnvironmentReference, providerThreadId: launchProviderThreadId({ agent: createAgentType,
|
|
895
|
+
sourceEnvironmentReference, nativeConfigReceipt, providerThreadId: launchProviderThreadId({ agent: createAgentType,
|
|
894
896
|
launchPlan: preparedLaunchPlan, claudeResumeId, injectedSessionId: _injectedSessionId, args: launchArgs }) });
|
|
895
897
|
let spawnEnv = { ...applyModelExecutionRecipeToSpawnEnv(leadApplied.spawnEnv, executionRecipe,
|
|
896
898
|
{ sessionId: id, processGeneration: modelProcessGeneration }), CTM_MODEL_PROCESS_GENERATION: String(modelProcessGeneration) };
|
|
@@ -128,9 +128,6 @@ function lifecycleConfigFromEnv(env = process.env) {
|
|
|
128
128
|
prewarm: envBool(env, 'CTM_PREWARM', false),
|
|
129
129
|
prewarmTtlMs: envInt(env, 'CTM_PREWARM_TTL_MS', 60_000, 1_000),
|
|
130
130
|
prewarmTopK: envInt(env, 'CTM_PREWARM_TOP_K', 1, 1),
|
|
131
|
-
freezeTier: idlePark && envBool(env, 'CTM_FREEZE_TIER', false),
|
|
132
|
-
freezeAfterMs: envInt(env, 'CTM_FREEZE_AFTER_MS', 15 * 60_000, 1_000),
|
|
133
|
-
freezeParkAfterMs: 2 * 60 * 60_000,
|
|
134
131
|
// WP-3: default is derived from host RAM (see defaultSessionRssBudgetMB), so the
|
|
135
132
|
// MemoryBudgetController is live out of the box. `CTM_SESSION_RSS_BUDGET_MB=0`
|
|
136
133
|
// remains the explicit disable — a 0 budget makes observe() inert again.
|
|
@@ -147,11 +144,7 @@ function lifecycleConfigFromEnv(env = process.env) {
|
|
|
147
144
|
|
|
148
145
|
function automaticParkSweepPolicy(config, options = {}) {
|
|
149
146
|
const pressure = ['warn', 'critical'].includes(options.pressure) ? options.pressure : 'normal';
|
|
150
|
-
const
|
|
151
|
-
const configuredThresholdMs = Math.max(0, Number(config?.idleParkAfterMs) || 0);
|
|
152
|
-
const idleParkAfterMs = freezeTier
|
|
153
|
-
? Math.max(configuredThresholdMs, Number(config?.freezeParkAfterMs) || 0)
|
|
154
|
-
: configuredThresholdMs;
|
|
147
|
+
const idleParkAfterMs = Math.max(0, Number(config?.idleParkAfterMs) || 0);
|
|
155
148
|
const disconnectCollapseMs = Math.max(
|
|
156
149
|
idleParkAfterMs,
|
|
157
150
|
Number(config?.disconnectCollapseMs) || 0,
|
|
@@ -223,7 +216,7 @@ function normalizeClientKind(value) {
|
|
|
223
216
|
function evaluatePark(session, host, config) {
|
|
224
217
|
const now = Number(host?.now) || Date.now();
|
|
225
218
|
const protections = [];
|
|
226
|
-
if (!session ||
|
|
219
|
+
if (!session || session.state !== 'live') protections.push('not-live');
|
|
227
220
|
if (session?.resumable === false) protections.push('not-resumable'); if (session?.parkable === false) protections.push('not-parkable-class');
|
|
228
221
|
if (session?.liveStatus === 'running' || session?.liveStatus === 'generating' || session?.liveStatus === 'working') protections.push('running');
|
|
229
222
|
if (session?.liveStatus === 'waiting_input' || session?.liveStatus === 'resuming') protections.push('waiting-input');
|
|
@@ -490,37 +483,6 @@ function describePressureCause(signals = {}, thresholds = {}, effectiveLevel = '
|
|
|
490
483
|
|| { kind: 'recovering', level, value: null };
|
|
491
484
|
}
|
|
492
485
|
|
|
493
|
-
function evaluateFreeze(session, host, config) {
|
|
494
|
-
if (config?.freezeTier !== true) {
|
|
495
|
-
return { eligible: false, protections: ['disabled'], idleMs: 0, thresholdMs: Number(config?.freezeAfterMs) || 0 };
|
|
496
|
-
}
|
|
497
|
-
if (!session || session.state !== 'live') {
|
|
498
|
-
return { eligible: false, protections: ['not-live'], idleMs: 0, thresholdMs: Number(config?.freezeAfterMs) || 0 };
|
|
499
|
-
}
|
|
500
|
-
return evaluatePark(session, host, {
|
|
501
|
-
...config,
|
|
502
|
-
idleParkAfterMs: Number(config.freezeAfterMs) || 15 * 60_000,
|
|
503
|
-
disconnectCollapseMs: Number(config.freezeAfterMs) || 15 * 60_000,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function selectFreezeVictim(sessions, host, config) {
|
|
508
|
-
const eligible = [];
|
|
509
|
-
for (const session of sessions || []) {
|
|
510
|
-
const result = evaluateFreeze(session, host, config);
|
|
511
|
-
if (result.eligible) eligible.push({ session, result });
|
|
512
|
-
}
|
|
513
|
-
eligible.sort((a, b) => {
|
|
514
|
-
const activityDelta = (Number(a.session.lastMeaningfulActivityAt) || 0)
|
|
515
|
-
- (Number(b.session.lastMeaningfulActivityAt) || 0);
|
|
516
|
-
if (activityDelta !== 0) return activityDelta;
|
|
517
|
-
const rssDelta = sessionMemoryMB(b.session) - sessionMemoryMB(a.session);
|
|
518
|
-
if (rssDelta !== 0) return rssDelta;
|
|
519
|
-
return String(a.session.id).localeCompare(String(b.session.id));
|
|
520
|
-
});
|
|
521
|
-
return eligible[0]?.session || null;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
486
|
class PressureController {
|
|
525
487
|
constructor() {
|
|
526
488
|
this.level = 'normal';
|
|
@@ -773,7 +735,6 @@ class LifecycleDiagnostics {
|
|
|
773
735
|
this.activationByClient = {};
|
|
774
736
|
this.parkByReason = {};
|
|
775
737
|
this.prewarmByOutcome = {};
|
|
776
|
-
this.freezeByAction = {};
|
|
777
738
|
this.shadow = { candidates: 0, falsePositives: 0, projectedReclaimMB: 0 };
|
|
778
739
|
this.pressureTransitions = 0;
|
|
779
740
|
this.events = [];
|
|
@@ -813,12 +774,6 @@ class LifecycleDiagnostics {
|
|
|
813
774
|
this._event({ type: 'prewarm', id, outcome: normalized, reason: String(reason || '').slice(0, 48) });
|
|
814
775
|
}
|
|
815
776
|
|
|
816
|
-
recordFreeze({ id, action, idleMs = 0, reason = '' }) {
|
|
817
|
-
const normalized = String(action || 'unknown').slice(0, 32);
|
|
818
|
-
this._count(this.freezeByAction, normalized);
|
|
819
|
-
this._event({ type: 'freeze', id, action: normalized, idleMs: Math.max(0, idleMs), reason: String(reason || '').slice(0, 48) });
|
|
820
|
-
}
|
|
821
|
-
|
|
822
777
|
recordShadow({ id, action = 'candidate', rssMB = 0, activityAfterMs = 0, reason = '', signals = {} }) {
|
|
823
778
|
if (action === 'candidate') {
|
|
824
779
|
this.shadow.candidates += 1;
|
|
@@ -876,7 +831,6 @@ class LifecycleDiagnostics {
|
|
|
876
831
|
activationByClient: { ...this.activationByClient },
|
|
877
832
|
parkByReason: { ...this.parkByReason },
|
|
878
833
|
prewarmByOutcome: { ...this.prewarmByOutcome },
|
|
879
|
-
freezeByAction: { ...this.freezeByAction },
|
|
880
834
|
shadow: { ...this.shadow },
|
|
881
835
|
pressureTransitions: this.pressureTransitions,
|
|
882
836
|
wakeOutcomes: { ...this.wakeOutcomes },
|
|
@@ -901,11 +855,9 @@ module.exports = {
|
|
|
901
855
|
classifyWakeOutcome,
|
|
902
856
|
derivePressure,
|
|
903
857
|
describePressureCause,
|
|
904
|
-
evaluateFreeze,
|
|
905
858
|
evaluateManualPark,
|
|
906
859
|
evaluatePark,
|
|
907
860
|
summarizeParkEligibility,
|
|
908
|
-
selectFreezeVictim,
|
|
909
861
|
selectParkVictim,
|
|
910
862
|
sessionMemoryMB,
|
|
911
863
|
};
|
|
@@ -72,6 +72,28 @@ function parkRefusalDetail(protections) {
|
|
|
72
72
|
return 'The session could not be restarted right now. Try again in a moment.';
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// WP-0 D-10: a refusal code with no hand-written sentence still gets a family sentence that NAMES
|
|
76
|
+
// the code, so no refusal can reach the person as a generic line. Checked after the explicit map and
|
|
77
|
+
// before any caller-supplied message.
|
|
78
|
+
const HISTORY_REFUSAL_FAMILIES = Object.freeze([
|
|
79
|
+
[/^unsupported_native_/, 'This history carries a record type that has not been qualified for transfer.'],
|
|
80
|
+
[/^native_world_state_/, 'The native Codex world state could not be replayed safely.'],
|
|
81
|
+
[/^native_history_/, 'The native Codex history could not be prepared for transfer.'],
|
|
82
|
+
[/^tool_call_/, 'This history has an incomplete tool exchange, so it cannot be transferred safely.'],
|
|
83
|
+
[/^history_/, 'The source history could not be verified for this provider change.'],
|
|
84
|
+
[/^source_/, 'The source session could not be verified for this provider change.'],
|
|
85
|
+
[/^target_/, 'The target session could not be verified for this provider change.'],
|
|
86
|
+
[/^fork_/, 'The native fork could not be verified in Codex history.'],
|
|
87
|
+
[/^native_(?:rpc|process|fork|adapter|readback|initialize)_/, 'The native Codex process could not complete the handoff.'],
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
function historyRefusalFamilyMessage(reason, tail) {
|
|
91
|
+
for (const [pattern, sentence] of HISTORY_REFUSAL_FAMILIES) {
|
|
92
|
+
if (pattern.test(reason)) return `${sentence} (code: ${reason}) ${tail}`;
|
|
93
|
+
}
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
// History checks run before the source is parked. Keep their bounded reason codes useful to the
|
|
76
98
|
// person making the change without exposing scanner records, paths, or provider credentials.
|
|
77
99
|
function historyPreparationMessage(reason, suppliedMessage = '', { postPark = false } = {}) {
|
|
@@ -83,10 +105,13 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
|
|
|
83
105
|
source_checkpoint_changed_after_park: `The source checkpoint changed during handoff verification. ${recovery}`,
|
|
84
106
|
source_config_policy_unqualified: `The captured Codex configuration changed during handoff verification. ${recovery}`,
|
|
85
107
|
native_history_control_failed: `Codex could not create the native history checkpoint. ${recovery}`,
|
|
108
|
+
native_handoff_inject_failed: `The handoff summary could not be written into the new Codex thread. ${recovery}`,
|
|
109
|
+
fork_target_handoff_missing: `The new Codex thread does not prove it received the handoff summary for its encrypted history. ${recovery}`,
|
|
86
110
|
fork_readback_unverified: `The native fork could not be verified in Codex history. ${recovery}`,
|
|
87
111
|
fork_journal_owner_changed: `The transition owner changed during handoff. ${recovery}`,
|
|
88
112
|
};
|
|
89
|
-
return messages[reason] ||
|
|
113
|
+
return messages[reason] || historyRefusalFamilyMessage(reason, recovery)
|
|
114
|
+
|| `The target handoff could not be verified. ${recovery}`;
|
|
90
115
|
}
|
|
91
116
|
const messages = {
|
|
92
117
|
native_target_profile_unavailable: 'The native Codex model does not publish the context limits needed to verify this history move. The current session is still running unchanged.',
|
|
@@ -100,7 +125,6 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
|
|
|
100
125
|
// WP-2 (docs/codex-encrypted-history-lead-switch-2026-10-17.html §5/§8): the name stays; the copy
|
|
101
126
|
// now says what the user actually has (an encrypted summary) and what did NOT happen to them.
|
|
102
127
|
encrypted_history_not_portable: 'Earlier history exists only as an encrypted summary that the target model cannot read, so it cannot be carried over. The current session is still running unchanged.',
|
|
103
|
-
compacted_source_fork_unqualified: 'This source history was compacted, and its active checkpoint cannot yet be verified across providers. The current session is still running unchanged.',
|
|
104
128
|
source_compaction_unqualified: 'The source history is too large for the target, and safely compacting it is not supported yet. The current session is still running unchanged.',
|
|
105
129
|
history_estimate_unknown: 'The source history size could not be verified safely. The current session is still running unchanged.',
|
|
106
130
|
target_compaction_hash_unknown: 'The target profile cannot verify the source history compaction settings. The current session is still running unchanged.',
|
|
@@ -109,9 +133,41 @@ function historyPreparationMessage(reason, suppliedMessage = '', { postPark = fa
|
|
|
109
133
|
unpaired_tool_history: 'This history has an incomplete tool exchange, so it cannot be transferred safely. The current session is still running unchanged.',
|
|
110
134
|
source_origin_unknown: 'The source provider could not be verified. The current session is still running unchanged.',
|
|
111
135
|
source_origin_unsupported: 'This source provider has no qualified history handoff yet. The current session is still running unchanged.',
|
|
136
|
+
transition_cancelled: 'The history check was cancelled before it finished. The current session is still running unchanged.',
|
|
137
|
+
history_scan_incomplete: 'The source history scan did not finish, so nothing was moved. The current session is still running unchanged.',
|
|
138
|
+
source_checkpoint_unknown: 'The source history has no verified checkpoint to carry over. The current session is still running unchanged.',
|
|
139
|
+
source_previous_model_mismatch: 'The model recorded on the source history does not match the captured source model. The current session is still running unchanged.',
|
|
140
|
+
source_model_changed: 'The source model changed while the history was being prepared. The current session is still running unchanged.',
|
|
141
|
+
lead_source_changed: 'The source session this switch started from is no longer the live one. The current session is still running unchanged.',
|
|
142
|
+
provider_identity_unknown: 'The source or target provider for this history move could not be identified. The current session is still running unchanged.',
|
|
143
|
+
target_budget_unknown: 'The target model does not publish the context budget needed to carry this history over. The current session is still running unchanged.',
|
|
144
|
+
history_estimate_evidence_unknown: 'The source history size was measured, but not in a way that can be verified for this move. The current session is still running unchanged.',
|
|
145
|
+
compaction_hash_mismatch: 'The source and target history compaction settings disagree, so the move cannot be verified safely. The current session is still running unchanged.',
|
|
146
|
+
native_transition_policy_unknown: 'The Codex permission policy for the source or the target could not be read. The current session is still running unchanged.',
|
|
147
|
+
native_transition_policy_mismatch: 'The Codex permission policy on the target differs from the source, so the history cannot be moved safely. The current session is still running unchanged.',
|
|
148
|
+
native_fork_adapter_unqualified: 'This Codex installation or adapter is not qualified to carry this history over. The current session is still running unchanged.',
|
|
149
|
+
native_policy_override_unqualified: 'This source session uses a Codex policy override that cannot yet be reproduced safely for history transfer. The current session is still running unchanged.',
|
|
150
|
+
native_token_usage_record_invalid: 'The Codex usage record for this session could not be verified, so the history size cannot be trusted. The current session is still running unchanged.',
|
|
151
|
+
history_media_not_portable: 'This history contains an image, and the target model cannot receive images. The current session is still running unchanged.',
|
|
152
|
+
// WP-2 (docs/codex-lead-routed-switch-history-handoff-2026-10-21.html §5 D-5/D-6, §6.5): the
|
|
153
|
+
// encrypted-compaction path. The source model writes a plaintext handoff before the switch, and
|
|
154
|
+
// each way that handoff can fail says which one it was.
|
|
155
|
+
encrypted_compaction_multiple: 'This history holds more than one encrypted compaction, and only one of them can be replaced by a handoff summary. The current session is still running unchanged.',
|
|
156
|
+
encrypted_compaction_handoff_unavailable: 'This history exists only as an encrypted summary, and the model that wrote it cannot be asked to write a plaintext handoff for this provider change. The current session is still running unchanged.',
|
|
157
|
+
handoff_fork_failed: 'The handoff summary could not be started from this session\'s history. The current session is still running unchanged.',
|
|
158
|
+
handoff_turn_failed: 'The source model did not finish writing the handoff summary. The current session is still running unchanged.',
|
|
159
|
+
handoff_empty: 'The source model returned a handoff summary that is too short to carry this history over. The current session is still running unchanged.',
|
|
160
|
+
handoff_too_long: 'The source model returned a handoff summary that is too long to carry this history over. The current session is still running unchanged.',
|
|
161
|
+
handoff_marker_in_text: 'The handoff summary contains text reserved for this move, so it cannot be trusted. The current session is still running unchanged.',
|
|
162
|
+
handoff_timeout: 'The source model did not finish writing the handoff summary in time. The current session is still running unchanged.',
|
|
163
|
+
// Raised while the forked target is being written, but the code is enumerated from its own
|
|
164
|
+
// source file, so it carries a sentence on both paths rather than falling back to the generic one.
|
|
165
|
+
native_handoff_inject_failed: 'The handoff summary could not be written into the new Codex thread. The current session is still running unchanged.',
|
|
112
166
|
};
|
|
113
167
|
const safe = typeof suppliedMessage === 'string' ? suppliedMessage.trim().slice(0, 240) : '';
|
|
114
|
-
return messages[reason]
|
|
168
|
+
return messages[reason]
|
|
169
|
+
|| historyRefusalFamilyMessage(reason, 'The current session is still running unchanged.')
|
|
170
|
+
|| safe || 'History could not be verified for this provider change. The current session is still running unchanged.';
|
|
115
171
|
}
|
|
116
172
|
|
|
117
173
|
function refusal(status, code, extra) {
|
|
@@ -126,6 +126,33 @@ function parseFootprintJson(text) {
|
|
|
126
126
|
return results;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// WP-A P-1 (freeze-probes): the CATEGORIZED `footprint -j` document (no `--noCategories`) --
|
|
130
|
+
// { unit, 'bytes per unit', processes: [{ pid, footprint, categories: { name: { dirty, swapped, ... } } }] }.
|
|
131
|
+
// The 4<->9 GB server swing measured 2026-10-21 lives entirely in the "Malloc Large" category, which
|
|
132
|
+
// the --noCategories summary above cannot name. Pure; returns { totalMB, top: [{name,dirtyMB,swappedMB}] }
|
|
133
|
+
// for the FIRST process, null for unparseable input or a non-byte unit (same rules as parseFootprintJson).
|
|
134
|
+
function parseFootprintRegions(text) {
|
|
135
|
+
let doc;
|
|
136
|
+
try { doc = JSON.parse(String(text ?? '')); } catch { return null; } // not JSON at all (empty stdout on a failed run)
|
|
137
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null;
|
|
138
|
+
if (doc.unit !== 'byte') return null;
|
|
139
|
+
const bytesPerUnit = Number(doc['bytes per unit']) || 1;
|
|
140
|
+
const proc = (Array.isArray(doc.processes) ? doc.processes : []).find(entry => entry && typeof entry === 'object');
|
|
141
|
+
if (!proc) return null;
|
|
142
|
+
const mb = (bytes) => Math.round((bytes / 1048576) * 10) / 10;
|
|
143
|
+
const rows = Object.entries(proc.categories && typeof proc.categories === 'object' ? proc.categories : {})
|
|
144
|
+
.map(([name, region]) => {
|
|
145
|
+
const dirty = (Number(region?.dirty) || 0) * bytesPerUnit;
|
|
146
|
+
const swapped = (Number(region?.swapped) || 0) * bytesPerUnit;
|
|
147
|
+
return { name: String(name).slice(0, 40), dirtyMB: mb(dirty), swappedMB: mb(swapped), total: dirty + swapped };
|
|
148
|
+
})
|
|
149
|
+
.sort((a, b) => b.total - a.total);
|
|
150
|
+
return {
|
|
151
|
+
totalMB: Number.isFinite(proc.footprint) ? mb(proc.footprint * bytesPerUnit) : null,
|
|
152
|
+
top: rows.slice(0, 6).map(({ name, dirtyMB, swappedMB }) => ({ name, dirtyMB, swappedMB })),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
129
156
|
// Cleanup never turns an already-decided sample (number or D-3 null) into a rejected sweep.
|
|
130
157
|
function removeTempFileQuietly(file) {
|
|
131
158
|
return fs.promises.rm(file, { force: true }).then(() => true, () => false);
|
|
@@ -540,6 +567,26 @@ class SessionResourceSampler {
|
|
|
540
567
|
return results;
|
|
541
568
|
}
|
|
542
569
|
|
|
570
|
+
// WP-A P-1: ONE fresh categorized `/usr/bin/footprint -j <tmp> <pid>` for the MAIN pid, every call.
|
|
571
|
+
// Deliberately NOT the TTL-cached sampleFootprints() path: a stale cache entry cannot name the region
|
|
572
|
+
// that just grew. Same gate/tmp/limits/cleanup mechanics as _sampleFootprintBatch, minus the cache.
|
|
573
|
+
// Never rejects: null on non-darwin, on the primary-only gate, or on any failure. Categorized
|
|
574
|
+
// `footprint` does not suspend its target (measured 2026-10-21), unlike vmmap/sample/leaks.
|
|
575
|
+
async sampleFootprintRegions(pid) {
|
|
576
|
+
const key = Number(pid);
|
|
577
|
+
if (this.platform !== 'darwin' || !(key > 0)) return null;
|
|
578
|
+
if (this.exec === execFileAsync && !hostIdentityAccessAllowed({ env: this.env })) return null;
|
|
579
|
+
const tmp = path.join(os.tmpdir(), `ctm-footprint-regions-${process.pid}-${crypto.randomUUID()}.json`);
|
|
580
|
+
try {
|
|
581
|
+
await this.exec(FOOTPRINT_BIN, ['-j', tmp, String(key)], { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
582
|
+
return parseFootprintRegions(await fs.promises.readFile(tmp, 'utf8'));
|
|
583
|
+
} catch {
|
|
584
|
+
return null; // footprint missing/denied or temp file unreadable (D-3: null, never a throw)
|
|
585
|
+
} finally {
|
|
586
|
+
await removeTempFileQuietly(tmp);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
543
590
|
// D-1: sweep counterpart to sampleFootprints() (same batch and TTL cache), resolving to
|
|
544
591
|
// `{ footprintMB, sampledAt }` per pid so a consumer can tell how fresh an entry is.
|
|
545
592
|
async sampleFootprintSnapshots({ pids, now } = {}) {
|
|
@@ -558,6 +605,7 @@ module.exports = {
|
|
|
558
605
|
FOOTPRINT_SAMPLE_TTL_MS,
|
|
559
606
|
hostMemoryFromVmStat,
|
|
560
607
|
parseFootprintJson,
|
|
608
|
+
parseFootprintRegions,
|
|
561
609
|
parseMemoryPressure,
|
|
562
610
|
parsePageouts,
|
|
563
611
|
parseProcessTable,
|
|
@@ -11,7 +11,7 @@ const RESUMING_RESUME_PHASES = new Set(['preparing', 'spawning', 'loading', 'str
|
|
|
11
11
|
// projectSessionStatus(session) is a pure reducer over the fields server.js already writes
|
|
12
12
|
// today (`_lifecycleState`, `_resumeAttemptState.phase`, `_resumeBlocked`, `_pendingApproval`,
|
|
13
13
|
// `_pressureShed`) — see server.js's RESUME_PHASE enum (lib/resume-attempt-state.js) and the
|
|
14
|
-
// `_lifecycleState` values ('
|
|
14
|
+
// `_lifecycleState` values ('live'/'parking'/'parked'/'deferred'/'waking'/'degraded'/
|
|
15
15
|
// 'exited') for the source vocabulary this projects from.
|
|
16
16
|
//
|
|
17
17
|
// requestTransition(sessionId, to, cause) is the ONE gate for changing the FSM's *phase*.
|
|
@@ -48,7 +48,7 @@ function projectSessionStatus(session) {
|
|
|
48
48
|
else if (lifecycleState === 'parked' || lifecycleState === 'parking') phase = 'parked';
|
|
49
49
|
else if (lifecycleState === 'degraded' || resumePhase === 'blocked') phase = 'degraded';
|
|
50
50
|
else if (resumeBlocked || RESUMING_RESUME_PHASES.has(resumePhase)) phase = 'resuming';
|
|
51
|
-
else if (lifecycleState === 'waking' || lifecycleState === 'deferred'
|
|
51
|
+
else if (lifecycleState === 'waking' || lifecycleState === 'deferred') phase = 'attaching';
|
|
52
52
|
else if (lifecycleState === 'live' || (lifecycleState === '' && (resumePhase === 'ready' || resumePhase === ''))) phase = 'live';
|
|
53
53
|
else phase = 'detached';
|
|
54
54
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// WP-3 (composer-freeze-followups-load-sigstop-attribution-2026-10-20.html §5 D-C1/D-C2):
|
|
4
|
+
// READ THE STAGE NAMES HONESTLY. Every stage after `pty-written` EXCEPT `echo-bytes`/`echo-cell`
|
|
5
|
+
// is stamped on the session's whole pending id set by the FIRST output/feed/frame after the key
|
|
6
|
+
// -- "first X after the key", which is approximate, not causal (D-C1; the pty-output leg lands
|
|
7
|
+
// 0-3 ms after pty-written in 14 of 18 retained slow rows, which no agent CLI can do).
|
|
8
|
+
// `echo-bytes`/`echo-cell` are the only per-key matches today, and they are shadow-only
|
|
9
|
+
// instrumentation for WP-4/WP-5's decision (D-C2) -- nothing reads them as truth yet.
|
|
3
10
|
const STAGES = Object.freeze([
|
|
4
11
|
'browser-keydown',
|
|
5
12
|
'server-received',
|
|
@@ -11,6 +18,11 @@ const STAGES = Object.freeze([
|
|
|
11
18
|
'host-posted',
|
|
12
19
|
'pty-written',
|
|
13
20
|
'pty-output',
|
|
21
|
+
// WP-3 D-C2: the two shadow, per-key echo matchers -- VS Code typeahead's byte rule and
|
|
22
|
+
// mosh's cell rule (see lib/typing-worker-stages.js, which runs them in the mirror). Both are
|
|
23
|
+
// OPTIONAL (OPTIONAL_STAGES below), so neither can move lastCompletedBoundary.
|
|
24
|
+
'echo-bytes',
|
|
25
|
+
'echo-cell',
|
|
14
26
|
// WP-0 (composer-freeze-stop-and-wait-emitter-2026-09-16.html §5 D-1, §6 API specs, §8 WP-0):
|
|
15
27
|
// four stages stamped INSIDE the worker/pool-host, between `pty-output` and `mirror-revision`
|
|
16
28
|
// -- the 16.1 s gap the census found with zero instrumentation coverage (§1.5). All four are
|
|
@@ -52,7 +64,11 @@ const STAGE_INDEX = new Map(STAGES.map((stage, index) => [stage, index]));
|
|
|
52
64
|
// D-1: stages that may be absent from an otherwise-complete row without that absence meaning
|
|
53
65
|
// "the interaction stalled here" -- the sequential boundary walk in note() skips these rather
|
|
54
66
|
// than treating a miss as the end of the row's progress (see the walk below).
|
|
55
|
-
|
|
67
|
+
// WP-3 D-C2: 'echo-bytes'/'echo-cell' join them -- they are shadow stamps most keys never get.
|
|
68
|
+
const OPTIONAL_STAGES = new Set([
|
|
69
|
+
'pty-output', 'worker-received', 'mirror-written', 'emit-offered', 'emit-refused',
|
|
70
|
+
'echo-bytes', 'echo-cell',
|
|
71
|
+
]);
|
|
56
72
|
const WORKLOAD_FIELDS = Object.freeze([
|
|
57
73
|
'rawBatchClass', 'synchronizedFrameCount', 'mirrorChangedRows', 'frameKind',
|
|
58
74
|
'frameBytes', 'queuedBytes', 'queueDepth', 'codexRepairActions',
|
|
@@ -99,6 +115,23 @@ function stageMsFromKeydown(row, stage) {
|
|
|
99
115
|
return stageDelta(row, stage, 'browser-keydown');
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
// WP-3 D-C3: an expired row must not report an OPTIONAL stage as "where it got to" -- the
|
|
119
|
+
// reverse scan it used to do picked `pty-output` over `pty-written` for a key that stalled
|
|
120
|
+
// after a stray output chunk (I-6 / R9). `lastObservedStage` is therefore the last observed
|
|
121
|
+
// REQUIRED stage (fallback `browser-keydown`), and the last observed OPTIONAL stage is reported
|
|
122
|
+
// separately as `lastOptionalStage` ('' when there is none) so the shadow echo stamps stay
|
|
123
|
+
// readable without ever being mistaken for progress.
|
|
124
|
+
function observedStageSplit(row) {
|
|
125
|
+
let lastObservedStage = 'browser-keydown';
|
|
126
|
+
let lastOptionalStage = '';
|
|
127
|
+
for (const stage of STAGES) {
|
|
128
|
+
if (!row.stages[stage]) continue;
|
|
129
|
+
if (OPTIONAL_STAGES.has(stage)) lastOptionalStage = stage;
|
|
130
|
+
else lastObservedStage = stage;
|
|
131
|
+
}
|
|
132
|
+
return { lastObservedStage, lastOptionalStage };
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
// D-7/S-14: builds the flat, ≤24-key `typing-interaction-slow` payload from the SAME stage
|
|
103
136
|
// vector `note()`/`begin()` already maintain -- no second clock, no parallel bookkeeping.
|
|
104
137
|
// `lastStage` is passed explicitly by the two callers below rather than read from
|
|
@@ -118,6 +151,10 @@ function buildSlowInteractionRow(row, ktpMs, lastStage) {
|
|
|
118
151
|
printable: row.printable === true,
|
|
119
152
|
ktpMs: Math.max(0, finite(ktpMs, 0)),
|
|
120
153
|
lastStage: lastStage || row.lastCompletedBoundary || 'browser-keydown',
|
|
154
|
+
// WP-3 D-C3: the last OPTIONAL stage observed for this row ('' when none) -- carried on
|
|
155
|
+
// every slow/worst vector so `lastStage` can stay required-only without losing the shadow
|
|
156
|
+
// echo stamps.
|
|
157
|
+
lastOptionalStage: observedStageSplit(row).lastOptionalStage,
|
|
121
158
|
dropReason: dropped ? boundedText(dropped.reason, 64) : '',
|
|
122
159
|
gate: refused ? refused.gate : '',
|
|
123
160
|
gateTop: refused ? refused.gate : '',
|
|
@@ -131,6 +168,9 @@ function buildSlowInteractionRow(row, ktpMs, lastStage) {
|
|
|
131
168
|
serverReceivedMs: stageMsFromKeydown(row, 'server-received'),
|
|
132
169
|
ptyWrittenMs: stageMsFromKeydown(row, 'pty-written'),
|
|
133
170
|
ptyOutputMs: stageMsFromKeydown(row, 'pty-output'),
|
|
171
|
+
// WP-3 D-C2: the shadow per-key matchers' legs, same keydown rule as ptyOutputMs above.
|
|
172
|
+
echoBytesMs: stageMsFromKeydown(row, 'echo-bytes'),
|
|
173
|
+
echoCellMs: stageMsFromKeydown(row, 'echo-cell'),
|
|
134
174
|
mirrorRevisionMs: stageMsFromKeydown(row, 'mirror-revision'),
|
|
135
175
|
frameSentMs: stageMsFromKeydown(row, 'frame-sent'),
|
|
136
176
|
socketReceivedMs: stageMsFromKeydown(row, 'socket-received'),
|
|
@@ -302,12 +342,14 @@ function createTypingInteractionTraceStore(options = {}) {
|
|
|
302
342
|
|
|
303
343
|
function captureExpiredInteraction(row, at) {
|
|
304
344
|
if (row.stages.painted) return;
|
|
305
|
-
|
|
345
|
+
// WP-3 D-C3: required-only `lastObservedStage` + a separate `lastOptionalStage` (I-6/R9).
|
|
346
|
+
const { lastObservedStage, lastOptionalStage } = observedStageSplit(row);
|
|
306
347
|
const ageMs = Math.max(0, at - row.startedAt);
|
|
307
348
|
pushBounded(pendingExpired, rollupGroupKey(row.sessionId, row.agentType), {
|
|
308
349
|
ageMs,
|
|
309
350
|
clientId: row.clientId,
|
|
310
351
|
lastObservedStage,
|
|
352
|
+
lastOptionalStage,
|
|
311
353
|
});
|
|
312
354
|
windowExpiredHistory.push({ at });
|
|
313
355
|
if (windowExpiredHistory.length > WINDOW_HISTORY_CAP) windowExpiredHistory.shift();
|