Skip to content

feat(contracts): carry typed problem detail beside the message - #2259

Merged
ScriptedAlchemy merged 4 commits into
masterfrom
fleet/daemon-problem-typed-detail
Sep 26, 2026
Merged

ScriptedAlchemy merged 4 commits into
masterfrom
fleet/daemon-problem-typed-detail

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

What was wrong

The problem envelope the daemon sends to clients (ApplicationProblem, and the ApplicationProblemRecord every surface serializes) had only a code and a message. So producers wrote their facts into the message string:

A CLI, MCP, HTTP, or SDK client that wanted the cause had to parse English.

What changed

The shape is unreleased (the typed envelope shipped this week), so it changes in place. There is no V2 and no alias.

  • ApplicationProblemDetailV1 (schemars, in tracedecay-contracts) is a tagged enum and the only authority for these facts:
    • parked { cause, remedy, retries_on_wake }
    • stale_refresh_frontier { requested, committed, active }
    • lock_deadline { resource, deadline_ms }
  • ApplicationProblem::from_detail(detail) is the only constructor for a detailed problem. The detail determines the kind, code, retry, legal actions, and message:
    • parked: unavailable, never, reconcile
    • stale frontier: stale, after_revalidate, refresh
    • lock deadline: saturated, after_delay, retry
  • One renderer per surface:
    • detail.message() is the only message rendering. validate() rejects any problem whose fields differ from from_detail(detail), so a hand-formatted message or a detail grafted onto another kind fails closed.
    • detail.labelled_fields() supplies the labelled lines for MCP and CLI text output.
  • Record: problem.detail is always serialized, and is null when the problem has no detail, following the same required-nullable rule as committed_receipt. It reaches MCP structuredContent.problem, the CLI --json envelope, the HTTP problem body, and the TS SDK record, which validates it.
  • Producers migrated:
    • primitive parked refusal (OwnedPrimitiveRuntime::parked_refusal)
    • retained session refresh begin/status stale frontier
    • TraceDecayError::LockDeadline { resource, deadline_ms } replaces SyncLock at the two deadline sites (source-edit writer lock, response-handle writer lock). Source-edit and retained map_execution_error now map it to the detailed saturated problem. The handle-store busy classification (MCP truncation status, LSP feedback-cycle-handle-store-busy) keys on the typed variant.
    • tracedecay_search's unavailable output carries the same detail for a terminal park and renders its failure message from it. The old hand-formatted "parked: …; remedy: …" string is gone.
  • Regenerated dashboard/src/contracts and the TS SDK types; contracts:check is green.

Per-surface literals (parked, through the shipped daemon)

crates/tracedecay/tests/daemon_suite/code_index_park_test.rs makes a committed file unreadable, waits for the park, then asserts {"kind":"parked","cause":CAUSE,"remedy":REMEDY,"retries_on_wake":false} on each surface, plus the unchanged message text:

  • tracedecay_search JSON detail
  • MCP content problem.detail and structuredContent.problem.detail
  • CLI tool code_symbol_search --json problem.detail and message
  • CLI text output: - Parked cause: … and - Retries on wake: false
  • HTTP POST /projects/{id}/application/code/code_symbol_search: status 503, and value.problem.detail / message

Fails on master: I ran the same test binary with TRACEDECAY_TEST_BIN pointing at an origin/master (08a6807) daemon build. It fails at the first detail assertion with left: Null and passes against this branch.

