Diagnosis from recorded history
Before concluding that a mechanism is or is not the cause, query the recorded history for that mechanism's signature.
Throughput, concurrency, regression, and "why is this slow" questions are
answered from work_attempts history, never from /api/v1/state. The state
endpoint is a point-in-time snapshot. It can answer what Detent sees now, but
it cannot establish a trend, a sustained ceiling, or what happened before the
sample.
The dashboard's Hourly concurrency card is the first stop for throughput. It
uses a rolling 24-hour interval sweep of work_attempts and shows fleet-wide
and per-project median, p90, and maximum concurrency. Use the queries below
when the dashboard needs corroboration or a different window.
Recovery operating procedure
Recovery is an operator responsibility through verification and handoff. A tag, green CI, installed binary, occupied slot, claim or lease heartbeat proves only its own boundary. None establishes that affected projects are delivering work. Keep monitoring after deployment; use scoped claims such as “installed” or “landing path verified” until the delivery evidence supports a broader result. This procedure adds no runtime gate, polling service or automatic lane writer.
Establish a comparable baseline
Choose closed UTC windows of equal length before and after the intervention. Record project scope, window bounds, deployed builds, available routing and workload differences. Separate autonomous delivery from operator merges and state corrections. Do not compare an incomplete current window with a complete baseline or pool unlike projects into an unexplained fleet average.
| Measure | Evidence and limits |
|---|---|
| Landings | Confirm the Change's landed record or the configured repository's actual merge, then reconcile tracker completion. Deduplicate by delivered Change, not successful merge attempts, repeated events or release cherry-picks. Report missing completion separately. |
| Repeated work | Count attempts, first failures and retries by issue and reproduced root cause. Separate source, test, provider, host/setup, policy, publication and operator-action failures; preserve infrastructure attribution. An eventual pass does not erase the first failure. |
| Stage time | Report setup, implementation, source publication, security review, validation/acceptance and landing p50/p90 with completed sample counts, time units and coverage. Separate command runtime, queue wait and unfinished lower bounds. Missing timing is unknown; overlapping work cannot be summed into wall time. |
| Blocked work | Group current reasons into genuine human/product holds, dependencies, policy/access, source custody, infrastructure and outdated state. Cite the actual owner and latest evidence; a lane count alone does not explain eligibility. |
| Capacity | Map each project's eligible runners, pool and lane limits, policy, provider availability and host pressure. Distinguish configured/effective capacity, claims, live providers and finalization. Free organization slots may be ineligible; fully occupied productive slots are healthy. |
Use existing supported API/MCP projections, aggregates and bounded histories. Start with summaries, then fetch receipts for the affected cohort. Follow cursors only until the selected window or question is covered; retain that cursor and reuse the captured evidence instead of repeatedly downloading large histories. Declare partial, omitted and unavailable data. A partial page with no landing is not evidence of zero landings. Never bypass application scope or permissions by copying tenant databases or inspecting hidden credentials.
Repair and verify the failing path
- Reproduce the current failure on its actual source/build and name the existing owner. Match an existing issue or failure fingerprint before filing. Simplify that owner or remove the unnecessary restriction before adding checks. Preserve live work, published source and genuine human holds; use supported revision-aware operations for authorized historical corrections.
- For this repository, resume an active, unshipped frozen candidate instead
of starting a competing release. With no active candidate, if the prior
series has shipped and a new full release is requested, cut the next
release/vX.Yfrom current develop once and freeze it. Fixes land on develop first, then reviewedcherry-pick -xPRs reach the release. Its green full suite permits an immutable tag, staging smoke and production smoke. Follow the release contract; other projects retain their chosen contract. Do not restart successful validation or chase a moving candidate instead of stabilizing the active one. - Verify the installed Hub and every relevant runner's version, commit and observation time. Requested, downloaded or saved updates are not applied updates. Check terminal operation receipts and fresh readback. Track offline runners as exceptions; on return, verify their build, current lease generation and publication custody before relying on them. Do not clear live leases or discard work to make occupancy look correct.
- Follow formerly failing work after rollout from its exact retained or fresh source through publication, security audit, validation/acceptance and actual landing. Identify each receipt's version/base/head and producing build. A new unrelated issue landing does not prove the repaired path, and a pre-rollout failure does not establish a current regression. If a required stage is not observed, leave that part of verification pending.
- Compare the equal windows again: delivered changes, repeat failure classes, stage distributions and outstanding holds. Explain changes in workload, topology or evidence coverage before attributing improvement to the repair. A higher slot count without more landings can be more waiting or repeated work.
Reuse applicable exact-source host receipts only where the project's existing completion contract permits it. Stop duplicate validation caused solely by polling, restarts or a new operator session; a changed source, landing base or required environment still needs its applicable checks. Keep diagnostics focused and process budgets coordinated across the host. Retain the original failure and every check's scope rather than presenting a short gate as a full suite.
Continue or hand off explicitly
Publish observations, source identities, decisions, cursors and evidence links to the selected tracker or Change/PR, never a repository incident notebook. Report what is verified, what remains unknown, and each known exception's owner, next action and observation trigger. Continue the existing monitoring cadence or hand it to an accepting operator; deployment alone does not end recovery. Keep unrelated projects moving. A genuine hold or one offline runner does not make the whole fleet blocked, and successful work elsewhere does not clear it. Use project delivery guidance when changing a project's gates, concurrency or release policy.
Authority by question
| Question | Authoritative surface | Common trap |
|---|---|---|
| Did throughput or concurrency change? | work_attempts intervals, summarized over the relevant window |
One /api/v1/state response |
| Is a capacity or rate-window mechanism binding? | Its recorded scheduler_decisions.wait_reason signature, correlated with work_attempts concurrency |
Inferring a cause from a low current count or a null live bucket |
| How often did one issue dispatch? | The issue's work_attempts history and per-issue timeline |
Board or list endpoints, which collapse repeated attempts |
| Why is an issue in this lane? | workflow_phase_events, or /api/v1/workflow/timeline?project_id=<project>&identifier=<identifier> |
The issue's current lane alone |
| Where did an agent spend time following instructions? | Recorded agent_activity profiles, their activity intervals, instruction hashes and attribution labels |
Treating a file read or an activity category as proof that instructions caused all later work |
| Which Detent build served the page? | The page's data-detent-served-version attribute |
detent version, which identifies the CLI binary invoked from the shell |
| What is true right now? | /api/v1/state |
Treating that snapshot as historical evidence |
| What does an omitted setting mean? | The loaded configuration and its code default | Treating absence as an unset runtime value |
Read the served build directly from the live page:
curl -fsS http://127.0.0.1:4000/ | rg -o 'data-detent-served-version="[^"]+"' -m 1
Activity classification uses the provider's existing parsed command actions
when available, preserving the original command fingerprint. Each tool span
retains an ordered actions list with fixed types/categories, hashed type/name/
path identities, action fingerprints, repeats and instruction candidates. A
mixed command retains one timed span: actions have no independent durations or
outcomes. Sort spans by elapsed_seconds in the existing workflow timeline API
to identify the largest observed operations; compare actions[].fingerprint
and repeat under the same workspace head to identify repeated work. Native
action fingerprints include cwd; raw tool command fingerprints remain unchanged.
The first available native list from start or completion is retained once. Compare
actions[].sources using path_ref, sha256, observed_at and matched_line
to distinguish source versions and inferred instruction candidates. Do not sum
parallel command durations as sequential elapsed time or assign a compound
command's total to every action.
Native instruction reads use the provider's path and working directory (the
workspace when cwd is absent). Native snapshots cover only WORKFLOW.md,
AGENTS.md and CLAUDE.md regular files inside the workspace, including nested
files. The existing recorder goroutine performs these bounded local reads;
dispatch and worker
commands never wait for them. Snapshots are limited to 64 read requests and 64
source records per profile, 256 KiB per file. The queue retains at most 32 actions
and 8 KiB of native metadata including cwd per event; actions_dropped reports
truncation. Each action retains at most four inferred instruction candidates.
source_coverage explains missing paths, external files, unavailable snapshots
and exhausted limits. File contents, commands, arguments, output and provider
names/paths are excluded from persisted records.
A read request is evidence that an instruction file was requested, not that it
caused later work or succeeded. recorder_snapshot_after_native_read records
the version available when telemetry processed the event, not a proof of the
bytes the provider read; edits before queue consumption may change it. Prior
versions remain in instructions, while text matching uses the latest local
snapshot. Exact command matches are inferred_text_match. Existing native
events supply no instruction-origin provenance, so causal_attribution remains
unknown_provider_origin, including observed reads and inferred matches.
coverage_notes, dropped/unpaired counts and unobserved intervals explain
partial coverage. Older profiles lack structured actions and cannot be repaired
from discarded evidence; absent causal attribution is unknown.
Hub API latency
Every authenticated /api/v2 response carries a Server-Timing header with
db, compute, encode and total durations in milliseconds. db is the
time spent executing SQLite statements and stepping their rows on any pool,
encode is JSON encoding, and compute is the remainder of total. The
db span leaves out BEGIN, COMMIT and the wait for a pool connection;
that time falls into compute.
Reads made of several sub-reads add one named span per sub-read after
total, such as reports_analytics, reports_diagnostics,
reports_completion, reports_residence, reports_prior and the
analytics_* parts of the analytics read; spans of concurrent project reads
add up. The hub request timing log line of a slow request carries db_ms
and the same spans in spans_ms. Neither appears in any UI.
Unauthenticated responses carry no timing. One header is a single sample; it
never establishes a trend.
The tenant request-metrics store records per-route latency history: route
template, status class, request count, p50, p95 and max, in hourly buckets
kept for 90 days. Requests only update an in-memory batch, which is flushed to
the metrics store and never to the tenant writer. Organization administrators
read the history through route_latency in the time_series MCP tool and the
request diagnostics API (Hub API). Slowdown and regression
claims cite this history.
| Read | p95 budget |
|---|---|
| Board and issue reads | under 200 ms |
| Activity | under 300 ms |
| Reports and Diagnostics | under 500 ms |
TestHotReadQueryPlans runs EXPLAIN QUERY PLAN on every statement that
Activity, the work-items list, board lanes, projects, Reports and Diagnostics
issue. A hot read never fully scans native_attempts,
collaboration_events, change_requests, attempt_usage,
analytics_hourly_rollups or the rollup views, and never sorts rows of those
tables in a temporary B-tree. TestAnalyticsRollupReportPlans applies the
same rules to Reports over production-shaped tenants and logs their times. A statement that currently breaks
a rule is listed in the test's known-failing table with the issue that fixes
it, and the entry is removed when that fix lands.
GitHub usage timing and privacy
The existing github rest scope usage event now includes a bounded timings
aggregate alongside its unchanged REST request_count, reserve_refused,
fanout_deferred and steps. Correlate existing project/refresh/scope identity
with project refresh timing and sub-step completion events for wall elapsed.
GraphQL HTTP attempts are separate from REST requests and quota queries/points;
request count alone cannot establish latency or a saving.
Each timing key uses the existing fixed stage, step, endpoint family and outcome,
plus Boundary and an allowlisted GraphQL QueryPurpose. Unknown GraphQL purpose
or operation names become graphql in this aggregate without changing quota
accounting. Each completed observed boundary reports AttemptCount, TimedCount,
ElapsedSumNS and ElapsedMaxNS; elapsed values are nanoseconds. Coverage is
TimedCount / AttemptCount within that observed key, never inferred coverage of
all local work. An unavailable timing observation has an attempt but no timed
count or duration. Current instrumented completed attempts are all timed;
in-flight attempts are published when their boundary finishes. Drain clears
counts and timings together; a completion after a drain belongs to the next
outside-refresh cohort. No per-request intervals or rows are retained.
| Boundary | Observed entry paths | Meaning |
|---|---|---|
http_transport |
REST/RESTPage, RESTText/RESTTextWithSize, batch REST probe, GraphQL/GraphQLWithType, installation-token mint | Sum of the actual Do, body-read/counting and existing deferred drain/close segments. Includes transport and body/close failures, 304 and canceled attempts reaching Do. |
token_resolution_inclusive |
Existing Token invocation in regular REST, REST text/size, batch REST probe and GraphQL request entries, including auth retries |
Inclusive resolution with resolver locks/fallback and any nested installation-token HTTP. A cached token creates no HTTP attempt. |
The HTTP clock excludes response logging, progress callbacks, header accounting,
decoding and recursive auth retries between its measured segments. Existing
resource cleanup and request sequencing stay unchanged.
Bounded text reads time only the consumption/close they actually perform;
RESTTextWithSize includes its full-body counting pass. HTTP outcome is the
existing fixed HTTP status classification (200, 304, 429, error), with
body/close failures recorded as timing error without rewriting legacy REST
outcomes. HTTP 200 does not prove GraphQL logical success. Token 200 means a
nonempty token was resolved, not that a later HTTP request succeeded. Token
failures or local budget/backoff refusal create no fabricated HTTP observation.
Direct token retrieval outside these request entries, JWT preparation, JSON
encoding/decoding, actor queues and other local work are unmeasured. There is no
independent connection-pool/actor-queue owner. Do can include connection-pool
wait, DNS/TLS and server time, so this elapsed is HTTP/transport time, not pure
network time. Sums are overlapping work: two concurrent requests can sum to more
than wall elapsed, and inclusive token resolution can contain installation HTTP.
Never add token and HTTP as disjoint elapsed, subtract their sums from stage wall,
or infer critical path, busy-wall, savings or percentage attribution.
Timing is automatic in the existing aggregate event and requires no detailed request logs, extra API call, configuration, persistence table or polling. Persist/log only existing project/refresh/scope identity, fixed names and numeric aggregates. Raw URLs, query/variables, bodies, headers, credentials/hashes, command/environment, prompts and private operator criteria are excluded. Existing unrelated debug behavior is unchanged; it is not needed for timing.
Runtime acceptance belongs to Detent integration and the existing release owner after a normal authorized rollout. Record the deployed version/integration SHA and one naturally completed refresh's scope aggregate together with its stage and whole-refresh wall elapsed. Report REST and GraphQL HTTP/token attempt and timed counts, sums/maxima by fixed key, installation HTTP if observed, and any absent/unobserved boundaries. Explicitly leave actor queue, local work and pure network components unknown. Until that completed refresh is recorded, runtime acceptance remains pending; source diagnostics do not establish rollout evidence.
Token spend
The operator health metric is tokens per merged PR per project per UTC day, across both hosts (mac-studio and prometheus in the September 2026 audit). Use recorded database history, never the dashboard, for this number. Include spend on unsuccessful and still-open work in the numerator; merged-only spend answers a different question. Sum host numerators and deduplicate merged identifiers across hosts before dividing; do not average host ratios. Record the window, hosts, token source, and merge definition with every result.
Authority, joins, and time windows
usage_events is the authoritative complete source for recorded session token
usage, equivalent to workflow_phase_events filtered to
phase_type = 'agent_session' in the 2026-09-07..14 audit. Do not add those two
sources together. Complete here means recorded session usage: restart-abandoned
sessions can lack a phase/usage row. work_attempts.metrics_json is not an
alternative authority: rows written before the #2710 fix may retain only the
last checkpoint/recovery segment, including on session_duration_exceeded.
Terminal writes now use cumulative session usage and the session turn count;
historical rows are not backfilled. For attempt outcome
dimensions, use coalesce(phase_event.total_tokens, metrics_json.total_tokens, 0)
and report the fallback coverage. Preserve validator/routine sessions that have
no attempt when computing all-project spend. Missing metrics mean unknown
spend; the fallback zero is not evidence of free work.
The join keys are:
work_attempts.detent_session_id = workflow_phase_events.session_id = codex_sessions.id.work_attempts.provider_session_idis the Codex rollout session ID, not the Detent session ID. Use it to locate the provider transcript.
Stored timestamps are ISO strings containing T. SQLite
datetime('now', '-24 hours') emits a space: comparing it directly to these
strings can silently match every timestamp on the cutoff date, including hours
before the cutoff. Bind ISO literals such as '2026-09-14T00:00:00Z', or convert
both sides to epoch values. The copied audit queries below use the explicit
lower bound '2026-09-07T00:00:00Z'. Replace every occurrence for another audit;
for a closed window, add the same exclusive ISO upper bound to each underlying
source. Their original window is open-ended, so rerunning later includes newer
rows. Daily spend is assigned by session/attempt start time, not prorated.
What total tokens means
Codex counts the whole submitted context again on every model call.
total_tokens is cumulative processed input plus output, not unique text or
final-answer size. The September 7–14 audit measured roughly 96–98% cached
prefix and 0.3% output. These are observations, not guaranteed ratios. Calls
per session multiplied by context length per call explains most token volume;
a Detent turn may contain many model calls.
Cached reads still meter at one tenth of uncached input for the audited Astra
and Sol rates. Weight tokens by the model's applicable rate before comparing
models; equal token counts do not imply equal spend. Consult the
Codex pricing page for
current rates, billing units, and speed multipliers. With rates per million,
compute ((input_tokens - cached_input_tokens) * input_rate + cached_input_tokens * cached_rate + output_tokens * output_rate) / 1000000.
Cached tokens are already included in input; reasoning output is already part
of output. Do not charge either twice or present credits as dollars.
Rollout layout
On each audited host, transcripts live in
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl; the worker's isolated
CODEX_HOME sessions directory was empty. Inspect the host user's sessions
rather than assuming the worker directory contains the rollouts.
Filter JSONL session_meta records to
payload.originator == "detent-orchestrator". Sub-agent threads carry
parent_thread_id and duplicate parent history: keep their lineage and exclude
copied history when aggregating calls, text, or cumulative counters. Do not sum
parent and child transcript histories as independent spend.
event_msg records with payload.type == "token_count" carry
payload.info.total_token_usage (cumulative) and last_token_usage (last model
call). Do not sum cumulative snapshots; use the final cumulative total or
validated deltas, accounting for inherited history. In response_item records,
the first user message after the AGENTS.md item is the Detent prompt, and
payload.type == "custom_tool_call_output" denotes tool output. These locations
let an audit separate prompt overhead from tool-output growth.
Ready-to-paste audit queries
The setup and queries below are copied from the operator's
~/.detent/token-audit/baseline-2026-09-14/token-audit-appendix.md, MAC-DB
(Q0a/Q0e, Q2b, Q3b/Q3f, Q4b, Q5a, Q6d). The appendix abbreviates merged;
its full definition below comes from the companion audit_db.py, with the
window literal substituted. Semicolons are added for interactive SQLite use.
No access to the operator's audit directory is needed to run these copies.
Open a fresh connection on each host and paste the setup once, then any query:
sqlite3 -readonly "$HOME/.detent/detent.db"
.headers on
.mode column
Only temporary views are created; the database remains read-only. att.tokens
uses phase-event totals with attempt-metrics fallback; pe.total_tokens uses
only recorded agent sessions. The merge definition counts distinct identifiers
that reached Done, were delivered, or had a successful merge attempt with the
terminal status message. This is the audit's merged-PR proxy, not an independent
GitHub merge verification; Done can also represent an operator closure.
create temp view att as
select w.id, w.project_id, w.identifier, w.pr_number, w.lane, w.attempt_number, w.status,
w.started_at, w.completed_at, substr(w.started_at,1,10) day,
w.terminal_state, w.error_class, w.error_message, w.status_message, w.wait_reason, w.ci_state,
w.detent_session_id,
json_extract(w.worker_metadata_json,'$.run_mode') run_mode,
json_extract(w.worker_metadata_json,'$.work_product_pushed') pushed,
json_extract(w.worker_metadata_json,'$.completion_progress.outcome') cp_outcome,
json_extract(w.worker_metadata_json,'$.completion_progress.reason') cp_reason,
json_extract(w.worker_metadata_json,'$.completion_progress.progress_kinds') cp_kinds,
json_extract(w.worker_metadata_json,'$.completion_progress.workspace_diffstat.files_changed') cp_files,
json_extract(w.worker_metadata_json,'$.completion_progress.tracker_state') cp_tracker,
json_extract(w.worker_metadata_json,'$.cancellation.reason') cancel_reason,
json_extract(w.worker_metadata_json,'$.dispatch_loop_start.fingerprint.lane') fp_lane,
json_extract(w.worker_metadata_json,'$.dispatch_loop_start.fingerprint.pr_head_sha') fp_pr_head,
json_extract(w.worker_metadata_json,'$.dispatch_loop_start.fingerprint.workspace_head') fp_ws_head,
coalesce(json_extract(w.worker_metadata_json,'$.dispatch_loop_start.fingerprint.pr_head_sha'),
json_extract(w.worker_metadata_json,'$.dispatch_loop_start.fingerprint.workspace_head')) dispatch_head,
json_extract(w.worker_metadata_json,'$.pr_head_sha') end_pr_head,
json_extract(w.metrics_json,'$.total_tokens') wa_tokens,
json_extract(w.metrics_json,'$.turns') wa_turns,
json_extract(w.metrics_json,'$.runtime_seconds') wa_runtime,
p.id pe_id, p.total_tokens pe_tokens, p.input_tokens, p.output_tokens, p.cached_input_tokens,
p.reasoning_output_tokens, p.turns pe_turns, p.duration_seconds, p.model_context_window,
coalesce(p.total_tokens, json_extract(w.metrics_json,'$.total_tokens'), 0) tokens,
c.agent_role
from work_attempts w
left join workflow_phase_events p on p.session_id = w.detent_session_id and p.phase_type = 'agent_session'
left join codex_sessions c on c.id = w.detent_session_id
where w.started_at >= '2026-09-07T00:00:00Z';
create temp view pe as
select p.*, substr(p.started_at,1,10) day, c.agent_role, c.final_state, c.work_attempt_id
from workflow_phase_events p left join codex_sessions c on c.id = p.session_id
where p.phase_type = 'agent_session' and p.started_at >= '2026-09-07T00:00:00Z';
create temp view merged as
select distinct project_id, identifier from (
select project_id, identifier from workflow_phase_events where phase_type='lane' and phase_name='Done' and started_at >= '2026-09-07T00:00:00Z' and identifier is not null
union
select project_id, identifier from att where terminal_state='delivered'
union
select project_id, identifier from att where run_mode='merge' and terminal_state='success' and status_message='worker reached terminal state'
);
Source reconciliation
Report phase coverage and fallback totals before using attempt dimensions.
select count(*) attempts, sum(detent_session_id is not null) with_session,
sum(pe_id is not null) with_phase_event, sum(wa_tokens is not null) with_wa_metrics,
sum(wa_tokens) sum_wa_metrics_tokens, sum(pe_tokens) sum_pe_tokens, sum(tokens) sum_coalesced_tokens
from att;
Usage and phase equivalence
The original audit query checks usage rows against their matching phases. It does not detect phase-only sessions; run the independent check below too.
select count(*) usage_rows, sum(u.total_tokens) usage_tokens, sum(p.total_tokens) pe_tokens,
sum(u.total_tokens = p.total_tokens) equal_rows, sum(p.id is null) usage_without_pe
from usage_events u left join workflow_phase_events p on p.session_id = u.session_id and p.phase_type='agent_session'
where u.started_at >= '2026-09-07T00:00:00Z';
Check source totals independently and report both unmatched directions and non-unique session keys before treating the sources as equivalent. Totals alone can conceal offsetting differences; inspect any nonzero mismatch count.
with u as (
select session_id, total_tokens from usage_events
where started_at >= '2026-09-07T00:00:00Z'
), p as (
select session_id, total_tokens from workflow_phase_events
where phase_type='agent_session' and started_at >= '2026-09-07T00:00:00Z'
)
select (select count(*) from u) usage_rows,
(select count(*) from p) phase_rows,
(select sum(total_tokens) from u) usage_tokens,
(select sum(total_tokens) from p) phase_tokens,
(select count(*) from u where not exists
(select 1 from p where p.session_id=u.session_id)) usage_without_phase,
(select count(*) from p where not exists
(select 1 from u where u.session_id=p.session_id)) phase_without_usage,
(select count(*) from u join p using (session_id)
where u.total_tokens is not p.total_tokens) token_mismatches,
(select count(*) from
(select session_id from u group by session_id having count(*)>1)) usage_duplicate_keys,
(select count(*) from
(select session_id from p group by session_id having count(*)>1)) phase_duplicate_keys;
Tokens per merged PR per project
This original window-wide query uses all attempt tokens, including fallback, and separately shows merged-only spend. It excludes sessions without attempts; use the daily all-session query below for the operator health metric.
with proj as (
select project_id, sum(tokens) all_tokens, count(*) all_attempts, sum(pe_id is not null) all_sessions from att group by 1),
mg as (
select a.project_id, count(distinct a.identifier) merged_prs, sum(a.tokens) merged_tokens, count(*) merged_attempts, sum(a.pe_id is not null) merged_sessions
from att a join merged m on m.project_id=a.project_id and m.identifier=a.identifier group by 1)
select p.project_id, coalesce(mg.merged_prs,0) merged_prs, p.all_tokens, mg.merged_tokens,
round(p.all_tokens*1.0/nullif(mg.merged_prs,0)) tokens_per_merged_pr_all_basis,
round(mg.merged_tokens*1.0/nullif(mg.merged_prs,0)) tokens_per_merged_pr_merged_only,
p.all_sessions, round(p.all_sessions*1.0/nullif(mg.merged_prs,0),2) sessions_per_merged_pr_all_basis,
round(mg.merged_sessions*1.0/nullif(mg.merged_prs,0),2) sessions_per_merged_pr_merged_only,
p.all_attempts
from proj p left join mg on mg.project_id=p.project_id order by p.all_tokens desc;
Repeat attempts per identifier and head SHA
The dispatch head prefers the PR head and falls back to the workspace head. Unknown heads are excluded. The copied query groups by a ten-character head prefix for display; inspect full hashes if prefixes collide. Its sequence is diagnostic text, not a guaranteed chronological ordering.
with ranked as (
select id, project_id, identifier, dispatch_head, lane, terminal_state, tokens, started_at,
row_number() over (partition by identifier, dispatch_head order by started_at) rn
from att where dispatch_head is not null)
select project_id, identifier, substr(dispatch_head,1,10) head, count(*) attempts, sum(case when rn>1 then tokens else 0 end) repeat_tokens, sum(tokens) all_tokens,
group_concat(lane || '/' || coalesce(terminal_state,'active'), ' > ') sequence
from ranked group by 1,2,3 having count(*) > 1 order by repeat_tokens desc;
Tokens by terminal state and error class
Uses att.tokens, including the metrics fallback.
select terminal_state, error_class, count(*) n, sum(tokens) tokens, round(100.0*sum(tokens)/(select sum(tokens) from att),1) pct from att group by 1,2 order by tokens desc;
Cache ratio per project
Uses recorded phase events only; the denominator is input tokens.
select project_id, count(*) sessions, sum(input_tokens) input, sum(cached_input_tokens) cached, round(1.0*sum(cached_input_tokens)/nullif(sum(input_tokens),0),3) cache_ratio,
sum(output_tokens) output, round(1.0*sum(output_tokens)/nullif(sum(total_tokens),0),4) output_share,
sum(reasoning_output_tokens) reasoning, round(1.0*sum(reasoning_output_tokens)/nullif(sum(total_tokens),0),4) reasoning_share,
sum(cached_input_tokens is null) cached_null
from pe group by 1 order by input desc;
Sessions above 30 million tokens
Uses recorded phase events; missing-phase abandoned sessions are not included.
select p.project_id, p.agent_role, p.session_id, p.identifier, p.total_tokens, p.turns, p.duration_seconds, p.model_context_window, a.lane, a.terminal_state, a.error_class
from pe p left join att a on a.detent_session_id = p.session_id where p.total_tokens > 30000000 order by p.total_tokens desc;
Service-restart abandonment clusters
Uses att.tokens and groups completion timestamps by minute. tokens_lost
is the audit alias for spend on abandoned attempts, not proof that all work
was discarded. Correlate clusters with service logs; infrastructure failures
belong to the instance, never the issue.
select substr(completed_at,1,16) restart_minute, count(*) attempts_abandoned, sum(tokens) tokens_lost, group_concat(distinct project_id) projects
from att where terminal_state='abandoned' and error_class='service_restart' group by 1 order by 1;
Daily health metric and host combination
This daily extension of Q1b/Q2b includes all recorded sessions plus attempt metrics only where a phase event is missing. It counts merge evidence by its completion/Done day, independently of when the attempt started. Run after the setup above. Change the start literal here along with the setup. A null ratio means no merges that day; keep the spend visible.
create temp view merged_days as
select project_id, identifier, substr(min(merged_at),1,10) day
from (
select project_id, identifier, started_at merged_at
from workflow_phase_events
where phase_type='lane' and phase_name='Done'
and started_at >= '2026-09-07T00:00:00Z'
union all
select project_id, identifier, completed_at
from work_attempts
where completed_at >= '2026-09-07T00:00:00Z'
and (terminal_state='delivered' or
(json_extract(worker_metadata_json,'$.run_mode')='merge'
and terminal_state='success'
and status_message='worker reached terminal state'))
)
where identifier is not null
group by project_id, identifier;
create temp view spend_days as
select project_id, day, sum(recorded_tokens) recorded_tokens,
sum(fallback_tokens) fallback_tokens
from (
select project_id, day, total_tokens recorded_tokens, 0 fallback_tokens
from pe
union all
select project_id, day, 0, tokens from att where pe_id is null
)
group by project_id, day;
with days as (
select project_id, day from spend_days
union
select project_id, day from merged_days
), merges as (
select project_id, day, count(*) merged_prs
from merged_days group by project_id, day
)
select d.project_id, d.day,
coalesce(s.recorded_tokens,0) recorded_tokens,
coalesce(s.fallback_tokens,0) fallback_tokens,
coalesce(m.merged_prs,0) merged_prs,
(coalesce(s.recorded_tokens,0) + coalesce(s.fallback_tokens,0)) * 1.0
/ nullif(m.merged_prs,0) tokens_per_merged_pr
from days d
left join spend_days s using (project_id, day)
left join merges m using (project_id, day)
order by d.project_id, d.day;
Run against each host's local database, not an aggregate copy that repeats the
same sessions. Export spend_days and merged_days from the same connection:
select * from spend_days order by project_id, day;
select * from merged_days order by project_id, identifier;
For the both-host report, sum recorded_tokens and fallback_tokens by
(project_id, day) across the host exports. Union the merge exports, group by
(project_id, identifier), and take the earliest day so a merge seen by both
hosts counts once. Count those identifiers by project/day and divide summed
spend by that count. Retain the two token columns so every result states the
source and fallback contribution. Session IDs are host-local; do not join
numeric IDs between hosts. If replicated history is used, deduplicate it by
its originating host/session before summing. Compare like windows and flag
partial days rather than treating today's unfinished work as a regression.
Codex startup stalls
A backend_startup_timeout identifies a missed handshake deadline, not a
provider outage. The codex app-server startup failed log event contains
payload-free startup evidence even for admission runs. Issue attempts also
persist it in work_attempts.worker_metadata_json.backend_startup.
Compare before_cleanup with process (after cleanup). An absent
before_cleanup means the older runtime or transport did not capture it;
do not interpret absent counters as observed zeroes. Counters are cumulative
for that app-server process, including initialization and optional account
checks. They contain no methods, arguments, credentials, prompts, or stderr
contents.
| Observation | Interpretation and next check |
|---|---|
ready: false |
Initialization did not complete. Check executable/version, spawn errors, and local process pressure before investigating thread configuration. |
ready: true, sent messages advance, received messages do not |
Bytes were written to the provider pipe, but no additional complete RPC frame was decoded. This does not prove the provider consumed the request. Capture a bounded process sample to distinguish local blocking from remote I/O. |
receive_queue_depth grows or read_failed: true |
Investigate RPC decoding or consumer backpressure. A nonempty queue alone does not establish deadlock. |
stderr_bytes grows |
The provider emitted diagnostics. Inspect them locally under the operator's privacy rules; the startup event intentionally records only the count. |
before_cleanup.exit_observed: true |
The process exited before Detent began closing the transport. Check its exit status and launch environment. |
Exit observed only after termination_requested: true |
Detent requested termination during cleanup. A resulting signal: killed is not evidence of an earlier crash or OOM. |
exit_observed: true, cleanup_complete: false |
Process exit was observed, but the transport wait/drainers have not finished. Check inherited pipes and descendants. cleanup_complete records transport completion, not successful termination of every descendant. |
Response waits use one read_timeout_ms deadline across notifications and
server requests. Streaming turns retain their separate activity/stall rules.
A close timeout is retained in the error chain, but cannot turn an explicit
startup rejection or EOF into a startup timeout for capacity classification.
Retries retain workspace state and the existing startup failure breaker.
Admission startup failures retain their error in failed-run records instead
of becoming generic capacity deferrals. Quota and provider-overload errors
continue to defer admission without marking model output malformed.
For a recurrence, capture only the affected worker, without restarting or signaling the live Detent service:
- Record the attempt/session IDs, startup event, executable version, and
failed stage. Correlate the PID with
codex_sessions.worker_pidandworker_started_atand verify its executable and start time before sampling; a stale PID may belong to a different process. - During the stall, take a one-second macOS
sample <worker-pid> 1 1 -file "$TMPDIR/codex-startup.sample"with an outer five-second command deadline. Recordps -p <worker-pid> -o pid,ppid,lstart,state,%cpu,rssalongside host memory/CPU pressure. Keep the raw sample private; report only relevant stack symbols and aggregate metrics. Existing worker concurrency and free disk space cannot exclude memory pressure or per-process blocking. - Reproduce
initialize,initialized, andthread/startin an isolated app-server process with an absolute temporary workspace, no prompt orturn/start, a fixed request deadline, and guaranteed process-group cleanup. Match the installed protocol schema and runtime model/config. Compare a minimal Codex home with the configured plugins, hooks, and skills restored one category at a time. Keep credentials local and never print RPC payloads. - For launchd-only failures, compare an isolated temporary launch agent with a direct process. A matching TCC denial or blocking filesystem stack supports fixing access to that particular skill/config path; a successful direct launch alone does not. Do not mutate operator-owned links during diagnosis.
- Apply a remedy only to the established cause: correct a rejected startup setting; remove or repair the isolated failing plugin/hook; repair the proven launchd access restriction; reduce spawn pressure when correlated resource evidence supports it; or use the provider's recovery guidance when remote response/connection evidence establishes an outage. Preserve existing work and let the existing instance breaker limit repeated pre-turn failures. Do not increase the timeout or restart the service to substitute for diagnosis.
September 9, 2026 incident (#2376)
Read-only recorded history shows attempts 4959, 4960, and 4961 initialized
within 533–791 ms, then spent 5000–5001 ms waiting for thread/start. Attempt
4964 initialized within 338 ms and still failed after 30000 ms. All four
recorded one concurrent startup, with 8–10 active workers, and exited with
signal: killed after a further close deadline. Sessions 5844–5846 and 5849
were subsequently recorded as already exited by worker cleanup. Admission
session 5848 failed during the same interval and was also reaped.
Its admission run 9529 (00:45:00–00:46:10 UTC) instead recorded deferred,
agent_backend_capacity, and no error text. That loss of startup failure
attribution is reproduced by the admission regression and corrected here.
Attempt 4966 began at 00:48:20 UTC and obtained a provider identity around 00:49, before the unsuccessful timeout trial was reverted around 00:50. The unpushed workspace commit was preserved across the failed retries. The incident therefore establishes intermittent post-initialization silence and bounded termination, not a cause or a timeout-setting remedy. The recorded history lacks pre-cleanup I/O counters and process samples, so it cannot distinguish provider deadlock, configuration/plugin I/O, resource pressure, or external service failure retroactively. The notification deadline renewal and cleanup-error misclassification regressions reproduce independently; neither is claimed as the cause of these four silent waits.
Workspace scan deadlines (#2657)
A workspace scan command stage=wait error means the command started but the
scan did not complete before cancellation. A short start_elapsed measures
cmd.Start, not completion of executable initialization. When cancellation
returns before cmd.Wait, output_bytes=unknown means the waiter still owns
the output buffer. Older builds reported output_bytes=0 in that case even
when a child had written output; do not infer silence or a startup stall from
that historical value.
The reported Go 1.27.1 darwin/arm64 failure reached its individual 30-second
scan deadline during scratch verification. Bounded reproduction on the same
platform passed three runs of TestReapScratchProcessesOutsideWorkspace
(including all legacy cases). Three direct lsof -O -w -d cwd -F pn scans with
file-backed output exited successfully in 1.21–2.28 seconds with 208–211 KB of
output. This does not establish the cause of the historical timeout. A
separate inherited-pipe regression reproduces the misleading zero-byte
report deterministically; correcting that diagnostic preserves the existing
deadline and cancellation behavior, without adding a recovery mechanism.
For a recurrence, record the error and command PID, then take a bounded
process sample while the affected command is still running. Keep draining
stdout concurrently, or redirect an isolated probe to a temporary file:
pausing a pipe reader while sampling can itself block lsof in
__write_nocancel. Such probe-induced backpressure is not evidence of the
original failure. Record completed output size, exit status, elapsed time,
and relevant stack symbols, keeping process paths and raw samples private.
Do not increase the lifecycle deadline or attribute an unproven host failure
to the issue.
Ready-to-paste SQLite queries
These queries default to a rolling 24-hour window. Replace '-24 hours' or
add a project_id predicate when investigating a narrower incident.
Hourly maximum-concurrency interval sweep
This event sweep counts overlapping attempt intervals without relying on a single observation. Active attempts end at their recorded lease or heartbeat when no completion exists, so an old incomplete row does not extend forever.
WITH RECURSIVE
params AS (
SELECT unixepoch(strftime('%Y-%m-%dT%H:00:00Z', 'now'), '-24 hours') AS from_at,
unixepoch(strftime('%Y-%m-%dT%H:00:00Z', 'now')) AS to_at
),
hours(hour_start) AS (
SELECT from_at FROM params
UNION ALL
SELECT hour_start + 3600
FROM hours, params
WHERE hour_start + 3600 < to_at
),
project_hours AS (
SELECT DISTINCT attempts.project_id, hours.hour_start,
MIN(hours.hour_start + 3600, params.to_at) AS hour_end
FROM work_attempts AS attempts
CROSS JOIN hours
CROSS JOIN params
WHERE unixepoch(attempts.started_at) < MIN(hours.hour_start + 3600, params.to_at)
AND unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)) > hours.hour_start
),
raw_events AS (
SELECT ph.project_id, ph.hour_start, ph.hour_start AS event_at,
SUM(CASE WHEN unixepoch(attempts.started_at) < ph.hour_start
AND unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)) > ph.hour_start
THEN 1 ELSE 0 END) AS delta
FROM project_hours AS ph
LEFT JOIN work_attempts AS attempts ON attempts.project_id = ph.project_id
GROUP BY ph.project_id, ph.hour_start
UNION ALL
SELECT ph.project_id, ph.hour_start, unixepoch(attempts.started_at), 1
FROM project_hours AS ph
JOIN work_attempts AS attempts ON attempts.project_id = ph.project_id
WHERE unixepoch(attempts.started_at) >= ph.hour_start
AND unixepoch(attempts.started_at) < ph.hour_end
AND unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)) > unixepoch(attempts.started_at)
UNION ALL
SELECT ph.project_id, ph.hour_start,
unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)), -1
FROM project_hours AS ph
JOIN work_attempts AS attempts ON attempts.project_id = ph.project_id
WHERE unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)) > ph.hour_start
AND unixepoch(COALESCE(attempts.completed_at, attempts.lease_expires_at,
attempts.heartbeat_at, attempts.started_at)) < ph.hour_end
AND unixepoch(attempts.started_at) < ph.hour_end
),
events AS (
SELECT project_id, hour_start, event_at, SUM(delta) AS delta
FROM raw_events
GROUP BY project_id, hour_start, event_at
),
points AS (
SELECT project_id, hour_start, event_at,
SUM(delta) OVER (
PARTITION BY project_id, hour_start
ORDER BY event_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS concurrency
FROM events
)
SELECT project_id, datetime(hour_start, 'unixepoch') AS hour_start,
MAX(concurrency) AS max_concurrency
FROM points
GROUP BY project_id, hour_start
ORDER BY hour_start, project_id;
Correlate a pinned result with the mechanism signature before attributing it:
SELECT project_id,
strftime('%Y-%m-%dT%H:00:00Z', decision_at) AS hour,
COUNT(*) AS sampled_refusals
FROM scheduler_decisions
WHERE unixepoch(decision_at) >= unixepoch('now', '-24 hours')
AND result = 'skipped'
AND wait_reason = 'provider_rate_window_backpressure'
GROUP BY project_id, hour
ORDER BY hour, project_id;
Per-issue dispatch counts
SELECT project_id,
COALESCE(NULLIF(TRIM(identifier), ''), NULLIF(TRIM(issue_id), ''),
NULLIF(TRIM(issue_url), '')) AS issue,
COUNT(*) AS dispatches,
MIN(started_at) AS first_dispatch,
MAX(started_at) AS latest_dispatch
FROM work_attempts
WHERE unixepoch(started_at) >= unixepoch('now', '-24 hours')
GROUP BY project_id, issue
HAVING issue IS NOT NULL
ORDER BY dispatches DESC, latest_dispatch DESC;
Lane and project distribution
SELECT project_id,
COALESCE(NULLIF(TRIM(lane), ''), '(unrecorded)') AS lane,
COUNT(*) AS dispatches,
ROUND(SUM(MAX(
0,
unixepoch(MIN(COALESCE(completed_at, lease_expires_at, heartbeat_at,
started_at), datetime('now')))
- unixepoch(MAX(started_at, datetime('now', '-24 hours')))
)) / 3600.0, 2) AS agent_hours
FROM work_attempts
WHERE unixepoch(started_at) < unixepoch('now')
AND unixepoch(COALESCE(completed_at, lease_expires_at, heartbeat_at, started_at))
> unixepoch('now', '-24 hours')
GROUP BY project_id, lane
ORDER BY agent_hours DESC, dispatches DESC, project_id, lane;
Blocked-cause breakdown by recovery reason
workflow_phase_events.reason is the durable recovery reason on the recorded
entry into Blocked; the alias below matches the operator-facing field name.
SELECT project_id,
COALESCE(NULLIF(TRIM(reason), ''), '(unrecorded)') AS recovery_reason,
COUNT(*) AS blocked_entries,
MIN(started_at) AS first_seen,
MAX(started_at) AS latest_seen
FROM workflow_phase_events
WHERE phase_type = 'lane'
AND lower(phase_name) = 'blocked'
AND status = 'entered'
AND unixepoch(started_at) >= unixepoch('now', '-24 hours')
GROUP BY project_id, recovery_reason
ORDER BY blocked_entries DESC, project_id, recovery_reason;
Security audit waits (#2716)
security_audit_runs stores completed results, not launch records. Its
completed_at field is required. Absence of a row cannot establish that an
audit did not start. Correlate the wait timestamp with codex_sessions where
agent_role = 'security_audit', including started_at and completed_at.
Audit scratch paths alone are insufficient: allowance triage also uses the
security-audit scratch root.
On prometheus after the September 14 21:12Z restart, session 12329 for
digitaldrywood/pyroapex#2127 started at 23:49:18Z and completed at 23:58:06Z.
The 23:54:51Z security_audit_wait decision fell inside that interval.
Completed audit row 29 has the same timestamps, successful execution, and a
fail verdict. This evidence contradicts the reported absence of audit
launches; it does not establish the cause of other cards' allowance parks.
The in-memory stage registry prevents duplicate launches while a reviewer is
active. It is initialized empty on restart. Missing exact-base/head results
request an audit on the existing auto-promote pass, including Merging; a
trusted completed result remains usable after restart. Re-running a completed
passing audit merely because the process restarted is unnecessary.
TestSecurityAuditLifecycleAcrossRestart covers those boundaries with a held
reviewer, without wall-clock sleeps. No additional startup reconciliation is
needed to discard a registry that is never persisted.
Validation lock cost
For historical lock investigations, use scripts/validation-report.sql alongside the work-attempt throughput queries
above. Before the lock was removed from the Make targets, make check and make check-fast retained a synced event stream at
$(git rev-parse --path-format=absolute --git-common-dir)/detent-validation-events.jsonl.
This survives worktree removal. The stream includes host, unique run ID,
issue/workspace attribution, wait and hold seconds, command runtime, and queue
observations. It contains local filesystem paths; handle it like other local
operational history. Validation before checklock starts (including Make's
preflight and compiling the wrapper) is outside these measurements.
The following read-only report uses Python's standard library and SQLite 3.42+
(for subsecond timestamps). Set database to that host's Detent database,
events to the retained JSONL file, host to its recorded OS hostname,
worker_host to the corresponding work_attempts.worker_host, and day to a
UTC date (YYYY-MM-DD). Hostnames and configured worker-host names need not be
equal. For local attempts whose worker_host is null, set worker_host to
the empty string and use that originating host's database (not an aggregate
copy). Run from the repository root. The connection cannot write the history
database; all imported records and report tables are temporary.
python3 - "$database" "$events" "$host" "$worker_host" "$day" <<'PY'
import json
import pathlib
import sqlite3
import sys
history, events, host, worker, day = sys.argv[1:]
if sqlite3.sqlite_version_info < (3, 42, 0):
raise SystemExit("SQLite 3.42+ is required for subsecond timing")
uri = pathlib.Path(history).resolve().as_uri() + "?mode=ro"
with sqlite3.connect(uri, uri=True) as db:
db.row_factory = sqlite3.Row
db.execute("CREATE TEMP TABLE validation_events(payload TEXT)")
with open(events, encoding="utf-8") as stream:
db.executemany("INSERT INTO validation_events VALUES (?)",
((line.rstrip("\n"),) for line in stream if line.strip()))
db.execute("CREATE TEMP TABLE validation_report_params(host, worker_host, day)")
db.execute("INSERT INTO validation_report_params VALUES (?, ?, ?)",
(host, worker, day))
sections = pathlib.Path("scripts/validation-report.sql").read_text().split("-- Output ")
db.executescript(sections[0])
for section in sections[1:]:
title, query = section.split("\n", 1)
print(title)
for row in db.execute(query):
print(json.dumps(dict(row), sort_keys=True))
PY
The outputs are:
- Coverage and observed wait fraction: uniquely attributed waiting seconds / observed dispatched code-session seconds for that host/day. Attempts are clipped to the UTC day and current time; incomplete attempts end at their last heartbeat, not a future lease. Multiple simultaneous gates in one attempt are unioned before summing. Matching requires the same issue and host with the gate start inside exactly one attempt interval. Ambiguous, standalone, and unmatched runs are counted, not guessed into an attempt. No matched measurements produces a null fraction. Even a non-null fraction is a lower bound when instrumentation coverage is incomplete.
- Mean, median, p90, and maximum queue depth weighted by seconds with waiters,
reconstructed from observed wait intervals (including registration). Idle
periods are excluded from these busy-depth statistics. This describes the
lock represented by the input file, not unrelated repositories on the host.
The separately reported
max_observed_queue_depthcomes from queue-position lookups and includes older clients that have no retained timing stream;max_instrumented_queue_depthand the time-weighted statistics cover only recorded runs. During rollout these can differ substantially: neither the instrumented maximum nor its typical depth describes the whole queue until coverage is complete. Simultaneous interval edges are combined. Incomplete runs contribute only their observed wait lower bounds, never an assumed wait through midnight. - Whole-run wait/hold durations and completeness flags for runs observed in the day. Hold values on nonterminal records are not final measurements. These whole-run values must not be summed as clipped daily totals.
Malformed records and legacy records without run IDs are counted explicitly. Duplicate imports with the same run ID do not multiply measurements. A killed writer, a telemetry write error, an uninstrumented gate, or an incomplete event file prevents claims of complete coverage. The old worktree-local streams lack host/run attribution and cannot establish typical or worst-case historical queue depth. The #2962 observation (46m31.845s waiting, 11m14.254s running) is one run, not a host baseline. Collect a complete busy UTC day after rollout before using these statistics to choose a remedy; report the window, host mapping, coverage counts, and whether the day is partial.
Deadline and allowance accounting
Waiting can consume a configured worker session deadline. In
internal/runner/agent.go, runAgentTurn starts sessionLimit before the provider
turn; withAgentDurationLimit uses a context deadline. Running a shell command
that waits in checklock does not suspend that deadline. Checklock also has its
own no-progress and absolute queue-wait deadlines, which are distinct from the
parent session deadline. After acquisition those queue deadlines no longer
limit command runtime. A parent deadline can still cancel the command.
Waiting does not consume an additional attempt by itself, but the already
started code/rework session counts toward the issue's attempt allowance.
countSessionsWithoutMerge in internal/orchestrator/attempt_allowance.go
counts started sessions and excludes specifically classified infrastructure
failures, merge routing, and external waits. Validation-lock waiting has no
special exclusion. A session that exhausts its duration while queued can
therefore finish without running validation and still consume an allowance
slot. This documents current accounting; #2977 changes neither those policies
nor validation serialization.