feat(telemetry): instrument session lifetimes, tool call latency, and subsystem events - #7313
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Instrumentation with named causes throughout — every mechanism (crumb ownership, generation-named files, fail-closed backfill) is derived from a concrete failure, not speculation. Suggestions
[DESIGN-REVIEWED] c7ff7db |
352e562 to
fcb908f
Compare
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/metrics/sessions.py:247 -- Function-local [GPT-REVIEWED] c7ff7db False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All mechanical checks I need are done: First-Principles-Verdict: CONCERNS Every instrument earns its place, but a coverage-baseline exemption for an untouched file rides along, and three counters knowingly shadow What this change shipsIntent: let an operator see how long sessions live (including crashed ones), which part of a slow turn was the tool, and whether each subsystem is doing its job. This is an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c7ff7db |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsAll three candidates fail falsification:
No new Step-2 finding grounds to 80+. No findings. [OPUS-REVIEWED] c7ff7db Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
fcb908f to
34d141d
Compare
34d141d to
6046617
Compare
6046617 to
7f3af47
Compare
|
Round 4 dispositions on GPT 5.6 -- both BLOCKING findings, fixed structurally rather than patchedThe two reviewers found one root cause from two directions, and it was the same code: the crumb unlink going to the maintenance pool.
GPT's suggested fix (thread
That second change also closes a hazard neither reviewer named: with only an end flag, a successor's start lifted the flag, so a predecessor's in-flight writer could still land and make the successor's crash measure from the predecessor's start. Pinned by Mutation-verified, each applied alone: dropping the inline unlink turns 4 cases red; making the generation check always pass turns 2 red. GPT's second BLOCKING (queued workers outliving the per-test home) -- real, and a genuine test side effect: the Design Review -- CONCERNS
First Principles -- CONCERNS
Two description-vs-code mismatches I found and fixed while hereBoth were in the PR body, in the same class of defect the reviewers flag, and neither had been reported:
The body also carried a stale boot-ordering claim ("necessarily BEFORE this process opens any session of its own") that the Gates on |
7f3af47 to
3839e1e
Compare
|
Round 5 dispositions on GPT BLOCKING -- the post-write recheck could delete a successor's crumbCorrect, and it was mine from round 4. The recheck acts on a path the successor SHARES, so a predecessor finishing late unlinks the crumb its successor just wrote. Round 4 traded "wrong crumb" for "no crumb" and did not remove the race. This was the FOURTH finding in this one span (round 3: teardown ordering; round 4: pooled unlink, two ways; round 5: this). Rather than add a fifth guard I tested the premise underneath all four -- that the write must be deferred because the caller holds the session registry lock:
So the deferral was a micro-optimisation, and it was the sole source of the class. The write now happens inline in the same The three tests that encoded the deferral are replaced rather than deleted: one now pins that the writer has exactly ONE call site and it is inside the locked section (a guard can be removed and re-broken; a sole call site under the lock is what makes the ordering structural), and the Windows GPT FINDING -- a clean pop path leaves crumbs the next boot calls crashedRight about the mechanism, wrong about the path, and it led to three more. But auditing every registry mutation in
The reason this matters more than a missing sample, which the module previously claimed was the cost: an unrecorded removal leaves the crumb, so the next boot reports the session as The gate was the wrong shape and is replaced. It enumerated six method names in one module, so a seventh path -- or any pop in another module -- was invisible to it, which is exactly how all four escaped. It is now a fail-closed AST walk over every module that mutates the registry, recognising all three removal spellings ( GPT FINDING -- crumbs written while telemetry is disabledRebutted on the code, fixed on the documentation. The opt-out contract is a no-egress promise scoped to the OTEL metric pipeline, and the crumb is not in that pipeline: Always-on local writes carrying the session key are the established, documented pattern, not an outlier. The per-turn usage row store writes the raw session key as its What the finding did surface is a real ambiguity it could reasonably rely on: two spec sentences said "nothing is written" flatly, which reads more absolutely than the module behaves. Both are now scoped to the metric pipeline they actually govern, with the always-on local state named. No behavioural change. First Principles CONCERNS --
|
|
Round 10 on BLOCKING 1 -- distinct generations share one breadcrumb: FIXED, and the premise is goneCorrect, and it was reachable in a way none of the earlier rounds in this span had Fixed as suggested, by putting the writer and the generation in the filename
That third case was a real regression introduced by the rename, and the existing Two things this removes rather than adds: the post-write read-back check from round 9 BLOCKING 3 -- current-PID reuse hides crashed sessions: FIXEDAlso correct, and I want to record that the other lane looked at this and got it The fast path now compares start identifiers whenever both are readable, and an BLOCKING 2 -- unlink blocks the event loop: DECLINED, filed as #7537The mechanism is real and I am not disputing it. The remedy is what I am declining A deferred unlink has to be RELIABLE, not merely off-loop. Fire-and-forget is not: What has changed is the size of the prize. Naming the file after its writer and Shard 3 (Linux 3.10 and Windows): main-owned, not from this diffBoth failures on the previous head reproduce independently of this branch:
Neither fix is folded in here. This head is rebased onto current main to cut a fresh Gates59 + 37 + 29 cases in the three metrics files; the required set ( Three mutations verified rather than assumed, each reverting one half of this round: |
08e4d33 to
2aae59f
Compare
2aae59f to
0b676ca
Compare
0b676ca to
36cdd3c
Compare
|
/ai-review override gpt 36cdd3c: Known and deliberately deferred as #7537 -- offloading this unlink requires |
Human judgment recorded@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
… subsystem events Three populations that ran entirely unmeasured: how long a session lives and why it ended, how long each tool round-trip takes, and how often the subsystems behind a turn actually do their work. kirocrew.session.duration + kirocrew.session.started. Each session start drops a small crumb under <data home>/metrics/open-sessions/; a clean end reads it, emits the lifetime with the teardown path's own end_reason, and unlinks it. Whatever is still on disk at the next boot belongs to a session that never reached a teardown path, so gateway startup emits it as end_reason=crashed -- the population that would otherwise be missing entirely, since a crashed gateway runs no teardown. Consuming the crumb is what makes the accounting exact: the six teardown paths overlap (the idle sweep calls reset), and a back-filled session cannot be counted again on the boot after that. kirocrew.tool.call.duration. The turn histogram makes a slow turn visible but never says which part was slow. tool_kind is normalised against an allowlist, never the tool name: MCP tool names are unbounded and the ACP kind field arrives verbatim from the agent, so an un-normalised label is a cardinality bomb. An MCP-served call is labelled by its transport. AcpClient and the shared dispatch parser are sibling implementations of the same protocol, so both are instrumented; one process-global registry keyed by toolCallId, popped on the terminal status, keeps that layering at exactly one sample per call. Seven business counters at their own subsystem call sites, through the metrics/events facade: subagents spawned (carrying the concurrency it was admitted at, so the max over that attribute is the high-water mark), cron fires, artifacts created, workflow runs, context compactions, MCP stub reconnects, and tool-approval decisions. The approval counter generalises the backend-child permission pair to every surface's gate; those two are left exactly as they are, since they measure a different population. Both new histograms are registered in provider._HISTOGRAM_BUCKETS_MS, session duration under a new minutes-to-days boundary family and tool calls under a sub-millisecond-to-an-hour one -- without an entry each would fall back to OTEL's 10s default ceiling and report a floored percentile as a real latency. Refs #7232 #7257
Problem / Motivation
Three populations behind every KiroCrew turn are not measured at all today.
How long a session lives, and why it ended.
kirocrew.session.startup.durationtimes the cold start of the agent PROCESS and
kirocrew.session.idle_expiredcounts one specific teardown cause, but nothing records a session's LIFETIME or
how those lifetimes split across the ways a session can end. There is also no
count of sessions started, so no denominator for anything session-scoped.
How long a tool call takes.
kirocrew.turn.durationreports the whole agentloop, which is model calls plus every tool round-trip. A four-minute turn is
therefore indistinguishable between a slow model and one shell command that ran
for four minutes, and the tool population that dominates real latency cannot be
seen at all.
Whether the subsystems behind a turn did their work. Subagent spawns, cron
fires, artifact creates, workflow runs, context compactions, MCP stub reconnects
and tool-approval decisions each have logs and, in some cases, an audit record --
but no counter. Their rates are only recoverable by grepping logs after the fact.
Why it matters
Without session lifetimes, the single most interesting slice is invisible: a
session that ends because the gateway crashed. It runs no teardown path, so it
would contribute no sample even once the histogram exists, which means the
distribution would describe orderly shutdowns and quietly omit the failures.
Without tool-call latency, a latency regression can be localised only by
guessing. The turn histogram already exists and already says "something in this
turn was slow"; the missing half is which part.
Without the business counters, load and health questions have no numbers behind
them -- how often approvals are actually shown versus auto-approved, whether MCP
backends are churning under live sessions, how often compaction runs and how
often it recovers headroom. Each of those is a per-subsystem fact today, held
only in a log line.
What changed (motivation -> approach -> change)
Session lifetime, including the crashed population
Goal: one histogram whose population includes sessions that died with their
process, and whose
end_reasondistinguishes how a session ended.Approach considered and rejected: keep start times in memory. That loses exactly
the crashed sessions. Also rejected: infer "unclean" from the session map, whose
entries deliberately outlive a session so a tab can resume later -- presence
there proves a session once existed, not that one exists now. Also rejected: the
transcript's own
closed/closed_atstamp, which only the dashboard tab-closepath writes (so channel, cron, subagent and task-runner sessions never get one)
and whose ABSENCE cannot separate a crashed session from a live-idle one -- there
is no positive crash flag on disk today. Boot offers nothing to piggyback on
either: the restore path is seed-driven from
open_slots.jsonand never walks thetranscript directory.
Chosen: a crumb on disk. Each start writes one small JSON file under
<data home>/metrics/open-sessions/; a clean end unlinks it and emits thelifetime from an in-memory start time. Whatever is still there at the next boot
belongs to a session that never reached a teardown path, so
backfill_crashed_sessions(started_before)emits those asend_reason=crashed.started_beforeis the calling process's own start time and crumbs at or afterit are left alone, which is what lets the scan run OFF the boot path as a tracked
worker-thread task instead of having to complete before this process opens its
first session.
The file is named after its writer and its generation, not just the session
key. A session key is not unique across processes:
BACKGROUND_KEYis a fixedconstant, so a
kirocrew runand the gateway can hold the same key at the sametime with a live session behind each. While the name was the key's digest alone
they shared one file, and whichever session ended first unlinked the other's
record -- so the survivor's later crash went unreported. The locks in this module
are thread locks and never spanned processes, so nothing here could have prevented
that. Putting the pid and the start time in the name makes a crumb removable only
by the session that wrote it, and three behaviours follow from it:
digest belong to other processes or earlier runs, and removing them is exactly
the bug above;
the table cap, each reap the generation they orphan -- once the table has
forgotten a generation, nothing can ever name its file again, and it would reach
the next boot as a crash that never happened. Both ride the same awaited worker
hop as the start's own write, so those unlinks stay off the event loop without
adding a second cancellation window.
The backfill therefore finds several files per key legitimately, each a separate
session judged on its own recorded owner. It is also the only reader of these
files, so an unparseable crumb is reaped there rather than by an end unlinking
blind.
The crumb also records who owns it, because the cutoff alone is not enough.
cli.pyandeval/runner.pyeach build their own session manager against thesame data home, so a
kirocrew runsession is writing crumbs there too. Thecutoff only ever protected THIS process's own crumbs, so a sibling's live session
started before the gateway booted was read as a crash -- inventing a sample AND
deleting the live crumb, which loses the real crash that session might later
suffer. Each crumb therefore carries the writing process's pid plus a per-process
start identifier, and the backfill
skips any crumb whose owner is still running. That check fails CLOSED: an owner
that cannot be decided counts as running, so an ambiguous crumb waits for a later
boot. Losing a real crash sample costs one data point; inventing one corrupts the
population this instrument exists to report.
The identifier is what makes pid reuse safe, including reuse of the current
process's own pid. A container hands the gateway PID 1 on every restart, so a
crashed predecessor's crumb arrives carrying this process's pid; trusting the pid
alone meant that crumb was skipped on this boot and on every boot after -- its
crash never emitted, its file never cleaned. The identifiers are compared whenever
both are readable, and an unreadable identity still counts as running.
A registration that never became a session is discarded, not ended. There is
one suspension point between inserting a session into the registry and finishing
its start record. Cancelled there, the caller hard-kills the provider while the
entry is still visible, so a claimant could be handed a dying session -- and the
crumb would outlive it into a false crash. Both allocation paths and the
background path now roll the exact inserted entry back and call
discard_session_start, which consumes the crumb WITHOUT emitting. It isdeliberately not a twelfth
end_reason: those describe how a live session ENDED,and a session that never lived has no lifetime belonging in the histogram.
Consuming the crumb is what makes the accounting exact, and that is load-bearing
rather than incidental: the six teardown paths are not mutually exclusive (the
idle sweep calls
reset), so whichever reaches a session first records it andthe rest are no-ops; and a back-filled session cannot be counted again on the
boot after that.
A crashed session's END time comes from its transcript's mtime -- the last moment
it was observably alive, and the honest maximum available after the fact. A
session with no transcript on disk (a subagent leaves only a replay log) yields
no end time, so its crumb is consumed with no sample rather than recorded as a
plausible-looking zero.
end_reasonlabels the teardown PATH, not the cause.resetis the widest ofthem: the idle sweep and a slot reset both reach teardown through it. That is
deliberate, because the finer causes behind it are already counted separately
(
kirocrew.session.idle_expired,kirocrew.watchdog.recovery.outcome), and ametric should not be the reason a lifecycle signature every surface calls grows a
parameter. Two paths that pop the registry WITHOUT going through
resetreporttheir own reasons instead: an identity retirement reports
retired, andcompaction replacing a provider in place reports
recycled.The start hook sits at the registry insertions, inside the registry lock, and the
crumb is written in that same critical section.
Why the write is awaited on a worker thread. Two constraints look opposed and
are not, and three earlier shapes each satisfied one and broke the other. A
fire-and-forget pool write kept the loop clear but let a writer land after its
session ended, or after a SUCCESSOR registered under the same key -- and every
attempt to detect that after the fact was itself racy, because every check still
acted on a path the successor SHARED, so a late predecessor deleted the
crumb its successor had just written. Writing inline fixed the ordering and put
filesystem I/O on the event loop, where a slow or network-homed data home stalls
every gateway task behind one session insertion -- the same reason
SessionMapoffloads its own persist rather than writing inline. Naming the file after its
writer and generation is what finally removed that shared path, so the writer's
post-write check now removes only its own file.
Awaiting an
asyncio.to_threadhop while the caller still holds the sessionregistry lock satisfies both: the syscalls leave the loop, and no start, end or
successor can interleave, because they all serialise on that lock. The writer
takes
_live_lockitself, because the one toucher of these paths NOT serialisedby the registry lock is the crashed-session backfill.
The backfill holds that lock across read, cutoff decision and unlink, which
now serialises the scan against this process's own crumb writer rather than
guarding an identity race. The race it once guarded is gone: a path names one
writer and one generation, so a session registering mid-scan gets a file of its
own and the successor this could once have deleted no longer shares a name with
anything the scan touches.
The end path's unlink stays inline and synchronous. One syscall, deliberately
not moved off the loop: behind an await it reopens the race the same-tick rule
exists to prevent, where a successor registering during those awaits has its
record consumed by its predecessor's teardown. Deferring it was weighed again and
declined -- a deferred unlink has to be RELIABLE, since a dropped one leaves a
cleanly ended session's crumb for the next boot to call
crashed, and reliabilitymeans awaiting the hop, which makes
record_session_endeda coroutine and givesroughly ten teardown sites a new cancellation window each. That is filed as
#7537 with the reasoning, because the naming change above removes the ordering
hazard and narrows the residual to one case: a process killed between the registry
pop and the unlink leaves a single crumb the next boot counts as a crash.
The crumb is written only under telemetry consent, checked fail-closed. It
exists solely to feed
kirocrew.session.duration, which is a no-op withoutconsent, so writing one on an unopted install would persist state nothing can
ever read -- against a documented default of collecting nothing. An earlier
revision of this PR instead relaxed that documented default to permit the write;
that was the wrong direction, and the default is restored intact.
Every registry removal must record an end
This is a correctness requirement, not a completeness one, and it is the part
worth reading twice. An unrecorded removal does not merely lose a sample: the
crumb survives it, so the next boot reports the session as
crashed. Itmanufactures a failure that never happened, in the one population this histogram
exists to measure.
Four removal paths shipped unrecorded and are fixed here:
session_background.py::recycle_heartbeat-- pops the background session atcycle end. Now reports
recycled.session_allocation.py::_evict_stale_sessionand the same dead-provider checkinside
get_or_create-- drop a registry entry whose provider is already gone.Now report a new
evictedreason: nothing was torn down here, the process hadalready died, so the population answers "how often did a session die under us
and get noticed on the next lookup" rather than "how often did we end one".
session_lifecycle.py::drain_all_providers-- a mass pop that recordednothing. Its one current caller drains an already-empty registry (it calls
reload_provider_factoryfirst, which clears and records), so this was alatent trap rather than a live leak; it now records per popped key so no future
caller can reintroduce it.
The gate for this was the wrong shape and has been replaced. It enumerated six
method names in one module, so a seventh path -- or any pop in another module --
was invisible to it, which is why all four escaped. It is now a fail-closed AST
walk over every module that mutates the registry, recognising all three removal
spellings (
pop,del,clear), so a NEW removal path fails by default.Tool call latency
tool_kindis normalised against an allowlist and the tool NAME is never anattribute. Two reasons, both structural: MCP tool names are unbounded, since any
server the user installs contributes its own; and the ACP
kindfield arrivesverbatim from the agent, which
hooks.pyalready documents when it explains whyits auto-approve decision is an allow-list rather than a denylist. Anything
outside the allowlist becomes
other, so an agent cannot mint series byinventing kinds. An MCP-served call is labelled
mcpwhatever kind it claims --the kind an MCP server reports is its own vocabulary, and "this call left the
process over MCP" is the more useful fact.
There is no single choke point, so this is instrumented in two layers,
because the two backends parse tool frames in different places. The kiro backend
runs on
AcpRuntime+AcpSessionHandle, which parses through the sharedacp/_dispatch.pybuilders. The claude backend -- and the app worker pools thatconstruct a client directly, e.g.
knowledge/llm_pool-- stays onacp/client.py, which re-implements the same shaping inline and never callsparse_session_update.providers/acp.pybuilds anAcpClientand then swaps inan
AcpSessionProviderat startup for the kiro path, so both parsers are live ina normal install.
Every surface (dashboard, Slack, Discord, cron, subagents, task runner, workflow)
sits DOWNSTREAM of those two: they consume the emitted
AcpEventstream ratherthan re-parsing frames. So instrumenting both parsers covers every surface and
nothing else needs a call site.
The layering is made safe by construction rather than by hoping the paths are
disjoint: start times live in ONE process-global registry keyed by
toolCallId,and a finish POPS its entry, so a call with no recorded start emits nothing. If a
frame is ever seen by both layers, the first finish records it and the second is a
no-op. A repeated start does not restart the clock, so the
tool_call_updaterefinements that follow a call cannot shrink the measured span.
Why not reuse the watchdog's existing dispatch clock.
acp/liveness.py::ToolCallStatealready stampsdispatch_ts, andAcpSessionHandleclears it on the result. Not reused deliberately:_inflight_toolis a SINGLE SLOT holding the most recent call -- the right shapefor stall attribution (the oracle only asks what we are waiting on now) and the
wrong shape for a histogram, since interleaved tool calls overwrite each other and
durations would land on the wrong call or go unrecorded. It also exists only on
the kiro path, so it could not serve the claude one.
The finish is stamped before the output parsing in both layers, because that
parsing returns None for an output-less update and a tool that completes with no
output is still a completed round-trip. A non-terminal status is a no-op, so a
mid-stream update leaves the clock running for the real completion.
The clock is
perf_counter, notmonotonic, and that was a real defect ratherthan a preference. On Windows
time.monotonicadvances in ~15.6ms ticks, so anycall completing inside one tick measured exactly 0.0 and was dropped by the
skip-non-positive guard. That silently hid every sub-tick tool call on the
platform -- which is most cached reads -- and Windows CI caught it as 18 failures
in this PR's own tests.
perf_counteris the highest-resolution monotonic clockavailable everywhere, so the guard keeps meaning "unmeasurable" rather than
"fast".
Business counters
Seven counters at their own subsystem call sites, through the
metrics/events.py::emit_counterfacade (which exists so low-level modules canemit without importing
metrics.providerat module top and forming a cycle).Every attribute value is a member of a closed set:
kirocrew.subagent.spawned-- at the admission increment, carrying theconcurrencyit was admitted at. That value is bounded by the spawn cap, sothe aggregator's MAX over the attribute IS the concurrency high-water mark and
no second instrument is needed. Imported at call time here because
bind_component_globalsrebinds every*_implfunction's__globals__tosubagent's namespace, so a module-level import in that file is not visiblefrom inside the function.
kirocrew.cron.fires--kindseparates the three dispatch shapes (scriptand
commandbypass the model entirely,agentruns an LLM turn), which isthe split between jobs that cost tokens and jobs that cost none.
kirocrew.artifact.created-- after the write, so a failed create countsnothing;
kind/sourceare the values the module's own validators alreadyrestrict to closed sets.
kirocrew.workflow.runs-- at the audited run start, which both the foregroundand background entry points cross.
kirocrew.context.compactions-- at the single verdict funnel, placed above itsearly return so surfaces that register no callback are still counted.
successreports whether the compaction ATTEMPT itself completed, NOT how much context
was reclaimed: no before/after reading is taken, and a compaction can finish
having freed little. It is also NOT the callback's
success: the recycle pathfires the funnel with
success=Truebecause the SESSION now has headroom, whichis what the callback needs, but that path is reached exactly when an in-place
/compactFAILED and the provider had to be replaced. Counting it as asuccessful compaction would report the failure mode as the success case, so the
counter reports
Falsethere, discriminated by the recycling marker that isstill set at that point.
kirocrew.mcp.reconnects-- at the stub's reconnect success point.kirocrew.approval.decisions-- emitted by the fourToolHookResultresultfactories through one private helper, so it fires exactly once per gate
consultation. Instrumenting
HookManager.on_tool_callinstead would meantouching each of its 23 exits. A surface that OVERRIDES the gate builds a
result directly and is deliberately NOT counted: counting every construction
would report one request as two decisions and keep a count for a verdict that
was discarded. Counting from the factories is also what let the
from_gatefield go -- it had no reader but the counter, so it was state carried purely to
signal.
decision=allowis the branch that falls through TO an interactiveprompt, so that slice is approvals shown and
denyis approvals denied;security_denyseparates a hard security refusal from a policy-state one. Noreason string reaches the recorder. The existing backend-child permission pair
is left exactly as it is -- it measures a specific hang-resilience fix, and its
population is not this one's.
Bucket registration
Both new histograms are registered in
provider._HISTOGRAM_BUCKETS_MS. Withoutan entry a histogram silently falls back to OTEL's default 10s ceiling and its
derived percentile pins to that bound -- a ceiling artifact rendered as a real
latency. Session duration gets a new minutes-to-days family (1s to 7 days,
densest from a minute to a few hours), because it is the only instrument here
measured in hours and days. Tool calls get a sub-millisecond-to-an-hour family:
the fine end because cached reads dominate by count, the ceiling matching the
turn family because a tool call cannot outlive its turn.
Tests
test/metrics/test_session_duration.py(59 cases) drives the production helperswith a patched recorder and a redirected data home:
named by digest, so a key containing
../cannot escape the directorylive generation reaps the crumb it displaced
end_reason+session_source, andconsumes the crumb
reset)end_reason, and a non-positive lifetime eachemit nothing; an unknown reason also leaves the crumb intact
re-walked every boot -- the end path no longer unlinks what it cannot name
dead predecessor and is claimed (the container PID 1 case); a matching identifier
is still us and survives; an unreadable identifier fails closed
crashedsample sized from the transcript mtime;a second backfill emits nothing; a session with no transcript yields no sample
but still loses its crumb; a cleanly-ended session is never back-filled
landing after a SUCCESSOR registered cannot reach the successor's crumb at all
no two paths share a reason, and the boot backfill is ordered after the
orphan-process cleanup
test/metrics/test_tool_call_duration.py(37 cases): every known kind passesthrough; an MCP call is labelled by transport; an agent-authored kind and an
absent kind both become
other; every classification is inside the declared set;each terminal status is an outcome while
pending/in_progress/Noneleave theclock running; a finish with no start, a second finish, and an empty id each
record nothing; a repeated start does not restart the clock; the registry is
bounded. The parser-wiring cases drive the real
_dispatchbuilders, includingan output-less completion (still measured) and a doubled result frame (still one
sample). The scope cases cover two sessions reusing one
toolCallId, a finish inthe wrong scope failing to steal an entry, and -- at both the source and the
behaviour -- the two layers deriving one frame's scope from the same session id,
so whichever sees it second pops the entry the first opened.
test/metrics/test_business_counters.py(29 cases): the approval counter isdriven through the real
ToolHookResultfactories, including that a securitydeny and a policy deny are distinguishable, that the deny REASON never reaches
the attributes, and that the gate still returns its verdict when the emit raises.
Compaction is driven through the real funnel, including the no-callback surface.
Two contract gates cover the rest: each counter's owning module must reference
its constant, and an AST pass asserts no
emit_countercall site passes anf-string or a concatenation as an attribute value.
Mutation-verified, each mutant applied alone and reverted after:
_unlinkfrom the end path turns 4 cases redfail-closed removal gate red, naming the offending path -- which is what
confirms the gate detects a NEW unrecorded removal rather than just the four
already fixed
classify_tool_kindtrust the agent-supplied kind turns 4 cases red,including
assert 'totally_new_kind_9000' == 'other'Manual verification
N/A -- no user-visible surface changes, and every new emit is driven through its
production call site by the tests above rather than through a stub.
Gates run locally: the review gate set (
test/metrics test_acp_tool_identity test_dashboard_reset_sessions) 633 passed.flake8,isort --check-onlyandmypyclean on the changed files, andscripts/docs_lint.pypasses over all 248markdown files. The repo's own black gate passes; the touched files inside
.github/black-baseline.txtwere NOT whole-file reformatted.One coverage-baseline entry, and why it is in this PR
This PR adds one line to
.github/coverage-baselines/backend.txtexemptingsrc/kiro_crew/builtin_skills/pipeline-conductor/scripts/fleet_probe.py. Thatfile is not part of this change and no commit here touches it. The entry is a
deliberate, tracked, revocable measure to clear a CI-side blocker, not a
loosened gate, so here is the whole reasoning in the open.
It is not missing tests.
TestFleetProbeexists attest/test_pipeline_conductor_agent.py:179, its 47 tests pass, and the filemeasures 86% (201/229) when coverage is scoped by PATH. The floor's own
remedy -- "add tests, do not extend the baseline" -- cannot move this number,
because the tests are already there and already green.
What is actually broken is attribution. The backend lane measures with
--cov=kiro_crew, scoped by import NAME. Under that exact flag the same passingtests attribute nothing to this file: locally it does not appear among the 1214
reported rows at all. The
14.8% (34/229)the gate reports is precisely theimport-only footprint of a bare
exec_module-- verified by measuring one. Thisis tracked as #7597 and has had no upstream movement: the file has not been
touched since
c0dca4a8b(2026-09-01), and the issue is still open.Why the entry, rather than another rebase. The file's visibility to the
per-file floor is unstable, and absence is the normal state -- #7300 passed with
no row for it at all. So a rebase alone does not fix anything; it re-rolls
whether the file happens to be imported into the measured shard this time. The
entry removes that coin flip.
Why it surfaced here and not everywhere. Until now it was masked. Coverage
Gate fails CLOSED on the upstream backend conclusion, so while the shard was red
for an unrelated main-owned reason the gate never downloaded or parsed the
artifact and never evaluated the floor. The ordering analysis is on
#7757.
It cannot outlive the defect silently. The gate enforces its own removal.
Once attribution works the file reports at or above 82.0% (the 80% floor plus
its 2.0pp tolerance band), which is the
graduatedverdict -- an::error::that names the line and demands it be deleted. Verified by running the real gate
against a report at 90%:
exit=1,1 baselined file(s) now meet the floor. Remove them so the baseline keeps shrinking. The same run at the import-onlyrate exits 0, and an absent file is a warn-only note. So the three reachable
states are: defect present -> exempt and green; file unmeasured -> note; defect
fixed -> red until the entry is removed.
The entry carries this rationale as a comment beside it in the baseline file,
including the removal condition, so a future reader does not have to find this
PR to understand it.
Related Issues
Refs #7232 #7257
Checklist
docs/system-specs/modules/metrics.mdgains aregistry row per new instrument (attributes, emit site, rationale), the two
new histogram bucket families, and a subsection recording why these
counters are deliberately not merged with the in-memory
stats.pytalliesand which population each measures