Other problems:

  • Stale frontier: retained_timeout_dispatch_tests::a_stale_refresh_frontier_reaches_mcp_as_typed_detail sends the daemon's problem through a serde round trip of the daemon protocol, then through the production MCP retained adapter. It asserts the literal {kind: stale_refresh_frontier, requested: 120, committed: 40, active: 80}, the unchanged message, and the markdown labelled lines. session-runtime a_stale_begin_frontier_is_a_stale_problem_naming_the_committed_frontier pins the same detail on the begin projection.
  • Lock deadline: invocation::source_edit::tests::a_missed_writer_lock_deadline_is_retryable_capacity_with_typed_detail asserts the daemon wire: saturated/after_delay/["retry"], the rendered message, and {kind: lock_deadline, resource: "source-edit writer lock", deadline_ms: 30000}. On master the same error was execution_failed with the path in the message.
  • Record contract: a_detailed_problem_carries_its_facts_beside_the_rendered_message covers the round trip, a restated detail rejected against its message, an omitted detail rejected, and a detail grafted onto invalid_request rejected.
  • TS SDK: surfaces a problem's typed detail and refuses one on a kind without detail.

Runtime journey (debug CLI build from this branch)

Isolated HOME/TRACEDECAY_DATA_DIR; one daemon under systemd-run --user --scope -p MemoryMax=6G -p MemorySwapMax=1G; src/model.rs set to mode 000 and committed.

$ tracedecay init
initialized …/proj; daemon code-index reconciliation requested
$ tracedecay tool tracedecay_search --query alpha --format json
{…,"detail":{"cause":"code-index repository status failed: code-index classification: IO error while writing blob or reading file metadata or changing filetype","kind":"parked","remedy":"indexing this worktree fails the same way on every pass over unchanged source; fix what the named failure points at, then run `tracedecay sync` to retry; if it names an internal indexing contract, run `tracedecay upgrade` (a restarted daemon retries automatically) and report the failure if it persists","retries_on_wake":false},…,"status":"unavailable",…}
$ tracedecay tool code_symbol_search --args '{"query":"alpha",…}' --json   # exit 1
 "kind": "unavailable", "code": "application.code-index.parked", "retry": "never", "legal_actions": ["reconcile"],
 "detail": {"kind": "parked", "cause": "code-index repository status failed: …", "remedy": "indexing this worktree fails …", "retries_on_wake": false}
$ tracedecay tool code_symbol_search --args '…'
- Problem: `application.code-index.parked`
- Parked cause: code-index repository status failed: code-index classification: IO error while writing blob or reading file metadata or changing filetype
- Parked remedy: indexing this worktree fails the same way on every pass over unchanged source; …
- Retries on wake: false
- Retryable: `false`

Local verification

  • Lib suites:
    • tracedecay-contracts 420
    • tracedecay-daemon-protocol 65
    • tracedecay-dashboard-api 173 (2 ignored)
    • tracedecay-mcp 391
    • tracedecay-application 476
    • tracedecay-session-memory 303
    • tracedecay-session-runtime 122
    • tracedecay-source-edit 88
    • tracedecay-domain 222
    • tracedecay-api 53
    • tracedecay-daemon-service 320 passed, 1 failed. The failure is adoption_observation::…census…, which also fails on master and is filed as test(daemon-service): adoption census expects 34 retrieval capabilities, catalog has 41 #2246.
  • tracedecay lib daemon:: + mcp::tools::handlers::: 624/624. After the final rebase, 620 passed in the parallel run and the other 4 were load flakes that passed sequentially.
  • daemon_suite park test: 1 passed
  • sdk_suite: 21 passed
  • core_cli_suite: 139/139. The first run had 134 pass. Of the 5 failures, one was a missing example fixture and four were load-timing flakes, and all 5 passed when rerun sequentially under the cap.
  • mcp_suite: 136 passed, covering every module whose fixtures changed plus every test that failed in the first attempt. The first pass had 105 pass; its 31 failures all timed out in the harness's 20s code-index readiness wait under load, and all 31 passed sequentially. The full 569-test suite was OOM-killed twice by the 6G scope cap, at about 139 tests each time, so it has not completed under the cap.
  • dashboard vitest: 210 files, 2027 tests; typecheck; contracts:check
  • TS SDK: 30 tests; typecheck
  • cargo clippy --all-targets -D warnings: clean with and without test-transport
  • cargo fmt --all -- --check: clean

