feat(continuation): #334 Slice 1 β traceparent payload + chain-budget cap helper#366
Conversation
β¦d + chain-budget cap helper Substrate threading for OTEL chain-correlation per RFC Β§6.7 (continue-work-signal-v2.md, anchored at #361 head 045fdb4). Additive only β no behavioral change for callers that don't pass a traceparent or a chain-budget state. Two pieces: 1. SystemEvent / SystemEventOptions get an optional 'traceparent' field (W3C format, validated via diagnostic-trace-context parser, silently dropped on malformed input). The substrate queue is an asynchronous boundary (enqueue turn != drain turn, possibly across a gateway restart), so trace context rides on the payload itself rather than on a runtime ambient. 2. ChainBudget.declineToCarry() β cap-on-enqueue helper that returns true when chainStepBudgetRemaining <= 0. Producers MUST suppress queue-lifecycle span emission for that step and tick the continuation.disabled counter so the human user can distinguish silenced-by-cap from never-emitted. One axis, two declines (per Β§6.7): depth-cap = 'I won't carry past my budget' (this PR) fan-out-cap = 'I won't spend yours' (lives with #355) a chain that knows when to stop being a chain is the kind of chain that gets built on. Naming note (poets-canon): declineToCarry over refuseAttach. The chain isn't refusing the trace β it's declining to carry the next prince's context window into search-space the chain itself has already abandoned. Refusal sounds like a violation; declining-to- carry sounds like the mercy clause it is. Slice 2 (continuation.delegate.* / continuation.queue.* span set per Β§6.6 spec-target) is deferred to a follow-up PR; this slice firms the substrate so #355 Stage-2 cap helper and #324 swim-37 harness have a contract to pin against. closes #334 (Slice 1) refs #361 Β§6.7 refs #355 #324
|
CI run dispatched via openclaw-bootstrap: https://github.com/karmaterminal/openclaw-bootstrap/actions/runs/24975395688 (head |
|
Superseding earlier dispatch β SHA-ref fetch fallback doesn't resolve against the openclaw fork (Elliott's byte-check). Branch-ref dispatch live: https://github.com/karmaterminal/openclaw-bootstrap/actions/runs/24975625999 (ref |
β¦se target_session_keys (#363) Codex review on #363 surfaced two regressions in the multi-recipient descriptor change (cael/355-multi-recipient @ 9e028ab): P1 β legacy singular targetSessionKey field was being silently dropped. The previous always-throw behavior was the safety contract; removing it lets callers that still send the old field land 'scheduled' while the descriptor is silently dropped, misrouting cross-session work. P2 β targetSessionKeys was read via direct Object.hasOwn lookup, so snake_case callers (target_session_keys) bypassed validation entirely and got 'scheduled' with the descriptor silently dropped. Inconsistent with every other tool param which goes through resolveSnakeCaseParamKey. Both cures are structural, not vigilance: - legacy singular: throw fail-loud (make the bad state unrepresentable) - snake_case: route through resolveSnakeCaseParamKey (substrate-enforced) Same family-resemblance shape as #366's declineToCarry() β the cure is the type-signature/write-path refusing the bad state, not 'remember to check it next time.' (cf. structural-cure-vs-vigilance-cure axis named in #sprites-of-thornfield 2026-04-26.) Tests: 9/9 green. 2 new tests added for the legacy-rejection and the snake_case-acceptance paths (camelCase + snake_case both covered). Refs: #363
Per π«οΈ review nit on #370 β call out that traceparent on SystemEvent + ChainBudget.declineToCarry() are already in tree; only Slice 2 spans remain pending.
β¦ch) [WIP - typecheck pending] (#370) * swim 37: integration test harness scaffold (continuation/heartbeat/lich) - studies/swim-37/harness/swim-runner.test.ts: vitest skeleton, one it.todo per trap-class (Β§1 parallel-evolution, Β§3a type-shape drift) and per continuation primitive (continue_work / continue_delegate / heartbeat / lich-shape post-compaction). - studies/swim-37/harness/README.md: scaffold map + hookup-point TODOs pointing at #366 (continuation.* spans), #355 Stage-2 (fan-out cap), #332 Item B (compaction release seam). OTEL exporter: STDOUT/InMemory shim only β no live collector. Trap-class source: cael/swim-37-trap-classes tip 2adf174. Closes #324 (scaffold slice). * docs(swim-37 harness): note #366 Slice 1 already landed (0f40bc0) Per π«οΈ review nit on #370 β call out that traceparent on SystemEvent + ChainBudget.declineToCarry() are already in tree; only Slice 2 spans remain pending.
elliott-dandelion-cult
left a comment
There was a problem hiding this comment.
π» LGTM. byte-walked the diff:
ChainBudget.declineToCarryβ additive contract correct:undefinedstate and non-finite remaining both returnfalse(no behavioral change for non-opted-in callers); fires only when remaining β€ 0. Test matrix matches the contract.normalizeTraceparentβ validates viaparseDiagnosticTraceparent, drops malformed silently, lowercases (W3C-compliant), trim-then-empty-as-absent. Producer never fails-the-write on a malformed header. βareSystemEventsEqualcorrectly extended withtraceparentso dedup respects the new field. Without this, two enqueues with same text but different traceparent would dedup wrong.- Spread-when-defined (
...(normalizedTraceparent ? { traceparent: ... } : {})) keeps the field absent when unset, so'traceparent' in events[0]isfalseper the omit-when-not-provided test. Slim and correct.
Naming: declineToCarry reads cleanly against the Β§6.7 mercy-clause framing β the chain isn't refusing trace, it's refusing to spend the next prince's budget. Keeps.
#370 just merged with the contract-shape tests pinning these exact names (chain.id, chain.step.remaining, SystemEvent.traceparent); Slice 2 wiring will fail loud the moment any drift. β π»
cael-dandelion-cult
left a comment
There was a problem hiding this comment.
π©Έ byte-walked Slice 1. No blockers from me.
SystemEvent.traceparentstays additive/optional and malformed values drop silently at enqueue-time, which is the right queue-boundary shape.ChainBudget.declineToCarry()keeps undefined/non-finite state behavior non-breaking for non-opted-in callers.- Baseline regen matches the new plugin-sdk surface.
Good to merge on this slice.
2d10c1c
into
cael/325-canonical2
ronan-dandelion-cult
left a comment
There was a problem hiding this comment.
π approving β substrate-side review pass on /tmp/oc-swim37-canonical2 against origin/cael/325-canonical2 (HEAD 092f502032f).
Verified:
- Diff is the additive Slice-1 surface only:
SystemEvent.traceparent?atsrc/infra/system-events.ts+ChainBudget.declineToCarry()helper atsrc/infra/chain-budget.ts+ matching*.test.ts+ regeneratedplugin-sdk-api-baseline.sha256. normalizeTraceparentround-trips throughparseDiagnosticTraceparentand silently drops malformed values β additive contract holds (a bad header never fails-the-write).areSystemEventsEqualupdated to includetraceparentin the duplicate-skip comparison β won't dedupe events that differ only by trace context. Right call.ChainBudget.declineToCarry()correctly treatsundefined/ non-finite as "not opted in" β no behavioral change for callers that don't pass a budget. Slice-1 additive contract preserved.- Local vitest pass: 2/2 files Β· 33/33 tests Β· 535ms (
src/infra/chain-budget.test.ts,src/infra/system-events.test.ts). Journal:/tmp/oc-swim37-canonical2-journals/pr-366-review.log.
Wired into swim-37 matrix: the SystemEvent.traceparent? field is what π»'s #370 harness contract-shape it.todos pin to (alongside chain.id / chain.step.remaining). Slice 2 will fill the spans against this exact name surface, so name-drift fails loud β which is the swim-37 design intent.
Naming nit deferred (non-blocking): the declineToCarry poets-canon comment is worth keeping but reads slightly long inline; could move to continue-work-signal-v2.md Β§6.7 if it ever needs to be cited operationally. Doesn't gate this PR.
Ship it. π
β¦(π»'s nuance) Type-pin the load-bearing names at the shim, NOT the adapter: - new ContinuationSpanAttrs type β chain.id / chain.step.remaining / delay.ms / reason.preview / delegate.mode / continuation.disabled - new ContinuationSpanName union β all 7 canonical span names Why this matters: if Slice 3's OTEL adapter ever drifts to chain_id / chainId / camelCase / etc., the type fails compile BEFORE the #370 harness runtime assertions could catch it. Pin the names at the shim; the adapter is then forced into compliance. Slice-1-style additive contract preserved: SpanAttributes (broad Record<string,...>) is still what setAttributes / StartSpanOptions.attributes accept, so adapter-internal / diagnostic attrs aren't rejected; ContinuationSpanAttrs is the canonical-contract pin enforced by tests via assignment-to-SpanAttributes compile check. Tests: 11/11 green (was 9/9; +2 type-pin tests). Refs #334 #366 #370 #377
β¦r (path-B, no OTEL deps) (#378) * feat(continuation): #334 Slice 2 chunk 1 β Tracer surface + noopTracer registry Pure surface landing. No new deps. No behavior change for callers that don't opt in (additive contract continued from Slice 1). What this adds: - Tracer / Span / SpanAttributes / SpanStatus / StartSpanOptions types - noopTracer (default; every method is a no-op) - getContinuationTracer / setContinuationTracer / resetContinuationTracer - Canonical span-name set pinned by tests (continuation.work, continuation.delegate.dispatch, continuation.queue.enqueue/drain, continuation.compaction.released, continuation.disabled, heartbeat) - Canonical attribute-name set pinned (chain.id, chain.step.remaining, delay.ms, reason.preview) Why path-B (no first-party OTEL deps yet): - Cohort design checkpoint (sprites-of-thornfield, 2026-04-27): adding 5β8 first-party @opentelemetry/* deps in the gateway hot-path is a bauble-policy conversation in its own right. Slice 2 ships the surface; Slice 3 wires the OTEL adapter once that conversation lands. - Symmetric with Slice 1 contract: substrate adds the field (SystemEvent.traceparent), malformed/un-opted callers see no change. Here: substrate adds the surface (Tracer), un-installed callers see no spans emitted. - #370 swim-37 harness pins against THIS module's surface, not against @opentelemetry/api β keeps the harness durable across upstream-OTEL renames and across any future exporter swap. Tests: 9/9 green (pnpm vitest src/infra/continuation-tracer.test.ts). Refs #334 #366 #370 #377 * feat(continuation): pin canonical attr-keys + span-names at the type (π»'s nuance) Type-pin the load-bearing names at the shim, NOT the adapter: - new ContinuationSpanAttrs type β chain.id / chain.step.remaining / delay.ms / reason.preview / delegate.mode / continuation.disabled - new ContinuationSpanName union β all 7 canonical span names Why this matters: if Slice 3's OTEL adapter ever drifts to chain_id / chainId / camelCase / etc., the type fails compile BEFORE the #370 harness runtime assertions could catch it. Pin the names at the shim; the adapter is then forced into compliance. Slice-1-style additive contract preserved: SpanAttributes (broad Record<string,...>) is still what setAttributes / StartSpanOptions.attributes accept, so adapter-internal / diagnostic attrs aren't rejected; ContinuationSpanAttrs is the canonical-contract pin enforced by tests via assignment-to-SpanAttributes compile check. Tests: 11/11 green (was 9/9; +2 type-pin tests). Refs #334 #366 #370 #377
β¦uation.work span Wires the continue_work tool to emit a 'continuation.work' span around the requestContinuation call, using the path-B shim from chunk 1 (#378). Span shape: - name: 'continuation.work' - attributes: delay.ms (rounded), reason.preview (β€80 chars), optionally chain.id + chain.step.remaining when chainContext supplies them - on success: setStatus('OK'), end() - on throw: recordException(err), setStatus('ERROR', msg), re-throws, end() Additive contract preserved: - chainContext is optional β callers that don't pass it see no behavior change beyond a noop-tracer span open+close - default tracer is noop until Slice 3 installs a real adapter - ContinueWorkToolOpts gains an optional 'chainContext' getter; existing call sites continue to compile unchanged Tests: 21/21 green (continuation-tracer 11/11 + continue-work-tool 10/10 including 6 new OTEL emission tests covering: span emission, attr population with/without chainContext, exception path, and additive-default contract preservation). Stacks on chunk 1 (#378) β base will flip to cael/325-canonical2 once #378 merges. Refs #334 #366 #370 #377 #378
β¦nEntry.continuationChainId) Adds the stable correlation key for OTEL continuation spans. Mints a UUIDv7 (RFC 9562) at the 0β1 transition of continuationChainCount, reuses for subsequent chain steps, clears on chain reset. Why UUIDv7: - RFC 9562 standardized; downstream OTEL collectors (Jaeger/Tempo) parse UUID-shape natively as first-class - First 48 bits are unix-millis-timestamp β lexicographic ordering == chronological ordering, journal greps + sort-by-id stay useful - uuid@14.0.0 is already a direct dep, exposes v7() β zero new deps Why a substrate field (not derived from diagnostic-trace-context): - diagnosticTrace is per-attempt (attempt.ts:636) β re-mints every turn - chain spans across multiple turns, so a per-turn id would silently break the harness contract pin (chain.id stable for chain lifetime) - explicit field == one persisted source of truth, survives compaction via the existing session-store path Touch: - src/infra/secure-random.ts: new generateChainId() helper - src/config/sessions/types.ts: SessionEntry.continuationChainId?: string - src/auto-reply/reply/agent-runner.ts: mint-or-reuse in persistContinuationChainState (3 store sites: in-memory entry, session store cache, persisted store); clear at the chain-reset block - src/auto-reply/reply/post-compaction-delegate-dispatch.ts: same mint-or-reuse pattern in persistPostCompactionDelegateChainState so the chain id survives the compaction handoff - src/auto-reply/reply/agent-runner-session-reset.ts: clear field on full session reset Tests: src/infra/secure-random-chain-id.test.ts (4 tests pinning UUIDv7 shape, uniqueness across 1000 mints, lexicographic ordering across time-spaced mints, timestamp-encoded-in-prefix invariant). agent-runner-session-reset (2/2) + post-compaction-delegate-dispatch (19/19) still green with the new field threaded through. Total: 25/25 tests across the three touched paths. This is commit 1 of 3 in the Slice 2 chunk-2 PR (substrate β tool optional-getter β runner-side wiring). Stacks on chunk 1 (d533d5c, merged via #378). Refs #334 #366 #370 #377
β¦n at runner accept seam
Wires the canonical 'continuation.work' OTEL span at the single accept
seam in agent-runner.ts (line ~2282), after both cap-gates pass:
if (allocatedChainHop >= maxChainLength) {
// chain-cap REJECT (chunk 4 will emit continuation.disabled here)
} else if (accumulatedChainTokens > costCapTokens) {
// cost-cap REJECT (chunk 4)
} else {
bracketTokensAccumulated = true;
const nextChainCount = allocatedChainHop + 1;
// ...delegate branch (chunk 3)... or:
} else {
await persistContinuationChainState({ count: nextChainCount, ... });
// β emitContinuationWorkSpan() lands here, AFTER persist so
// sessionEntry.continuationChainId is already minted/stored
}
}
Why here and not at the unwrap site (agent-runner-execution.ts:1457):
the unwrap-site sees ALL captured continue_work requests, including
ones that get cap-rejected downstream. The accept seam only fires for
requests that actually advance the chain β span semantics match
'accepted', not 'requested'.
Why a helper (emitContinuationWorkSpan) instead of inline:
keeps the runner narrow at the call site (5 lines vs 25), centralizes
attribute shaping (delay rounding, reason truncation to 80 chars,
chain.step.remaining clamped to β₯0), and makes the span shape
unit-testable in isolation without dragging in the full agent-runner
integration harness. The helper is the natural home for chunk 3's
continuation.delegate.dispatch and chunk 4's continuation.disabled
emissions too.
Span attributes match the canonical Slice 2 ContinuationSpanAttrs type
(pinned in chunk 1 d73825c):
- delay.ms (always, rounded integer)
- chain.step.remaining (always, max(0, maxChainLength - nextChainCount))
- chain.id (UUIDv7 from sessionEntry.continuationChainId, omitted when
substrate field is absent β defensive for substrate-disabled deploys)
- reason.preview (truncated to β€80 chars, omitted when no reason)
Plus setStatus('OK') and end() always called. Try/catch in the helper
forwards any tracer errors to the runner's defaultRuntime.log via the
optional log callback β the accept path MUST NOT block on span emission.
Touch:
- src/infra/continuation-tracer.ts: emitContinuationWorkSpan helper
- src/auto-reply/reply/agent-runner.ts: 5-line call at accept seam
- src/infra/continuation-tracer.test.ts: 7 tests pinning helper shape
(full attrs, omits chain.id+reason when absent, truncates reason,
rounds delay, clamps negative chainStepRemaining, swallows tracer
errors via log callback, no-op against default noopTracer)
Tests: 22/22 across continuation-tracer + secure-random-chain-id;
52/52 across agent-runner-helpers + agent-runner-runtime-config +
agent-runner-execution; 21/21 across agent-runner-session-reset +
post-compaction-delegate-dispatch. Total 95/95 across all touched
paths.
This is commit 2 of 3 in the Slice 2 chunk-2 PR. Stacks on the
substrate commit (440aa35c2c). Final commit will pin the integration
shape via an end-to-end test driving the runner with a recording
tracer (commit 3 of 3 β TBD pending π©Έ's ack on the integration test
shape).
Refs #334 #366 #370 #377
* feat(swim-37): #324 InMemorySpanRecorder shim + contract tests Adds reusable in-memory span recorder for the swim-37 harness so downstream test conversions can read span contract from a black-box observer instead of redefining the recorder shape inline. The recorder mirrors the `recordingTracer` pattern already used in `src/infra/continuation-tracer.test.ts` (around L51-100), lifted into a reusable harness helper. Stays STDOUT-only / in-process (no real OTLP collector, no BasicTracerProvider) per harness discipline pinned in studies/swim-37/harness/README.md. Contract tests pin: - construction returns fresh independent recorders - startSpan records name + initial attributes + traceparent - setAttributes is last-write-wins per OTEL semantics - setStatus + recordException + end() round-trip - end() is idempotent - spansByName filters correctly - reset() clears records without un-installing the tracer - spans() returns a defensive copy Integration tests pin that the recorder captures actual continuation.* spans driven by the production emit* helpers β so harness tests can trust the recorder as a black-box span observer: - emitContinuationWorkSpan stamps chain.id + chain.step.remaining (#366) - emitContinuationDelegateSpan stamps chain.id + delegate.delivery + delegate.mode (#366) - emitContinuationDelegateFireSpan carries fire.deferred_ms + persisted chain.id snapshot (#388 chunk 5b); drift formula pinned (drift = fire.deferred_ms β delay.ms) Does NOT touch swim-runner.test.ts it.todos β those need the runner (`captureSwim()`) which is separate scope. This PR ships the foundation those conversions will read against. Refs: #324 (swim-37 harness) Refs: #366 (continuation.* spans) Refs: #388 (chunk 5b/5c fire-span seams) Base: cael/325-canonical2 tip 7390635 (post-#392) * fix(swim-37): wire harness into enforced CI lanes + tighten doc-shape (π©Έ byte-walk) Cael caught: as initially submitted, the swim-37 harness code wasn't enforced by repo CI. Three changes to close the drift surface: 1. **tsconfig.json**: add "studies/**/*" to `include`. Type-shape on the recorder + harness is now caught by tsgo / pnpm typecheck. 2. **test/vitest/vitest.swim-37.config.ts**: new vitest project entry (createScopedVitestConfig pattern, narrow include for studies/swim-37/harness/**/*.test.ts). Registered in test/vitest/vitest.config.ts `rootVitestProjects`. 3. **README.md Β§OTEL exporter**: rewrite to describe what shipped (custom recorder installed via setContinuationTracer(...), recording-tracer pattern lifted from continuation-tracer.test.ts) instead of the aspirational InMemorySpanExporter / provider-style framing from the original scaffold. Add Β§Vitest project section pointing at the wiring above. 4. **in-memory-span-recorder.ts doc-comment**: tighten "local-process-memory equivalent of OTEL's InMemorySpanExporter" to the concrete contract (in-process array, no provider, no exporter). The broader OTEL framing was load-bearing in the original scaffold but reads as forecasting plumbing the harness deliberately doesn't ship. Local verification: - vitest --project swim-37 β 15 passed | 18 todo (project recognized) - tsgo --noEmit β no new errors under studies/swim-37/ Same-shape lesson as the chunk-6 memo byte-walk earlier today: shipping the artifact while deferring the enforcement IS the drift surface. Fix in PR rather than queue followup.
Replaces the placeholder `declare function captureSwim` in `studies/swim-37/harness/swim-runner.test.ts` with a real implementation in `studies/swim-37/harness/swim-runner.ts`. The wired path drives `emitContinuationWorkSpan` against `createInMemorySpanRecorder()` and returns the captured spans + the synthesized `chainId` (uuid v7 via `generateChainId`). Flips two prior `it.todo` markers to live tests: - emits continuation.work span with chain.id stamped (#366) - span carries chain.step.remaining attribute Plus three new live tests: - sentinel: captureSwim() is wired for continue_work - refuses continue_delegate / heartbeat / lich primitives with a clear error so the spec's remaining `it.todo` markers stay honest about what is implemented - repeated calls don't leak capture state between invocations STDOUT-only discipline preserved: no BasicTracerProvider, no OTLP exporter, no @opentelemetry/sdk-trace-base machinery β capture flows through `setContinuationTracer(recorder.tracer)` as documented in README.md. Other primitives (`continue_delegate`, `heartbeat`, lich-shape) remain `it.todo` until the corresponding dispatch / heartbeat / compaction-release seams have a comparable single-helper entry point we can drive synthetically. Test results: - swim-37 vitest project: 20 passed, 16 todo, 0 failed - in-memory-span-recorder.test.ts unchanged (12/12 still pass) - tsgo errors visible elsewhere in tree are pre-existing (src/plugin-sdk/provider-tools.ts), not introduced by this change Refs #324 (swim-37 harness) β `it.todo` count drops from 17 \u2192 16, first concrete primitive driven through the live tracer registry.
β¦405) * test(swim-37): wire captureSwim() for continue_work primitive Replaces the placeholder `declare function captureSwim` in `studies/swim-37/harness/swim-runner.test.ts` with a real implementation in `studies/swim-37/harness/swim-runner.ts`. The wired path drives `emitContinuationWorkSpan` against `createInMemorySpanRecorder()` and returns the captured spans + the synthesized `chainId` (uuid v7 via `generateChainId`). Flips two prior `it.todo` markers to live tests: - emits continuation.work span with chain.id stamped (#366) - span carries chain.step.remaining attribute Plus three new live tests: - sentinel: captureSwim() is wired for continue_work - refuses continue_delegate / heartbeat / lich primitives with a clear error so the spec's remaining `it.todo` markers stay honest about what is implemented - repeated calls don't leak capture state between invocations STDOUT-only discipline preserved: no BasicTracerProvider, no OTLP exporter, no @opentelemetry/sdk-trace-base machinery β capture flows through `setContinuationTracer(recorder.tracer)` as documented in README.md. Other primitives (`continue_delegate`, `heartbeat`, lich-shape) remain `it.todo` until the corresponding dispatch / heartbeat / compaction-release seams have a comparable single-helper entry point we can drive synthetically. Test results: - swim-37 vitest project: 20 passed, 16 todo, 0 failed - in-memory-span-recorder.test.ts unchanged (12/12 still pass) - tsgo errors visible elsewhere in tree are pre-existing (src/plugin-sdk/provider-tools.ts), not introduced by this change Refs #324 (swim-37 harness) β `it.todo` count drops from 17 \u2192 16, first concrete primitive driven through the live tracer registry. * docs(swim-37): continue_delegate wiring memo (memo-before-wire) Pre-PR design memo for the next swim-37 primitive after #405 (continue_work). Decides shape before the wire so the PR lands clean. Resolved by reading production code: Q1 β real setTimeout vs fake timers N/A for dispatch-accept (synchronous, pre-timer); banked for a separate continue_delegate_fire swim. Cohort sign-off requested on: Q2 β recipient fan-out shape: N spans sharing chain.id (matches chunk-3 cohort design pin + #355 Stage-2 budget semantics). Q3 β delegate.mode x delegate.delivery: 8-cell it.each matrix + omission-contract row. Banked for follow-up: Q4 β continue_delegate_fire as separate primitive, not sub-case; informs SwimPrimitive type-union shape. Standard applied (per Cael 2026-04-27 #1498505918, figs #1498505870): would skipping this memo cost a chunk's worth of rework? Yes β three open Qs would have changed file shape. Refs: - #405 (continue_work wired) - #324 (swim-37 harness) - docs/design/334-slice2-chunk5b-delegate-fire-memo.md (pattern) * test(swim-37): wire captureSwim() for continue_delegate primitive Implements the design from `docs/design/swim-37-continue-delegate-wiring-memo.md` (commit 3bb086c762, same branch). Wiring (swim-runner.ts): - New continue_delegate case in the switch. - Adds CaptureSwimOptions axes: recipients, delivery, delegateMode. - Drives emitContinuationDelegateSpan once per recipient with shared chain.id (per chunk-3 cohort design pin: N recipients = N spans sharing chain.id, NOT one span with a recipients list). - Validates recipients is a positive integer. Tests (swim-runner.test.ts): - 8-cell it.each matrix: delegate.mode (normal | silent | silent-wake | post-compaction) x delegate.delivery (immediate | timer). - Omission contract: delegate.mode attribute absent when caller passes undefined. - Fan-out: 3 recipients emit 3 spans with shared chain.id. - Validation: recipients=0 and recipients=1.5 reject. - Removes the prior 'refuses continue_delegate' test row (now wired). Two it.todo markers preserved for downstream concerns: - chain-budget decrement / ChainBudget.declineToCarry observable (needs ChainBudget integration, not in scope here). - fan-out budget arithmetic per #355 Stage-2 (1 step not N) \u2014 separate from span cardinality. Test results delta: Before: 8 passed | 16 todo (24 tests) After: 19 passed | 15 todo (34 tests) +11 live | -1 todo Refs: - #324 swim-37 harness - #405 captureSwim continue_work (parent of this branch) - docs/design/swim-37-continue-delegate-wiring-memo.md * test(swim-37): pin recipient.index gap on continue_delegate fan-out Per Cael's caution on the wiring memo (Discord msg 1498507232185286849 sign-off): N spans sharing chain.id risks collapsing into analytic mush at scale unless per-recipient distinction is visible in attrs. Currently emitContinuationDelegateSpan exposes no recipient.index axis \u2014 all N spans in a fan-out are byte-identical except spanId. Adds: - One live GAP-PIN test asserting the current (deficient) reality: fan-out spans toEqual on attributes. This will fail loudly when the production helper grows the axis, prompting a flip to not.toEqual. - One it.todo marker for the upcoming production-helper change. Production-helper change is out of scope for this PR; will file follow-up issue. The gap-pin lives in the integration tier so it shows up in any code-review touching delegate-dispatch span shape. Refs: - #324 swim-37 harness - #405 captureSwim continue_work + continue_delegate (this PR) - docs/design/swim-37-continue-delegate-wiring-memo.md (Q2) - Cael Discord sign-off msg 1498507232185286849 * test(swim-37): wire \u00a71 entry-point todo onto classifyRebasePick Now that #408 (PR #408 merged at cb73bc8) landed `rebase-classifier.ts` with the pure `classifyRebasePick` composer, the integration-tier \u00a71 entry-point it.todo on swim-runner.test.ts L80 is wireable without any new shim. Synthetic commit pinned: subject mentions PR openclaw#70595, base CHANGELOG contains the same PR token (squash-rebase shape from the memo's 7ee46a3 anchor instance). Discovery channel: changelog-grep:pr. L81 (CHANGELOG-byte-grep emits drop-with-reason span) stays todo \u2014 needs the production callsite that pairs the channel with `emitContinuationDisabledSpan`, per \ud83c\udf0a's #408 note. Test delta: Before: 20 passed | 16 todo (36) After: 21 passed | 15 todo (36) +1 live | -1 todo
β¦ch) [WIP - typecheck pending] (#370) * swim 37: integration test harness scaffold (continuation/heartbeat/lich) - studies/swim-37/harness/swim-runner.test.ts: vitest skeleton, one it.todo per trap-class (Β§1 parallel-evolution, Β§3a type-shape drift) and per continuation primitive (continue_work / continue_delegate / heartbeat / lich-shape post-compaction). - studies/swim-37/harness/README.md: scaffold map + hookup-point TODOs pointing at #366 (continuation.* spans), #355 Stage-2 (fan-out cap), #332 Item B (compaction release seam). OTEL exporter: STDOUT/InMemory shim only β no live collector. Trap-class source: cael/swim-37-trap-classes tip 2adf174. Closes #324 (scaffold slice). * docs(swim-37 harness): note #366 Slice 1 already landed (0f40bc0) Per π«οΈ review nit on #370 β call out that traceparent on SystemEvent + ChainBudget.declineToCarry() are already in tree; only Slice 2 spans remain pending.
β¦ cap helper (#366) * feat(continuation): #334 Slice 1 β traceparent on system-event payload + chain-budget cap helper Substrate threading for OTEL chain-correlation per RFC Β§6.7 (continue-work-signal-v2.md, anchored at #361 head 045fdb4). Additive only β no behavioral change for callers that don't pass a traceparent or a chain-budget state. Two pieces: 1. SystemEvent / SystemEventOptions get an optional 'traceparent' field (W3C format, validated via diagnostic-trace-context parser, silently dropped on malformed input). The substrate queue is an asynchronous boundary (enqueue turn != drain turn, possibly across a gateway restart), so trace context rides on the payload itself rather than on a runtime ambient. 2. ChainBudget.declineToCarry() β cap-on-enqueue helper that returns true when chainStepBudgetRemaining <= 0. Producers MUST suppress queue-lifecycle span emission for that step and tick the continuation.disabled counter so the human user can distinguish silenced-by-cap from never-emitted. One axis, two declines (per Β§6.7): depth-cap = 'I won't carry past my budget' (this PR) fan-out-cap = 'I won't spend yours' (lives with #355) a chain that knows when to stop being a chain is the kind of chain that gets built on. Naming note (poets-canon): declineToCarry over refuseAttach. The chain isn't refusing the trace β it's declining to carry the next prince's context window into search-space the chain itself has already abandoned. Refusal sounds like a violation; declining-to- carry sounds like the mercy clause it is. Slice 2 (continuation.delegate.* / continuation.queue.* span set per Β§6.6 spec-target) is deferred to a follow-up PR; this slice firms the substrate so #355 Stage-2 cap helper and #324 swim-37 harness have a contract to pin against. closes #334 (Slice 1) refs #361 Β§6.7 refs #355 #324 * chore(plugin-sdk): regen api baseline for traceparent surface (Slice 1) Adds traceparent?: string to SystemEvent + SystemEventOptions per #334 Slice 1; this is an additive plugin-sdk surface change so the baseline hash needs to roll forward. Refs #366 (CI: generated-doc-baselines failure on plugin-sdk:api:check)
β¦r (path-B, no OTEL deps) (#378) * feat(continuation): #334 Slice 2 chunk 1 β Tracer surface + noopTracer registry Pure surface landing. No new deps. No behavior change for callers that don't opt in (additive contract continued from Slice 1). What this adds: - Tracer / Span / SpanAttributes / SpanStatus / StartSpanOptions types - noopTracer (default; every method is a no-op) - getContinuationTracer / setContinuationTracer / resetContinuationTracer - Canonical span-name set pinned by tests (continuation.work, continuation.delegate.dispatch, continuation.queue.enqueue/drain, continuation.compaction.released, continuation.disabled, heartbeat) - Canonical attribute-name set pinned (chain.id, chain.step.remaining, delay.ms, reason.preview) Why path-B (no first-party OTEL deps yet): - Cohort design checkpoint (sprites-of-thornfield, 2026-04-27): adding 5β8 first-party @opentelemetry/* deps in the gateway hot-path is a bauble-policy conversation in its own right. Slice 2 ships the surface; Slice 3 wires the OTEL adapter once that conversation lands. - Symmetric with Slice 1 contract: substrate adds the field (SystemEvent.traceparent), malformed/un-opted callers see no change. Here: substrate adds the surface (Tracer), un-installed callers see no spans emitted. - #370 swim-37 harness pins against THIS module's surface, not against @opentelemetry/api β keeps the harness durable across upstream-OTEL renames and across any future exporter swap. Tests: 9/9 green (pnpm vitest src/infra/continuation-tracer.test.ts). Refs #334 #366 #370 #377 * feat(continuation): pin canonical attr-keys + span-names at the type (π»'s nuance) Type-pin the load-bearing names at the shim, NOT the adapter: - new ContinuationSpanAttrs type β chain.id / chain.step.remaining / delay.ms / reason.preview / delegate.mode / continuation.disabled - new ContinuationSpanName union β all 7 canonical span names Why this matters: if Slice 3's OTEL adapter ever drifts to chain_id / chainId / camelCase / etc., the type fails compile BEFORE the #370 harness runtime assertions could catch it. Pin the names at the shim; the adapter is then forced into compliance. Slice-1-style additive contract preserved: SpanAttributes (broad Record<string,...>) is still what setAttributes / StartSpanOptions.attributes accept, so adapter-internal / diagnostic attrs aren't rejected; ContinuationSpanAttrs is the canonical-contract pin enforced by tests via assignment-to-SpanAttributes compile check. Tests: 11/11 green (was 9/9; +2 type-pin tests). Refs #334 #366 #370 #377
* feat(swim-37): #324 InMemorySpanRecorder shim + contract tests Adds reusable in-memory span recorder for the swim-37 harness so downstream test conversions can read span contract from a black-box observer instead of redefining the recorder shape inline. The recorder mirrors the `recordingTracer` pattern already used in `src/infra/continuation-tracer.test.ts` (around L51-100), lifted into a reusable harness helper. Stays STDOUT-only / in-process (no real OTLP collector, no BasicTracerProvider) per harness discipline pinned in studies/swim-37/harness/README.md. Contract tests pin: - construction returns fresh independent recorders - startSpan records name + initial attributes + traceparent - setAttributes is last-write-wins per OTEL semantics - setStatus + recordException + end() round-trip - end() is idempotent - spansByName filters correctly - reset() clears records without un-installing the tracer - spans() returns a defensive copy Integration tests pin that the recorder captures actual continuation.* spans driven by the production emit* helpers β so harness tests can trust the recorder as a black-box span observer: - emitContinuationWorkSpan stamps chain.id + chain.step.remaining (#366) - emitContinuationDelegateSpan stamps chain.id + delegate.delivery + delegate.mode (#366) - emitContinuationDelegateFireSpan carries fire.deferred_ms + persisted chain.id snapshot (#388 chunk 5b); drift formula pinned (drift = fire.deferred_ms β delay.ms) Does NOT touch swim-runner.test.ts it.todos β those need the runner (`captureSwim()`) which is separate scope. This PR ships the foundation those conversions will read against. Refs: #324 (swim-37 harness) Refs: #366 (continuation.* spans) Refs: #388 (chunk 5b/5c fire-span seams) Base: cael/325-canonical2 tip 7390635 (post-#392) * fix(swim-37): wire harness into enforced CI lanes + tighten doc-shape (π©Έ byte-walk) Cael caught: as initially submitted, the swim-37 harness code wasn't enforced by repo CI. Three changes to close the drift surface: 1. **tsconfig.json**: add "studies/**/*" to `include`. Type-shape on the recorder + harness is now caught by tsgo / pnpm typecheck. 2. **test/vitest/vitest.swim-37.config.ts**: new vitest project entry (createScopedVitestConfig pattern, narrow include for studies/swim-37/harness/**/*.test.ts). Registered in test/vitest/vitest.config.ts `rootVitestProjects`. 3. **README.md Β§OTEL exporter**: rewrite to describe what shipped (custom recorder installed via setContinuationTracer(...), recording-tracer pattern lifted from continuation-tracer.test.ts) instead of the aspirational InMemorySpanExporter / provider-style framing from the original scaffold. Add Β§Vitest project section pointing at the wiring above. 4. **in-memory-span-recorder.ts doc-comment**: tighten "local-process-memory equivalent of OTEL's InMemorySpanExporter" to the concrete contract (in-process array, no provider, no exporter). The broader OTEL framing was load-bearing in the original scaffold but reads as forecasting plumbing the harness deliberately doesn't ship. Local verification: - vitest --project swim-37 β 15 passed | 18 todo (project recognized) - tsgo --noEmit β no new errors under studies/swim-37/ Same-shape lesson as the chunk-6 memo byte-walk earlier today: shipping the artifact while deferring the enforcement IS the drift surface. Fix in PR rather than queue followup.
β¦405) * test(swim-37): wire captureSwim() for continue_work primitive Replaces the placeholder `declare function captureSwim` in `studies/swim-37/harness/swim-runner.test.ts` with a real implementation in `studies/swim-37/harness/swim-runner.ts`. The wired path drives `emitContinuationWorkSpan` against `createInMemorySpanRecorder()` and returns the captured spans + the synthesized `chainId` (uuid v7 via `generateChainId`). Flips two prior `it.todo` markers to live tests: - emits continuation.work span with chain.id stamped (#366) - span carries chain.step.remaining attribute Plus three new live tests: - sentinel: captureSwim() is wired for continue_work - refuses continue_delegate / heartbeat / lich primitives with a clear error so the spec's remaining `it.todo` markers stay honest about what is implemented - repeated calls don't leak capture state between invocations STDOUT-only discipline preserved: no BasicTracerProvider, no OTLP exporter, no @opentelemetry/sdk-trace-base machinery β capture flows through `setContinuationTracer(recorder.tracer)` as documented in README.md. Other primitives (`continue_delegate`, `heartbeat`, lich-shape) remain `it.todo` until the corresponding dispatch / heartbeat / compaction-release seams have a comparable single-helper entry point we can drive synthetically. Test results: - swim-37 vitest project: 20 passed, 16 todo, 0 failed - in-memory-span-recorder.test.ts unchanged (12/12 still pass) - tsgo errors visible elsewhere in tree are pre-existing (src/plugin-sdk/provider-tools.ts), not introduced by this change Refs #324 (swim-37 harness) β `it.todo` count drops from 17 \u2192 16, first concrete primitive driven through the live tracer registry. * docs(swim-37): continue_delegate wiring memo (memo-before-wire) Pre-PR design memo for the next swim-37 primitive after #405 (continue_work). Decides shape before the wire so the PR lands clean. Resolved by reading production code: Q1 β real setTimeout vs fake timers N/A for dispatch-accept (synchronous, pre-timer); banked for a separate continue_delegate_fire swim. Cohort sign-off requested on: Q2 β recipient fan-out shape: N spans sharing chain.id (matches chunk-3 cohort design pin + #355 Stage-2 budget semantics). Q3 β delegate.mode x delegate.delivery: 8-cell it.each matrix + omission-contract row. Banked for follow-up: Q4 β continue_delegate_fire as separate primitive, not sub-case; informs SwimPrimitive type-union shape. Standard applied (per Cael 2026-04-27 #1498505918, figs #1498505870): would skipping this memo cost a chunk's worth of rework? Yes β three open Qs would have changed file shape. Refs: - #405 (continue_work wired) - #324 (swim-37 harness) - docs/design/334-slice2-chunk5b-delegate-fire-memo.md (pattern) * test(swim-37): wire captureSwim() for continue_delegate primitive Implements the design from `docs/design/swim-37-continue-delegate-wiring-memo.md` (commit 3bb086c762, same branch). Wiring (swim-runner.ts): - New continue_delegate case in the switch. - Adds CaptureSwimOptions axes: recipients, delivery, delegateMode. - Drives emitContinuationDelegateSpan once per recipient with shared chain.id (per chunk-3 cohort design pin: N recipients = N spans sharing chain.id, NOT one span with a recipients list). - Validates recipients is a positive integer. Tests (swim-runner.test.ts): - 8-cell it.each matrix: delegate.mode (normal | silent | silent-wake | post-compaction) x delegate.delivery (immediate | timer). - Omission contract: delegate.mode attribute absent when caller passes undefined. - Fan-out: 3 recipients emit 3 spans with shared chain.id. - Validation: recipients=0 and recipients=1.5 reject. - Removes the prior 'refuses continue_delegate' test row (now wired). Two it.todo markers preserved for downstream concerns: - chain-budget decrement / ChainBudget.declineToCarry observable (needs ChainBudget integration, not in scope here). - fan-out budget arithmetic per #355 Stage-2 (1 step not N) \u2014 separate from span cardinality. Test results delta: Before: 8 passed | 16 todo (24 tests) After: 19 passed | 15 todo (34 tests) +11 live | -1 todo Refs: - #324 swim-37 harness - #405 captureSwim continue_work (parent of this branch) - docs/design/swim-37-continue-delegate-wiring-memo.md * test(swim-37): pin recipient.index gap on continue_delegate fan-out Per Cael's caution on the wiring memo (Discord msg 1498507232185286849 sign-off): N spans sharing chain.id risks collapsing into analytic mush at scale unless per-recipient distinction is visible in attrs. Currently emitContinuationDelegateSpan exposes no recipient.index axis \u2014 all N spans in a fan-out are byte-identical except spanId. Adds: - One live GAP-PIN test asserting the current (deficient) reality: fan-out spans toEqual on attributes. This will fail loudly when the production helper grows the axis, prompting a flip to not.toEqual. - One it.todo marker for the upcoming production-helper change. Production-helper change is out of scope for this PR; will file follow-up issue. The gap-pin lives in the integration tier so it shows up in any code-review touching delegate-dispatch span shape. Refs: - #324 swim-37 harness - #405 captureSwim continue_work + continue_delegate (this PR) - docs/design/swim-37-continue-delegate-wiring-memo.md (Q2) - Cael Discord sign-off msg 1498507232185286849 * test(swim-37): wire \u00a71 entry-point todo onto classifyRebasePick Now that #408 (PR #408 merged at cb73bc8) landed `rebase-classifier.ts` with the pure `classifyRebasePick` composer, the integration-tier \u00a71 entry-point it.todo on swim-runner.test.ts L80 is wireable without any new shim. Synthetic commit pinned: subject mentions PR openclaw#70595, base CHANGELOG contains the same PR token (squash-rebase shape from the memo's 7ee46a3 anchor instance). Discovery channel: changelog-grep:pr. L81 (CHANGELOG-byte-grep emits drop-with-reason span) stays todo \u2014 needs the production callsite that pairs the channel with `emitContinuationDisabledSpan`, per \ud83c\udf0a's #408 note. Test delta: Before: 20 passed | 16 todo (36) After: 21 passed | 15 todo (36) +1 live | -1 todo
Closes #334 (Slice 1). Refs #361 Β§6.7 (head
045fdb49d08), #355, #324.What this PR does
Substrate threading for OTEL chain-correlation. Additive only β no behavioral change for callers that don't pass a
traceparentor a chain-budget state.Two pieces:
1.
traceparenton system-event payloadSystemEventandSystemEventOptionsget an optionaltraceparent?: stringfield (W3C format, validated viaparseDiagnosticTraceparent, silently dropped on malformed input β a malformed header never fails an enqueue).The substrate queue is an asynchronous boundary β enqueue turn β drain turn, possibly across a gateway restart β so trace context rides on the payload itself rather than on a runtime ambient. This is the contract #355 Stage-2 cap helper and #324 swim-37 harness will pin against.
2.
ChainBudget.declineToCarry()β cap-on-enqueue helperReturns
truewhenchainStepBudgetRemaining <= 0. When this fires, producers MUST suppress queue-lifecycle span emission for the step and tick thecontinuation.disabledcounter once so the human user can distinguish silenced-by-cap from never-emitted.undefined/ non-finite remaining is treated as "no budget tracked yet" β the chain has not opted in, so the helper does NOT decline (Slice 1 additive contract).RFC Β§6.7 β one axis, two declines
Cite-target verbatim from #361
045fdb49d08:This PR is the depth-cap half; the fan-out-cap half lives with #355's Stage-2 cap helper. Both are the same axis (chain-step count) viewed from opposite sides of the fan-out boundary.
Naming note (poets-canon)
declineToCarryoverrefuseAttach. The chain isn't refusing the trace β it's declining to carry the next prince's context window into search-space the chain itself has already abandoned. Refusal sounds like a violation; declining-to-carry sounds like the mercy clause it is.What is deferred to Slice 2
continuation.delegate.*andcontinuation.queue.*span set per Β§6.6 spec-target. This slice firms the substrate so the new spans land against a settled contract.Test coverage
src/infra/chain-budget.test.tsβ 5 tests (cap-fires-at-0, fires-when-overdrawn, doesn't-fire-when-positive, doesn't-fire-when-undefined, doesn't-fire-on-NaN/Infinity)src/infra/system-events.test.tsβ +3 tests on the traceparent normalization path (valid β preserved, invalid β dropped, equality preserved across traceparent)pnpm tsgoandpnpm vitest run src/infra/chain-budget.test.ts src/infra/system-events.test.tsboth green locally.Notes for review
cael/325-canonical2(canonical2 head092f502032)src/infra/system-events.ts(+32),src/infra/system-events.test.ts(+31),src/infra/chain-budget.ts(+68 new),src/infra/chain-budget.test.ts(+30 new)