HOMELAB-1850: feat(otel): span attribution closure + phase tags (LD4.5d) #60
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1850-attribution"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the LD4.5d leg in pi-extensions: the HOMELAB-1591 residuals the brief names, the
LD4.5d phase key, and the dispatch recipe that binds it.
Brief:
.rpiv/artifacts/epic-ld4/design-LD4.5d-attribution.md(ticket HOMELAB-1850).Territory: LD4.4 owns the
sandbox_envkey + R-06 rollup; this PR owns thephasekey + span-parent residuals at the same emission sites.
routines/rollup.go,sandbox.ts, pi-friction and the swarm-view UI are untouched.Commit 1 — 1591 residuals + the phase key (pi-otel-telemetry)
plane.ticket_idis a VALUE ("none") on every subagent span, never anabsent attribute. The token counter has always written
none; a span withoutone made "no ticket context" indistinguishable from "span predates the key".
pi.cwdon every subagent span (event cwd → dispatching session cwd), thesame join key the root span already writes, so a reviewer dispatched from a
worktree is placeable.
TRACEPARENTingestion for subprocess children (newtraceparent.ts).Every landing-pipeline reviewer is
pi -p --no-sessionspawned from bash, emitsno swarm event, and used to start a NEW trace.
pendingDispatchesstill wins forin-process children; the env value is validated (W3C version 00, no all-zero
placeholders, no extra fields) and degrades to no-parent rather than to a
half-extracted context.
phase.ts— the closed enumbuild | review | verifyplus the honest fourthvalue
unphased, resolved ONCE at tracker construction (a mirror ofsandbox.ts, deliberately copied rather than reinvented). The match is exact:"Build"/"deploy"/""/"bogus; rm -rf"are REFUSED intounphased,never guessed at.
phaseon every subagent span AND on thepi_agent_tokens_totalsample,resolved once per dispatch (spawn declaration → process binding →
unphased) andstored on the open-span entry, so span and counter can never disagree — a
disagreement would silently break the sum invariant.
pi_agent_unphased_spend_total— the finding series, mirroringpi_agent_unbound_spend_total, so the Go leg's P-ROUT-012 is alertable.Commit 2 — the spawn declaration + the dispatch recipe (pi-swarm-suite)
AgentRecord.phase(next tobackground/isolated, same stated reason:the span needs it at span-create time),
SpawnOptions.phase, validated byassertValidSpawnPhase— reject, never coerce — and both spawn paths plus theAgent tool's new optional
phaseparameter.subagents:startednow carriesphaseandcwd.dispatch-env.ts: the recipe as data — the file-delivery shapeClaimBinding.EnvPairs()established, renderingLD_ROLE/LD_INSTANCE(LD2.7)LD_PHASE+TRACEPARENT, withrenderDispatchEnvFileandsubprocessEnvPrefix.docs/dispatch-attribution-recipe.md: who renders what where. Claim binding =identity; dispatch recipe = stage (the stage changes per dispatch inside one
sandbox, which a claim-time binding cannot express); telemetry reads and refuses.
Verification
pi-otel-telemetry: 62 tests passing (phase.test.ts,traceparent.test.tsadded; the
sandbox_envanti-spoof pattern mirrored for phase — a child thatmutates the env after capture changes nothing; span/counter agreement asserted).
pi-swarm-suite: 1464 tests passing (dispatch-env.test.tsadded).tsc --noEmitclean in both units.Not in this PR (by territory)
sandbox_envattribute/key and the R-06 rollup (LD4.4).src/view/*) — 3.4's slot. The record now carriesphase, so that surface can derive it without another schema change.pi-friction(4.6 owns it).routines/phase.go) and the Tempo values changeland separately (infra-core; the Tempo one flagged OPERATOR REVIEW).
The HOMELAB-1591 residuals the brief names, at the subagent emission site, plus the LD4.5d phase key they share the site with: * plane.ticket_id is now a VALUE ("none") on every subagent span, never an absent attribute — the token counter has always written "none", so a TraceQL reader can no longer confuse "no ticket context" with "span predates the key". * pi.cwd is on every subagent span (event cwd -> dispatching session cwd), the SAME join key the root span already writes, so a reviewer dispatched from a worktree is placeable. * TRACEPARENT ingestion for SUBPROCESS children (new traceparent.ts): every landing-pipeline reviewer is `pi -p --no-session` spawned from bash, emits no swarm event, and used to start a new trace. `pendingDispatches` still wins for in-process children; the env value is validated (W3C version 00, no all-zero placeholders) and degrades to no-parent rather than a half-extracted context. * phase.ts: the closed enum (build|review|verify) + the honest fourth value `unphased`, resolved once at tracker construction (mirror of sandbox.ts), refusing anything out of the enum — exact match, never guessed. * subagents.ts/index.ts: `phase` on every subagent span AND on the pi_agent_tokens_total sample, resolved once per dispatch (spawn declaration -> process binding -> unphased) so span and counter can never disagree; plus pi_agent_unphased_spend_total, the finding series (P-ROUT-012 in the Go leg). Tests: 62 passing in pi-otel-telemetry (phase + traceparent suites added, the sandbox_env anti-spoof pattern mirrored for phase). tsc --noEmit clean.Review verdict — CLEAN (no blocker findings)
Reviewed from a fresh context on primary sources only: the PR diff (13 files, +986/−9), the HOMELAB-1850 ticket/verify lines,
design-LD4.5d-attribution.md, and the repo at head59518ae. Reviewer ≠ implementer; implementer's summary not used as the brief.Findings per review focus
phase.ts— PASS. Closed enumPHASES = ["build","review","verify"];unphasedis the fourth value, explicitly not a member. Exact match (no case-folding, deliberately stricter thannormalizeSandboxEnv):"Build","deploy","","bogus; rm -rf"all →unphased. Resolved once at construction —SubagentSpanTrackerctor param 6 defaults toresolvePhase();index.tsresolves it once besideresolveSandboxEnv()and injects it. Unspoofability tested: mutatingprocess.env.LD_PHASEafter construction leaves the emitted attribute"build". Emitted as an attribute value on every span, never absent. Out-of-enum refused intounphased(not coerced, not fallen back to a stale binding —effectivePhase("Deploy", bound("build")) === "unphased", tested).plane.ticket_idwritten ascarry.ticketId || "none"on every subagent span (value, never absence).pi.cwdwritten frome.cwd ?? opts?.cwd, withStartOptions.cwd = state.cwdpopulated unconditionally inbefore_agent_start(systemPromptOptions.cwd || process.cwd()) — same join key as the root span.TRACEPARENTingested atclaimDispatchParent(key) ?? envTraceparent() ?? paperclipTraceparent— correct precedence (in-process wins); validator enforces W3C version00, rejects all-zero trace/parent ids and extra fields, degrades to no parent rather than a half-extracted context.pi-otel-telemetry62 passed (4 files);pi-swarm-suite1464 passed (104 files).tsc --noEmitexit 0 in both units.pi-otel-telemetry+pi-swarm-suite.sandbox.ts,routines/rollup.go/rollup-adjacent code,src/view/*andpi-friction(HOMELAB-1690) are absent from the diff — 4.4's and the sibling's territory untouched.mainadvanced only invoice-whispersince the branch base; zero file overlap, so green CI on head is valid for the merge tree.samples).toEqual([...])assertions remain exact full-object equality; they only gainedphase: "build". No loosening totoMatchObject/partial matching.TokenSample.phasebeing required plus cleantscproves no sample-construction site was missed.Agent tool params.phase→SpawnOptions.phase(validated byassertValidSpawnPhase, reject-never-coerce, at both spawn paths) → singleAgentRecordconstruction site (phase: options.phase ?? undefined) →subagents:startedpayload (phase, pluscwd) →StartedEvent.phase→effectivePhaseat span create → span attrphaseandentry.phaseon the open-span entry →TokenSample.phase. Span and counter share one resolved value, so the sum invariant cannot silently break.Concerns (non-blocking, posted for the record)
trackerPhase()is exported but has no consumer — it mirrors the pre-existing unconsumedtrackerSandboxEnv(), so it follows precedent rather than introducing a new dead-code pattern. The startup log usesphaseBinding.sourcedirectly.SpawnOptions.phase = ""is rejected on the spawn path whiledispatch-env'sassertPhase("")treats an empty value as an absent binding. Both are documented and defensible (API reject vs. omit-key); no behavioural risk.pi.cwdisif (cwd)-guarded rather than guaranteed-value; in productionstate.cwdis always populated so every subagent span carries it. Worth noting only for a hypothetical caller with neither record nor session cwd.Landing: CI green on
59518ae(run #116, 12/12 jobs incl.pi-otel-telemetry+pi-swarm-suite). Class C (extension TS), not dangerous — merge proceeds.