Operations report
Read GET /api/v1/operations from a runner's local API using the same
authentication and read scope as the state API. The report includes its
generation time and producing instance name.
The JSON structure is shared in internal/operations. Historical aggregation
lives in the store; the web layer adds instance identity, current merge-queue
telemetry, explicit human gates, and issue links.
The operator health metric is tokens per merged PR per project per day across both hosts. Compute it from recorded history, never the dashboard or completion receipt averages. See Token spend diagnosis for the authoritative sources, merge definition, and runnable audit queries.
Stats
The two windows are the trailing 24 hours and seven days, ending at data_time.
Historical windows include their start and exclude their end.
- Closes count completed efficiency receipts. Merges use the existing cost-per-outcome convention: completed receipts with a positive PR number. Per-day values divide those counts by the window duration in days. These measurements cover recorded completions, not an independent GitHub audit.
- Cycle time uses the first recorded In Progress entry and first Done entry per
project and issue, including In Progress entries before the reporting window.
Repeated transitions do not add cycle samples. Median and p75 use linear
interpolation. Missing start times are excluded and
cycle_samplesrecords the denominator; no samples produces null percentiles. - Clean-attempt rate is the fraction of completed receipts with exactly one attempt. Tokens per completed issue is the mean total across those receipts. Both values are null without completions.
- Blocked nights are UTC calendar dates intersecting the window. Each project and issue counts once on each date, even if it enters Blocked repeatedly. Dates with no entries are omitted.
- Project dispatch counts use selected scheduler decisions; the five most frequent skip reasons are sorted by count, then reason.
- Queue depth sums the reported native queue depth per project, falling back
to queued Merging work for the serialized path.
merge_group_sizeandmerge_group_wait_secondsmirror the repository queue's maximum entries to merge and minimum wait when a native queue entry is observed; both are null without a queue, and unavailable is not zero.
Actions and decisions
since is an optional RFC3339 timestamp, exclusive, applying only to actions.
It defaults to 24 hours before the current request. Pass the preceding report's
data_time to retrieve actions since that render. One reader never consumes
another reader's actions.
Only applied lane-ledger writes stamped with the operator_routine origin
appear. The operator routine below produces these writes; the report does not
execute remediations. Each action includes its ledger ID, project, issue, kind,
reason, time, and evidence link.
Decisions include current explicit Workpad human actions and other human gates. Historical question receipts are excluded from live decisions. Issue and PR links provide the context needed to act. Decisions are independent of the action cursor.
Operator routine
Board remediation runs as one routine on the refresh tick, gated by a per-project allowlist. It consolidates the former separate loops; there is no separate tick path for any of them and no external repair script.
operator:
actions: [return_retired_parks, clear_closed_dependencies, restore_stuck_merging]
merge_wedge_seconds: 7200
| Action | Absorbs | Lane reason | Default |
|---|---|---|---|
return_retired_parks |
blocked-cause recovery and recorded-blocker recovery (tracker.blocked_recovery) |
blocked_recovery, cause_blocked_recovery, recorded_blocker_recovery |
on |
clear_closed_dependencies |
dependency auto-unblock (tracker.dependency_auto_unblock) |
dependency_auto_unblock |
on |
restore_stuck_merging |
stale-Merging reconciliation | the existing stale-Merging reasons | on |
merge_when_wedged |
manual merge of a gated-green PR when the merge path is wedged | merge_worker_programmatic_merge |
off |
Each absorbed loop keeps its own configuration and reason vocabulary; the
allowlist only decides whether it runs. Every lane write an action applies is
stamped with origin operator_routine and the action kind, which is what the
Actions section reads. An explicit empty actions list disables all of them.
merge_when_wedged merges only when the issue is in Merging (so its promotion
gate already passed), the current head's CI is green, the PR is open, not a
draft, not dirty, and has no unresolved review threads, the issue has been in
Merging for at least merge_wedge_seconds, and neither a native queue entry
nor a merge worker is acting on it. It uses the project's configured
merge_method and records the merge under merge_worker_programmatic_merge.
Actions never change repository settings and never pause or unpause projects.
file_deduped_issue and apply_doctor_config_fix from the design are not
available yet; naming them in actions is a configuration error.
Exporting the page
detent report --html <path> writes the same report as a self-contained HTML
file for readers away from the runner. It replaces the external
monitor.py watcher and repair.py repair script, which are retired; actions
now come from the operator routine and are visible in the report itself. A
launchd agent keeps a synced copy current:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.detent-report</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/detent</string>
<string>report</string>
<string>--html</string>
<string>/Users/operator/Dropbox/Detent/Detent Status.html</string>
</array>
<key>StartInterval</key><integer>600</integer>
<key>EnvironmentVariables</key>
<dict><key>DETENT_API_TOKEN</key><string>replace-with-your-token</string></dict>
</dict>
</plist>
On Linux, a systemd user timer calling the same command is equivalent.
Runner service logs
detent start and runner registration install launchd or systemd services with
stdout and stderr appended to logs/service.out.log and logs/service.err.log
beside the runner's global config. With the default runner config, these are
~/.config/detent-runner/logs/service.out.log and
~/.config/detent-runner/logs/service.err.log. Installation prints both paths.
Failed service starts are throttled to one restart per minute by the service
manager.
While the runner is running, it checks these two files at startup and once per
second, independently of worker activity. Service retention shares the application
logging settings: log_max_size_bytes defaults to 52428800 (50 MiB), and
log_max_backups defaults to 5. Nonpositive values use these finite defaults for
service files. Each file keeps up to five numbered snapshots (.1 is newest),
each containing at most the most recent 50 MiB at the default limit. An already
oversized file is reduced on the first check. The active file can exceed the
threshold by output written between checks; retained history is capped at
250 MiB per stream with the defaults.
Retention copies the bounded tail to a private temporary file and closes it successfully before rotating existing snapshots and promoting the new snapshot. Failed snapshot writes leave existing history and active output intact and remove the incomplete temporary file. It then truncates the active file in place, keeping the service's and inherited workers' append descriptors usable without a restart or a worker drain. Output concurrent with copy/truncate may be lost. Snapshots are private (0600); active file permissions and log redaction are unchanged. Only the installed service log files actually connected to stdout/stderr are retained. Services using the system journal keep OS-owned retention. These files are disposable diagnostics and are never used to recover jobs.
Startup validation is project-specific. A project with unusable tracker credentials reports its error and repair command through runner problems while other projects continue serving work. Credential failures use the existing project retry path, so repairing GitHub CLI authentication allows the project to start without restarting the runner. If every configured project fails, the runner reports its problems to the Hub before exiting non-zero.
Codex disk retention
Detent retains seven days of Codex feedback log rows in the .detent-worker
and .detent-launchd profiles beneath each configured Codex home. At boot,
after the prior-worker sweep, it deletes older logs_<N>.sqlite rows and
vacuums the database. If the database still exceeds 1 GiB, it removes that
log database and its SQLite sidecars; Codex recreates it. Maintenance skips
when any Codex app-server is running or the process list cannot be inspected.
It never stops a process to reclaim disk space. Worker thread state and
thread-history databases are not pruned.
Shared-root rollout deletion is deferred. Worker profiles share the source
Codex home's sessions directory, and independent Detent runtime databases
cannot identify another instance's unfinished sessions. The originator field
alone does not establish instance ownership. Detent does not automatically
delete rollouts; the doctor total remains available for operator inspection.
detent doctor reports each worker-profile SQLite file size (including
sidecars), the Detent rollout total, and their combined size. It warns above
2 GiB; the doctor check does not delete files. Inspect the sizes before and
after an operator-controlled boot on each host to verify reclaimed bytes.
A host with a running Codex app-server will defer log maintenance.
User-level Codex databases, such as ~/.codex/logs_<N>.sqlite, are the
operator's responsibility. With Codex closed, its disposable log database and
sidecars can safely be removed and will be recreated. Thread state and history
contain session data: back them up and review what would be lost before manual
pruning. Detent never prunes user-level SQLite databases.
Profiling
Profiling is opt-in and disabled by default. Add this block to the runner's
global.yaml (for an enrolled runner, ~/.config/detent-runner/global.yaml):
profiling:
listen_addr: "127.0.0.1:6060"
capture:
enabled: true
interval: 15m
cpu_duration: 30s
dir: ""
max_age: 168h
max_bytes: 1GB
The Hub reads the same block from detent --config /path/to/global.yaml hub serve ... (or CONFIG/DETENT_CONFIG). When no instance config is supplied,
it reads the block from --hosted-config, if present. Runners and the Hub
reload profiling settings from their config file without restarting or ending
agent sessions. Invalid reloads retain the previous settings. Listener, capture,
and retention failures are logged through slog and do not stop the process.
Leave listen_addr empty to disable live endpoints; capture works independently.
The dedicated profiling listener accepts only loopback IP addresses or
localhost, which binds directly to 127.0.0.1. It never shares the application
listener. For a live heap profile:
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
When dir is empty, bundles land beside the instance database under
profiles/orchestrator, profiles/runner, or profiles/hub. An explicit directory
can be absolute, home-relative, or relative to the configuration file. Each
interval captures CPU for cpu_duration, then writes binary heap.pprof,
allocs.pprof, goroutine.pprof, mutex.pprof, and block.pprof files alongside
cpu.pprof in a UTC timestamped directory. Durations and retention bounds must
be positive; CPU duration must not exceed the interval. max_bytes accepts
integer bytes or integer sizes such as 1GB (decimal) and 1GiB (binary).
Bundles publish only after all six profiles are written. Canceled or failed
captures remove their incomplete directory. Retention runs at process startup,
when capture starts, and after each capture attempt, removing bundles older than max_age, then the
oldest bundles until their combined file size is at most max_bytes. Timestamped
.partial directories left by a process crash count toward retention; unrelated
directories and symlinks are left alone. A single bundle larger than the size
limit is removed. Use a separate directory per process when overriding dir.
Capture enables mutex sampling at one in five events and block sampling at an average of one sample per millisecond blocked; both reset to zero when capture is disabled or the process shuts down. Go allows only one CPU profiler at a time, so a live CPU request overlapping periodic capture can fail; the next capture interval still runs. Profiles can contain sensitive process data and are written with owner-only file and bundle directory permissions.
Compare two recorded bundles from the same process using:
go tool pprof -diff_base /path/to/older/heap.pprof /path/to/newer/heap.pprof
go tool pprof -diff_base /path/to/older/cpu.pprof /path/to/newer/cpu.pprof
Toolchain caches
Workers use the host toolchain caches, including Go's native build and module
caches; no per-project cache choice exists (INV-12). The existing reaper trim
expires old Go build entries using global.cache.max_age. For the runner-owned
cache, the same cleanup also enforces global.cache.max_bytes, waiting for
active consumers under the existing admission lock before reclaiming recent
eligible entries oldest first under the exclusive consumer lock. Native operator
caches retain age-only expiry and are not reclaimed by managed cleanup. Native
Cloud runner capacity stops new work when a cache or workspace volume lacks
its disk reserve or storage cannot be observed, and resumes when those
observations recover. Retained cache size does not stop claims while physical
free space is sufficient. Active jobs retain their leases and files.
detent doctor reports the native
paths, current sizes, and last completed reaper trim per project, and warns about
remaining Detent-owned cache roots without modifying them.