Left open

  • GithubSourceUnavailable { state, remedy } (fix(configuration): provision the GitHub origin source binding #2221) is not added. No problem producer flattens it: the GitHub source state and remedy only travel as the typed github_source status field. A variant with no producer would be unreachable.
  • Dashboard: its only park display, IndexFreshness, already reads cause and remedy from the typed CodeIndexConvergenceParkedV1 status, and no dashboard route receives a parked application problem. The change there is regenerated contracts plus detail: null in the hand-built record fixtures.
  • Source-edit and graph-tool client adapters still flatten the problem into ProjectRoute errors, so their MCP and CLI output carries the rendered message but not detail or legal_actions: source-edit and graph-tool adapters drop the typed problem record #2255.
  • Other producers still format facts into Unsupported diagnostics (the TypeScript producer's missing-compiler and no-tsconfig refusals). They are candidates for further detail kinds.

The application problem carried only a code and a message, so producers
flattened structured facts into the message: the park cause and remedy,
the stale refresh frontier, and a writer lock's missed deadline.

ApplicationProblemDetailV1 is now the one authority for those facts. A
problem built from a detail derives its kind, code, retry, legal actions
and message from it, and the record serializes the detail on every
surface: MCP structured content, CLI JSON and labelled lines, and HTTP.
A source-edit lock deadline is now retryable saturation with its lock
class, not a permanent execution failure that leaked the lock path.
@changeset-bot

changeset-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: e0f9e74

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48807b1c8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +583 to +584
if let Some(detail) = self.detail()
&& *self != Self::from_detail(detail.clone())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require detail for reserved problem codes

When detail is None, this validation skips detail identity enforcement entirely. As a result, a generic Unavailable, Stale, or Saturated problem can use a reserved code such as application.code-index.parked with detail: null and still pass both construction and wire deserialization, silently dropping the typed facts clients rely on. Reject detail-owned codes unless their matching detail is present.

AGENTS.md reference: AGENTS.md:L9-L12

Useful? React with 👍 / 👎.

Comment on lines +397 to +398
(problem.detail !== null &&
!(typeof problem.kind === "string" && detailKinds.includes(problem.kind))) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind each detail variant to its canonical problem kind

The SDK only checks that any non-null detail accompanies one of three broad problem kinds, so it accepts combinations Rust rejects, such as a lock_deadline detail on an unavailable problem or a parked detail on stale; it likewise never verifies the detail-derived code, message, retry, or legal actions. Such malformed HTTP responses are exposed as valid typed problems instead of raising TraceDecayMalformedResponseError. Validate each detail variant against its full canonical problem tuple.

AGENTS.md reference: AGENTS.md:L9-L12

Useful? React with 👍 / 👎.

Comment on lines +807 to +810
let response = tracedecay_daemon_protocol::DaemonInvocationResponse::application_problem(
&request.request_id,
self.problem.clone(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exercise the real stale-frontier producer

This executor returns a preconstructed ApplicationProblem directly, so the new test never exercises the production DaemonSessionRefreshService → SessionRefreshOutcome::StaleFrontier → begin_result/effect_error path that is supposed to preserve requested, committed, and active. A regression in that wiring therefore remains green; create the stale store/service state and invoke the production journey instead of injecting its expected result.

AGENTS.md reference: AGENTS.md:L172-L177

Useful? React with 👍 / 👎.

Comment on lines +302 to +304
if let Some(detail) = &problem.detail {
for (label, value) in detail.labelled_fields() {
self.text(label, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize detail values before human rendering

When a parked cause or remedy contains control characters, for example from an error mentioning a repository path, message() folds them but labelled_fields() returns the original raw strings. Passing those strings directly to the human view emits controls such as ESC in CLI/MCP text because the Markdown renderer only replaces CR/LF and Markdown punctuation, allowing terminal-control injection and bypassing the safety guaranteed by SafeDiagnostic; fold or validate these values before rendering.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-26T19:28:11.033990Z 48807b1 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…m-typed-detail

# Conflicts:
#	crates/tracedecay-contracts/src/lib.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant