From be2dfac5eea8564e877abc35f0dd792cdec2d06e Mon Sep 17 00:00:00 2001 From: David Sisco Date: Sun, 2 Aug 2026 23:40:26 -0700 Subject: [PATCH 01/18] docs: implementation plan to improve DwarfSpec CLI error reporting --- docs/connection-probe-error-reporting.todo | 326 +++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/connection-probe-error-reporting.todo diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo new file mode 100644 index 0000000..19c80cc --- /dev/null +++ b/docs/connection-probe-error-reporting.todo @@ -0,0 +1,326 @@ +DwarfSpec Connection Probe Error Reporting +=========================================== + +Source proposal: + ☐ The error-reporting proposal in the conversation that identified the + strict `DWARFSPEC_PROBE` final-line comparison as the source of the + misleading "DFHack is not running or did not provide a healthy core Lua + context" diagnostic. + +Goal: + ☐ Preserve the lightweight DFHack connection preflight while reporting the + specific subprocess, protocol, context, or capability condition that + prevented a run. + ☐ Keep canonical live-spec discovery and selection independent from DFHack + connection diagnosis. + ☐ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the existing + connection-error result state for all probe failures. + ☐ Keep the probe dependency-free so it remains usable when DwarfSpec module + loading or the DFHack Lua environment is incomplete. + ☐ Treat source, unit, package, and live-runtime evidence as distinct + completion requirements. + +Non-goals: + ☐ Do not change project-root resolution, test discovery, selector glob + semantics, runner lookup order, run admission, bootstrap transport, or + scheduler behavior. + ☐ Do not accept an incompatible protocol, a non-core Lua context, or a + missing `dfhack.timeout` capability. + ☐ Do not introduce a new public failure kind or change established process + exit codes solely to improve diagnostic detail. + ☐ Do not make the probe depend on JSON libraries, project modules, the + consumer's Lua path, or an already-loaded DwarfSpec host service. + +Assumptions and open questions: + ☐ Confirm that protocol 2 remains the controller-to-host compatibility + contract for this change. + ☐ Decide whether the diagnostic probe should report `dfhack.VERSION` as an + optional field; it may improve supportability but must not become a + health requirement. + ☐ Decide the exact bounded-output limits before implementation. The proposed + default is at most eight non-empty lines and 2 KiB of rendered text. + ☐ Decide whether a package version bump is required for distributing the + changed probe and controller together; record the decision and rationale. + +Phase 1: Establish the probe response and diagnostic contracts + ☐ Exit with one documented probe grammar, one classification table, and + explicit compatibility invariants before changing runtime behavior. + +1.1 Probe response grammar: + ☐ Define `DWARFSPEC_PROBE` as a single line with whitespace-separated + `name=value` fields. + ☐ Require exactly one probe marker in the complete subprocess output. + ☐ Require `protocol`, `core`, and `timeout` fields. + ☐ Define the accepted healthy values as `protocol=2`, `core=true`, and + `timeout=function`. + ☐ Define unknown fields as forward-compatible diagnostics that the current + parser ignores after validating the required fields. + ☐ Define duplicate required fields, missing values, invalid booleans, and + malformed tokens as malformed probe reports. + ☐ Define whether `dfhack` or other optional diagnostic field values require + escaping or are restricted to safe non-whitespace tokens. + +1.2 Failure classification and messages: + ☐ Specify distinct messages for every controller-observable condition: + ☐ Process invocation throws before returning a result. + ☐ The probe process exits nonzero. + ☐ The process succeeds but emits no probe marker. + ☐ The process emits a malformed probe marker. + ☐ The process emits multiple probe markers. + ☐ The probe protocol differs from the controller protocol. + ☐ The probe reports a non-core Lua context. + ☐ The probe reports `dfhack.timeout` with a type other than `function`. + ☐ Include the resolved runner path in invocation failures without implying + that test selection or the selected spec caused the failure. + ☐ Make the protocol-mismatch message identify expected and observed values + and recommend checking for mixed installed DwarfSpec package versions. + ☐ Make missing-marker and nonzero-exit messages include a bounded excerpt of + captured output when output is available. + ☐ Define a stable placeholder for empty output so the diagnostic never ends + with an unexplained blank suffix. + ☐ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the connection + result state for every classified probe failure. + +1.3 Bounded subprocess output contract: + ☐ Define deterministic selection of non-empty output lines. + ☐ Define per-line and total-length truncation behavior. + ☐ Add an explicit truncation marker when content is omitted. + ☐ Normalize line endings and control characters that would corrupt a + one-line CLI diagnostic while preserving useful DFHack error text. + ☐ Do not expose command arguments or environment variables that were not + already present in captured subprocess output. + +Completion criteria: + ☐ Every condition currently collapsed into the generic health message maps + to one documented, objectively testable diagnostic. + ☐ The contract explicitly preserves existing failure kinds, result states, + exit codes, and selection boundaries. + +Phase 2: Make the host probe safe and self-describing + ☐ Exit with a dependency-free probe that reports observable context state + instead of crashing while inspecting an incomplete DFHack environment. + +2.1 Safe capability inspection: + ☐ Update `src/dwarfspec/host/entrypoints/probe.lua` to inspect the global + `dfhack` value without indexing it unless it is a table. + ☐ Report `core` using `tostring(dfhack.is_core_context)` when the table is + available and a deterministic unavailable value otherwise. + ☐ Report `timeout` using `type(dfhack.timeout)` when the table is available + and a deterministic unavailable type otherwise. + ☐ Emit the protocol supported by the probe script. + ☐ Emit the approved optional DFHack version field without making it part of + the healthy-context predicate. + ☐ Keep the entrypoint free of `require`, `reqscript`, JSON, project + configuration, and host-service dependencies. + ☐ Add language-standard documentation comments for any new helper methods. + +2.2 Entrypoint contract tests: + ☐ Extend the host entrypoint unit fixtures to capture the exact probe line. + ☐ Verify the healthy core-context response. + ☐ Verify that an absent `dfhack` global produces a parseable unhealthy + response instead of an indexing exception. + ☐ Verify missing `is_core_context`, missing `timeout`, and incorrectly typed + capability values independently. + ☐ Verify optional DFHack version presence and absence according to the + settled response grammar. + ☐ Verify the probe does not load DwarfSpec or third-party modules. + +Completion criteria: + ☐ The probe emits exactly one parseable marker for every modeled Lua-context + shape and does not throw while gathering its required fields. + ☐ The healthy response remains compatible with the controller protocol + settled in Phase 1. + +Phase 3: Parse and classify probe results in the controller + ☐ Exit with controller-side parsing that accepts unrelated DFHack output but + rejects ambiguous or unhealthy probe reports with precise messages. + +3.1 Probe parsing: + ☐ Add a focused private parser in + `src/dwarfspec/controller/execution/transport_client.lua` or a narrowly + scoped controller module if the parser and formatting responsibilities + would otherwise obscure transport invocation. + ☐ Scan every captured line for the exact `DWARFSPEC_PROBE` marker instead of + assuming the marker is the final output line. + ☐ Reject zero markers and multiple markers as distinct conditions. + ☐ Parse required fields by name rather than by positional whole-line + equality. + ☐ Reject malformed tokens, missing required fields, duplicate required + fields, and invalid required values with field-specific context. + ☐ Ignore approved unknown fields without weakening required-field checks. + ☐ Add language-standard documentation comments for every new parser or + formatter method. + +3.2 Failure construction: + ☐ Handle process invocation exceptions separately from returned subprocess + failures. + ☐ Check `exit_code` before interpreting a successful probe response. + ☐ Format nonzero exits with the numeric exit code and bounded output. + ☐ Format missing and malformed reports with bounded output or the offending + marker as established in Phase 1. + ☐ Compare the parsed protocol to the controller protocol and report both + values on mismatch. + ☐ Report `core` and `timeout` health failures independently. + ☐ Return success only for exactly one well-formed response with every + required healthy value. + ☐ Remove the generic + `DFHack is not running or did not provide a healthy core Lua context` + fallback after every observable condition has a precise replacement. + ☐ Keep all failures classified as `RunnerFailureKind.CONNECTION` so runner + orchestration and result interpretation remain compatible. + +3.3 Bounded output formatting: + ☐ Implement the settled line and byte limits deterministically. + ☐ Preserve useful stderr text already merged into the subprocess result. + ☐ Make truncation visible. + ☐ Verify output formatting cannot itself throw on absent, empty, sparse, or + non-string fixture values. + +Completion criteria: + ☐ A valid marker can appear before or after unrelated output and still pass. + ☐ Every invalid subprocess result produces its specific Phase 1 diagnostic. + ☐ No connection-probe branch retains the old generic fallback. + +Phase 4: Lock down controller and runner compatibility + ☐ Exit with focused unit coverage proving the new detail does not change + established orchestration outcomes. + +4.1 Transport client tests: + ☐ Replace the combined process-exception/unhealthy-probe test with separate + cases that assert the exact classification and meaningful message detail. + ☐ Verify a healthy marker as the only output line. + ☐ Verify a healthy marker with unrelated output before it. + ☐ Verify a healthy marker with unrelated output after it. + ☐ Verify invocation exceptions include the runner path and original error. + ☐ Verify nonzero exits with empty and non-empty output. + ☐ Verify empty successful output and successful output without a marker. + ☐ Verify malformed, missing-field, duplicate-field, and multiple-marker + responses. + ☐ Verify protocol mismatch reports expected and observed protocol values. + ☐ Verify `core=false` and every non-function `timeout` value independently. + ☐ Verify unknown optional fields are ignored. + ☐ Verify line, per-line, and total-output truncation boundaries. + ☐ Verify every case retains the connection failure kind. + +4.2 Runner and result tests: + ☐ Verify `runner.run()` returns exit code 4 for each representative probe + failure category. + ☐ Verify the persisted result remains in the connection-error state where + result persistence applies. + ☐ Verify run bootstrap is never attempted after probe failure. + ☐ Verify selected identities and spec paths do not appear in the connection + explanation unless they were independently part of subprocess output. + ☐ Verify abort, status, history, show, logs, and executor-recovery commands + preserve their existing connection-failure behavior while surfacing the + improved detail. + +4.3 Regression suite: + ☐ Run the focused host-entrypoint, transport-client, runner, and result + interpreter unit specifications. + ☐ Run the complete recursive unit suite. + ☐ Run Lua syntax, formatting, and declaration checks for all changed files. + ☐ Run `git diff --check` and inspect the final focused diff. + +Completion criteria: + ☐ Focused tests cover every classification row and boundary condition. + ☐ The complete unit and static-analysis suites pass without changing public + failure kinds, result states, or exit codes. + +Phase 5: Document and package the improved diagnostics + ☐ Exit with user-facing guidance and package artifacts that cannot mix the + new controller with an obsolete probe unnoticed. + +5.1 Documentation: + ☐ Update `docs/command-line.md` to describe connection exit code 4 and the + actionable probe diagnostics. + ☐ Document that test selection completes before the DFHack connection + preflight, so a connection error does not implicate the selected file. + ☐ Document protocol-mismatch remediation in terms of controller/probe + package alignment without prescribing project-specific paths. + ☐ Update other connection-error examples that quote or promise the removed + generic message. + +5.2 Package integrity: + ☐ Apply the settled package-version decision consistently to package + metadata, CLI version output, and changelog entries. + ☐ Build the LuaRocks artifact using the repository packaging workflow. + ☐ Inspect the artifact manifest and archive contents to prove the updated + controller parser and probe entrypoint are both present. + ☐ Install the artifact into a disposable LuaRocks tree. + ☐ Verify the disposable command resolves its controller and probe from the + same package version and layout. + ☐ Remove the disposable installation and confirm cleanup. + +Completion criteria: + ☐ Documentation distinguishes runner invocation, subprocess exit, missing + report, malformed report, protocol mismatch, and capability failures. + ☐ The packaged controller and probe implement the same response protocol + and the disposable package smoke checks pass. + +Phase 6: Validate installed and live-runtime behavior + ☐ Exit with bounded evidence that the packaged CLI reports real DFHack + connection failures accurately and still accepts a healthy core context. + +6.1 Installed consumer preparation: + ☐ Select one consumer project with a canonical nested `.ds.lua` identity. + ☐ Record the exact installed DwarfSpec package version, resolved + `dwarfspec` command, resolved `dfhack-run`, project root, and selected + identity before execution. + ☐ Confirm the controller and probe resolve from the same installed artifact. + ☐ Preserve the consumer worktree and do not substitute a source-tree module + path for installed-package evidence. + +6.2 Failure-path evidence: + ☐ With no reachable DFHack process, verify the CLI reports the actual + nonzero connection-probe exit and bounded runner output. + ☐ Using a controlled protocol-mismatch fixture or disposable mismatched + package layout, verify expected and observed protocol values are reported. + ☐ Using controlled probe fixtures, verify non-core and missing-timeout + diagnostics without weakening the real health predicate. + ☐ Confirm each failure returns exit code 4, does not attempt bootstrap, and + does not attribute the failure to the selected spec path. + +6.3 Healthy live evidence: + ☐ Start from a known responsive DFHack process and run one exact nested + project-relative identity through the installed CLI. + ☐ Confirm unrelated DFHack output does not invalidate the single healthy + marker. + ☐ Confirm the run proceeds beyond preflight into bootstrap and reaches a + terminal DwarfSpec result. + ☐ Record terminal status, exit code, result artifact status, and + `cleanup_confirmed` independently from the connection result. + ☐ Confirm the DFHack executor is idle, the queue is empty, quarantine is + absent, and no test-owned resources remain after the run. + +6.4 Followup review: + ☐ Review the implementation against every requirement and non-goal in this + plan after source, package, and live validation are complete. + ☐ Recheck that no generic fallback still hides captured probe information. + ☐ Recheck that healthy validation did not become permissive while making + error messages more detailed. + ☐ Recheck that package-skew guidance is supported by the final installed + layout and does not claim a mismatch when none was observed. + ☐ Record any deferred item with its rationale and a concrete follow-up owner + or removal condition. + +Completion criteria: + ☐ Installed failure scenarios produce specific actionable diagnostics. + ☐ One installed healthy run reaches a terminal result with cleanup + confirmed and final executor state verified. + ☐ The followup review finds every requirement satisfied or explicitly + deferred with rationale. + +Final acceptance: + ☐ Every proposal requirement is implemented or explicitly deferred with a + recorded rationale. + ☐ The probe is dependency-free, safe against incomplete DFHack globals, and + emits exactly one parseable response. + ☐ The controller parses the response by fields, tolerates unrelated output, + and reports every failure condition precisely. + ☐ Connection failures retain their existing kind, result state, and exit + code while exposing bounded subprocess evidence. + ☐ Source, focused unit, complete unit, static-analysis, package, installed, + and live-runtime evidence are recorded separately. + ☐ Documentation and package metadata describe the shipped behavior. + ☐ Temporary fixtures, disposable installations, result artifacts, and live + test resources are removed with cleanup confirmed. From 547bbc217254187d30405aea92c75da51de546b3 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Sun, 2 Aug 2026 23:52:15 -0700 Subject: [PATCH 02/18] [Phase 1]: Establish the probe response and diagnostic contracts --- docs/connection-probe-contract.md | 180 +++++++++++++++++++++ docs/connection-probe-error-reporting.todo | 59 +++---- 2 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 docs/connection-probe-contract.md diff --git a/docs/connection-probe-contract.md b/docs/connection-probe-contract.md new file mode 100644 index 0000000..1684b37 --- /dev/null +++ b/docs/connection-probe-contract.md @@ -0,0 +1,180 @@ +# DwarfSpec connection probe contract + +## Purpose + +The connection probe determines whether the resolved `dfhack-run` process can +execute the minimum DFHack core Lua API required by DwarfSpec. It runs after +project discovery and test selection but before bootstrap, admission, or test +execution. + +The probe reports observed facts. The controller owns parsing, compatibility +decisions, failure classification, and user-facing diagnostics. + +## Compatibility invariants + +- The controller and probe protocol remains version 2. +- Every unsuccessful probe is a `RunnerFailureKind.CONNECTION` failure, maps to + the `connection_error` invocation result state, and exits with code 4. +- A diagnostic improvement must not change project-root resolution, test + discovery, selector glob semantics, runner lookup, registration, bootstrap, + admission, scheduler behavior, or cleanup behavior. +- A selected spec identity is not part of a connection diagnostic unless the + invoked subprocess independently emitted it. +- Protocol mismatches, non-core contexts, and missing required capabilities + remain fatal. More precise reporting must not make health validation more + permissive. +- The probe has no DwarfSpec module, project module, JSON, configuration, host + service, or third-party dependency. +- The distributed controller and probe must come from the same DwarfSpec + package. Shipping this contract requires a patch-version package release so + the two artifacts are updated together. + +## Probe response grammar + +The probe emits exactly one response line. Unrelated output from DFHack may +appear before or after it. + +```text +DWARFSPEC_PROBE protocol=2 core=true timeout=function [dfhack=] +``` + +A candidate response line begins with the exact ASCII marker +`DWARFSPEC_PROBE`, followed by the end of the line or one ASCII space. A bare +marker is therefore a malformed candidate with missing required fields. A +marker embedded later in a line is ordinary subprocess output. + +After the marker, the response consists of one or more fields separated by one +or more ASCII spaces. Each field has the form `name=value`: + +- `name` matches `[a-z][a-z0-9_]*`; +- `value` matches `[A-Za-z0-9._+-]+`; +- field names are unique; +- `protocol`, `core`, and `timeout` occur exactly once; +- `protocol` is a positive base-10 integer without a sign; +- `core` is `true`, `false`, or `unavailable`; +- `timeout` is one of the Lua `type()` names `nil`, `boolean`, `number`, + `string`, `function`, `userdata`, `thread`, or `table`, or the value + `unavailable`. + +The optional `dfhack` field contains `dfhack.VERSION` only when its string form +matches the value grammar. The probe omits it otherwise. Its presence and value +are diagnostic only and never affect health. + +Unknown well-formed fields are permitted and ignored by controllers that do not +recognize them. This permits additive diagnostics without weakening required +field validation. A duplicate field, including an unknown field, is malformed +because its meaning would be ambiguous. + +The probe observes incomplete contexts without indexing an unavailable value: + +- when the global `dfhack` value is not a table, it reports + `core=unavailable timeout=unavailable`; +- when `dfhack.is_core_context` is absent, `core=unavailable`; +- otherwise `core` is the string form of the boolean value, with any value + other than `true` or `false` normalized to `unavailable`; +- when `dfhack.timeout` is absent because no DFHack table is available, + `timeout=unavailable`; +- otherwise `timeout` is the result of Lua `type(dfhack.timeout)`, including + `nil` when the field is absent from an available table. + +A response is healthy only when it contains `protocol=2`, `core=true`, and +`timeout=function`. + +## Controller classification order + +The controller classifies a probe result in this order so one subprocess result +has one deterministic primary cause: + +1. Invocation exception: invoking the resolved runner did not return a result. +2. Nonzero exit: the subprocess returned an exit code other than zero. Marker + parsing is not attempted because process failure is primary. +3. Missing marker: a zero-exit result contains no candidate probe response. +4. Multiple markers: a zero-exit result contains more than one candidate. +5. Malformed response: the sole candidate violates the response grammar. +6. Protocol mismatch: the parsed protocol differs from 2. +7. Core-context failure: `core` is not `true`. +8. Timeout-capability failure: `timeout` is not `function`. +9. Healthy response: every required field has its accepted value. + +The controller searches the complete output instead of requiring the probe to +be the final line. Unrelated output does not invalidate one otherwise healthy +response. + +## Diagnostic catalog + +Messages use these stable forms. Angle-bracketed terms are substituted with +observed, cleaned, and bounded values. + +| Condition | Diagnostic | +| --- | --- | +| Invocation exception | `Could not invoke DFHack runner "": ` | +| Nonzero exit | `DFHack connection probe through "" exited with code . Output: ` | +| Missing marker | `DFHack responded through "", but emitted no DwarfSpec probe report. Output: ` | +| Multiple markers | `DFHack emitted DwarfSpec probe reports; expected exactly one. Output: ` | +| Malformed response | `DFHack emitted a malformed DwarfSpec probe report: . Probe: ` | +| Protocol mismatch | `DwarfSpec protocol mismatch: controller expects 2, probe reported . Check for mixed installed DwarfSpec package versions.` | +| Core-context failure | `DFHack probe did not run in a healthy core Lua context: expected core=true, reported core=.` | +| Timeout-capability failure | `DFHack core Lua context is missing the required dfhack.timeout function: reported timeout=.` | + +Invocation exceptions include the resolved runner path and the cleaned exception +text. They do not claim that DFHack accepted a connection. + +A malformed-response reason identifies the first grammar violation in parsing +order: invalid token, invalid field name, empty value, duplicate field, missing +required field, invalid protocol, invalid core value, or invalid timeout value. + +## Bounded output excerpts + +Diagnostics may include subprocess output for nonzero exits, missing markers, +multiple markers, and malformed responses. Formatting is deterministic: + +1. Convert each supplied line to a safe string under a protected call so a + failing `tostring` metamethod cannot raise a secondary formatting error. Use + `` when conversion fails. +2. Replace tab characters with one ASCII space. Replace ASCII control bytes + `0x00` through `0x1f` and `0x7f` with `?`, then trim surrounding ASCII + whitespace. +3. Discard empty normalized lines. +4. Limit each line to 512 bytes, including the suffix + `...` when truncation occurs. +5. Retain the final eight non-empty lines in their original order. When earlier + lines were omitted, prepend ``. +6. Join rendered entries with ` | `. +7. Limit the complete excerpt to 2,048 bytes. If necessary, preserve the most + recent output and prefix it with ` ` within that limit. +8. Render `` when no non-empty content remains. + +The limits are byte limits because Lua strings and the current subprocess +surface are byte-oriented. An implementation must not split a valid UTF-8 code +point when it truncates otherwise valid UTF-8 output. + +The formatter may reproduce paths or other text already emitted by the +subprocess. It must not add command arguments, environment variables, or secret +values from controller state. Existing result persistence rules determine +whether the resulting connection failure message is written to a result file. + +## Ownership boundaries + +- `src/dwarfspec/host/entrypoints/probe.lua` owns safe observation and one-line + response emission. +- `src/dwarfspec/controller/execution/transport_client.lua`, or a focused + controller helper extracted from it, owns response parsing, output bounding, + health checks, and connection failure construction. +- `src/dwarfspec/controller/execution/runner.lua` continues to orchestrate + preflight before bootstrap and does not interpret probe fields. +- Test discovery continues to own canonical identity selection before runner + orchestration. It does not diagnose DFHack connectivity. + +## Verification obligations + +Later implementation work must independently prove: + +- probe behavior for healthy, absent, and incomplete DFHack globals; +- parser behavior for noise, missing and multiple markers, malformed fields, + protocol mismatch, unhealthy capabilities, and unknown fields; +- exact bounded-output behavior at line-count, per-line, total-byte, control + character, empty-output, and UTF-8 boundaries; +- preservation of connection failure kind, result state, and exit code; +- absence of bootstrap after any failed probe; +- package co-location of the controller and probe; and +- installed live success plus terminal cleanup evidence. diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo index 19c80cc..7c6b7d4 100644 --- a/docs/connection-probe-error-reporting.todo +++ b/docs/connection-probe-error-reporting.todo @@ -43,57 +43,58 @@ Assumptions and open questions: changed probe and controller together; record the decision and rationale. Phase 1: Establish the probe response and diagnostic contracts - ☐ Exit with one documented probe grammar, one classification table, and + ☒ Exit with one documented probe grammar, one classification table, and explicit compatibility invariants before changing runtime behavior. + - Evidence: `docs/connection-probe-contract.md`. 1.1 Probe response grammar: - ☐ Define `DWARFSPEC_PROBE` as a single line with whitespace-separated + ☒ Define `DWARFSPEC_PROBE` as a single line with whitespace-separated `name=value` fields. - ☐ Require exactly one probe marker in the complete subprocess output. - ☐ Require `protocol`, `core`, and `timeout` fields. - ☐ Define the accepted healthy values as `protocol=2`, `core=true`, and + ☒ Require exactly one probe marker in the complete subprocess output. + ☒ Require `protocol`, `core`, and `timeout` fields. + ☒ Define the accepted healthy values as `protocol=2`, `core=true`, and `timeout=function`. - ☐ Define unknown fields as forward-compatible diagnostics that the current + ☒ Define unknown fields as forward-compatible diagnostics that the current parser ignores after validating the required fields. - ☐ Define duplicate required fields, missing values, invalid booleans, and + ☒ Define duplicate required fields, missing values, invalid booleans, and malformed tokens as malformed probe reports. - ☐ Define whether `dfhack` or other optional diagnostic field values require + ☒ Define whether `dfhack` or other optional diagnostic field values require escaping or are restricted to safe non-whitespace tokens. 1.2 Failure classification and messages: - ☐ Specify distinct messages for every controller-observable condition: - ☐ Process invocation throws before returning a result. - ☐ The probe process exits nonzero. - ☐ The process succeeds but emits no probe marker. - ☐ The process emits a malformed probe marker. - ☐ The process emits multiple probe markers. - ☐ The probe protocol differs from the controller protocol. - ☐ The probe reports a non-core Lua context. - ☐ The probe reports `dfhack.timeout` with a type other than `function`. - ☐ Include the resolved runner path in invocation failures without implying + ☒ Specify distinct messages for every controller-observable condition: + ☒ Process invocation throws before returning a result. + ☒ The probe process exits nonzero. + ☒ The process succeeds but emits no probe marker. + ☒ The process emits a malformed probe marker. + ☒ The process emits multiple probe markers. + ☒ The probe protocol differs from the controller protocol. + ☒ The probe reports a non-core Lua context. + ☒ The probe reports `dfhack.timeout` with a type other than `function`. + ☒ Include the resolved runner path in invocation failures without implying that test selection or the selected spec caused the failure. - ☐ Make the protocol-mismatch message identify expected and observed values + ☒ Make the protocol-mismatch message identify expected and observed values and recommend checking for mixed installed DwarfSpec package versions. - ☐ Make missing-marker and nonzero-exit messages include a bounded excerpt of + ☒ Make missing-marker and nonzero-exit messages include a bounded excerpt of captured output when output is available. - ☐ Define a stable placeholder for empty output so the diagnostic never ends + ☒ Define a stable placeholder for empty output so the diagnostic never ends with an unexplained blank suffix. - ☐ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the connection + ☒ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the connection result state for every classified probe failure. 1.3 Bounded subprocess output contract: - ☐ Define deterministic selection of non-empty output lines. - ☐ Define per-line and total-length truncation behavior. - ☐ Add an explicit truncation marker when content is omitted. - ☐ Normalize line endings and control characters that would corrupt a + ☒ Define deterministic selection of non-empty output lines. + ☒ Define per-line and total-length truncation behavior. + ☒ Add an explicit truncation marker when content is omitted. + ☒ Normalize line endings and control characters that would corrupt a one-line CLI diagnostic while preserving useful DFHack error text. - ☐ Do not expose command arguments or environment variables that were not + ☒ Do not expose command arguments or environment variables that were not already present in captured subprocess output. Completion criteria: - ☐ Every condition currently collapsed into the generic health message maps + ☒ Every condition currently collapsed into the generic health message maps to one documented, objectively testable diagnostic. - ☐ The contract explicitly preserves existing failure kinds, result states, + ☒ The contract explicitly preserves existing failure kinds, result states, exit codes, and selection boundaries. Phase 2: Make the host probe safe and self-describing From 5847dad3a28a1d7aac3ae4ea26b7031e795d9f44 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 00:08:44 -0700 Subject: [PATCH 03/18] [Phase 2]: Make the host probe safe and self-describing --- docs/connection-probe-error-reporting.todo | 35 +++--- src/dwarfspec/host/entrypoints/probe.lua | 41 ++++++- tests/unit/host/entrypoints/probe_spec.lua | 127 +++++++++++++++++++++ 3 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 tests/unit/host/entrypoints/probe_spec.lua diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo index 7c6b7d4..b8e23af 100644 --- a/docs/connection-probe-error-reporting.todo +++ b/docs/connection-probe-error-reporting.todo @@ -98,38 +98,41 @@ Completion criteria: exit codes, and selection boundaries. Phase 2: Make the host probe safe and self-describing - ☐ Exit with a dependency-free probe that reports observable context state + ☒ Exit with a dependency-free probe that reports observable context state instead of crashing while inspecting an incomplete DFHack environment. + - Evidence: `src/dwarfspec/host/entrypoints/probe.lua`, + `tests/unit/host/entrypoints/probe_spec.lua`, 8 focused successes, + 776 recursive unit successes, and Lua checks for 295 files. 2.1 Safe capability inspection: - ☐ Update `src/dwarfspec/host/entrypoints/probe.lua` to inspect the global + ☒ Update `src/dwarfspec/host/entrypoints/probe.lua` to inspect the global `dfhack` value without indexing it unless it is a table. - ☐ Report `core` using `tostring(dfhack.is_core_context)` when the table is + ☒ Report `core` using `tostring(dfhack.is_core_context)` when the table is available and a deterministic unavailable value otherwise. - ☐ Report `timeout` using `type(dfhack.timeout)` when the table is available + ☒ Report `timeout` using `type(dfhack.timeout)` when the table is available and a deterministic unavailable type otherwise. - ☐ Emit the protocol supported by the probe script. - ☐ Emit the approved optional DFHack version field without making it part of + ☒ Emit the protocol supported by the probe script. + ☒ Emit the approved optional DFHack version field without making it part of the healthy-context predicate. - ☐ Keep the entrypoint free of `require`, `reqscript`, JSON, project + ☒ Keep the entrypoint free of `require`, `reqscript`, JSON, project configuration, and host-service dependencies. - ☐ Add language-standard documentation comments for any new helper methods. + ☒ Add language-standard documentation comments for any new helper methods. 2.2 Entrypoint contract tests: - ☐ Extend the host entrypoint unit fixtures to capture the exact probe line. - ☐ Verify the healthy core-context response. - ☐ Verify that an absent `dfhack` global produces a parseable unhealthy + ☒ Extend the host entrypoint unit fixtures to capture the exact probe line. + ☒ Verify the healthy core-context response. + ☒ Verify that an absent `dfhack` global produces a parseable unhealthy response instead of an indexing exception. - ☐ Verify missing `is_core_context`, missing `timeout`, and incorrectly typed + ☒ Verify missing `is_core_context`, missing `timeout`, and incorrectly typed capability values independently. - ☐ Verify optional DFHack version presence and absence according to the + ☒ Verify optional DFHack version presence and absence according to the settled response grammar. - ☐ Verify the probe does not load DwarfSpec or third-party modules. + ☒ Verify the probe does not load DwarfSpec or third-party modules. Completion criteria: - ☐ The probe emits exactly one parseable marker for every modeled Lua-context + ☒ The probe emits exactly one parseable marker for every modeled Lua-context shape and does not throw while gathering its required fields. - ☐ The healthy response remains compatible with the controller protocol + ☒ The healthy response remains compatible with the controller protocol settled in Phase 1. Phase 3: Parse and classify probe results in the controller diff --git a/src/dwarfspec/host/entrypoints/probe.lua b/src/dwarfspec/host/entrypoints/probe.lua index b55f75d..5a06ba4 100644 --- a/src/dwarfspec/host/entrypoints/probe.lua +++ b/src/dwarfspec/host/entrypoints/probe.lua @@ -1,4 +1,41 @@ -- Production adapter that verifies access to DFHack's core Lua context. -print(('DWARFSPEC_PROBE protocol=2 core=%s timeout=%s') - :format(tostring(dfhack.is_core_context), type(dfhack.timeout))) +---Returns the available DFHack table without indexing an invalid global. +---@return table|nil +local function dfhack_context() + local context = rawget(_G, 'dfhack') + return type(context) == 'table' and context or nil +end + +---Returns the normalized core-context capability value. +---@param context table|nil +---@return string +local function core_capability(context) + if context and type(context.is_core_context) == 'boolean' then + return tostring(context.is_core_context) + end + return 'unavailable' +end + +---Returns the normalized timeout capability type. +---@param context table|nil +---@return string +local function timeout_capability(context) + if not context then return 'unavailable' end + return type(context.timeout) +end + +---Returns an optional safe DFHack version field. +---@param context table|nil +---@return string +local function version_field(context) + if not context or context.VERSION == nil then return '' end + local ok, version = pcall(tostring, context.VERSION) + if not ok or not version:match('^[A-Za-z0-9._+-]+$') then return '' end + return ' dfhack=' .. version +end + +local context = dfhack_context() +print(('DWARFSPEC_PROBE protocol=2 core=%s timeout=%s%s') + :format(core_capability(context), timeout_capability(context), + version_field(context))) diff --git a/tests/unit/host/entrypoints/probe_spec.lua b/tests/unit/host/entrypoints/probe_spec.lua new file mode 100644 index 0000000..14553cb --- /dev/null +++ b/tests/unit/host/entrypoints/probe_spec.lua @@ -0,0 +1,127 @@ +-- Unit contracts for the dependency-free DFHack connection probe. + +local layout = require('dwarfspec.layout') + +---Loads the direct probe entrypoint through the package layout authority. +---@return function +local function load_probe() + return assert(loadfile(layout.current().host_scripts.probe)) +end + +---Returns the number of currently loaded Lua modules. +---@return integer +local function loaded_module_count() + local count = 0 + for _ in pairs(package.loaded) do count = count + 1 end + return count +end + +describe('DFHack connection probe entrypoint', function() + local original_dfhack + local original_print + local original_require + local original_reqscript + local lines + + before_each(function() + original_dfhack = rawget(_G, 'dfhack') + original_print = rawget(_G, 'print') + original_require = rawget(_G, 'require') + original_reqscript = rawget(_G, 'reqscript') + lines = {} + rawset(_G, 'print', function(line) + table.insert(lines, line) + end) + end) + + after_each(function() + rawset(_G, 'dfhack', original_dfhack) + rawset(_G, 'print', original_print) + rawset(_G, 'require', original_require) + rawset(_G, 'reqscript', original_reqscript) + end) + + ---Executes the probe with one modeled DFHack global. + ---@param context any + ---@return string + local function probe(context) + rawset(_G, 'dfhack', context) + assert.has_no.errors(load_probe()) + assert.equals(1, #lines) + assert.matches('^DWARFSPEC_PROBE ', lines[1]) + return lines[1] + end + + it('reports the exact healthy protocol 2 response', function() + local line = probe({ + VERSION='53.15-r1', + is_core_context=true, + timeout=function() end, + }) + + assert.equals('DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function dfhack=53.15-r1', line) + end) + + it('reports an absent DFHack global without throwing', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=unavailable', probe(nil)) + end) + + it('reports a missing core-context capability independently', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=function', probe({timeout=function() end})) + end) + + it('reports a missing timeout capability independently', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true timeout=nil', + probe({is_core_context=true})) + end) + + it('normalizes an incorrectly typed core-context capability', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=function', probe({ + is_core_context='true', + timeout=function() end, + })) + end) + + it('reports an incorrectly typed timeout capability', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true timeout=table', + probe({is_core_context=true, timeout={}})) + end) + + it('omits an unsafe optional DFHack version', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function', probe({ + VERSION='53.15 release candidate', + is_core_context=true, + timeout=function() end, + })) + end) + + it('does not load project or third-party modules', function() + rawset(_G, 'dfhack', { + is_core_context=true, + timeout=function() end, + }) + local chunk = load_probe() + rawset(_G, 'require', function() + error('probe must not call require') + end) + rawset(_G, 'reqscript', function() + error('probe must not call reqscript') + end) + local loaded_before = loaded_module_count() + + local ok, probe_error = pcall(chunk) + local loaded_after = loaded_module_count() + rawset(_G, 'require', original_require) + rawset(_G, 'reqscript', original_reqscript) + + assert.is_true(ok, probe_error) + assert.equals(loaded_before, loaded_after) + assert.same({'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function'}, lines) + end) +end) From d781136128adcdebede9eef060393d8e6c0549e5 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 00:24:40 -0700 Subject: [PATCH 04/18] [Phase 3]: Parse and classify probe results in the controller --- docs/connection-probe-error-reporting.todo | 52 ++-- .../controller/execution/transport_client.lua | 223 +++++++++++++++++- .../unit/controller/execution/runner_spec.lua | 6 +- .../execution/transport_client_spec.lua | 147 +++++++++++- 4 files changed, 389 insertions(+), 39 deletions(-) diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo index b8e23af..392b14c 100644 --- a/docs/connection-probe-error-reporting.todo +++ b/docs/connection-probe-error-reporting.todo @@ -136,54 +136,58 @@ Completion criteria: settled in Phase 1. Phase 3: Parse and classify probe results in the controller - ☐ Exit with controller-side parsing that accepts unrelated DFHack output but + ☒ Exit with controller-side parsing that accepts unrelated DFHack output but rejects ambiguous or unhealthy probe reports with precise messages. + - Evidence: `src/dwarfspec/controller/execution/transport_client.lua`, + `tests/unit/controller/execution/transport_client_spec.lua`, focused + 12 successes, 782 recursive unit successes, and Lua checks for 295 + files. 3.1 Probe parsing: - ☐ Add a focused private parser in + ☒ Add a focused private parser in `src/dwarfspec/controller/execution/transport_client.lua` or a narrowly scoped controller module if the parser and formatting responsibilities would otherwise obscure transport invocation. - ☐ Scan every captured line for the exact `DWARFSPEC_PROBE` marker instead of + ☒ Scan every captured line for the exact `DWARFSPEC_PROBE` marker instead of assuming the marker is the final output line. - ☐ Reject zero markers and multiple markers as distinct conditions. - ☐ Parse required fields by name rather than by positional whole-line + ☒ Reject zero markers and multiple markers as distinct conditions. + ☒ Parse required fields by name rather than by positional whole-line equality. - ☐ Reject malformed tokens, missing required fields, duplicate required + ☒ Reject malformed tokens, missing required fields, duplicate required fields, and invalid required values with field-specific context. - ☐ Ignore approved unknown fields without weakening required-field checks. - ☐ Add language-standard documentation comments for every new parser or + ☒ Ignore approved unknown fields without weakening required-field checks. + ☒ Add language-standard documentation comments for every new parser or formatter method. 3.2 Failure construction: - ☐ Handle process invocation exceptions separately from returned subprocess + ☒ Handle process invocation exceptions separately from returned subprocess failures. - ☐ Check `exit_code` before interpreting a successful probe response. - ☐ Format nonzero exits with the numeric exit code and bounded output. - ☐ Format missing and malformed reports with bounded output or the offending + ☒ Check `exit_code` before interpreting a successful probe response. + ☒ Format nonzero exits with the numeric exit code and bounded output. + ☒ Format missing and malformed reports with bounded output or the offending marker as established in Phase 1. - ☐ Compare the parsed protocol to the controller protocol and report both + ☒ Compare the parsed protocol to the controller protocol and report both values on mismatch. - ☐ Report `core` and `timeout` health failures independently. - ☐ Return success only for exactly one well-formed response with every + ☒ Report `core` and `timeout` health failures independently. + ☒ Return success only for exactly one well-formed response with every required healthy value. - ☐ Remove the generic + ☒ Remove the generic `DFHack is not running or did not provide a healthy core Lua context` fallback after every observable condition has a precise replacement. - ☐ Keep all failures classified as `RunnerFailureKind.CONNECTION` so runner + ☒ Keep all failures classified as `RunnerFailureKind.CONNECTION` so runner orchestration and result interpretation remain compatible. 3.3 Bounded output formatting: - ☐ Implement the settled line and byte limits deterministically. - ☐ Preserve useful stderr text already merged into the subprocess result. - ☐ Make truncation visible. - ☐ Verify output formatting cannot itself throw on absent, empty, sparse, or + ☒ Implement the settled line and byte limits deterministically. + ☒ Preserve useful stderr text already merged into the subprocess result. + ☒ Make truncation visible. + ☒ Verify output formatting cannot itself throw on absent, empty, sparse, or non-string fixture values. Completion criteria: - ☐ A valid marker can appear before or after unrelated output and still pass. - ☐ Every invalid subprocess result produces its specific Phase 1 diagnostic. - ☐ No connection-probe branch retains the old generic fallback. + ☒ A valid marker can appear before or after unrelated output and still pass. + ☒ Every invalid subprocess result produces its specific Phase 1 diagnostic. + ☒ No connection-probe branch retains the old generic fallback. Phase 4: Lock down controller and runner compatibility ☐ Exit with focused unit coverage proving the new detail does not change diff --git a/src/dwarfspec/controller/execution/transport_client.lua b/src/dwarfspec/controller/execution/transport_client.lua index 7ead570..3c76ea7 100644 --- a/src/dwarfspec/controller/execution/transport_client.lua +++ b/src/dwarfspec/controller/execution/transport_client.lua @@ -4,6 +4,177 @@ local process = require('dwarfspec.controller.execution.process') local reports = require('dwarfspec.controller.reporting.report') local M = {} +local PROBE_MARKER = 'DWARFSPEC_PROBE' +local EXPECTED_PROTOCOL = 2 +local MAX_OUTPUT_LINES = 8 +local MAX_LINE_BYTES = 512 +local MAX_OUTPUT_BYTES = 2048 +local LINE_TRUNCATED = '...' +local OUTPUT_TRUNCATED = ' ' + +---Converts an arbitrary captured value without allowing tostring errors to escape. +---@param value any +---@return string +local function safe_tostring(value) + local ok, rendered = pcall(tostring, value) + if not ok or type(rendered) ~= 'string' then return '' end + return rendered +end + +---Returns the longest prefix within the byte limit without splitting valid UTF-8. +---@param value string +---@param limit integer +---@return string +local function utf8_prefix(value, limit) + if #value <= limit then return value end + local finish = limit + while finish > 0 do + local next_byte = value:byte(finish + 1) + if not next_byte or next_byte < 0x80 or next_byte > 0xbf then break end + finish = finish - 1 + end + return value:sub(1, finish) +end + +---Returns the longest suffix within the byte limit without splitting valid UTF-8. +---@param value string +---@param limit integer +---@return string +local function utf8_suffix(value, limit) + if #value <= limit then return value end + local first = #value - limit + 1 + while first <= #value do + local byte = value:byte(first) + if not byte or byte < 0x80 or byte > 0xbf then break end + first = first + 1 + end + return value:sub(first) +end + +---Collects captured output in deterministic numeric-index order. +---@param lines any +---@return any[] +local function ordered_output(lines) + if lines == nil then return {} end + if type(lines) ~= 'table' then return {lines} end + local indexes = {} + local index = next(lines) + while index ~= nil do + if type(index) == 'number' and index >= 1 and index % 1 == 0 then + indexes[#indexes + 1] = index + end + index = next(lines, index) + end + table.sort(indexes) + local ordered = {} + for _, numeric_index in ipairs(indexes) do + ordered[#ordered + 1] = lines[numeric_index] + end + return ordered +end + +---Normalizes and bounds one captured output line. +---@param value any +---@return string|nil +local function format_output_line(value) + local rendered = safe_tostring(value):gsub('\t', ' '):gsub('[%z\1-\31\127]', '?') + rendered = rendered:gsub('^ +', ''):gsub(' +$', '') + if rendered == '' then return nil end + if #rendered > MAX_LINE_BYTES then + rendered = utf8_prefix(rendered, MAX_LINE_BYTES - #LINE_TRUNCATED) .. + LINE_TRUNCATED + end + return rendered +end + +---Formats recent merged subprocess output within deterministic byte and line limits. +---@param lines any +---@return string +local function format_output(lines) + local formatted = {} + for _, value in ipairs(ordered_output(lines)) do + local line = format_output_line(value) + if line then formatted[#formatted + 1] = line end + end + if #formatted == 0 then return '' end + + local retained = {} + local first = math.max(1, #formatted - MAX_OUTPUT_LINES + 1) + if first > 1 then + retained[#retained + 1] = + ('<%d earlier lines omitted>'):format(first - 1) + end + for index = first, #formatted do + retained[#retained + 1] = formatted[index] + end + + local output = table.concat(retained, ' | ') + if #output > MAX_OUTPUT_BYTES then + output = OUTPUT_TRUNCATED .. utf8_suffix(output, + MAX_OUTPUT_BYTES - #OUTPUT_TRUNCATED) + end + return output +end + +---Finds exact probe marker lines without treating embedded marker text as a report. +---@param lines any +---@return string[] +local function probe_candidates(lines) + local candidates = {} + for _, value in ipairs(ordered_output(lines)) do + local line = safe_tostring(value) + if line == PROBE_MARKER or + line:sub(1, #PROBE_MARKER + 1) == PROBE_MARKER .. ' ' then + candidates[#candidates + 1] = line + end + end + return candidates +end + +---Parses one exact probe report according to the controller probe grammar. +---@param line string +---@return table|nil, string|nil +local function parse_probe(line) + local fields = {} + local remainder = line:sub(#PROBE_MARKER + 2) + for token in remainder:gmatch('[^ ]+') do + local name, value = token:match('^([^=]+)=([^=]*)$') + if not name then + return nil, 'invalid token: ' .. safe_tostring(token) + end + if not name:match('^[a-z][a-z0-9_]*$') then + return nil, 'invalid field name: ' .. safe_tostring(name) + end + if value == '' then return nil, 'empty value for field ' .. name end + if not value:match('^[A-Za-z0-9._+%-]+$') then + return nil, ('invalid value for field %s: %s'):format( + name, safe_tostring(value)) + end + if fields[name] ~= nil then return nil, 'duplicate field: ' .. name end + fields[name] = value + end + + for _, name in ipairs({'protocol', 'core', 'timeout'}) do + if fields[name] == nil then + return nil, 'missing required field: ' .. name + end + end + if not fields.protocol:match('^[1-9][0-9]*$') then + return nil, 'invalid protocol value: ' .. fields.protocol + end + if fields.core ~= 'true' and fields.core ~= 'false' and + fields.core ~= 'unavailable' then + return nil, 'invalid core value: ' .. fields.core + end + local timeout_values = { + ['nil']=true, boolean=true, number=true, string=true, ['function']=true, + userdata=true, thread=true, table=true, unavailable=true, + } + if not timeout_values[fields.timeout] then + return nil, 'invalid timeout value: ' .. fields.timeout + end + return fields, nil +end ---Creates a transport client over the subprocess and report authorities. ---@param dependencies table @@ -33,14 +204,54 @@ function M.new(dependencies) local ok, result = pcall(invoke, runner, builder.probe(options)) if not ok then error(failure(kinds.CONNECTION, - 'could not contact DFHack through ' .. runner .. ': ' .. - clean_message(result)), 0) + ('Could not invoke DFHack runner "%s": %s'):format( + runner, clean_message(result))), 0) + end + if result.exit_code ~= 0 then + error(failure(kinds.CONNECTION, + ('DFHack connection probe through "%s" exited with code %s. ' .. + 'Output: %s'):format(runner, safe_tostring(result.exit_code), + format_output(result.lines))), 0) + end + + local candidates = probe_candidates(result.lines) + if #candidates == 0 then + error(failure(kinds.CONNECTION, + ('DFHack responded through "%s", but emitted no DwarfSpec ' .. + 'probe report. Output: %s'):format( + runner, format_output(result.lines))), 0) + end + if #candidates > 1 then + error(failure(kinds.CONNECTION, + ('DFHack emitted %d DwarfSpec probe reports; expected exactly ' .. + 'one. Output: %s'):format( + #candidates, format_output(result.lines))), 0) + end + + local probe, reason = parse_probe(candidates[1]) + if not probe then + error(failure(kinds.CONNECTION, + ('DFHack emitted a malformed DwarfSpec probe report: %s. ' .. + 'Probe: %s'):format(format_output({reason}), + format_output({candidates[1]}))), 0) + end + if probe.protocol ~= tostring(EXPECTED_PROTOCOL) then + error(failure(kinds.CONNECTION, + ('DwarfSpec protocol mismatch: controller expects %d, probe ' .. + 'reported %s. Check for mixed installed DwarfSpec package ' .. + 'versions.'):format(EXPECTED_PROTOCOL, probe.protocol)), 0) + end + if probe.core ~= 'true' then + error(failure(kinds.CONNECTION, + ('DFHack probe did not run in a healthy core Lua context: ' .. + 'expected core=true, reported core=%s.'):format( + probe.core)), 0) end - if result.exit_code ~= 0 or - result.lines[#result.lines] ~= - 'DWARFSPEC_PROBE protocol=2 core=true timeout=function' then + if probe.timeout ~= 'function' then error(failure(kinds.CONNECTION, - 'DFHack is not running or did not provide a healthy core Lua context'), 0) + ('DFHack core Lua context is missing the required ' .. + 'dfhack.timeout function: reported timeout=%s.'):format( + probe.timeout)), 0) end end diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index 35179b4..bcd44ea 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -857,7 +857,8 @@ describe('DwarfSpec external runner', function() assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], outcome.exit_code) assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('DFHack is not running', outcome.error.message, + assert.matches('DFHack connection probe through "bin/dwarfspec" ' .. + 'exited with code 1. Output: not running', outcome.error.message, 1, true) assert.is_nil(outcome.report) end) @@ -893,7 +894,8 @@ describe('DwarfSpec external runner', function() assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], outcome.exit_code) assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('could not contact DFHack through', + assert.matches('Could not invoke DFHack runner "bin/dwarfspec": ' .. + 'process launch failed', outcome.error.message, 1, true) end) diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index 12a203c..255c5b9 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -20,6 +20,22 @@ local function client() }) end +---Captures one connection failure for a simulated subprocess result. +---@param lines any +---@param exit_code any|nil +---@return table +local function connection_failure(lines, exit_code) + local transport = client() + local ok, detail = pcall(transport.verify_connection, { + invoke=function() + return {exit_code=exit_code == nil and 0 or exit_code, lines=lines} + end, + }, 'runner') + assert.is_false(ok) + assert.same('connection', detail.kind) + return detail +end + ---Builds one valid terminal transport at the requested cursor. ---@param after_sequence integer ---@return string[] @@ -44,25 +60,142 @@ local function transport_lines(after_sequence) end describe('controller transport client', function() - it('classifies process exceptions and unhealthy probes as connection failures', function() + it('classifies probe invocation exceptions separately', function() local transport = client() local ok, detail = pcall(transport.verify_connection, { invoke=function() error('bridge unavailable') end}, 'runner') assert.is_false(ok) assert.same('connection', detail.kind) - ok, detail = pcall(transport.verify_connection, { - invoke=function() return {exit_code=0, lines={'wrong'}} end}, 'runner') - assert.is_false(ok) - assert.same('connection', detail.kind) + assert.is_truthy(detail.message:find( + 'Could not invoke DFHack runner "runner":', 1, true)) + assert.is_truthy(detail.message:find('bridge unavailable', 1, true)) end) - it('accepts the exact healthy probe', function() + it('accepts one healthy probe among unrelated output', function() local transport = client() local options = {invoke=function() return {exit_code=0, lines={ - 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} end} + 'before', + 'prefix DWARFSPEC_PROBE protocol=999 core=false timeout=nil', + 'DWARFSPEC_PROBE timeout=function future=value protocol=2 core=true', + 'after'}} end} assert.has_no.errors(function() transport.verify_connection(options, 'runner') end) end) + it('reports nonzero probe exits before parsing marker output', function() + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + 'subprocess failed', + }, 17) + assert.same('DFHack connection probe through "runner" exited with code 17. ' .. + 'Output: DWARFSPEC_PROBE protocol=2 core=true timeout=function | ' .. + 'subprocess failed', detail.message) + end) + + it('distinguishes missing and multiple probe reports', function() + local no_output_message = 'DFHack responded through "runner", but emitted ' .. + 'no DwarfSpec probe report. Output: ' + assert.same(no_output_message, connection_failure(nil).message) + assert.same(no_output_message, connection_failure({}).message) + + local detail = connection_failure({'ordinary DFHack output'}) + assert.same('DFHack responded through "runner", but emitted no DwarfSpec ' .. + 'probe report. Output: ordinary DFHack output', detail.message) + + detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + }) + assert.same('DFHack emitted 2 DwarfSpec probe reports; expected exactly ' .. + 'one. Output: DWARFSPEC_PROBE protocol=2 core=true timeout=function | ' .. + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', detail.message) + end) + + it('reports malformed probe fields with specific context', function() + local cases = { + {'DWARFSPEC_PROBE protocol', 'invalid token: protocol'}, + {'DWARFSPEC_PROBE Protocol=2 core=true timeout=function', + 'invalid field name: Protocol'}, + {'DWARFSPEC_PROBE protocol= core=true timeout=function', + 'empty value for field protocol'}, + {'DWARFSPEC_PROBE protocol=2 protocol=3 core=true timeout=function', + 'duplicate field: protocol'}, + {'DWARFSPEC_PROBE core=true timeout=function', + 'missing required field: protocol'}, + {'DWARFSPEC_PROBE protocol=02 core=true timeout=function', + 'invalid protocol value: 02'}, + {'DWARFSPEC_PROBE protocol=2 core=yes timeout=function', + 'invalid core value: yes'}, + {'DWARFSPEC_PROBE protocol=2 core=true timeout=callable', + 'invalid timeout value: callable'}, + {'DWARFSPEC_PROBE protocol=2 core=true timeout=function future=bad/value', + 'invalid value for field future: bad/value'}, + } + for _, case in ipairs(cases) do + local detail = connection_failure({case[1]}) + assert.is_truthy(detail.message:find( + 'DFHack emitted a malformed DwarfSpec probe report: ' .. case[2], + 1, true), case[1]) + assert.is_truthy(detail.message:find('Probe: ' .. case[1], 1, true), + case[1]) + end + end) + + it('classifies protocol, core, and timeout health independently', function() + local cases = { + { + 'DWARFSPEC_PROBE protocol=3 core=false timeout=nil', + 'DwarfSpec protocol mismatch: controller expects 2, probe reported 3. ' .. + 'Check for mixed installed DwarfSpec package versions.', + }, + { + 'DWARFSPEC_PROBE protocol=2 core=unavailable timeout=nil', + 'DFHack probe did not run in a healthy core Lua context: expected ' .. + 'core=true, reported core=unavailable.', + }, + { + 'DWARFSPEC_PROBE protocol=2 core=true timeout=nil', + 'DFHack core Lua context is missing the required dfhack.timeout ' .. + 'function: reported timeout=nil.', + }, + } + for _, case in ipairs(cases) do + assert.same(case[2], connection_failure({case[1]}).message) + end + end) + + it('bounds and sanitizes sparse non-string probe output', function() + local unprintable = setmetatable({}, { + __tostring=function() error('cannot render') end, + }) + local lines = { + [1]=' first\tline\1 ', + [3]=unprintable, + [5]=string.rep('x', 600), + } + local detail = connection_failure(lines) + assert.is_truthy(detail.message:find('first line?', 1, true)) + assert.is_truthy(detail.message:find('', 1, true)) + assert.is_truthy(detail.message:find('...', 1, true)) + + detail = connection_failure({string.rep('\195\169', 300)}) + local output = assert(detail.message:match('Output: (.*)$')) + assert.is_not_nil(utf8.len(output)) + assert.is_true(#output <= 512) + end) + + it('retains recent probe output within line and total byte limits', function() + local lines = {} + for index = 1, 10 do + lines[index] = ('line-%02d-%s'):format(index, string.rep('x', 500)) + end + local detail = connection_failure(lines) + local output = assert(detail.message:match('Output: (.*)$')) + assert.is_true(#output <= 2048) + assert.is_truthy(output:find(' ', 1, true)) + assert.is_falsy(output:find('line-01-', 1, true)) + assert.is_truthy(output:find('line-10-', 1, true)) + end) + it('classifies nonzero exits before canonical parsing', function() local transport = client() local ok, detail = pcall(transport.transport, { From 3af3d022cc73df4f417807281fe4d21f6160c14c Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 00:41:43 -0700 Subject: [PATCH 05/18] [Phase 4]: Lock down controller and runner compatibility --- docs/connection-probe-error-reporting.todo | 54 +++--- .../unit/controller/execution/runner_spec.lua | 155 +++++++++++++++--- .../execution/transport_client_spec.lua | 137 ++++++++++++---- 3 files changed, 261 insertions(+), 85 deletions(-) diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo index 392b14c..525507b 100644 --- a/docs/connection-probe-error-reporting.todo +++ b/docs/connection-probe-error-reporting.todo @@ -190,48 +190,52 @@ Completion criteria: ☒ No connection-probe branch retains the old generic fallback. Phase 4: Lock down controller and runner compatibility - ☐ Exit with focused unit coverage proving the new detail does not change + ☒ Exit with focused unit coverage proving the new detail does not change established orchestration outcomes. + - Evidence: focused probe 8 successes, transport 18 successes, runner + 32 successes, and result-interpreter 4 successes; 789 recursive unit + successes; Lua checks for 295 files; LuaLS valid declarations with no + diagnostics and invalid declarations with six expected warnings. 4.1 Transport client tests: - ☐ Replace the combined process-exception/unhealthy-probe test with separate + ☒ Replace the combined process-exception/unhealthy-probe test with separate cases that assert the exact classification and meaningful message detail. - ☐ Verify a healthy marker as the only output line. - ☐ Verify a healthy marker with unrelated output before it. - ☐ Verify a healthy marker with unrelated output after it. - ☐ Verify invocation exceptions include the runner path and original error. - ☐ Verify nonzero exits with empty and non-empty output. - ☐ Verify empty successful output and successful output without a marker. - ☐ Verify malformed, missing-field, duplicate-field, and multiple-marker + ☒ Verify a healthy marker as the only output line. + ☒ Verify a healthy marker with unrelated output before it. + ☒ Verify a healthy marker with unrelated output after it. + ☒ Verify invocation exceptions include the runner path and original error. + ☒ Verify nonzero exits with empty and non-empty output. + ☒ Verify empty successful output and successful output without a marker. + ☒ Verify malformed, missing-field, duplicate-field, and multiple-marker responses. - ☐ Verify protocol mismatch reports expected and observed protocol values. - ☐ Verify `core=false` and every non-function `timeout` value independently. - ☐ Verify unknown optional fields are ignored. - ☐ Verify line, per-line, and total-output truncation boundaries. - ☐ Verify every case retains the connection failure kind. + ☒ Verify protocol mismatch reports expected and observed protocol values. + ☒ Verify `core=false` and every non-function `timeout` value independently. + ☒ Verify unknown optional fields are ignored. + ☒ Verify line, per-line, and total-output truncation boundaries. + ☒ Verify every case retains the connection failure kind. 4.2 Runner and result tests: - ☐ Verify `runner.run()` returns exit code 4 for each representative probe + ☒ Verify `runner.run()` returns exit code 4 for each representative probe failure category. - ☐ Verify the persisted result remains in the connection-error state where + ☒ Verify the persisted result remains in the connection-error state where result persistence applies. - ☐ Verify run bootstrap is never attempted after probe failure. - ☐ Verify selected identities and spec paths do not appear in the connection + ☒ Verify run bootstrap is never attempted after probe failure. + ☒ Verify selected identities and spec paths do not appear in the connection explanation unless they were independently part of subprocess output. - ☐ Verify abort, status, history, show, logs, and executor-recovery commands + ☒ Verify abort, status, history, show, logs, and executor-recovery commands preserve their existing connection-failure behavior while surfacing the improved detail. 4.3 Regression suite: - ☐ Run the focused host-entrypoint, transport-client, runner, and result + ☒ Run the focused host-entrypoint, transport-client, runner, and result interpreter unit specifications. - ☐ Run the complete recursive unit suite. - ☐ Run Lua syntax, formatting, and declaration checks for all changed files. - ☐ Run `git diff --check` and inspect the final focused diff. + ☒ Run the complete recursive unit suite. + ☒ Run Lua syntax, formatting, and declaration checks for all changed files. + ☒ Run `git diff --check` and inspect the final focused diff. Completion criteria: - ☐ Focused tests cover every classification row and boundary condition. - ☐ The complete unit and static-analysis suites pass without changing public + ☒ Focused tests cover every classification row and boundary condition. + ☒ The complete unit and static-analysis suites pass without changing public failure kinds, result states, or exit codes. Phase 5: Document and package the improved diagnostics diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index bcd44ea..647d0bf 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -271,6 +271,48 @@ local function options(run_id) } end +---Runs one representative failed probe through the complete run boundary. +---@param case table +---@return table, table +local function run_probe_failure(case) + local run_options = options('connection-' .. case.name) + run_options.identities = {'tests/private-selected-' .. case.name .. '.ds.lua'} + run_options.test_glob = 'tests/private-selection-' .. case.name .. '/*.lua' + run_options.result_path = 'D:/results/connection-' .. case.name .. '.json' + local persisted + run_options.result_store = { + write=function(_, result) persisted = result end, + } + local calls = 0 + local bootstrap_attempted = false + run_options.invoke = function(_, arguments) + calls = calls + 1 + if not arguments[3]:match('probe%.lua$') then + bootstrap_attempted = true + end + if case.exception then error(case.exception) end + return case.result + end + + local outcome = runner.run(run_options) + + assert.equals(4, outcome.exit_code, case.name) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind, case.name) + assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state, case.name) + assert.equals(ResultState.CONNECTION_ERROR, persisted.state, case.name) + assert.is_false(bootstrap_attempted, case.name) + assert.equals(1, calls, case.name) + assert.is_truthy(outcome.error.message:find(case.message, 1, true), case.name) + for _, selected_path in ipairs({ + run_options.project_root, run_options.test_glob, + run_options.identities[1], + }) do + assert.is_falsy(outcome.error.message:find(selected_path, 1, true), + case.name .. ': ' .. selected_path) + end + return outcome, persisted +end + describe('DwarfSpec external runner', function() it('streams progress and returns zero only after passing cleanup', function() local calls = 0 @@ -848,19 +890,35 @@ describe('DwarfSpec external runner', function() outcome.error.message, 1, true) end) - it('returns a connection failure before bootstrap', function() - local run_options = options('connection-run') - run_options.invoke = function() - return {exit_code=1, lines={'not running'}} + it('preserves orchestration outcomes for every probe failure category', function() + local cases = { + {name='invocation', exception='process launch failed', + message='Could not invoke DFHack runner "bin/dwarfspec":'}, + {name='nonzero', result={exit_code=1, lines={'not running'}}, + message='exited with code 1. Output: not running'}, + {name='missing', result={exit_code=0, lines={'ordinary output'}}, + message='emitted no DwarfSpec probe report'}, + {name='multiple', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + }}, message='emitted 2 DwarfSpec probe reports'}, + {name='malformed', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true', + }}, message='malformed DwarfSpec probe report'}, + {name='protocol', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=3 core=true timeout=function', + }}, message='controller expects 2, probe reported 3'}, + {name='core', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=false timeout=function', + }}, message='reported core=false'}, + {name='timeout', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=nil', + }}, message='reported timeout=nil'}, + } + for _, case in ipairs(cases) do + local outcome = run_probe_failure(case) + assert.is_nil(outcome.report, case.name) end - local outcome = runner.run(run_options) - assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], - outcome.exit_code) - assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('DFHack connection probe through "bin/dwarfspec" ' .. - 'exited with code 1. Output: not running', outcome.error.message, - 1, true) - assert.is_nil(outcome.report) end) it('classifies a missing configured runner as a dependency failure', @@ -884,21 +942,6 @@ describe('DwarfSpec external runner', function() outcome.error.message, 1, true) end) - it('classifies a probe launch exception as an actionable connection error', - function() - local run_options = options('probe-launch') - run_options.invoke = function() - error('process launch failed') - end - local outcome = runner.run(run_options) - assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], - outcome.exit_code) - assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('Could not invoke DFHack runner "bin/dwarfspec": ' .. - 'process launch failed', - outcome.error.message, 1, true) - end) - it('treats interruption as abort and confirms native cleanup', function() local run_options = options('interrupted-run') run_options.sleep = function() error('interrupted by user') end @@ -1300,6 +1343,64 @@ describe('DwarfSpec external runner', function() outcome.error.message) end) + it('attributes a selected path only when subprocess output emitted it', function() + local run_options = options('emitted-selection') + local identity = 'tests/private-emitted-selection.ds.lua' + run_options.identities = {identity} + run_options.invoke = function() + return {exit_code=1, lines={'runner echoed ' .. identity}} + end + + local outcome = runner.run(run_options) + + assert.equals(4, outcome.exit_code) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind) + assert.is_truthy(outcome.error.message:find(identity, 1, true)) + end) + + it('preserves connection preflight for every auxiliary command', function() + local cases = { + {name='abort', invoke=function(run_options) + return runner.abort(run_options, 'retained-run') + end}, + {name='status', invoke=function(run_options) + return runner.status(run_options) + end}, + {name='history', invoke=function(run_options) + return runner.history(run_options) + end}, + {name='show', invoke=function(run_options) + return runner.inspect(run_options, 'retained-run') + end}, + {name='logs', invoke=function(run_options) + return runner.logs(run_options, 'retained-run') + end}, + {name='executor-recovery', invoke=function(run_options) + return runner.recover_executor(run_options, 'retained-run', 3, + 'operator verified clean state') + end}, + } + for _, case in ipairs(cases) do + local run_options = options('command-' .. case.name) + local calls = 0 + run_options.invoke = function() + calls = calls + 1 + return {exit_code=7, lines={case.name .. ' probe unavailable'}} + end + + local outcome = case.invoke(run_options) + + assert.equals(4, outcome.exit_code, case.name) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind, + case.name) + assert.is_truthy(outcome.error.message:find( + 'DFHack connection probe through "bin/dwarfspec" exited with ' .. + 'code 7. Output: ' .. case.name .. ' probe unavailable', + 1, true), case.name) + assert.equals(1, calls, case.name) + end + end) + it('recovers one exact quarantined generation through host verification', function() local run_options = options('unused-recovery-id') diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index 255c5b9..da70680 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -3,6 +3,8 @@ local module = require('dwarfspec.controller.execution.transport_client') local json = require('dkjson') local RunState = require('dwarfspec.protocol.enums.run_states') +local HEALTHY_PROBE = + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function' ---Creates a transport client with a minimal command builder. ---@return table @@ -36,6 +38,25 @@ local function connection_failure(lines, exit_code) return detail end +---Verifies that one simulated subprocess result passes connection preflight. +---@param lines any +local function assert_connection_success(lines) + local transport = client() + local options = {invoke=function() + return {exit_code=0, lines=lines} + end} + assert.has_no.errors(function() + transport.verify_connection(options, 'runner') + end) +end + +---Returns the bounded output excerpt from one missing-marker diagnostic. +---@param lines any +---@return string +local function output_excerpt(lines) + return assert(connection_failure(lines).message:match('Output: (.*)$')) +end + ---Builds one valid terminal transport at the requested cursor. ---@param after_sequence integer ---@return string[] @@ -71,24 +92,36 @@ describe('controller transport client', function() assert.is_truthy(detail.message:find('bridge unavailable', 1, true)) end) - it('accepts one healthy probe among unrelated output', function() - local transport = client() - local options = {invoke=function() return {exit_code=0, lines={ - 'before', - 'prefix DWARFSPEC_PROBE protocol=999 core=false timeout=nil', - 'DWARFSPEC_PROBE timeout=function future=value protocol=2 core=true', - 'after'}} end} - assert.has_no.errors(function() transport.verify_connection(options, 'runner') end) + it('accepts a healthy probe as the only output line', function() + assert_connection_success({HEALTHY_PROBE}) end) - it('reports nonzero probe exits before parsing marker output', function() - local detail = connection_failure({ - 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + it('accepts unrelated output before a healthy probe', function() + assert_connection_success({'before', HEALTHY_PROBE}) + end) + + it('accepts unrelated output after a healthy probe', function() + assert_connection_success({HEALTHY_PROBE, 'after'}) + end) + + it('ignores embedded markers and well-formed unknown fields', function() + assert_connection_success({ + 'prefix DWARFSPEC_PROBE protocol=999 core=false timeout=nil', + 'DWARFSPEC_PROBE timeout=function future=value protocol=2 core=true', + }) + end) + + it('reports nonzero probe exits with empty and non-empty output', function() + local detail = connection_failure({}, 17) + assert.same('DFHack connection probe through "runner" exited with code 17. ' .. + 'Output: ', detail.message) + + detail = connection_failure({ + HEALTHY_PROBE, 'subprocess failed', }, 17) assert.same('DFHack connection probe through "runner" exited with code 17. ' .. - 'Output: DWARFSPEC_PROBE protocol=2 core=true timeout=function | ' .. - 'subprocess failed', detail.message) + 'Output: ' .. HEALTHY_PROBE .. ' | subprocess failed', detail.message) end) it('distinguishes missing and multiple probe reports', function() @@ -140,26 +173,34 @@ describe('controller transport client', function() end end) - it('classifies protocol, core, and timeout health independently', function() - local cases = { - { - 'DWARFSPEC_PROBE protocol=3 core=false timeout=nil', - 'DwarfSpec protocol mismatch: controller expects 2, probe reported 3. ' .. - 'Check for mixed installed DwarfSpec package versions.', - }, - { - 'DWARFSPEC_PROBE protocol=2 core=unavailable timeout=nil', - 'DFHack probe did not run in a healthy core Lua context: expected ' .. - 'core=true, reported core=unavailable.', - }, - { - 'DWARFSPEC_PROBE protocol=2 core=true timeout=nil', - 'DFHack core Lua context is missing the required dfhack.timeout ' .. - 'function: reported timeout=nil.', - }, - } - for _, case in ipairs(cases) do - assert.same(case[2], connection_failure({case[1]}).message) + it('reports expected and observed protocol values before health failures', function() + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=3 core=false timeout=nil', + }) + assert.same('DwarfSpec protocol mismatch: controller expects 2, probe ' .. + 'reported 3. Check for mixed installed DwarfSpec package versions.', + detail.message) + end) + + it('reports core=false independently from timeout health', function() + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=false timeout=function', + }) + assert.same('DFHack probe did not run in a healthy core Lua context: ' .. + 'expected core=true, reported core=false.', detail.message) + end) + + it('reports every non-function timeout type independently', function() + for _, timeout_type in ipairs({ + 'nil', 'boolean', 'number', 'string', 'userdata', 'thread', + 'table', 'unavailable', + }) do + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=' .. timeout_type, + }) + assert.same('DFHack core Lua context is missing the required ' .. + 'dfhack.timeout function: reported timeout=' .. timeout_type .. '.', + detail.message) end end) @@ -196,6 +237,36 @@ describe('controller transport client', function() assert.is_truthy(output:find('line-10-', 1, true)) end) + it('enforces exact line-count and byte truncation boundaries', function() + local lines = {} + for index = 1, 8 do lines[index] = 'line-' .. index end + assert.same(table.concat(lines, ' | '), output_excerpt(lines)) + + table.insert(lines, 'line-9') + assert.same('<1 earlier lines omitted> | ' .. + table.concat({table.unpack(lines, 2, 9)}, ' | '), + output_excerpt(lines)) + + local exact_line = string.rep('x', 512) + assert.same(exact_line, output_excerpt({exact_line})) + local truncated_line = output_excerpt({string.rep('x', 513)}) + assert.same(512, #truncated_line) + assert.is_truthy(truncated_line:find('...', 1, true)) + + lines = {} + for index = 1, 7 do lines[index] = string.rep('x', 253) end + lines[8] = string.rep('x', 256) + local exact_output = output_excerpt(lines) + assert.same(2048, #exact_output) + assert.is_falsy(exact_output:find(' ', 1, true)) + + lines[8] = string.rep('x', 257) + local truncated_output = output_excerpt(lines) + assert.same(2048, #truncated_output) + assert.same(' ', truncated_output:sub(1, 19)) + assert.same(lines[8], truncated_output:sub(-#lines[8])) + end) + it('classifies nonzero exits before canonical parsing', function() local transport = client() local ok, detail = pcall(transport.transport, { From c21276ef9f974ad545b6da63b574ea3cfff72cff Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 01:22:48 -0700 Subject: [PATCH 06/18] [Phase 5]: Document and package the improved diagnostics --- CHANGELOG.md | 15 +++++++ docs/command-line.md | 29 ++++++++++++++ docs/connection-probe-error-reporting.todo | 39 ++++++++++++------- docs/installation.md | 2 +- ...1-1.rockspec => dwarfspec-0.2.2-1.rockspec | 4 +- src/dwarfspec/controller/command_line.lua | 4 +- src/dwarfspec/host/execution/host.lua | 2 +- tests/unit/cli_selection_spec.lua | 2 +- tests/unit/controller/application_spec.lua | 2 +- tests/unit/controller/command_line_spec.lua | 2 +- .../entrypoints/entrypoint_contract_spec.lua | 4 +- 11 files changed, 81 insertions(+), 24 deletions(-) rename dwarfspec-0.2.1-1.rockspec => dwarfspec-0.2.2-1.rockspec (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52b1ec..688dc04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/). and return zero-based inclusive screen-cell bounds. Subject and rectangle areas are spatial filters, not render-ownership claims. +## [0.2.2] - 2026-08-03 + +### Changed + +- Report connection preflight failures as distinct invocation, subprocess, + missing-response, malformed-response, protocol, core-context, and timeout + diagnostics with bounded captured output. +- Ship the updated controller and DFHack probe together while retaining probe + protocol version 2. + +### Fixed + +- Avoid attributing a failed DFHack connection to a selected specification + when test selection succeeded before the connection preflight. + ## [0.2.1] - 2026-07-31 ### Added diff --git a/docs/command-line.md b/docs/command-line.md index e5b3bdb..42cdb94 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -160,6 +160,35 @@ complete terminal result without writing a file. A terminal service generation is acknowledged only after its file replacement succeeds, or after successful no-results validation. +### Connection preflight diagnostics + +`dwarfspec run` completes discovery and selection before it starts the DFHack +connection preflight. A connection failure therefore does not implicate the +selected specification path. The path is included in a diagnostic only when +DFHack's subprocess output itself includes it. + +Every connection preflight failure exits with code 4, but its message identifies +the failed boundary and the action needed to investigate it: + +- an invocation failure reports the resolved runner path and the original + process-launch error; +- a nonzero probe exit reports the numeric exit code and bounded probe output; +- a missing response marker reports that no probe response was found and + includes bounded probe output; +- multiple response markers report the observed count and bounded probe output; +- a malformed response reports the first grammar error and the bounded + offending response line; +- a protocol mismatch reports the expected and observed protocol versions; +- a core-context failure reports that DFHack did not provide a healthy core Lua + context; and +- a timeout-capability failure reports that the required timeout support is + unavailable. + +Captured subprocess output is length-bounded and sanitized before display. For +a protocol mismatch, reinstall or upgrade DwarfSpec from one package artifact +so that the external controller and bundled DFHack probe come from the same +DwarfSpec release. The probe protocol remains version 2 for this release. + Focus diagnostics are nonfatal. They do not change test counts, terminal state, cleanup confirmation, or the process result. A run whose tests pass and whose cleanup is confirmed therefore exits with code 0 even when it retains focus diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo index 525507b..182f29a 100644 --- a/docs/connection-probe-error-reporting.todo +++ b/docs/connection-probe-error-reporting.todo @@ -239,34 +239,47 @@ Completion criteria: failure kinds, result states, or exit codes. Phase 5: Document and package the improved diagnostics - ☐ Exit with user-facing guidance and package artifacts that cannot mix the + ☒ Exit with user-facing guidance and package artifacts that cannot mix the new controller with an obsolete probe unnoticed. + - Evidence: release documentation and version surfaces, successful + package build and archive inspection, disposable LuaRocks install, + installed command/layout/hash verification, and confirmed cleanup. 5.1 Documentation: - ☐ Update `docs/command-line.md` to describe connection exit code 4 and the + ☒ Update `docs/command-line.md` to describe connection exit code 4 and the actionable probe diagnostics. - ☐ Document that test selection completes before the DFHack connection + ☒ Document that test selection completes before the DFHack connection preflight, so a connection error does not implicate the selected file. - ☐ Document protocol-mismatch remediation in terms of controller/probe + ☒ Document protocol-mismatch remediation in terms of controller/probe package alignment without prescribing project-specific paths. - ☐ Update other connection-error examples that quote or promise the removed + ☒ Update other connection-error examples that quote or promise the removed generic message. + - Evidence: `docs/command-line.md`; repository search found no other + user-facing example retaining the removed generic diagnostic. 5.2 Package integrity: - ☐ Apply the settled package-version decision consistently to package + ☒ Apply the settled package-version decision consistently to package metadata, CLI version output, and changelog entries. - ☐ Build the LuaRocks artifact using the repository packaging workflow. - ☐ Inspect the artifact manifest and archive contents to prove the updated + ☒ Build the LuaRocks artifact using the repository packaging workflow. + ☒ Inspect the artifact manifest and archive contents to prove the updated controller parser and probe entrypoint are both present. - ☐ Install the artifact into a disposable LuaRocks tree. - ☐ Verify the disposable command resolves its controller and probe from the + - Evidence: `dwarfspec-0.2.2-1.rockspec`, CLI and host package version + `0.2.2`, `CHANGELOG.md`, successful `tools/Publish.ps1`, and + `dist/dwarfspec-0.2.2-1.all.rock` with controller and probe protocol + `2` entries listed in `rock_manifest`. + ☒ Install the artifact into a disposable LuaRocks tree. + ☒ Verify the disposable command resolves its controller and probe from the same package version and layout. - ☐ Remove the disposable installation and confirm cleanup. + ☒ Remove the disposable installation and confirm cleanup. + - Evidence: the read-only mounted `0.2.2-1` artifact installed into a + container-local LuaRocks tree; its command reported `0.2.2`, the + controller, probe, and host resolved from the same tree and matched + archive hashes, and the `--rm` container was confirmed absent. Completion criteria: - ☐ Documentation distinguishes runner invocation, subprocess exit, missing + ☒ Documentation distinguishes runner invocation, subprocess exit, missing report, malformed report, protocol mismatch, and capability failures. - ☐ The packaged controller and probe implement the same response protocol + ☒ The packaged controller and probe implement the same response protocol and the disposable package smoke checks pass. Phase 6: Validate installed and live-runtime behavior diff --git a/docs/installation.md b/docs/installation.md index 5f6a9fc..273c809 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,7 +10,7 @@ For a local release candidate, build and install the generated rock instead of loading files from a sibling checkout: ```powershell -luarocks install .\dist\dwarfspec-0.2.1-1.all.rock +luarocks install .\dist\dwarfspec-0.2.2-1.all.rock ``` The VS Code `Publish` task, or `tools/Publish.ps1`, produces that portable diff --git a/dwarfspec-0.2.1-1.rockspec b/dwarfspec-0.2.2-1.rockspec similarity index 95% rename from dwarfspec-0.2.1-1.rockspec rename to dwarfspec-0.2.2-1.rockspec index 59b3c5c..39c7951 100644 --- a/dwarfspec-0.2.1-1.rockspec +++ b/dwarfspec-0.2.2-1.rockspec @@ -1,11 +1,11 @@ rockspec_format = "3.0" package = "dwarfspec" -version = "0.2.1-1" +version = "0.2.2-1" source = { url = "git+https://github.com/dsisco11/DwarfSpec.git", - tag = "v0.2.1", + tag = "v0.2.2", } description = { diff --git a/src/dwarfspec/controller/command_line.lua b/src/dwarfspec/controller/command_line.lua index a4d2dec..0374fe1 100644 --- a/src/dwarfspec/controller/command_line.lua +++ b/src/dwarfspec/controller/command_line.lua @@ -5,11 +5,11 @@ local glob = require('dwarfspec.support.glob') local result_store = require('dwarfspec.controller.result_store') local command_line = { - version='0.2.1', + version='0.2.2', } local HELP = [[ -DwarfSpec 0.2.1 - live DFHack automation with in-process Busted +DwarfSpec 0.2.2 - live DFHack automation with in-process Busted Usage: dwarfspec diff --git a/src/dwarfspec/host/execution/host.lua b/src/dwarfspec/host/execution/host.lua index fc9428a..f62b8cc 100644 --- a/src/dwarfspec/host/execution/host.lua +++ b/src/dwarfspec/host/execution/host.lua @@ -27,7 +27,7 @@ local run_lifecycle_module = require('dwarfspec.host.execution.run_lifecycle') local M = { protocol_version=2, - package_version='0.2.1', + package_version='0.2.2', } local RUN_STATE_TERMINAL = { diff --git a/tests/unit/cli_selection_spec.lua b/tests/unit/cli_selection_spec.lua index a476cb2..f92ad80 100644 --- a/tests/unit/cli_selection_spec.lua +++ b/tests/unit/cli_selection_spec.lua @@ -288,7 +288,7 @@ describe('DwarfSpec CLI selection', function() assert.matches('Usage: dwarfspec run', output.text, 1, true) output.text = '' assert.equals(0, cli.main({'version'}, context)) - assert.equals('DwarfSpec 0.2.1\n', output.text) + assert.equals('DwarfSpec 0.2.2\n', output.text) assert.is_nil(invoked) end) diff --git a/tests/unit/controller/application_spec.lua b/tests/unit/controller/application_spec.lua index 8ce4c56..8b5c509 100644 --- a/tests/unit/controller/application_spec.lua +++ b/tests/unit/controller/application_spec.lua @@ -70,7 +70,7 @@ describe('DwarfSpec application', function() output.text = '' assert.equals(0, application.main({'version'}, context)) - assert.equals('DwarfSpec 0.2.1\n', output.text) + assert.equals('DwarfSpec 0.2.2\n', output.text) output.text = '' assert.equals(0, application.main({'list'}, context)) diff --git a/tests/unit/controller/command_line_spec.lua b/tests/unit/controller/command_line_spec.lua index d05550a..189cccf 100644 --- a/tests/unit/controller/command_line_spec.lua +++ b/tests/unit/controller/command_line_spec.lua @@ -4,7 +4,7 @@ local command_line = require('dwarfspec.controller.command_line') describe('DwarfSpec command line', function() it('constructs general and command-specific help documents', function() - assert.matches('DwarfSpec 0%.2%.1', command_line.help()) + assert.matches('DwarfSpec 0%.2%.2', command_line.help()) for _, topic in ipairs({ 'list', 'run', 'status', 'history', 'show', 'logs', 'abort', 'recover-executor'}) do diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index 0923e08..8ae8dc7 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -182,10 +182,10 @@ describe('version 2 automation entrypoint contract', function() assert.equals('registration', encoded[4].kind) assert.is_false(encode_options[4].pretty) assert.matches('incompatible automation package version: ' .. - 'expected 0.1.3, found 0.2.1', encoded[4].message, 1, true) + 'expected 0.1.3, found 0.2.2', encoded[4].message, 1, true) assert.is_nil(registry.runs['entrypoint-version-rejection']) - registry.package_version = '0.2.1' + registry.package_version = '0.2.2' registry.quarantine = { active=true, run_id=run.run_id, From 8e477db25238215e1abe2ea3e37238fa3b3d061a Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 01:55:01 -0700 Subject: [PATCH 07/18] fix: cli dfhack detection --- src/dwarfspec/host/entrypoints/probe.lua | 6 ++--- tests/unit/host/entrypoints/probe_spec.lua | 26 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/dwarfspec/host/entrypoints/probe.lua b/src/dwarfspec/host/entrypoints/probe.lua index 5a06ba4..6bcfe88 100644 --- a/src/dwarfspec/host/entrypoints/probe.lua +++ b/src/dwarfspec/host/entrypoints/probe.lua @@ -1,10 +1,10 @@ -- Production adapter that verifies access to DFHack's core Lua context. ----Returns the available DFHack table without indexing an invalid global. +---Returns the available DFHack table through the active script environment. ---@return table|nil local function dfhack_context() - local context = rawget(_G, 'dfhack') - return type(context) == 'table' and context or nil + local ok, context = pcall(function() return dfhack end) + return ok and type(context) == 'table' and context or nil end ---Returns the normalized core-context capability value. diff --git a/tests/unit/host/entrypoints/probe_spec.lua b/tests/unit/host/entrypoints/probe_spec.lua index 14553cb..8029de5 100644 --- a/tests/unit/host/entrypoints/probe_spec.lua +++ b/tests/unit/host/entrypoints/probe_spec.lua @@ -3,9 +3,12 @@ local layout = require('dwarfspec.layout') ---Loads the direct probe entrypoint through the package layout authority. +---@param environment table|nil ---@return function -local function load_probe() - return assert(loadfile(layout.current().host_scripts.probe)) +local function load_probe(environment) + local path = layout.current().host_scripts.probe + if environment == nil then return assert(loadfile(path)) end + return assert(loadfile(path, 't', environment)) end ---Returns the number of currently loaded Lua modules. @@ -63,6 +66,25 @@ describe('DFHack connection probe entrypoint', function() 'timeout=function dfhack=53.15-r1', line) end) + it('resolves DFHack through the script environment lookup chain', function() + local context = { + VERSION='53.15-r2', + is_core_context=true, + timeout=function() end, + } + local base_environment = setmetatable({ + dfhack=context, + print=function(line) table.insert(lines, line) end, + }, {__index=_G}) + local environment = setmetatable({}, {__index=base_environment}) + environment._G = environment + + assert.has_no.errors(load_probe(environment)) + + assert.same({'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function dfhack=53.15-r2'}, lines) + end) + it('reports an absent DFHack global without throwing', function() assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. 'timeout=unavailable', probe(nil)) From 31e31945d7481d50ea4902ef00cdb5e1928c4aac Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 02:11:15 -0700 Subject: [PATCH 08/18] docs: initial error reporting improvement implementation plan --- docs/package-version-mismatch-rejection.todo | 320 +++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/package-version-mismatch-rejection.todo diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo new file mode 100644 index 0000000..71241e3 --- /dev/null +++ b/docs/package-version-mismatch-rejection.todo @@ -0,0 +1,320 @@ +DwarfSpec Structured Package-Version Mismatch Rejection +======================================================== + +Source proposal: + ☐ The conversation that identified the ambiguous bootstrap diagnostic + `incompatible automation package version: expected ..., found ...` and + proposed carrying the running and requested package versions as fields in + the existing `dwarfspec.error.v1` rejection envelope. + +Goal: + ☐ Make a bootstrap package-version mismatch identify which version is + already loaded by the running DFHack process and which version the + current DwarfSpec command requested. + ☐ Tell the operator that DwarfSpec is process-wide, that returning to the + title screen or unloading a world is insufficient, and that fully exiting + and relaunching Dwarf Fortress/DFHack is required before retrying. + ☐ Represent the mismatch as a structured registration subtype so the + controller does not infer machine-readable meaning from human text. + ☐ Preserve the existing `dwarfspec.error.v1` transport, + `RunnerFailureKind.REGISTRATION`, registration-error result state, exit + code 5, rejection atomicity, and no-recovery behavior. + ☐ Keep source, focused unit, complete unit, package, installed, and live + DFHack evidence distinct. + +Non-goals: + ☐ Do not introduce a new top-level error schema, runner failure kind, + result state, or process exit code. + ☐ Do not change package compatibility rules, protocol compatibility, + project registration, scheduler admission, quarantine handling, retained + service state, or recovery behavior. + ☐ Do not add an in-process service unload or hot-reload path. + ☐ Do not generalize every assertion or registration failure into a new + structured hierarchy as part of this work. + ☐ Do not silently select, install, downgrade, or remove a DwarfSpec + package on the operator's behalf. + +Assumptions and open questions: + ☐ Treat `code='package_version_mismatch'`, `running_version`, and + `requested_version` as the stable wire names proposed in the + conversation. + ☐ Decide whether `package_version_mismatch` should be centralized in a + narrowly scoped immutable enum or constant module; do not add a broader + rejection framework without demonstrated need. + ☐ Decide whether the canonical multi-line remediation text is stored + verbatim in persisted result errors or whether persistence retains a + single-line message while the CLI renderer adds layout. Verify existing + result consumers before settling this boundary. + ☐ Decide whether shipping the changed host and controller requires a + package version bump, and record the compatibility rationale. + +Phase 1: Establish the additive rejection contract + ☐ Exit with one documented package-mismatch subtype and exact user-facing + semantics before changing service or controller behavior. + +1.1 Wire contract: + ☐ Extend the existing `dwarfspec.error.v1` registration envelope with the + optional `code` field rather than creating another schema or changing the + broad `kind='registration'` classification. + ☐ Define `code='package_version_mismatch'` as requiring non-empty string + fields `running_version` and `requested_version`. + ☐ Define `running_version` as the package version retained by the + process-wide DFHack service registry. + ☐ Define `requested_version` as the package version supplied by the host + loaded from the current DwarfSpec command's package. + ☐ Keep `message` required and independently meaningful so diagnostics + remain useful to consumers that only display the base error text. + ☐ Preserve generic registration envelopes without `code` and preserve + the existing structured executor-quarantine envelope unchanged. + ☐ Define unknown future registration codes to fall back to their + supplied message rather than being mistaken for a version mismatch. + +1.2 User-facing diagnostic: + ☐ Settle and test wording that labels both values without the ambiguous + `expected` and `found` terms: + ☐ `Running DFHack service: `. + ☐ `Current DwarfSpec command: `. + ☐ Explain that DFHack already has a different DwarfSpec version loaded. + ☐ Direct the operator to save, fully exit Dwarf Fortress/DFHack, + relaunch it, and retry the command. + ☐ State that returning to the title screen or unloading the world does + not unload the process-wide DwarfSpec service. + ☐ Keep the diagnostic independent of project paths, selected specs, + installation-tree paths, and assumptions about how DFHack was launched. + +Completion criteria: + ☐ The contract distinguishes machine-readable classification from + human-readable wording and defines every new field unambiguously. + ☐ Existing registration, quarantine, failure-kind, result-state, and + exit-code contracts remain explicitly preserved. + +Phase 2: Produce structured mismatch rejections in the host + ☐ Exit with the service and bootstrap entrypoint carrying version fields + without mutating retained service state or weakening compatibility. + +2.1 Service rejection: + ☐ Replace only the incompatible bootstrap package-version assertion in + `src/dwarfspec/host/service/service.lua` with a structured error value + containing the stable code and both version fields. + ☐ Preserve request validation before registry access and preserve the + current protocol-version validation order. + ☐ Raise the structured value without an incidental Lua source prefix or + string coercion that would discard its fields. + ☐ Keep successful first bootstrap and compatible repeated bootstrap + behavior unchanged. + ☐ Verify an incompatible bootstrap creates no project, run, queue, + scheduler, ownership, timestamp, or registry mutation. + ☐ Add language-standard documentation comments for every new helper or + public contract surface. + +2.2 Bootstrap adapter serialization: + ☐ Extend `src/dwarfspec/host/entrypoints/bootstrap.lua` to recognize the + structured mismatch value and emit its `code`, `running_version`, and + `requested_version` fields in `dwarfspec.error.v1` JSON. + ☐ Retain `kind='registration'`, protocol 2, and a non-empty fallback + message for the mismatch response. + ☐ Preserve generic string-error serialization for all unrelated + bootstrap failures. + ☐ Preserve executor-quarantine classification and its structured fields + without routing it through version-mismatch formatting. + ☐ Avoid exposing package roots or other machine-specific service data. + +2.3 Host-focused tests: + ☐ Update `tests/unit/host/service/service_spec.lua` to assert the exact + structured mismatch value and unchanged retained registry snapshot. + ☐ Update + `tests/unit/host/entrypoints/entrypoint_contract_spec.lua` to assert the + exact JSON schema, protocol, registration kind, code, running version, + requested version, and non-empty message. + ☐ Retain independent coverage for generic registration errors and + executor quarantine. + ☐ Verify matching versions still bootstrap normally and emit no error + envelope. + +Completion criteria: + ☐ A version mismatch crosses the host entrypoint as structured JSON with + both correctly oriented version values. + ☐ Host-focused tests prove rejection atomicity and no regression in + compatible bootstrap or quarantine behavior. + +Phase 3: Validate and consume the structured rejection in the controller + ☐ Exit with strict field validation and code-based diagnostic formatting + that no longer parses human message text. + +3.1 Controller response validation: + ☐ Extend the adapter-error validator in + `src/dwarfspec/controller/reporting/report.lua` to accept optional + registration codes while retaining JSON-safety validation. + ☐ Require non-empty `running_version` and `requested_version` strings + when `code='package_version_mismatch'`. + ☐ Reject missing, empty, or incorrectly typed required mismatch fields + as malformed adapter responses instead of rendering misleading guidance. + ☐ Continue accepting generic registration errors without a code. + ☐ Continue validating executor-quarantine fields exactly as before. + ☐ Preserve unknown future registration codes as generic message-bearing + rejections unless the settled contract requires stricter handling. + ☐ Add language-standard documentation comments for every changed or new + validation and formatting method. + +3.2 Runner formatting: + ☐ Change the registration formatter in + `src/dwarfspec/controller/execution/runner.lua` to receive the validated + rejection object rather than only its message string. + ☐ Branch on `code == 'package_version_mismatch'` and format the settled + labels and remediation from the structured version fields. + ☐ Remove the substring match on + `incompatible automation package version` after structured coverage is + complete. + ☐ Keep generic registration rejections prefixed consistently and do not + append restart advice to unrelated errors or unknown codes. + ☐ Preserve one bootstrap attempt, no bootstrap retry, no recovery call, + registration failure classification, result persistence, and exit code 5 + for an explicit mismatch rejection. + +3.3 Controller-focused tests: + ☐ Add report-parser cases for a valid structured mismatch and every + missing, empty, or incorrectly typed required field. + ☐ Verify generic registration and executor-quarantine envelopes remain + accepted and retain their existing fields. + ☐ Update `tests/unit/controller/execution/runner_spec.lua` to assert the + exact running/current labels, values, full-exit guidance, title-screen or + world-unload clarification, classification, result state, and exit code. + ☐ Verify the diagnostic contains no ambiguous `expected` or `found` + labels. + ☐ Verify a generic registration message that happens to contain the old + mismatch phrase receives no special restart guidance. + ☐ Verify unknown registration codes fall back to the supplied message + and do not receive version-mismatch formatting. + ☐ Verify malformed structured responses fail through the existing + invalid-bootstrap-response path without attempting recovery. + +Completion criteria: + ☐ No controller behavior depends on parsing the host's human mismatch + message. + ☐ Valid structured rejections render both versions and precise recovery + instructions while all adjacent rejection behavior remains compatible. + +Phase 4: Lock down documentation, regression, and package integrity + ☐ Exit with user documentation, complete source validation, and one + internally consistent package artifact. + +4.1 Documentation and release surfaces: + ☐ Update `docs/command-line.md` to describe the package-mismatch + diagnostic, process-wide lifetime, complete-restart requirement, and + preserved exit code 5. + ☐ Add a changelog entry describing structured version labels and removal + of ambiguous `expected`/`found` wording. + ☐ Apply the settled package-version decision consistently to the + rockspec, host package version, CLI version, documentation examples, and + artifact name when a bump is required. + ☐ Search user-facing documentation and tests for the old mismatch + wording and retain it only where explicitly testing backward input or + historical behavior. + +4.2 Source validation: + ☐ Run focused service, host-entrypoint, report-parser, and runner unit + specifications through build/test subagents. + ☐ Run the complete recursive unit suite through a build/test subagent. + ☐ Run Lua syntax, formatting, and declaration checks for every changed + source and test file through a build/test subagent. + ☐ Run `git diff --check` and inspect the focused diff without altering + unrelated worktree or index state. + ☐ Record focused and complete-suite evidence separately. + +4.3 Package integrity: + ☐ Build the LuaRocks artifact with the repository publishing workflow + through a build/test subagent. + ☐ Inspect the archive manifest and contents to prove the matching + service, bootstrap adapter, report parser, runner, and version metadata + are present. + ☐ Install the artifact into a disposable LuaRocks tree and verify the + command and bundled host modules resolve from that same artifact. + ☐ Remove the disposable tree and confirm cleanup without modifying the + operator's normal LuaRocks installation. + +Completion criteria: + ☐ Focused, recursive, syntax, formatting, declaration, and diff checks + pass with evidence recorded independently. + ☐ Documentation and package metadata describe the same structured + behavior shipped in the inspected artifact. + +Phase 5: Prove the installed mismatch and restart workflows + ☐ Exit with installed, live DFHack evidence for both the mismatch + diagnostic and the healthy post-restart path, followed by an independent + implementation review. + +5.1 Reproducible installed setup: + ☐ Prepare separate disposable installed trees for two known DwarfSpec + package versions without replacing the operator's default installation. + ☐ Record both package versions, command paths, module roots, the selected + consumer project, the exact project-relative spec identity, the resolved + `dfhack-run`, and the target DFHack process before execution. + ☐ Confirm each command resolves its controller and bootstrap entrypoint + from its own single package artifact. + ☐ Ensure the selected live test can terminate and clean up normally + before intentionally introducing package skew. + +5.2 Live mismatch evidence: + ☐ Start a clean DFHack process and use the older installed command to + bootstrap the process-wide DwarfSpec service. + ☐ Confirm the initial run reaches a terminal result with cleanup + confirmed and leaves the service idle, the queue empty, and quarantine + absent. + ☐ Without restarting DFHack, invoke the newer installed command against + the same process and exact consumer identity. + ☐ Verify the rejection labels the older registry value as the running + service and the newer package value as the current command. + ☐ Verify the complete-exit/relaunch guidance and the clarification that + title-screen return or world unload is insufficient. + ☐ Verify registration failure classification, registration-error result + state where persisted, exit code 5, one rejected bootstrap attempt, no + recovery attempt, no admitted run, and unchanged service state. + ☐ Capture bounded command output and service status without claiming the + selected spec itself failed. + +5.3 Post-restart healthy evidence and cleanup: + ☐ Save and fully exit the test Dwarf Fortress/DFHack process, confirm it + is no longer running, then relaunch it. + ☐ Re-run the same exact consumer identity with the newer installed + command and confirm it proceeds beyond bootstrap to a terminal result. + ☐ Record exit code, result artifact, test outcome, cleanup confirmation, + executor idle state, empty queue, and absent quarantine independently. + ☐ Remove disposable installed trees, live result artifacts, and + test-owned resources, and confirm cleanup. + ☐ Restore the operator's original running-process and command-selection + state if the validation workflow changed either one. + +5.4 Followup review: + ☐ Perform an independent review of the implementation against every + requirement, non-goal, assumption, and completion criterion in this plan. + ☐ Recheck field orientation from the retained registry through JSON, + controller validation, rendered CLI text, and persisted result output. + ☐ Recheck that no substring-based version-mismatch classification + remains and no unrelated registration rejection receives restart advice. + ☐ Recheck that no new unload, downgrade, installation, scheduler, + recovery, or compatibility behavior entered the final diff. + ☐ Record every deferred item with rationale and a concrete follow-up or + removal condition. + +Completion criteria: + ☐ A real installed mixed-version process produces the exact actionable + structured diagnostic with correctly oriented version values. + ☐ A complete restart followed by the newer command reaches a terminal + live result with cleanup and final service state confirmed. + ☐ The followup review finds every requirement satisfied or explicitly + deferred with rationale. + +Final acceptance: + ☐ Every proposal requirement is implemented or explicitly deferred with + a recorded rationale. + ☐ Package mismatch uses the existing structured error schema with a + stable registration subtype and validated running/requested fields. + ☐ The controller formats the diagnostic by structured code and fields, + never by parsing human text. + ☐ The message clearly distinguishes the running DFHack service from the + current command and gives complete, accurate restart instructions. + ☐ Registration failure kind, result state, exit code, rejection + atomicity, no-recovery behavior, and quarantine handling are preserved. + ☐ Source, focused unit, complete unit, static-analysis, package, + installed mismatch, post-restart live, cleanup, and followup-review + evidence are recorded separately. From 225d405a3862860b3fd155f5b677515686aa067c Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 02:21:11 -0700 Subject: [PATCH 09/18] docs: amend error reporting improvement implementation plan --- docs/package-version-mismatch-rejection.todo | 372 +++++++++++++++++-- 1 file changed, 347 insertions(+), 25 deletions(-) diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 71241e3..4be78fd 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -1,11 +1,15 @@ -DwarfSpec Structured Package-Version Mismatch Rejection -======================================================== +DwarfSpec Structured Host Error Responses +========================================== Source proposal: ☐ The conversation that identified the ambiguous bootstrap diagnostic `incompatible automation package version: expected ..., found ...` and proposed carrying the running and requested package versions as fields in the existing `dwarfspec.error.v1` rejection envelope. + ☐ The follow-up boundary audit that identified bootstrap admission + conflicts, mutation and recovery rejections, and polling or event + transport failures whose structured host context is currently discarded + or reduced to a subprocess exit code. Goal: ☐ Make a bootstrap package-version mismatch identify which version is @@ -19,6 +23,14 @@ Goal: ☐ Preserve the existing `dwarfspec.error.v1` transport, `RunnerFailureKind.REGISTRATION`, registration-error result state, exit code 5, rejection atomicity, and no-recovery behavior. + ☐ Reuse one validated error-envelope contract across host entrypoints so + expected domain rejections retain stable codes and safe structured + context from the service boundary through the controller. + ☐ Preserve scheduler admission classifications for project, request-key, + and result-path conflicts instead of collapsing them into run-state text. + ☐ Preserve actionable run, generation, state, ownership, quarantine, and + clean-state verification context for mutation, recovery, polling, and + event operations without exposing capabilities or unrelated paths. ☐ Keep source, focused unit, complete unit, package, installed, and live DFHack evidence distinct. @@ -26,11 +38,15 @@ Non-goals: ☐ Do not introduce a new top-level error schema, runner failure kind, result state, or process exit code. ☐ Do not change package compatibility rules, protocol compatibility, - project registration, scheduler admission, quarantine handling, retained - service state, or recovery behavior. + project registration semantics, scheduler admission decisions, + quarantine rules, retained service state, or recovery authority. ☐ Do not add an in-process service unload or hot-reload path. - ☐ Do not generalize every assertion or registration failure into a new - structured hierarchy as part of this work. + ☐ Do not generalize internal programming assertions, malformed registry + invariants, failed module loads, impossible transition assertions, or + developer-only dependency failures into public domain rejection codes. + ☐ Do not replace structured `service_loaded`, `found`, transport + snapshots, event journals, test failures, or the dependency-free + `DWARFSPEC_PROBE` grammar with adapter-error responses. ☐ Do not silently select, install, downgrade, or remove a DwarfSpec package on the operator's behalf. @@ -39,8 +55,18 @@ Assumptions and open questions: `requested_version` as the stable wire names proposed in the conversation. ☐ Decide whether `package_version_mismatch` should be centralized in a - narrowly scoped immutable enum or constant module; do not add a broader - rejection framework without demonstrated need. + shared immutable error-code module with the other accepted domain codes. + ☐ Confirm that broadening the accepted error kinds and codes remains an + additive `dwarfspec.error.v1` change. If backward compatibility cannot be + proved, stop and revise this plan instead of silently introducing a new + top-level schema. + ☐ Define the boundary between expected domain rejections and unexpected + host faults before converting any auxiliary entrypoint; unknown faults + must remain distinguishable from stable public rejection codes. + ☐ Decide whether adapters emit a structured error with subprocess exit + code zero, emit structured JSON alongside a nonzero exit, or support both + during migration. The controller must not discard a valid error envelope + solely because the bridge returned nonzero. ☐ Decide whether the canonical multi-line remediation text is stored verbatim in persisted result errors or whether persistence retains a single-line message while the CLI renderer adds layout. Verify existing @@ -194,11 +220,271 @@ Completion criteria: ☐ Valid structured rejections render both versions and precise recovery instructions while all adjacent rejection behavior remains compatible. -Phase 4: Lock down documentation, regression, and package integrity +Phase 4: Establish one shared adapter-error boundary + ☐ Exit with reusable construction, serialization, parsing, and validation + rules that later entrypoints can adopt without duplicating bootstrap's + special-case logic. + ☐ Treat the package-version work in Phases 2 and 3 as the first complete + vertical slice, then extract its proven contract into shared machinery + before migrating additional error families. + +4.1 Error taxonomy and field policy: + ☐ Inventory every error-producing host entrypoint and classify each + failure as an expected domain rejection, structured state already + represented by another schema, subprocess or connection failure, or + unexpected internal fault. + ☐ Define `kind` as the existing broad runner classification and `code` as + the stable domain subtype; document which layer owns each value. + ☐ Define common optional fields such as `operation`, `run_id`, + `generation`, `state`, `blocking_run_id`, and `blocking_generation`. + ☐ Define subtype-specific required fields and forbid fields whose values + would expose owner capabilities, authorization proofs, package roots, or + unrelated machine-specific paths. + ☐ Define compatibility behavior for generic errors without `code`, known + codes, unknown future codes, malformed known-code payloads, and + unexpected internal exceptions. + ☐ Preserve existing failure kinds, result states, exit codes, primary + versus secondary error precedence, and recovery decisions unless a later + task explicitly documents an approved mapping. + +4.2 Shared host construction and serialization: + ☐ Add one narrowly scoped protocol or host-support abstraction for + constructing JSON-safe domain rejection objects with required `code`, + `message`, and subtype fields. + ☐ Add one shared entrypoint serializer for canonical adapter errors so + bootstrap, mutation, recovery, polling, and event adapters do not each + implement their own field-copy rules. + ☐ Preserve bootstrap's executor-quarantine payload and the structured + package-version mismatch as compatibility fixtures for the shared path. + ☐ Migrate package-version mismatch and executor quarantine onto the + shared constructor and serializer, then remove any temporary + mismatch-only field-copy or dispatch scaffolding after parity is proved. + ☐ Ensure error serialization itself cannot throw on a malformed or + non-string internal exception; retain a bounded generic host-fault + fallback without falsely assigning a public code. + ☐ Add language-standard documentation comments for every new class, + method, constructor, validator, and public contract surface. + +4.3 Shared controller parsing: + ☐ Generalize adapter-error validation in + `src/dwarfspec/controller/reporting/report.lua` so all approved broad + kinds and codes are validated by the same contract. + ☐ Update transport-client operations to inspect and validate a canonical + error envelope before replacing a response with generic + ` exited with ` text. + ☐ Preserve bounded captured output when a subprocess fails without a + valid structured response. + ☐ Return validated error objects to runner and recovery orchestration + without flattening them to strings prematurely. + ☐ Preserve generic registration fallback, executor quarantine, healthy + transport parsing, read-only response schemas, and connection-probe + behavior. + +4.4 Shared contract tests: + ☐ Add round-trip tests from domain rejection construction through JSON + serialization, controller validation, and retained fields. + ☐ Verify known codes require their exact fields and reject missing, + empty, incorrectly typed, non-JSON-safe, or forbidden values. + ☐ Verify unknown codes and generic uncoded messages follow the settled + compatibility policy without receiving known-code guidance. + ☐ Verify nonzero subprocess results with valid structured JSON preserve + the structured error under the settled migration contract. + ☐ Verify nonzero results without valid JSON remain classified as bridge + or host failures with bounded diagnostic output. + +Completion criteria: + ☐ One documented and tested error-envelope path serves bootstrap and is + ready for every approved auxiliary adapter. + ☐ Structured domain rejections and unexpected internal faults remain + observably distinct end to end. + +Phase 5: Preserve bootstrap admission conflicts + ☐ Exit with scheduler admission classifications reaching the CLI as + actionable structured registration rejections without changing whether + a run is accepted, reused, or rejected. + +5.1 Admission subtype contracts: + ☐ Define structured registration codes for `project_busy`, + `request_key_conflict`, and `result_path_busy` using the existing + `SchedulerFailureKind` values where compatible. + ☐ Define safe blocking context for each subtype, including the blocking + run identity, generation, and state when available. + ☐ Decide whether project identity or normalized result-path identity is + necessary for remediation; omit raw paths and internal identifiers when + the blocking run identity is sufficient. + ☐ Define actionable messages that name the actual conflict rather than + reporting only that another run is queued, active, or terminal. + +5.2 Host propagation: + ☐ Update `src/dwarfspec/host/execution/host.lua` to preserve + `outcome.kind`, `outcome.reason`, identity, and snapshot context from + `service.submit()` when admission is rejected. + ☐ Route each expected admission outcome through the shared structured + rejection constructor and bootstrap serializer. + ☐ Preserve accepted first submissions and identical request-key retries + as successful, idempotent transport responses. + ☐ Preserve rejection atomicity, outstanding-run ownership, queue order, + generation, leases, and result-path reservations. + ☐ Leave invalid scheduler invariants and generator failures as internal + faults rather than assigning them admission codes. + +5.3 Controller formatting and behavior: + ☐ Validate every admission subtype and its required blocking context. + ☐ Format project-busy, request-key-conflict, and result-path-busy + guidance from structured fields without parsing `reason` or `message`. + ☐ Preserve registration failure classification, registration-error + persistence, exit code 5, no bootstrap retry, and no recovery attempt. + ☐ Ensure conflict messages do not blame the selected specification or + disclose unrelated consumer configuration. + +5.4 Admission tests: + ☐ Add focused scheduler and service fixtures for all three rejection + outcomes and for accepted idempotent reuse. + ☐ Add bootstrap-entrypoint tests for exact error schema, code, broad + kind, safe blocking fields, and non-empty message. + ☐ Add controller tests for exact subtype classification, actionable + rendering, persisted result state, exit code, and no recovery. + ☐ Verify each rejection leaves the complete registry and scheduler state + unchanged except for state that legitimately predates the attempted run. + +Completion criteria: + ☐ Every expected admission conflict retains its scheduler classification + and safe blocking context through the CLI. + ☐ No admission conflict is reduced to generic run-state prose. + +Phase 6: Structure mutation and recovery rejections + ☐ Exit with abort, cancel, recover, acknowledge, discard, and executor + recovery adapters returning actionable domain rejections instead of only + subprocess exit codes. + +6.1 Operation subtype contracts: + ☐ Define stable codes and required safe fields for `service_not_loaded`, + `run_not_found`, `generation_mismatch`, `invalid_run_state`, + `owner_capability_rejected`, `quarantine_mismatch`, and + `clean_state_unverified`, adjusting names only when the contract review + identifies an existing canonical term. + ☐ Map each code only to expected operator- or orchestration-triggerable + conditions; keep malformed internal requests and impossible service + invariants unclassified. + ☐ Define broad failure-kind, result-state, and exit-code mappings for + direct operator commands and secondary recovery or acknowledgement + failures. + ☐ Preserve the original run failure as primary when a structured + recovery or acknowledgement rejection is appended as secondary context. + +6.2 Service and host boundaries: + ☐ Replace expected assertion-only rejections in the approved operation + paths with structured domain values at the layer that owns the decision. + ☐ Preserve exact run, project, service-instance, generation, capability, + state, quarantine, and clean-state authorization checks. + ☐ Ensure owner capabilities and authorization proofs are never copied + into error payloads, logs, persisted results, or CLI output. + ☐ Preserve successful operation state transitions, native cleanup, + acknowledgement, discard, quarantine clearing, and subsequent queue + activation exactly as before. + ☐ Prove rejected operations do not renew leases, mutate journals, + release ownership, clear quarantine, discard results, or invoke native + cleanup. + +6.3 Entrypoint adoption: + ☐ Adopt the shared serializer in `abort.lua`, `cancel.lua`, `recover.lua`, + `acknowledge.lua`, `discard.lua`, and `recover_executor.lua`. + ☐ Ensure each adapter emits exactly one canonical JSON response for + either success or a modeled domain rejection. + ☐ Retain nonzero or fallback behavior for module-load, malformed + argument, serialization, and unexpected internal failures according to + the settled migration contract. + ☐ Keep entrypoints thin and free of duplicated business classification + or remediation logic. + +6.4 Controller and recovery consumption: + ☐ Extend transport and recovery clients to return structured operation + rejections without collapsing them to generic `exited with` messages. + ☐ Render run identifiers, generations, current states, and remediation + only from validated subtype fields. + ☐ Preserve direct abort and executor-recovery command exit behavior. + ☐ Preserve recovery error precedence and append structured secondary + detail without replacing the original timeout, host, interruption, or + test failure. + ☐ Preserve successful transport validation and cleanup confirmation + requirements. + +6.5 Mutation and recovery tests: + ☐ Add service tests for every modeled rejection and its no-mutation + guarantee. + ☐ Add entrypoint tests for structured success-versus-error exclusivity + and forbidden sensitive fields. + ☐ Add transport-client and recovery tests for each subtype, broad kind, + exit code, primary-error precedence, and exact useful context. + ☐ Retain success coverage for queued cancellation, active abort with + cleanup, terminal acknowledgement, explicit discard, and verified + executor recovery. + +Completion criteria: + ☐ Every expected mutation or recovery rejection crosses the adapter + boundary as a validated object with safe actionable context. + ☐ No successful behavior, authorization rule, state transition, cleanup + requirement, or error precedence changes. + +Phase 7: Structure polling and event transport rejections + ☐ Exit with status polling, event reading, and run-specific scheduler + transport preserving expected stale-state details while retaining + existing read-only response schemas. + +7.1 Polling and event subtype contracts: + ☐ Map expected missing-service, missing-run, stale-generation, + capability, cursor, and invalid-state conditions to the shared codes or + define narrowly scoped additional codes when their remediation differs. + ☐ Distinguish an operator-addressable stale run from malformed transport, + corrupt registry state, impossible event-journal state, or an unexpected + host exception. + ☐ Define which polling rejections remain primary host failures and which + trigger the runner's existing state-aware recovery path. + +7.2 Entrypoint adoption: + ☐ Replace `status.lua`'s modeled-domain `qerror` path with canonical + structured serialization while retaining an internal-fault fallback. + ☐ Adopt the shared serializer for modeled failures in `event_read.lua` + and the run-specific branch of `scheduler_status.lua`. + ☐ Ensure status and event adapters emit exactly one canonical JSON + response and do not emit a partial success envelope before a failure. + ☐ Keep the service-wide `dwarfspec.status.v1` response and its + `service_loaded` state unchanged. + ☐ Keep `history`, `show`, and `logs` using their existing + `service_loaded` and `found` fields rather than converting normal absence + into adapter errors. + +7.3 Controller behavior: + ☐ Parse validated error envelopes from poll, event-read, and run-status + operations before applying generic subprocess failure handling. + ☐ Preserve safe structured context in primary and recovery diagnostics. + ☐ Preserve retry, timeout, event-cursor advancement, lease renewal, + acknowledgement, and state-aware recovery behavior. + ☐ Ensure a rejected or malformed poll never advances the event cursor or + fabricates a newer transport generation. + +7.4 Polling and event tests: + ☐ Add host and entrypoint tests for every modeled polling or event + rejection, exactly-one-response behavior, and no lease or cursor mutation. + ☐ Add transport-client and runner tests proving structured details are + retained for direct polling failures and secondary recovery failures. + ☐ Retain healthy polling, unrelated-output, terminal observation, + service-unloaded status, missing read-only run, and malformed transport + coverage. + ☐ Verify unexpected internal failures remain distinguishable and include + bounded captured output rather than being assigned a domain code. + +Completion criteria: + ☐ Every approved polling and event rejection retains safe structured + context across the host/controller boundary. + ☐ Existing cursor, lease, retry, timeout, recovery, and read-only query + semantics remain unchanged. + +Phase 8: Lock down documentation, regression, and package integrity ☐ Exit with user documentation, complete source validation, and one internally consistent package artifact. -4.1 Documentation and release surfaces: +8.1 Documentation and release surfaces: ☐ Update `docs/command-line.md` to describe the package-mismatch diagnostic, process-wide lifetime, complete-restart requirement, and preserved exit code 5. @@ -210,10 +496,16 @@ Phase 4: Lock down documentation, regression, and package integrity ☐ Search user-facing documentation and tests for the old mismatch wording and retain it only where explicitly testing backward input or historical behavior. + ☐ Document admission, mutation, recovery, polling, and event rejection + codes that have an operator-facing remediation path. + ☐ Document the distinction between a structured domain rejection, an + unavailable service or run represented by read-only state, a bridge + failure, and an unexpected internal host fault. -4.2 Source validation: - ☐ Run focused service, host-entrypoint, report-parser, and runner unit - specifications through build/test subagents. +8.2 Source validation: + ☐ Run focused service, scheduler, host-entrypoint, report-parser, + transport-client, recovery, and runner unit specifications through + build/test subagents. ☐ Run the complete recursive unit suite through a build/test subagent. ☐ Run Lua syntax, formatting, and declaration checks for every changed source and test file through a build/test subagent. @@ -221,12 +513,12 @@ Phase 4: Lock down documentation, regression, and package integrity unrelated worktree or index state. ☐ Record focused and complete-suite evidence separately. -4.3 Package integrity: +8.3 Package integrity: ☐ Build the LuaRocks artifact with the repository publishing workflow through a build/test subagent. ☐ Inspect the archive manifest and contents to prove the matching - service, bootstrap adapter, report parser, runner, and version metadata - are present. + service, shared error contract, all migrated entrypoints, report parser, + transport and recovery clients, runner, and version metadata are present. ☐ Install the artifact into a disposable LuaRocks tree and verify the command and bundled host modules resolve from that same artifact. ☐ Remove the disposable tree and confirm cleanup without modifying the @@ -238,12 +530,12 @@ Completion criteria: ☐ Documentation and package metadata describe the same structured behavior shipped in the inspected artifact. -Phase 5: Prove the installed mismatch and restart workflows +Phase 9: Prove installed rejection and restart workflows ☐ Exit with installed, live DFHack evidence for both the mismatch diagnostic and the healthy post-restart path, followed by an independent implementation review. -5.1 Reproducible installed setup: +9.1 Reproducible installed setup: ☐ Prepare separate disposable installed trees for two known DwarfSpec package versions without replacing the operator's default installation. ☐ Record both package versions, command paths, module roots, the selected @@ -254,7 +546,7 @@ Phase 5: Prove the installed mismatch and restart workflows ☐ Ensure the selected live test can terminate and clean up normally before intentionally introducing package skew. -5.2 Live mismatch evidence: +9.2 Live mismatch evidence: ☐ Start a clean DFHack process and use the older installed command to bootstrap the process-wide DwarfSpec service. ☐ Confirm the initial run reaches a terminal result with cleanup @@ -272,7 +564,23 @@ Phase 5: Prove the installed mismatch and restart workflows ☐ Capture bounded command output and service status without claiming the selected spec itself failed. -5.3 Post-restart healthy evidence and cleanup: +9.3 Installed operational rejection evidence: + ☐ Exercise one safe controlled admission conflict and verify its exact + structured code, blocking context, registration classification, exit + code, no recovery, and no state mutation. + ☐ Exercise representative missing-run or stale-generation failures for + direct operator mutation and executor recovery without altering a valid + retained run. + ☐ Exercise one controlled polling or event stale-state rejection and + verify the cursor, lease, journal, and scheduler remain unchanged. + ☐ Verify each installed diagnostic retains useful validated fields and + does not expose capabilities, authorization proofs, package roots, or + unrelated paths. + ☐ Keep artificial fixtures distinct from real installed DFHack evidence + and record exactly which conditions were induced versus naturally + observed. + +9.4 Post-restart healthy evidence and cleanup: ☐ Save and fully exit the test Dwarf Fortress/DFHack process, confirm it is no longer running, then relaunch it. ☐ Re-run the same exact consumer identity with the newer installed @@ -284,7 +592,7 @@ Phase 5: Prove the installed mismatch and restart workflows ☐ Restore the operator's original running-process and command-selection state if the validation workflow changed either one. -5.4 Followup review: +9.5 Followup review: ☐ Perform an independent review of the implementation against every requirement, non-goal, assumption, and completion criterion in this plan. ☐ Recheck field orientation from the retained registry through JSON, @@ -293,6 +601,12 @@ Phase 5: Prove the installed mismatch and restart workflows remains and no unrelated registration rejection receives restart advice. ☐ Recheck that no new unload, downgrade, installation, scheduler, recovery, or compatibility behavior entered the final diff. + ☐ Recheck every migrated entrypoint emits one success or structured + domain rejection response, never both, for modeled outcomes. + ☐ Recheck internal assertions were not mislabeled as public domain codes + and structured state schemas were not unnecessarily converted to errors. + ☐ Recheck sensitive capabilities, proofs, roots, and unrelated paths are + absent from error payloads, output, and persisted results. ☐ Record every deferred item with rationale and a concrete follow-up or removal condition. @@ -301,6 +615,9 @@ Completion criteria: structured diagnostic with correctly oriented version values. ☐ A complete restart followed by the newer command reaches a terminal live result with cleanup and final service state confirmed. + ☐ Representative installed admission, mutation or recovery, and polling + or event rejections retain structured actionable context without state + mutation or sensitive-data disclosure. ☐ The followup review finds every requirement satisfied or explicitly deferred with rationale. @@ -309,12 +626,17 @@ Final acceptance: a recorded rationale. ☐ Package mismatch uses the existing structured error schema with a stable registration subtype and validated running/requested fields. + ☐ Admission conflicts preserve their scheduler classifications and safe + blocking context through the bootstrap response. + ☐ Mutation, recovery, polling, and event adapters preserve every approved + expected domain rejection through the shared response contract. ☐ The controller formats the diagnostic by structured code and fields, never by parsing human text. ☐ The message clearly distinguishes the running DFHack service from the current command and gives complete, accurate restart instructions. - ☐ Registration failure kind, result state, exit code, rejection - atomicity, no-recovery behavior, and quarantine handling are preserved. + ☐ Failure kinds, result states, exit codes, rejection atomicity, + primary-error precedence, recovery behavior, authorization rules, + cursor and lease semantics, and quarantine handling are preserved. ☐ Source, focused unit, complete unit, static-analysis, package, - installed mismatch, post-restart live, cleanup, and followup-review - evidence are recorded separately. + installed mismatch, installed operational rejection, post-restart live, + cleanup, and followup-review evidence are recorded separately. From 8faee2cc722c8fce8a62419290d8d2705a29ca9f Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 02:30:53 -0700 Subject: [PATCH 10/18] [Phase 1]: Establish the additive rejection contract --- docs/command-line.md | 19 ++++-- docs/package-version-mismatch-rejection.todo | 34 +++++----- docs/test-runner-service-design.md | 65 +++++++++++++++++++- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/docs/command-line.md b/docs/command-line.md index 42cdb94..9bc3f10 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -207,6 +207,14 @@ Exit codes are stable: | 7 | Execution timeout or the distinct queue-timeout classification | | 8 | Active abort or the distinct pre-activation cancellation classification | +When bootstrap rejects a command because the running DFHack process retained a +different DwarfSpec package version, the diagnostic identifies the running +service version and the current command version separately. Save and fully +exit Dwarf Fortress/DFHack, relaunch it, and retry the command. Returning to +the title screen or unloading the world does not unload the process-wide +DwarfSpec service. This remains a registration rejection with +`registration_error` result state and exit code 5. + On timeout, interruption, or malformed transport after bootstrap, the command asks the service to recover from authoritative current state. A queued run is cancelled without native cleanup; an active run is aborted with cleanup. If @@ -214,8 +222,9 @@ recovery also fails, the original runner failure remains primary. ## Protocol compatibility -All bundled commands and adapters use `dwarfspec.transport.v2`, structured -`dwarfspec.event.v1` events, `dwarfspec.run.v2` snapshots, and -`dwarfspec.result.v2` results. Legacy `dwarfspec.run.v1` reports and formatted -progress lines are not accepted. Readers reject unknown schemas and protocols -instead of guessing. +All bundled commands and adapters use `dwarfspec.transport.v2`; expected +adapter rejections use `dwarfspec.error.v1`; structured events use +`dwarfspec.event.v1`; snapshots use `dwarfspec.run.v2`; and results use +`dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports and formatted progress +lines are not accepted. Readers reject unknown schemas and protocols instead +of guessing. diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 4be78fd..0cb6975 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -75,43 +75,43 @@ Assumptions and open questions: package version bump, and record the compatibility rationale. Phase 1: Establish the additive rejection contract - ☐ Exit with one documented package-mismatch subtype and exact user-facing + ☒ Exit with one documented package-mismatch subtype and exact user-facing semantics before changing service or controller behavior. 1.1 Wire contract: - ☐ Extend the existing `dwarfspec.error.v1` registration envelope with the + ☒ Extend the existing `dwarfspec.error.v1` registration envelope with the optional `code` field rather than creating another schema or changing the broad `kind='registration'` classification. - ☐ Define `code='package_version_mismatch'` as requiring non-empty string + ☒ Define `code='package_version_mismatch'` as requiring non-empty string fields `running_version` and `requested_version`. - ☐ Define `running_version` as the package version retained by the + ☒ Define `running_version` as the package version retained by the process-wide DFHack service registry. - ☐ Define `requested_version` as the package version supplied by the host + ☒ Define `requested_version` as the package version supplied by the host loaded from the current DwarfSpec command's package. - ☐ Keep `message` required and independently meaningful so diagnostics + ☒ Keep `message` required and independently meaningful so diagnostics remain useful to consumers that only display the base error text. - ☐ Preserve generic registration envelopes without `code` and preserve + ☒ Preserve generic registration envelopes without `code` and preserve the existing structured executor-quarantine envelope unchanged. - ☐ Define unknown future registration codes to fall back to their + ☒ Define unknown future registration codes to fall back to their supplied message rather than being mistaken for a version mismatch. 1.2 User-facing diagnostic: - ☐ Settle and test wording that labels both values without the ambiguous + ☒ Settle and test wording that labels both values without the ambiguous `expected` and `found` terms: - ☐ `Running DFHack service: `. - ☐ `Current DwarfSpec command: `. - ☐ Explain that DFHack already has a different DwarfSpec version loaded. - ☐ Direct the operator to save, fully exit Dwarf Fortress/DFHack, + ☒ `Running DFHack service: `. + ☒ `Current DwarfSpec command: `. + ☒ Explain that DFHack already has a different DwarfSpec version loaded. + ☒ Direct the operator to save, fully exit Dwarf Fortress/DFHack, relaunch it, and retry the command. - ☐ State that returning to the title screen or unloading the world does + ☒ State that returning to the title screen or unloading the world does not unload the process-wide DwarfSpec service. - ☐ Keep the diagnostic independent of project paths, selected specs, + ☒ Keep the diagnostic independent of project paths, selected specs, installation-tree paths, and assumptions about how DFHack was launched. Completion criteria: - ☐ The contract distinguishes machine-readable classification from + ☒ The contract distinguishes machine-readable classification from human-readable wording and defines every new field unambiguously. - ☐ Existing registration, quarantine, failure-kind, result-state, and + ☒ Existing registration, quarantine, failure-kind, result-state, and exit-code contracts remain explicitly preserved. Phase 2: Produce structured mismatch rejections in the host diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index 7856eeb..88e20af 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -673,6 +673,64 @@ The JSON payload uses `dwarfspec.transport.v2` and contains: } ``` +### Adapter rejection envelope + +An expected adapter rejection uses the existing `dwarfspec.error.v1` +envelope. `kind` remains the broad runner classification, `message` remains a +required non-empty diagnostic that is meaningful without subtype handling, +and the optional `code` identifies a machine-readable subtype. Adding a code +does not change the runner failure kind, persisted result state, or process +exit code associated with `kind`. + +A package-version mismatch is a registration rejection with this contract: + +```json +{ + "schema": "dwarfspec.error.v1", + "protocol": 2, + "kind": "registration", + "code": "package_version_mismatch", + "message": "DFHack already has a different DwarfSpec version loaded", + "running_version": "0.2.1", + "requested_version": "0.2.2" +} +``` + +For `code="package_version_mismatch"`, `running_version` and +`requested_version` are required non-empty strings. `running_version` is the +DwarfSpec package version retained by the process-wide DFHack service +registry. `requested_version` is the version supplied by the host loaded from +the current DwarfSpec command's package. The response does not include project +paths, selected specifications, installation-tree paths, package roots, or +assumptions about how DFHack was launched. + +The controller renders a valid package-version mismatch with this canonical +diagnostic, substituting the two structured values: + +```text +DwarfSpec could not start because DFHack already has a different DwarfSpec version loaded. + + Running DFHack service: 0.2.1 + Current DwarfSpec command: 0.2.2 + +To use 0.2.2, save and fully exit Dwarf Fortress/DFHack, relaunch it, +and retry this command. Returning to the title screen or unloading the +world will not unload the process-wide DwarfSpec service. +``` + +Generic registration envelopes without `code` remain valid. An unknown future +registration code is displayed using its supplied `message` and must not be +treated as a package-version mismatch. The existing +`kind="executor_quarantined"` envelope and its required +`blocking_run_id`, `blocking_generation`, and `reason` fields are unchanged. +Generic and package-version registration rejections retain the `registration` +runner failure kind, `registration_error` persisted result state, and exit +code 5. The executor-quarantine envelope retains the `executor_quarantined` +runner failure kind and persisted result state, exit code 5, and its existing +recovery behavior. A package-version mismatch does not initiate recovery and +does not modify the service registry, projects, queue, scheduler, ownership, +timestamps, or retained runs. + `OUTPUT`, `DETAIL`, `HOST_ERROR`, and similar formatted protocol lines are not part of the service transport and are neither emitted nor parsed. The canonical JSON line contains all command feedback. @@ -906,9 +964,10 @@ queue timeout receive distinct classifications without changing existing code meanings. Native run snapshots use `dwarfspec.run.v2`; adapters use -`dwarfspec.transport.v2`; event envelopes use `dwarfspec.event.v1`; and -persisted results use `dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports -and formatted progress lines are unsupported. Schema identifiers version each +`dwarfspec.transport.v2`; expected adapter rejections use +`dwarfspec.error.v1`; event envelopes use `dwarfspec.event.v1`; and persisted +results use `dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports and +formatted progress lines are unsupported. Schema identifiers version each document type independently; readers reject unknown versions instead of guessing. From d59bfded566701a979d30953da6faed34cf4c844 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 02:48:12 -0700 Subject: [PATCH 11/18] [Phase 2]: Produce structured mismatch rejections in the host --- docs/package-version-mismatch-rejection.todo | 36 +++++++++---------- src/dwarfspec/host/entrypoints/bootstrap.lua | 8 +++++ src/dwarfspec/host/service/service.lua | 21 ++++++++--- .../entrypoints/entrypoint_contract_spec.lua | 27 ++++++++++++-- tests/unit/host/service/service_spec.lua | 18 +++++++--- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 0cb6975..5b58f4e 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -115,52 +115,52 @@ Completion criteria: exit-code contracts remain explicitly preserved. Phase 2: Produce structured mismatch rejections in the host - ☐ Exit with the service and bootstrap entrypoint carrying version fields + ☒ Exit with the service and bootstrap entrypoint carrying version fields without mutating retained service state or weakening compatibility. 2.1 Service rejection: - ☐ Replace only the incompatible bootstrap package-version assertion in + ☒ Replace only the incompatible bootstrap package-version assertion in `src/dwarfspec/host/service/service.lua` with a structured error value containing the stable code and both version fields. - ☐ Preserve request validation before registry access and preserve the + ☒ Preserve request validation before registry access and preserve the current protocol-version validation order. - ☐ Raise the structured value without an incidental Lua source prefix or + ☒ Raise the structured value without an incidental Lua source prefix or string coercion that would discard its fields. - ☐ Keep successful first bootstrap and compatible repeated bootstrap + ☒ Keep successful first bootstrap and compatible repeated bootstrap behavior unchanged. - ☐ Verify an incompatible bootstrap creates no project, run, queue, + ☒ Verify an incompatible bootstrap creates no project, run, queue, scheduler, ownership, timestamp, or registry mutation. - ☐ Add language-standard documentation comments for every new helper or + ☒ Add language-standard documentation comments for every new helper or public contract surface. 2.2 Bootstrap adapter serialization: - ☐ Extend `src/dwarfspec/host/entrypoints/bootstrap.lua` to recognize the + ☒ Extend `src/dwarfspec/host/entrypoints/bootstrap.lua` to recognize the structured mismatch value and emit its `code`, `running_version`, and `requested_version` fields in `dwarfspec.error.v1` JSON. - ☐ Retain `kind='registration'`, protocol 2, and a non-empty fallback + ☒ Retain `kind='registration'`, protocol 2, and a non-empty fallback message for the mismatch response. - ☐ Preserve generic string-error serialization for all unrelated + ☒ Preserve generic string-error serialization for all unrelated bootstrap failures. - ☐ Preserve executor-quarantine classification and its structured fields + ☒ Preserve executor-quarantine classification and its structured fields without routing it through version-mismatch formatting. - ☐ Avoid exposing package roots or other machine-specific service data. + ☒ Avoid exposing package roots or other machine-specific service data. 2.3 Host-focused tests: - ☐ Update `tests/unit/host/service/service_spec.lua` to assert the exact + ☒ Update `tests/unit/host/service/service_spec.lua` to assert the exact structured mismatch value and unchanged retained registry snapshot. - ☐ Update + ☒ Update `tests/unit/host/entrypoints/entrypoint_contract_spec.lua` to assert the exact JSON schema, protocol, registration kind, code, running version, requested version, and non-empty message. - ☐ Retain independent coverage for generic registration errors and + ☒ Retain independent coverage for generic registration errors and executor quarantine. - ☐ Verify matching versions still bootstrap normally and emit no error + ☒ Verify matching versions still bootstrap normally and emit no error envelope. Completion criteria: - ☐ A version mismatch crosses the host entrypoint as structured JSON with + ☒ A version mismatch crosses the host entrypoint as structured JSON with both correctly oriented version values. - ☐ Host-focused tests prove rejection atomicity and no regression in + ☒ Host-focused tests prove rejection atomicity and no regression in compatible bootstrap or quarantine behavior. Phase 3: Validate and consume the structured rejection in the controller diff --git a/src/dwarfspec/host/entrypoints/bootstrap.lua b/src/dwarfspec/host/entrypoints/bootstrap.lua index 80490b1..80467be 100644 --- a/src/dwarfspec/host/entrypoints/bootstrap.lua +++ b/src/dwarfspec/host/entrypoints/bootstrap.lua @@ -159,6 +159,14 @@ local function emit_error(value) message=clean_message(value), } if type(value) == 'table' and + value.code == 'package_version_mismatch' then + response.code = value.code + response.running_version = value.running_version + response.requested_version = value.requested_version + response.message = type(value.message) == 'string' and + value.message ~= '' and value.message or + 'DFHack already has a different DwarfSpec version loaded' + elseif type(value) == 'table' and value.kind == SchedulerFailureKind.EXECUTOR_QUARANTINED then response.kind = RunnerFailureKind.EXECUTOR_QUARANTINED response.blocking_run_id = value.blocking_run_id diff --git a/src/dwarfspec/host/service/service.lua b/src/dwarfspec/host/service/service.lua index 3b9e320..53322bc 100644 --- a/src/dwarfspec/host/service/service.lua +++ b/src/dwarfspec/host/service/service.lua @@ -194,6 +194,19 @@ local function service_summary(registry) return summary end +---Returns a structured rejection for an incompatible bootstrap package. +---@param running_version string +---@param requested_version string +---@return table +local function package_version_mismatch(running_version, requested_version) + return { + code='package_version_mismatch', + message='DFHack already has a different DwarfSpec version loaded', + running_version=running_version, + requested_version=requested_version, + } +end + ---Validates one project client's compatibility with the running service. ---@param registry table ---@param request table @@ -223,10 +236,10 @@ function M.bootstrap(request, dependencies) local registry = namespace.dwarfspec if registry ~= nil then validate_registry(registry) - assert(request.package_version == registry.package_version, - ('incompatible automation package version: expected %s, found %s') - :format(registry.package_version, - tostring(request.package_version))) + if request.package_version ~= registry.package_version then + error(package_version_mismatch( + registry.package_version, request.package_version), 0) + end return service_summary(registry) end diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index 8ae8dc7..8e15e76 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -110,6 +110,23 @@ describe('version 2 automation entrypoint contract', function() assert.is_nil(dfhack.dwarfspec) end) + it('preserves generic string bootstrap rejections', function() + load_host_script('bootstrap')( + 'entrypoint-generic-rejection', '--unknown=value') + + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('dwarfspec.error.v1', encoded[1].schema) + assert.equals(2, encoded[1].protocol) + assert.equals('registration', encoded[1].kind) + assert.matches('unknown automation option: --unknown', + encoded[1].message, 1, true) + assert.is_nil(encoded[1].code) + assert.is_nil(encoded[1].running_version) + assert.is_nil(encoded[1].requested_version) + assert.is_false(encode_options[1].pretty) + assert.is_nil(dfhack.dwarfspec) + end) + it('starts and aborts through version 2 transport entrypoints', function() local root = require('lfs').currentdir() @@ -180,9 +197,15 @@ describe('version 2 automation entrypoint contract', function() assert.equals('dwarfspec.error.v1', encoded[4].schema) assert.equals(2, encoded[4].protocol) assert.equals('registration', encoded[4].kind) + assert.equals('package_version_mismatch', encoded[4].code) + assert.equals('0.1.3', encoded[4].running_version) + assert.equals('0.2.2', encoded[4].requested_version) + assert.is_string(encoded[4].message) + assert.is_true(encoded[4].message ~= '') assert.is_false(encode_options[4].pretty) - assert.matches('incompatible automation package version: ' .. - 'expected 0.1.3, found 0.2.2', encoded[4].message, 1, true) + assert.matches('DFHack already has a different DwarfSpec version ' .. + 'loaded', encoded[4].message, 1, true) + assert.is_nil(encoded[4].package_root) assert.is_nil(registry.runs['entrypoint-version-rejection']) registry.package_version = '0.2.2' diff --git a/tests/unit/host/service/service_spec.lua b/tests/unit/host/service/service_spec.lua index 511f2e5..fe7e63f 100644 --- a/tests/unit/host/service/service_spec.lua +++ b/tests/unit/host/service/service_spec.lua @@ -289,20 +289,30 @@ describe('multi-project automation service', function() local queue_before = registry.queue local quarantine_before = registry.quarantine local terminals_before = registry.latest_terminal_results + local registry_before = events.copy_json( + registry, 'registry before incompatible bootstrap') - assert.has_error(function() + local compatible, rejection = pcall(function() service.bootstrap(bootstrap_request(nil, '9.9.9'), dependencies) - end, 'incompatible automation package version: expected 0.2.1, ' .. - 'found 9.9.9') + end) + assert.is_false(compatible) + assert.same({ + code='package_version_mismatch', + message='DFHack already has a different DwarfSpec version loaded', + running_version='0.2.1', + requested_version='9.9.9', + }, rejection) assert.has_error(function() service.bootstrap({ protocol_version=1, package_root='D:/Packages/DwarfSpec', - package_version='0.2.1', + package_version='9.9.9', }, dependencies) end, 'incompatible automation service protocol: expected 2, found 1') assert.equals(registry, namespace.dwarfspec) + assert.same(registry_before, events.copy_json( + registry, 'registry after incompatible bootstrap')) assert.equals(projects_before, registry.projects) assert.equals(runs_before, registry.runs) assert.equals(queue_before, registry.queue) From 048a43cf99ee67fad60dcdbe6c3fc063b968b399 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 03:00:01 -0700 Subject: [PATCH 12/18] [Phase 3]: Validate and consume the structured rejection in the controller --- docs/package-version-mismatch-rejection.todo | 44 +++---- src/dwarfspec/controller/execution/runner.lua | 31 +++-- src/dwarfspec/controller/reporting/report.lua | 28 ++++- .../unit/controller/execution/runner_spec.lua | 113 ++++++++++++++++- tests/unit/controller/process_report_spec.lua | 116 +++++++++++++++++- 5 files changed, 294 insertions(+), 38 deletions(-) diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 5b58f4e..98efda7 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -164,60 +164,60 @@ Completion criteria: compatible bootstrap or quarantine behavior. Phase 3: Validate and consume the structured rejection in the controller - ☐ Exit with strict field validation and code-based diagnostic formatting + ☒ Exit with strict field validation and code-based diagnostic formatting that no longer parses human message text. 3.1 Controller response validation: - ☐ Extend the adapter-error validator in + ☒ Extend the adapter-error validator in `src/dwarfspec/controller/reporting/report.lua` to accept optional registration codes while retaining JSON-safety validation. - ☐ Require non-empty `running_version` and `requested_version` strings + ☒ Require non-empty `running_version` and `requested_version` strings when `code='package_version_mismatch'`. - ☐ Reject missing, empty, or incorrectly typed required mismatch fields + ☒ Reject missing, empty, or incorrectly typed required mismatch fields as malformed adapter responses instead of rendering misleading guidance. - ☐ Continue accepting generic registration errors without a code. - ☐ Continue validating executor-quarantine fields exactly as before. - ☐ Preserve unknown future registration codes as generic message-bearing + ☒ Continue accepting generic registration errors without a code. + ☒ Continue validating executor-quarantine fields exactly as before. + ☒ Preserve unknown future registration codes as generic message-bearing rejections unless the settled contract requires stricter handling. - ☐ Add language-standard documentation comments for every changed or new + ☒ Add language-standard documentation comments for every changed or new validation and formatting method. 3.2 Runner formatting: - ☐ Change the registration formatter in + ☒ Change the registration formatter in `src/dwarfspec/controller/execution/runner.lua` to receive the validated rejection object rather than only its message string. - ☐ Branch on `code == 'package_version_mismatch'` and format the settled + ☒ Branch on `code == 'package_version_mismatch'` and format the settled labels and remediation from the structured version fields. - ☐ Remove the substring match on + ☒ Remove the substring match on `incompatible automation package version` after structured coverage is complete. - ☐ Keep generic registration rejections prefixed consistently and do not + ☒ Keep generic registration rejections prefixed consistently and do not append restart advice to unrelated errors or unknown codes. - ☐ Preserve one bootstrap attempt, no bootstrap retry, no recovery call, + ☒ Preserve one bootstrap attempt, no bootstrap retry, no recovery call, registration failure classification, result persistence, and exit code 5 for an explicit mismatch rejection. 3.3 Controller-focused tests: - ☐ Add report-parser cases for a valid structured mismatch and every + ☒ Add report-parser cases for a valid structured mismatch and every missing, empty, or incorrectly typed required field. - ☐ Verify generic registration and executor-quarantine envelopes remain + ☒ Verify generic registration and executor-quarantine envelopes remain accepted and retain their existing fields. - ☐ Update `tests/unit/controller/execution/runner_spec.lua` to assert the + ☒ Update `tests/unit/controller/execution/runner_spec.lua` to assert the exact running/current labels, values, full-exit guidance, title-screen or world-unload clarification, classification, result state, and exit code. - ☐ Verify the diagnostic contains no ambiguous `expected` or `found` + ☒ Verify the diagnostic contains no ambiguous `expected` or `found` labels. - ☐ Verify a generic registration message that happens to contain the old + ☒ Verify a generic registration message that happens to contain the old mismatch phrase receives no special restart guidance. - ☐ Verify unknown registration codes fall back to the supplied message + ☒ Verify unknown registration codes fall back to the supplied message and do not receive version-mismatch formatting. - ☐ Verify malformed structured responses fail through the existing + ☒ Verify malformed structured responses fail through the existing invalid-bootstrap-response path without attempting recovery. Completion criteria: - ☐ No controller behavior depends on parsing the host's human mismatch + ☒ No controller behavior depends on parsing the host's human mismatch message. - ☐ Valid structured rejections render both versions and precise recovery + ☒ Valid structured rejections render both versions and precise recovery instructions while all adjacent rejection behavior remains compatible. Phase 4: Establish one shared adapter-error boundary diff --git a/src/dwarfspec/controller/execution/runner.lua b/src/dwarfspec/controller/execution/runner.lua index 5d697ea..e050695 100644 --- a/src/dwarfspec/controller/execution/runner.lua +++ b/src/dwarfspec/controller/execution/runner.lua @@ -130,16 +130,22 @@ local recovery = run_recovery_module.new({ clean_message=clean_message, }) ----Adds actionable guidance to one host registration rejection. ----@param message string +---Formats one validated host registration rejection. +---@param rejection table ---@return string -local function registration_message(message) - local result = 'DwarfSpec bootstrap rejected: ' .. message - if message:match('incompatible automation package version') then - result = result .. '. Restart DFHack to unload the running ' .. - 'DwarfSpec service before using a different package version' +local function registration_message(rejection) + if rejection.code == 'package_version_mismatch' then + return ('DwarfSpec could not start because DFHack already has a ' .. + 'different DwarfSpec version loaded.\n\n' .. + ' Running DFHack service: %s\n' .. + ' Current DwarfSpec command: %s\n\n' .. + 'To use %s, save and fully exit Dwarf Fortress/DFHack, ' .. + 'relaunch it,\nand retry this command. Returning to the title ' .. + 'screen or unloading the\nworld will not unload the process-wide ' .. + 'DwarfSpec service.'):format(rejection.running_version, + rejection.requested_version, rejection.requested_version) end - return result + return 'DwarfSpec bootstrap rejected: ' .. rejection.message end @@ -253,13 +259,20 @@ function M.run(options) bootstrap_rejected = true local message = response_error.kind == RunnerFailureKind.REGISTRATION and - registration_message(response_error.message) or + registration_message(response_error) or response_error.message fail(response_error.kind, message) end owner_capability = capability return transport else + if type(transport) == 'table' and + transport.invalid_adapter_error then + bootstrap_rejected = true + fail(RunnerFailureKind.REGISTRATION, + 'DwarfSpec bootstrap response was invalid: ' .. + clean_message(transport.message)) + end if type(transport) == 'table' and transport.exit_code and not transport.retryable then error(transport, 0) diff --git a/src/dwarfspec/controller/reporting/report.lua b/src/dwarfspec/controller/reporting/report.lua index 44330c2..a7efe49 100644 --- a/src/dwarfspec/controller/reporting/report.lua +++ b/src/dwarfspec/controller/reporting/report.lua @@ -28,7 +28,19 @@ local function validate_error(report) 'unsupported DwarfSpec adapter error kind: ' .. tostring(report.kind)) assert(type(report.message) == 'string' and report.message ~= '', 'DwarfSpec adapter error message must be a non-empty string') - if report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED then + if report.kind == RunnerFailureKind.REGISTRATION then + assert(report.code == nil or + type(report.code) == 'string' and report.code ~= '', + 'DwarfSpec registration error code must be a non-empty string') + if report.code == 'package_version_mismatch' then + assert(type(report.running_version) == 'string' and + report.running_version ~= '', + 'DwarfSpec package version mismatch requires running version') + assert(type(report.requested_version) == 'string' and + report.requested_version ~= '', + 'DwarfSpec package version mismatch requires requested version') + end + elseif report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED then assert(type(report.blocking_run_id) == 'string' and report.blocking_run_id ~= '', 'DwarfSpec quarantine error requires blocking run id') @@ -42,6 +54,16 @@ local function validate_error(report) return report end +---Wraps one malformed adapter-error diagnostic for bootstrap orchestration. +---@param value any +---@return table +local function invalid_adapter_error(value) + return { + invalid_adapter_error=true, + message=tostring(value), + } +end + ---Returns every machine-readable report payload in output order. ---@param lines string[] ---@return string[] @@ -109,7 +131,9 @@ end function M.parse_transport_response(lines, expected, decoder) local report, payload = decode_report(lines, decoder) if report.schema == 'dwarfspec.error.v1' then - return nil, payload, validate_error(report) + local valid, response_error = pcall(validate_error, report) + if not valid then error(invalid_adapter_error(response_error), 0) end + return nil, payload, response_error end if report.schema ~= 'dwarfspec.transport.v2' then error('unsupported DwarfSpec report schema: ' .. diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index 647d0bf..d98a548 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -1045,8 +1045,10 @@ describe('DwarfSpec external runner', function() schema='dwarfspec.error.v1', protocol=2, kind=runner.failure_kinds.REGISTRATION, - message='incompatible automation package version: ' .. - 'expected 0.1.3, found 0.2.1', + code='package_version_mismatch', + message='different version loaded', + running_version='0.1.3', + requested_version='0.2.1', })}} end recovery_calls = recovery_calls + 1 @@ -1061,9 +1063,112 @@ describe('DwarfSpec external runner', function() runner.failure_kinds.REGISTRATION], outcome.exit_code) assert.equals(runner.failure_kinds.REGISTRATION, outcome.error.kind) assert.equals(ResultState.REGISTRATION_ERROR, outcome.result.state) - assert.matches('expected 0.1.3, found 0.2.1', + assert.equals( + 'DwarfSpec could not start because DFHack already has a ' .. + 'different DwarfSpec version loaded.\n\n' .. + ' Running DFHack service: 0.1.3\n' .. + ' Current DwarfSpec command: 0.2.1\n\n' .. + 'To use 0.2.1, save and fully exit Dwarf Fortress/DFHack, ' .. + 'relaunch it,\nand retry this command. Returning to the ' .. + 'title screen or unloading the\nworld will not unload the ' .. + 'process-wide DwarfSpec service.', outcome.error.message) + assert.equals(outcome.error.message, outcome.result.error.message) + assert.is_nil(outcome.error.message:find('expected', 1, true)) + assert.is_nil(outcome.error.message:find('found', 1, true)) + assert.is_nil(outcome.report) + end) + + it('does not infer version guidance from generic registration text or code', + function() + local cases = { + { + name='generic-old-phrase', + response={ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + message='incompatible automation package version in ' .. + 'unrelated registration detail', + }, + }, + { + name='unknown-code', + response={ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code='future_registration_code', + message='future registration rejection', + }, + }, + } + for _, case in ipairs(cases) do + local recovery_calls = 0 + local run_options = options(case.name) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(case.response)}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(runner.exit_codes[ + runner.failure_kinds.REGISTRATION], outcome.exit_code) + assert.equals(ResultState.REGISTRATION_ERROR, + outcome.result.state) + assert.matches(case.response.message, outcome.error.message, + 1, true) + assert.is_nil(outcome.error.message:find( + 'Running DFHack service:', 1, true)) + assert.is_nil(outcome.error.message:find( + 'fully exit Dwarf Fortress/DFHack', 1, true)) + assert.equals(0, recovery_calls) + end + end) + + it('rejects a malformed mismatch response without recovery', function() + local bootstrap_calls = 0 + local recovery_calls = 0 + local run_options = options('malformed-version-rejection') + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + bootstrap_calls = bootstrap_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code='package_version_mismatch', + message='different version loaded', + running_version='0.1.3', + })}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(1, bootstrap_calls) + assert.equals(0, recovery_calls) + assert.equals(runner.exit_codes[ + runner.failure_kinds.REGISTRATION], outcome.exit_code) + assert.equals(runner.failure_kinds.REGISTRATION, outcome.error.kind) + assert.equals(ResultState.REGISTRATION_ERROR, outcome.result.state) + assert.matches('DwarfSpec bootstrap response was invalid', + outcome.error.message, 1, true) + assert.matches('requires requested version', outcome.error.message, 1, true) - assert.matches('Restart DFHack', outcome.error.message, 1, true) assert.is_nil(outcome.report) end) diff --git a/tests/unit/controller/process_report_spec.lua b/tests/unit/controller/process_report_spec.lua index bcec1f0..e8adccb 100644 --- a/tests/unit/controller/process_report_spec.lua +++ b/tests/unit/controller/process_report_spec.lua @@ -250,7 +250,7 @@ describe('DwarfSpec native reports', function() })) end) - it('returns a canonical adapter rejection separately from transport', + it('returns a generic registration rejection separately from transport', function() local transport, _, response_error = report.parse_transport_response({'DWARFSPEC_JSON ignored'}, { @@ -270,6 +270,120 @@ describe('DwarfSpec native reports', function() assert.equals('registration', response_error.kind) assert.equals('incompatible automation package version: ' .. 'expected 0.1.3, found 0.2.1', response_error.message) + assert.is_nil(response_error.code) + end) + + it('validates package mismatch and quarantine adapter errors', function() + local _, _, mismatch = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', + requested_version='0.2.2', + } + end) + assert.equals('package_version_mismatch', mismatch.code) + assert.equals('0.2.1', mismatch.running_version) + assert.equals('0.2.2', mismatch.requested_version) + + local _, _, quarantine = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='executor_quarantined', + message='cleanup unconfirmed', + blocking_run_id='run-blocking', + blocking_generation=7, + reason='cleanup unconfirmed', + } + end) + assert.equals('run-blocking', quarantine.blocking_run_id) + assert.equals(7, quarantine.blocking_generation) + assert.equals('cleanup unconfirmed', quarantine.reason) + end) + + it('accepts unknown registration codes without mismatch fields', function() + local _, _, response_error = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='future_registration_code', + message='future registration rejection', + } + end) + + assert.equals('future_registration_code', response_error.code) + assert.equals('future registration rejection', response_error.message) + assert.is_nil(response_error.running_version) + assert.is_nil(response_error.requested_version) + end) + + it('rejects malformed package mismatch fields', function() + local cases = { + {'running version missing', nil, '0.2.2', 'running version'}, + {'running version empty', '', '0.2.2', 'running version'}, + {'running version typed', 201, '0.2.2', 'running version'}, + {'requested version missing', '0.2.1', nil, 'requested version'}, + {'requested version empty', '0.2.1', '', 'requested version'}, + {'requested version typed', '0.2.1', 202, 'requested version'}, + } + for _, case in ipairs(cases) do + local accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version=case[2], + requested_version=case[3], + } + end) + assert.is_false(accepted, case[1]) + assert.is_table(rejection, case[1]) + assert.is_true(rejection.invalid_adapter_error, case[1]) + assert.matches(case[4], rejection.message, 1, true) + end + + local accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code=42, + message='typed code', + } + end) + assert.is_false(accepted) + assert.is_true(rejection.invalid_adapter_error) + assert.matches('code must be a non-empty string', + rejection.message, 1, true) + + accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + message='unsafe rejection', + unsafe=function() end, + } + end) + assert.is_false(accepted) + assert.is_true(rejection.invalid_adapter_error) + assert.matches('JSON-safe', rejection.message, 1, true) end) it('accepts one exact version 2 transport identity and cursor', function() From 770edfbffb3b6987f4be74f1cb3487cdb50c4376 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 03:15:21 -0700 Subject: [PATCH 13/18] [Phase 4]: Establish one shared adapter-error boundary --- docs/test-runner-service-design.md | 66 +++++ .../controller/execution/transport_client.lua | 44 ++- src/dwarfspec/controller/reporting/report.lua | 35 +-- src/dwarfspec/host/entrypoints/bootstrap.lua | 41 +-- src/dwarfspec/host/service/service.lua | 8 +- src/dwarfspec/protocol/adapter_errors.lua | 251 ++++++++++++++++++ .../execution/transport_client_spec.lua | 44 ++- tests/unit/protocol/adapter_errors_spec.lua | 116 ++++++++ 8 files changed, 522 insertions(+), 83 deletions(-) create mode 100644 src/dwarfspec/protocol/adapter_errors.lua create mode 100644 tests/unit/protocol/adapter_errors_spec.lua diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index 88e20af..ffb1836 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -955,6 +955,71 @@ It must also solve authentication, encryption, project synchronization, package deployment, and remote path identity. Enabling DFHack's unrestricted remote command listener is not considered a DwarfSpec remote execution design. +## Adapter error boundary + +`dwarfspec.error.v1` is the sole adapter-error envelope. Its `kind` is the +existing broad runner classification chosen at the adapter/controller boundary; +its optional `code` is a stable domain subtype chosen by the service or +scheduler layer that owns the rejection decision. Adding a code or safe field +is additive and does not change result state, exit code, retry, recovery, or +primary-versus-secondary error precedence. + +The shared contract accepts `registration`, `executor_quarantined`, and `host` +as envelope kinds. A domain rejection has a non-empty `code` and `message`. +Generic compatibility errors omit `code`; unknown future codes retain their +message and safe common fields but receive no code-specific guidance. Known +codes must contain exactly their required subtype fields. A malformed known +payload is an invalid host response, while an unexpected exception becomes a +bounded uncoded `host` error. These cases remain observably distinct. +Known subtype policy is centralized privately in +`dwarfspec.protocol.adapter_errors`; a separate public immutable code enum is +not introduced while package mismatch is the only migrated coded subtype. +Additional accepted families extend that registry as their contracts land. + +The common optional fields are `operation`, `run_id`, `generation`, `state`, +`blocking_run_id`, and `blocking_generation`. Identifiers and states are +non-empty strings; generations are positive integers. Subtype contracts add +only fields needed for remediation. Owner capabilities, authorization proofs, +package or project roots, result paths, and unrelated machine paths are +forbidden. The package mismatch code requires `running_version` and +`requested_version`. The existing uncoded executor-quarantine compatibility +shape requires `blocking_run_id`, `blocking_generation`, and `reason`. + +Adapters may emit a valid error envelope with either a zero or nonzero process +exit during migration. The controller inspects and validates the envelope +before interpreting the process exit. A valid structured rejection is retained; +a nonzero result without one remains a bridge or host failure and includes only +bounded, sanitized captured output. Healthy transports, read-only response +schemas, and the `DWARFSPEC_PROBE` connection grammar do not use this envelope. + +The canonical package-mismatch diagnostic remains the persisted result error +and CLI text. This additive envelope extraction does not require a package +version bump: it preserves schema and protocol versions, existing generic and +quarantine shapes, runner classifications, result states, and exit meanings. + +### Entrypoint failure inventory + +| Entrypoint | Expected domain rejection | Other structured state | Boundary or internal failures | +|---|---|---|---| +| `bootstrap` | package mismatch, scheduler admission, executor quarantine | successful `dwarfspec.transport.v2` | option, module-load, and unexpected host faults | +| `abort` | run identity, ownership, and state rejection | successful transport | argument, load, and unexpected host faults | +| `acknowledge` | generation, ownership, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `cancel` | run identity, ownership, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `discard` | run identity, generation, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `recover` | run identity, ownership, cursor, cleanup, and state rejection | successful transport | argument, load, and unexpected host faults | +| `recover_executor` | quarantine identity, generation, cursor, and clean-state rejection | successful transport | argument, load, and unexpected host faults | +| `status` | run identity, ownership, and cursor rejection | successful transport | subprocess/bridge, argument, load, and unexpected host faults | +| `event_read` | run identity and cursor rejection | successful transport | argument, load, and unexpected host faults | +| `scheduler_status` | none | `dwarfspec.status.v1` or scheduler/transport response | argument, load, and unexpected host faults | +| `run_query` | invalid query arguments | history, inspection, or log response schemas, including `found=false` | argument, load, and unexpected host faults | +| `probe` | none | `DWARFSPEC_PROBE` connection state | unavailable or malformed DFHack context | + +The domain families listed above are migrated separately. Query `found=false`, +an unloaded status response, healthy scheduler state, and probe state are data, +not rejections. Process invocation failures belong to the controller's +connection or host classification. Assertions for malformed internal requests, +impossible invariants, module loading, and serialization remain uncoded faults. + ## Compatibility The existing CLI command names and exit-code meanings remain stable. Terminal @@ -996,6 +1061,7 @@ The implementation uses these module boundaries: | `dwarfspec.protocol.enums.test_statuses` | Immutable Busted result-status identifiers. | | `dwarfspec.protocol.enums.result_policies` | Immutable result-persistence policies. | | `dwarfspec.protocol.schemas` | Versioned service, scheduler, run, transport, event, and result validation. | +| `dwarfspec.protocol.adapter_errors` | Canonical adapter-error construction, field policy, validation, and safe serialization. | | `dwarfspec.host.service.snapshots` | Immutable run, history, and scheduler snapshot construction. | | `dwarfspec.host.execution.host` | Busted execution, native state transitions, and cleanup. | | `dwarfspec.host.execution.output_handler` | Translation from Busted callbacks into service events. | diff --git a/src/dwarfspec/controller/execution/transport_client.lua b/src/dwarfspec/controller/execution/transport_client.lua index 3c76ea7..f3fc3f5 100644 --- a/src/dwarfspec/controller/execution/transport_client.lua +++ b/src/dwarfspec/controller/execution/transport_client.lua @@ -116,6 +116,27 @@ local function format_output(lines) return output end +---Returns a validated canonical adapter error from subprocess output, if any. +---@param result table +---@param expected table|nil +---@param decoder function|nil +---@return table|nil +local function adapter_error_from_result(result, expected, decoder) + local parsed, _, _, adapter_error = pcall( + reports.parse_transport_response, result.lines, expected or {}, decoder) + if parsed then return adapter_error end + return nil +end + +---Builds one bounded fallback for a nonzero subprocess result. +---@param operation string +---@param result table +---@return string +local function nonzero_message(operation, result) + return ('DwarfSpec %s exited with %s. Output: %s'):format( + operation, safe_tostring(result.exit_code), format_output(result.lines)) +end + ---Finds exact probe marker lines without treating embedded marker text as a report. ---@param lines any ---@return string[] @@ -279,8 +300,10 @@ function M.new(dependencies) operation, clean_message(result))), 0) end if result.exit_code ~= 0 then - error(failure(kinds.HOST, - ('DwarfSpec %s exited with %d'):format(operation, result.exit_code)), 0) + local adapter_error = adapter_error_from_result( + result, expected, options.decode_json) + if adapter_error then error(adapter_error, 0) end + error(failure(kinds.HOST, nonzero_message(operation, result)), 0) end return reports.parse_transport(result.lines, expected, options.decode_json) end @@ -300,8 +323,11 @@ function M.new(dependencies) error(detail, 0) end if result.exit_code ~= 0 then + local adapter_error = adapter_error_from_result( + result, expected, options.decode_json) + if adapter_error then return nil, nil, adapter_error end error(failure(kinds.REGISTRATION, - 'DwarfSpec bootstrap exited with ' .. result.exit_code), 0) + nonzero_message('bootstrap', result)), 0) end local transport, _, adapter_error = reports.parse_transport_response( result.lines, expected, options.decode_json) @@ -321,8 +347,10 @@ function M.new(dependencies) 'DwarfSpec status bridge failed: ' .. clean_message(result)), 0) end if result.exit_code ~= 0 then - error(failure(kinds.HOST, - 'DwarfSpec status exited with ' .. result.exit_code), 0) + local adapter_error = adapter_error_from_result( + result, nil, options.decode_json) + if adapter_error then error(adapter_error, 0) end + error(failure(kinds.HOST, nonzero_message('status', result)), 0) end return reports.parse_status(result.lines, options.decode_json) end @@ -342,9 +370,11 @@ function M.new(dependencies) operation, clean_message(result))), 0) end if result.exit_code ~= 0 then + local adapter_error = adapter_error_from_result( + result, nil, options.decode_json) + if adapter_error then error(adapter_error, 0) end error(failure(kinds.HOST, - ('DwarfSpec %s query exited with %d'):format( - operation, result.exit_code)), 0) + nonzero_message(operation .. ' query', result)), 0) end local parsers = { history=reports.parse_run_history, diff --git a/src/dwarfspec/controller/reporting/report.lua b/src/dwarfspec/controller/reporting/report.lua index a7efe49..4e76b80 100644 --- a/src/dwarfspec/controller/reporting/report.lua +++ b/src/dwarfspec/controller/reporting/report.lua @@ -2,6 +2,7 @@ local M = {} local events = require('dwarfspec.protocol.events') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local EventType = require('dwarfspec.protocol.enums.event_types') local diagnostic_formatter = require('dwarfspec.controller.reporting.diagnostic_formatter') local focus = @@ -11,7 +12,6 @@ local focus_warning = local schemas = require('dwarfspec.protocol.schemas') local SchedulerFailureKind = require('dwarfspec.protocol.enums.scheduler_failure_kinds') -local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') local PREFIX = 'DWARFSPEC_JSON ' local OWNER_PREFIX = 'DWARFSPEC_OWNER ' @@ -20,38 +20,7 @@ local OWNER_PREFIX = 'DWARFSPEC_OWNER ' ---@param report table ---@return table local function validate_error(report) - events.copy_json(report, 'adapter error response') - assert(report.protocol == 2, - 'unsupported DwarfSpec protocol: ' .. tostring(report.protocol)) - assert(report.kind == RunnerFailureKind.REGISTRATION or - report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED, - 'unsupported DwarfSpec adapter error kind: ' .. tostring(report.kind)) - assert(type(report.message) == 'string' and report.message ~= '', - 'DwarfSpec adapter error message must be a non-empty string') - if report.kind == RunnerFailureKind.REGISTRATION then - assert(report.code == nil or - type(report.code) == 'string' and report.code ~= '', - 'DwarfSpec registration error code must be a non-empty string') - if report.code == 'package_version_mismatch' then - assert(type(report.running_version) == 'string' and - report.running_version ~= '', - 'DwarfSpec package version mismatch requires running version') - assert(type(report.requested_version) == 'string' and - report.requested_version ~= '', - 'DwarfSpec package version mismatch requires requested version') - end - elseif report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED then - assert(type(report.blocking_run_id) == 'string' and - report.blocking_run_id ~= '', - 'DwarfSpec quarantine error requires blocking run id') - assert(type(report.blocking_generation) == 'number' and - report.blocking_generation > 0 and - report.blocking_generation % 1 == 0, - 'DwarfSpec quarantine error requires blocking generation') - assert(type(report.reason) == 'string' and report.reason ~= '', - 'DwarfSpec quarantine error requires a reason') - end - return report + return adapter_errors.validate(report) end ---Wraps one malformed adapter-error diagnostic for bootstrap orchestration. diff --git a/src/dwarfspec/host/entrypoints/bootstrap.lua b/src/dwarfspec/host/entrypoints/bootstrap.lua index 80467be..63eb35c 100644 --- a/src/dwarfspec/host/entrypoints/bootstrap.lua +++ b/src/dwarfspec/host/entrypoints/bootstrap.lua @@ -138,48 +138,15 @@ end local root, lua_root = package_root() local json = require('json') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') -local SchedulerFailureKind = - require('dwarfspec.protocol.enums.scheduler_failure_kinds') - ----Removes an incidental Lua source location from an adapter error. ----@param value any ----@return string -local function clean_message(value) - return tostring(value):gsub('^.-:%d+: ', '') -end ---Emits one canonical bootstrap rejection. ---@param value any local function emit_error(value) - local response = { - schema='dwarfspec.error.v1', - protocol=2, - kind=RunnerFailureKind.REGISTRATION, - message=clean_message(value), - } - if type(value) == 'table' and - value.code == 'package_version_mismatch' then - response.code = value.code - response.running_version = value.running_version - response.requested_version = value.requested_version - response.message = type(value.message) == 'string' and - value.message ~= '' and value.message or - 'DFHack already has a different DwarfSpec version loaded' - elseif type(value) == 'table' and - value.kind == SchedulerFailureKind.EXECUTOR_QUARANTINED then - response.kind = RunnerFailureKind.EXECUTOR_QUARANTINED - response.blocking_run_id = value.blocking_run_id - response.blocking_generation = value.blocking_generation - response.reason = value.reason - response.message = ('DwarfSpec executor is quarantined by run %s ' .. - 'generation %d: %s. Recover it with: dwarfspec ' .. - 'recover-executor %s --generation %d'):format( - value.blocking_run_id, value.blocking_generation, - value.reason, value.blocking_run_id, - value.blocking_generation) - end - print('DWARFSPEC_JSON ' .. json.encode(response, {pretty=false})) + local encoded = adapter_errors.serialize( + value, RunnerFailureKind.REGISTRATION, json.encode) + print('DWARFSPEC_JSON ' .. encoded) end ---Registers one run without emitting a partial success response. diff --git a/src/dwarfspec/host/service/service.lua b/src/dwarfspec/host/service/service.lua index 53322bc..ce76cbf 100644 --- a/src/dwarfspec/host/service/service.lua +++ b/src/dwarfspec/host/service/service.lua @@ -1,6 +1,7 @@ -- Process-wide multi-project automation service runtime and public boundary. local projects = require('dwarfspec.host.service.projects') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local events = require('dwarfspec.protocol.events') local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local RunState = require('dwarfspec.protocol.enums.run_states') @@ -199,12 +200,11 @@ end ---@param requested_version string ---@return table local function package_version_mismatch(running_version, requested_version) - return { - code='package_version_mismatch', - message='DFHack already has a different DwarfSpec version loaded', + return adapter_errors.domain('package_version_mismatch', + 'DFHack already has a different DwarfSpec version loaded', { running_version=running_version, requested_version=requested_version, - } + }) end ---Validates one project client's compatibility with the running service. diff --git a/src/dwarfspec/protocol/adapter_errors.lua b/src/dwarfspec/protocol/adapter_errors.lua new file mode 100644 index 0000000..d83ead1 --- /dev/null +++ b/src/dwarfspec/protocol/adapter_errors.lua @@ -0,0 +1,251 @@ +-- Canonical construction, validation, and serialization for adapter errors. + +local events = require('dwarfspec.protocol.events') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') + +local M = { + protocol=2, + schema='dwarfspec.error.v1', +} + +local COMMON_FIELDS = { + operation='string', + run_id='string', + generation='positive_integer', + state='string', + blocking_run_id='string', + blocking_generation='positive_integer', +} + +local FORBIDDEN_FIELDS = { + authorization_proof=true, + owner_capability=true, + package_root=true, + project_root=true, + result_path=true, +} + +local KNOWN_CODES = { + package_version_mismatch={ + kind=RunnerFailureKind.REGISTRATION, + required={ + running_version='string', + requested_version='string', + }, + }, +} + +local APPROVED_KINDS = { + [RunnerFailureKind.REGISTRATION]=true, + [RunnerFailureKind.EXECUTOR_QUARANTINED]=true, + [RunnerFailureKind.HOST]=true, +} + +---Returns whether a value is a positive integer. +---@param value any +---@return boolean +local function is_positive_integer(value) + return type(value) == 'number' and value > 0 and value % 1 == 0 +end + +---Validates one field against its public adapter-error type. +---@param value any +---@param field_type string +---@param field_name string +local function validate_field(value, field_type, field_name) + if field_type == 'positive_integer' then + assert(is_positive_integer(value), + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a positive integer') + else + assert(type(value) == field_type and value ~= '', + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a non-empty ' .. field_type) + end +end + +---Returns a safe bounded rendering of an arbitrary internal exception. +---@param value any +---@return string +function M.safe_message(value) + local ok, rendered = pcall(tostring, value) + if not ok or type(rendered) ~= 'string' then + return 'DwarfSpec host reported an unprintable internal error' + end + rendered = rendered:gsub('^.-:%d+: ', '') + :gsub('[%z\1-\31\127]', '?') + if rendered == '' then rendered = 'DwarfSpec host reported an internal error' end + if #rendered > 512 then rendered = rendered:sub(1, 509) .. '...' end + return rendered +end + +---Constructs one detached JSON-safe domain rejection value. +---@param code string +---@param message string +---@param fields table|nil +---@return table +function M.domain(code, message, fields) + assert(type(code) == 'string' and code ~= '', + 'DwarfSpec domain rejection code must be a non-empty string') + assert(type(message) == 'string' and message ~= '', + 'DwarfSpec domain rejection message must be a non-empty string') + local rejection = {code=code, message=message} + for name, value in pairs(fields or {}) do rejection[name] = value end + events.copy_json(rejection, 'DwarfSpec domain rejection') + local candidate = { + schema=M.schema, + protocol=M.protocol, + kind=KNOWN_CODES[code] and KNOWN_CODES[code].kind or + RunnerFailureKind.HOST, + } + for name, value in pairs(rejection) do candidate[name] = value end + M.validate(candidate) + return rejection +end + +---Constructs the compatibility executor-quarantine adapter error. +---@param value table +---@return table +function M.executor_quarantine(value) + local response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.EXECUTOR_QUARANTINED, + blocking_run_id=value.blocking_run_id, + blocking_generation=value.blocking_generation, + reason=value.reason, + message=('DwarfSpec executor is quarantined by run %s generation %s: ' .. + '%s. Recover it with: dwarfspec recover-executor %s --generation %s') + :format(M.safe_message(value.blocking_run_id), + M.safe_message(value.blocking_generation), + M.safe_message(value.reason), M.safe_message(value.blocking_run_id), + M.safe_message(value.blocking_generation)), + } + return M.validate(response) +end + +---Constructs a canonical envelope from a domain rejection or internal fault. +---@param value any +---@param default_kind string +---@return table +function M.envelope(value, default_kind) + if type(value) == 'table' and + value.kind == 'executor_quarantined' then + return M.executor_quarantine(value) + end + local response = { + schema=M.schema, + protocol=M.protocol, + kind=default_kind, + message=M.safe_message(value), + } + if type(value) == 'table' and type(value.code) == 'string' and + value.code ~= '' then + for name, field_value in pairs(value) do + if name ~= 'kind' then response[name] = field_value end + end + end + return M.validate(response) +end + +---Validates and returns one canonical adapter-error envelope. +---@param response table +---@return table +function M.validate(response) + events.copy_json(response, 'adapter error response') + assert(response.schema == M.schema, + 'unsupported DwarfSpec adapter error schema: ' .. tostring(response.schema)) + assert(response.protocol == M.protocol, + 'unsupported DwarfSpec protocol: ' .. tostring(response.protocol)) + assert(APPROVED_KINDS[response.kind], + 'unsupported DwarfSpec adapter error kind: ' .. tostring(response.kind)) + validate_field(response.message, 'string', 'message') + if response.code ~= nil then + local label = response.kind == RunnerFailureKind.REGISTRATION and + 'registration error code' or 'adapter error code' + assert(type(response.code) == 'string' and response.code ~= '', + 'DwarfSpec ' .. label .. ' must be a non-empty string') + end + + for name in pairs(FORBIDDEN_FIELDS) do + assert(response[name] == nil, + 'DwarfSpec adapter error forbids field ' .. name) + end + for name, field_type in pairs(COMMON_FIELDS) do + local quarantine_field = + response.kind == RunnerFailureKind.EXECUTOR_QUARANTINED and + response.code == nil and + (name == 'blocking_run_id' or name == 'blocking_generation') + if response[name] ~= nil and not quarantine_field then + validate_field(response[name], field_type, name) + end + end + + local allowed = {schema=true, protocol=true, kind=true, code=true, message=true} + for name in pairs(COMMON_FIELDS) do allowed[name] = true end + local contract = response.code and KNOWN_CODES[response.code] + if contract then + assert(response.kind == contract.kind, + 'DwarfSpec adapter error code has incompatible kind') + for name, field_type in pairs(contract.required) do + if response.code == 'package_version_mismatch' and + name == 'running_version' then + assert(type(response[name]) == 'string' and response[name] ~= '', + 'DwarfSpec package version mismatch requires running version') + elseif response.code == 'package_version_mismatch' and + name == 'requested_version' then + assert(type(response[name]) == 'string' and response[name] ~= '', + 'DwarfSpec package version mismatch requires requested version') + else + validate_field(response[name], field_type, name) + end + allowed[name] = true + end + elseif response.kind == RunnerFailureKind.EXECUTOR_QUARANTINED and + response.code == nil then + assert(type(response.blocking_run_id) == 'string' and + response.blocking_run_id ~= '', + 'DwarfSpec quarantine error requires blocking run id') + assert(is_positive_integer(response.blocking_generation), + 'DwarfSpec quarantine error requires blocking generation') + assert(type(response.reason) == 'string' and response.reason ~= '', + 'DwarfSpec quarantine error requires a reason') + allowed.reason = true + end + for name in pairs(response) do + assert(allowed[name], 'DwarfSpec adapter error has unsupported field ' .. name) + end + return response +end + +---Serializes one canonical adapter error with a non-throwing host-fault fallback. +---@param value any +---@param default_kind string +---@param encoder function +---@return string, table +function M.serialize(value, default_kind, encoder) + local built, response = pcall(M.envelope, value, default_kind) + if not built then + response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.HOST, + message='DwarfSpec host could not serialize an adapter error', + } + end + local encoded, json = pcall(encoder, response, {pretty=false}) + if encoded and type(json) == 'string' then return json, response end + response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.HOST, + message='DwarfSpec host could not serialize an adapter error', + } + local fallback = '{"schema":"dwarfspec.error.v1","protocol":2,' .. + '"kind":"host","message":' .. + '"DwarfSpec host could not serialize an adapter error"}' + return fallback, response +end + +return M diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index da70680..ec1757f 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -267,13 +267,53 @@ describe('controller transport client', function() assert.same(lines[8], truncated_output:sub(-#lines[8])) end) - it('classifies nonzero exits before canonical parsing', function() + it('preserves structured errors from nonzero subprocess results', function() + local lines = {'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind='registration', code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', requested_version='0.2.2', + })} local transport = client() local ok, detail = pcall(transport.transport, { - invoke=function() return {exit_code=4, lines={}} end}, 'runner', {}, {}, 'poll') + invoke=function() return {exit_code=4, lines=lines} end, + }, 'runner', {}, {}, 'poll') + assert.is_false(ok) + assert.equals('package_version_mismatch', detail.code) + assert.equals('0.2.1', detail.running_version) + + local response, owner, bootstrap_error = transport.bootstrap_response({ + invoke=function() return {exit_code=4, lines=lines} end, + }, 'runner', {}, {}) + assert.is_nil(response) + assert.is_nil(owner) + assert.equals('package_version_mismatch', bootstrap_error.code) + end) + + it('classifies nonzero exits without valid JSON using bounded output', function() + local transport = client() + local ok, detail = pcall(transport.transport, { + invoke=function() + return {exit_code=4, lines={string.rep('x', 3000)}} + end}, 'runner', {}, {}, 'poll') assert.is_false(ok) assert.same('host', detail.kind) assert.matches('poll exited with 4', detail.message) + assert.matches('Output:', detail.message, 1, true) + assert.is_true(#detail.message < 2200) + + local malformed = {'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind='registration', code='package_version_mismatch', + message='different version loaded', running_version='0.2.1', + })} + ok, detail = pcall(transport.transport, { + invoke=function() return {exit_code=4, lines=malformed} end, + }, 'runner', {}, {}, 'poll') + assert.is_false(ok) + assert.equals('host', detail.kind) + assert.is_nil(detail.code) + assert.matches('DWARFSPEC_JSON', detail.message, 1, true) end) it('rejects missing and malformed canonical envelopes', function() diff --git a/tests/unit/protocol/adapter_errors_spec.lua b/tests/unit/protocol/adapter_errors_spec.lua new file mode 100644 index 0000000..3015f46 --- /dev/null +++ b/tests/unit/protocol/adapter_errors_spec.lua @@ -0,0 +1,116 @@ +-- Unit contract for canonical adapter-error construction and serialization. + +local adapter_errors = require('dwarfspec.protocol.adapter_errors') +local reports = require('dwarfspec.controller.reporting.report') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') + +---Encodes one value through the controller test JSON implementation. +---@param value table +---@return string +local function encode(value) + return require('dkjson').encode(value) +end + +---Returns one valid package-version domain rejection. +---@return table +local function mismatch() + return adapter_errors.domain('package_version_mismatch', + 'different version loaded', { + running_version='0.2.1', + requested_version='0.2.2', + }) +end + +describe('adapter error protocol', function() + it('round trips construction, serialization, and controller validation', + function() + local json, emitted = adapter_errors.serialize(mismatch(), + RunnerFailureKind.REGISTRATION, encode) + local transport, payload, rejection = reports.parse_transport_response({ + 'DWARFSPEC_JSON ' .. json, + }, {}) + assert.is_nil(transport) + assert.equals(json, payload) + assert.same(emitted, rejection) + assert.same({ + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', + requested_version='0.2.2', + }, rejection) + end) + + it('requires every exact known-code field', function() + for _, fields in ipairs({ + {requested_version='0.2.2'}, + {running_version='0.2.1'}, + {running_version='', requested_version='0.2.2'}, + {running_version='0.2.1', requested_version=2}, + {running_version='0.2.1', requested_version='0.2.2', extra=true}, + }) do + assert.has_error(function() + adapter_errors.domain('package_version_mismatch', 'message', fields) + end) + end + end) + + it('rejects non-JSON-safe and forbidden domain fields', function() + for _, fields in ipairs({ + {operation=function() end}, + {owner_capability='secret'}, + {authorization_proof='secret'}, + {package_root='C:/private'}, + {project_root='C:/private'}, + {result_path='C:/private/result.json'}, + }) do + assert.has_error(function() + adapter_errors.domain('future_code', 'message', fields) + end) + end + end) + + it('retains generic and unknown compatibility without known fields', function() + local generic = adapter_errors.envelope('generic rejection', + RunnerFailureKind.REGISTRATION) + assert.is_nil(generic.code) + assert.equals('generic rejection', generic.message) + + local unknown = adapter_errors.envelope(adapter_errors.domain( + 'future_code', 'future rejection', { + operation='cancel', run_id='run-1', generation=2, + state='queued', blocking_run_id='run-0', + blocking_generation=1, + }), RunnerFailureKind.HOST) + assert.equals('future_code', unknown.code) + assert.equals('host', unknown.kind) + assert.equals('cancel', unknown.operation) + end) + + it('preserves the executor quarantine compatibility fixture', function() + local response = adapter_errors.executor_quarantine({ + blocking_run_id='run-1', + blocking_generation=3, + reason='cleanup was not confirmed', + }) + assert.equals('executor_quarantined', response.kind) + assert.is_nil(response.code) + assert.matches('recover-executor run-1 --generation 3', response.message, + 1, true) + end) + + it('uses an uncoded bounded host fallback when serialization fails', + function() + local unprintable = setmetatable({}, { + __tostring=function() error('cannot render') end, + }) + local json, response = adapter_errors.serialize(unprintable, + RunnerFailureKind.REGISTRATION, function() error('encode failure') end) + assert.matches('"kind":"host"', json, 1, true) + assert.is_nil(response.code) + assert.is_true(#response.message <= 512) + end) +end) From c945c409f1081b7e1ebc3701fead4d71f82a3949 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 03:31:51 -0700 Subject: [PATCH 14/18] [Phase 5]: Preserve bootstrap admission conflicts --- docs/package-version-mismatch-rejection.todo | 106 +++++++++--------- docs/test-runner-service-design.md | 29 ++++- src/dwarfspec/controller/execution/runner.lua | 25 +++++ src/dwarfspec/host/execution/host.lua | 33 +++++- src/dwarfspec/protocol/adapter_errors.lua | 29 +++++ .../unit/controller/execution/runner_spec.lua | 72 ++++++++++++ .../entrypoints/entrypoint_contract_spec.lua | 71 ++++++++++++ tests/unit/host/execution/host_spec.lua | 41 ++++++- .../host/service/service_scheduler_spec.lua | 4 + tests/unit/protocol/adapter_errors_spec.lua | 42 +++++++ 10 files changed, 385 insertions(+), 67 deletions(-) diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 98efda7..c9d08e3 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -51,27 +51,27 @@ Non-goals: package on the operator's behalf. Assumptions and open questions: - ☐ Treat `code='package_version_mismatch'`, `running_version`, and + ☒ Treat `code='package_version_mismatch'`, `running_version`, and `requested_version` as the stable wire names proposed in the conversation. - ☐ Decide whether `package_version_mismatch` should be centralized in a + ☒ Decide whether `package_version_mismatch` should be centralized in a shared immutable error-code module with the other accepted domain codes. - ☐ Confirm that broadening the accepted error kinds and codes remains an + ☒ Confirm that broadening the accepted error kinds and codes remains an additive `dwarfspec.error.v1` change. If backward compatibility cannot be proved, stop and revise this plan instead of silently introducing a new top-level schema. - ☐ Define the boundary between expected domain rejections and unexpected + ☒ Define the boundary between expected domain rejections and unexpected host faults before converting any auxiliary entrypoint; unknown faults must remain distinguishable from stable public rejection codes. - ☐ Decide whether adapters emit a structured error with subprocess exit + ☒ Decide whether adapters emit a structured error with subprocess exit code zero, emit structured JSON alongside a nonzero exit, or support both during migration. The controller must not discard a valid error envelope solely because the bridge returned nonzero. - ☐ Decide whether the canonical multi-line remediation text is stored + ☒ Decide whether the canonical multi-line remediation text is stored verbatim in persisted result errors or whether persistence retains a single-line message while the CLI renderer adds layout. Verify existing result consumers before settling this boundary. - ☐ Decide whether shipping the changed host and controller requires a + ☒ Decide whether shipping the changed host and controller requires a package version bump, and record the compatibility rationale. Phase 1: Establish the additive rejection contract @@ -221,136 +221,136 @@ Completion criteria: instructions while all adjacent rejection behavior remains compatible. Phase 4: Establish one shared adapter-error boundary - ☐ Exit with reusable construction, serialization, parsing, and validation + ☒ Exit with reusable construction, serialization, parsing, and validation rules that later entrypoints can adopt without duplicating bootstrap's special-case logic. - ☐ Treat the package-version work in Phases 2 and 3 as the first complete + ☒ Treat the package-version work in Phases 2 and 3 as the first complete vertical slice, then extract its proven contract into shared machinery before migrating additional error families. 4.1 Error taxonomy and field policy: - ☐ Inventory every error-producing host entrypoint and classify each + ☒ Inventory every error-producing host entrypoint and classify each failure as an expected domain rejection, structured state already represented by another schema, subprocess or connection failure, or unexpected internal fault. - ☐ Define `kind` as the existing broad runner classification and `code` as + ☒ Define `kind` as the existing broad runner classification and `code` as the stable domain subtype; document which layer owns each value. - ☐ Define common optional fields such as `operation`, `run_id`, + ☒ Define common optional fields such as `operation`, `run_id`, `generation`, `state`, `blocking_run_id`, and `blocking_generation`. - ☐ Define subtype-specific required fields and forbid fields whose values + ☒ Define subtype-specific required fields and forbid fields whose values would expose owner capabilities, authorization proofs, package roots, or unrelated machine-specific paths. - ☐ Define compatibility behavior for generic errors without `code`, known + ☒ Define compatibility behavior for generic errors without `code`, known codes, unknown future codes, malformed known-code payloads, and unexpected internal exceptions. - ☐ Preserve existing failure kinds, result states, exit codes, primary + ☒ Preserve existing failure kinds, result states, exit codes, primary versus secondary error precedence, and recovery decisions unless a later task explicitly documents an approved mapping. 4.2 Shared host construction and serialization: - ☐ Add one narrowly scoped protocol or host-support abstraction for + ☒ Add one narrowly scoped protocol or host-support abstraction for constructing JSON-safe domain rejection objects with required `code`, `message`, and subtype fields. - ☐ Add one shared entrypoint serializer for canonical adapter errors so + ☒ Add one shared entrypoint serializer for canonical adapter errors so bootstrap, mutation, recovery, polling, and event adapters do not each implement their own field-copy rules. - ☐ Preserve bootstrap's executor-quarantine payload and the structured + ☒ Preserve bootstrap's executor-quarantine payload and the structured package-version mismatch as compatibility fixtures for the shared path. - ☐ Migrate package-version mismatch and executor quarantine onto the + ☒ Migrate package-version mismatch and executor quarantine onto the shared constructor and serializer, then remove any temporary mismatch-only field-copy or dispatch scaffolding after parity is proved. - ☐ Ensure error serialization itself cannot throw on a malformed or + ☒ Ensure error serialization itself cannot throw on a malformed or non-string internal exception; retain a bounded generic host-fault fallback without falsely assigning a public code. - ☐ Add language-standard documentation comments for every new class, + ☒ Add language-standard documentation comments for every new class, method, constructor, validator, and public contract surface. 4.3 Shared controller parsing: - ☐ Generalize adapter-error validation in + ☒ Generalize adapter-error validation in `src/dwarfspec/controller/reporting/report.lua` so all approved broad kinds and codes are validated by the same contract. - ☐ Update transport-client operations to inspect and validate a canonical + ☒ Update transport-client operations to inspect and validate a canonical error envelope before replacing a response with generic ` exited with ` text. - ☐ Preserve bounded captured output when a subprocess fails without a + ☒ Preserve bounded captured output when a subprocess fails without a valid structured response. - ☐ Return validated error objects to runner and recovery orchestration + ☒ Return validated error objects to runner and recovery orchestration without flattening them to strings prematurely. - ☐ Preserve generic registration fallback, executor quarantine, healthy + ☒ Preserve generic registration fallback, executor quarantine, healthy transport parsing, read-only response schemas, and connection-probe behavior. 4.4 Shared contract tests: - ☐ Add round-trip tests from domain rejection construction through JSON + ☒ Add round-trip tests from domain rejection construction through JSON serialization, controller validation, and retained fields. - ☐ Verify known codes require their exact fields and reject missing, + ☒ Verify known codes require their exact fields and reject missing, empty, incorrectly typed, non-JSON-safe, or forbidden values. - ☐ Verify unknown codes and generic uncoded messages follow the settled + ☒ Verify unknown codes and generic uncoded messages follow the settled compatibility policy without receiving known-code guidance. - ☐ Verify nonzero subprocess results with valid structured JSON preserve + ☒ Verify nonzero subprocess results with valid structured JSON preserve the structured error under the settled migration contract. - ☐ Verify nonzero results without valid JSON remain classified as bridge + ☒ Verify nonzero results without valid JSON remain classified as bridge or host failures with bounded diagnostic output. Completion criteria: - ☐ One documented and tested error-envelope path serves bootstrap and is + ☒ One documented and tested error-envelope path serves bootstrap and is ready for every approved auxiliary adapter. - ☐ Structured domain rejections and unexpected internal faults remain + ☒ Structured domain rejections and unexpected internal faults remain observably distinct end to end. Phase 5: Preserve bootstrap admission conflicts - ☐ Exit with scheduler admission classifications reaching the CLI as + ☒ Exit with scheduler admission classifications reaching the CLI as actionable structured registration rejections without changing whether a run is accepted, reused, or rejected. 5.1 Admission subtype contracts: - ☐ Define structured registration codes for `project_busy`, + ☒ Define structured registration codes for `project_busy`, `request_key_conflict`, and `result_path_busy` using the existing `SchedulerFailureKind` values where compatible. - ☐ Define safe blocking context for each subtype, including the blocking + ☒ Define safe blocking context for each subtype, including the blocking run identity, generation, and state when available. - ☐ Decide whether project identity or normalized result-path identity is + ☒ Decide whether project identity or normalized result-path identity is necessary for remediation; omit raw paths and internal identifiers when the blocking run identity is sufficient. - ☐ Define actionable messages that name the actual conflict rather than + ☒ Define actionable messages that name the actual conflict rather than reporting only that another run is queued, active, or terminal. 5.2 Host propagation: - ☐ Update `src/dwarfspec/host/execution/host.lua` to preserve + ☒ Update `src/dwarfspec/host/execution/host.lua` to preserve `outcome.kind`, `outcome.reason`, identity, and snapshot context from `service.submit()` when admission is rejected. - ☐ Route each expected admission outcome through the shared structured + ☒ Route each expected admission outcome through the shared structured rejection constructor and bootstrap serializer. - ☐ Preserve accepted first submissions and identical request-key retries + ☒ Preserve accepted first submissions and identical request-key retries as successful, idempotent transport responses. - ☐ Preserve rejection atomicity, outstanding-run ownership, queue order, + ☒ Preserve rejection atomicity, outstanding-run ownership, queue order, generation, leases, and result-path reservations. - ☐ Leave invalid scheduler invariants and generator failures as internal + ☒ Leave invalid scheduler invariants and generator failures as internal faults rather than assigning them admission codes. 5.3 Controller formatting and behavior: - ☐ Validate every admission subtype and its required blocking context. - ☐ Format project-busy, request-key-conflict, and result-path-busy + ☒ Validate every admission subtype and its required blocking context. + ☒ Format project-busy, request-key-conflict, and result-path-busy guidance from structured fields without parsing `reason` or `message`. - ☐ Preserve registration failure classification, registration-error + ☒ Preserve registration failure classification, registration-error persistence, exit code 5, no bootstrap retry, and no recovery attempt. - ☐ Ensure conflict messages do not blame the selected specification or + ☒ Ensure conflict messages do not blame the selected specification or disclose unrelated consumer configuration. 5.4 Admission tests: - ☐ Add focused scheduler and service fixtures for all three rejection + ☒ Add focused scheduler and service fixtures for all three rejection outcomes and for accepted idempotent reuse. - ☐ Add bootstrap-entrypoint tests for exact error schema, code, broad + ☒ Add bootstrap-entrypoint tests for exact error schema, code, broad kind, safe blocking fields, and non-empty message. - ☐ Add controller tests for exact subtype classification, actionable + ☒ Add controller tests for exact subtype classification, actionable rendering, persisted result state, exit code, and no recovery. - ☐ Verify each rejection leaves the complete registry and scheduler state + ☒ Verify each rejection leaves the complete registry and scheduler state unchanged except for state that legitimately predates the attempted run. Completion criteria: - ☐ Every expected admission conflict retains its scheduler classification + ☒ Every expected admission conflict retains its scheduler classification and safe blocking context through the CLI. - ☐ No admission conflict is reduced to generic run-state prose. + ☒ No admission conflict is reduced to generic run-state prose. Phase 6: Structure mutation and recovery rejections ☐ Exit with abort, cancel, recover, acknowledge, discard, and executor diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index ffb1836..542c58a 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -971,10 +971,11 @@ message and safe common fields but receive no code-specific guidance. Known codes must contain exactly their required subtype fields. A malformed known payload is an invalid host response, while an unexpected exception becomes a bounded uncoded `host` error. These cases remain observably distinct. -Known subtype policy is centralized privately in -`dwarfspec.protocol.adapter_errors`; a separate public immutable code enum is -not introduced while package mismatch is the only migrated coded subtype. -Additional accepted families extend that registry as their contracts land. +Known subtype policy is centralized in +`dwarfspec.protocol.adapter_errors`. Package mismatch remains a private +protocol code; admission conflicts reuse the public immutable +`SchedulerFailureKind` values `project_busy`, `request_key_conflict`, and +`result_path_busy` so the scheduler classification is preserved verbatim. The common optional fields are `operation`, `run_id`, `generation`, `state`, `blocking_run_id`, and `blocking_generation`. Identifiers and states are @@ -985,6 +986,26 @@ forbidden. The package mismatch code requires `running_version` and `requested_version`. The existing uncoded executor-quarantine compatibility shape requires `blocking_run_id`, `blocking_generation`, and `reason`. +Each admission-conflict code has broad kind `registration` and requires +`blocking_run_id`, `blocking_generation`, `state`, and the scheduler's safe +`reason`. The blocking fields identify +the exact retained run that owns the conflict and are sufficient for +remediation, so the envelope deliberately omits project identity, normalized +result-path identity, and every raw path. `project_busy` tells the caller to +wait for and consume the outstanding result; `request_key_conflict` tells the +caller to retry the identical request or choose a new run identity; and +`result_path_busy` tells the caller to wait for result consumption or select a +different result destination. Controller guidance is selected by `code` and +formatted from the blocking fields, never by parsing `message` or `reason`. + +Admission rejection does not mutate the registry, outstanding ownership, +queue order, generation, leases, request-key bindings, or result-path +reservations. An identical request-key retry remains an accepted idempotent +transport response. Invalid scheduler invariants and identifier or capability +generator failures remain internal host faults. All three expected conflicts +retain the registration failure kind, `registration_error` persisted state, +exit code 5, a single bootstrap attempt, and no recovery attempt. + Adapters may emit a valid error envelope with either a zero or nonzero process exit during migration. The controller inspects and validates the envelope before interpreting the process exit. A valid structured rejection is retained; diff --git a/src/dwarfspec/controller/execution/runner.lua b/src/dwarfspec/controller/execution/runner.lua index e050695..153a4a5 100644 --- a/src/dwarfspec/controller/execution/runner.lua +++ b/src/dwarfspec/controller/execution/runner.lua @@ -8,6 +8,8 @@ local result_interpreter_module = require('dwarfspec.controller.execution.result local ErrorFormat = require('dwarfspec.protocol.configuration.error_formats') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') local M = {} @@ -145,6 +147,29 @@ local function registration_message(rejection) 'DwarfSpec service.'):format(rejection.running_version, rejection.requested_version, rejection.requested_version) end + if rejection.code == SchedulerFailureKind.PROJECT_BUSY then + return ('DwarfSpec could not start because this project already has ' .. + 'an outstanding run.\n\n Blocking run: %s\n Generation: %d\n' .. + ' State: %s\n\nWait for that run to finish and consume its result, ' .. + 'then retry this command.'):format(rejection.blocking_run_id, + rejection.blocking_generation, rejection.state) + end + if rejection.code == SchedulerFailureKind.REQUEST_KEY_CONFLICT then + return ('DwarfSpec could not start because this request identity is ' .. + 'already bound to a different run.\n\n Blocking run: %s\n' .. + ' Generation: %d\n State: %s\n\nRetry the identical request, ' .. + 'or submit this work with a new run identity.'):format( + rejection.blocking_run_id, rejection.blocking_generation, + rejection.state) + end + if rejection.code == SchedulerFailureKind.RESULT_PATH_BUSY then + return ('DwarfSpec could not start because the configured result ' .. + 'destination is reserved by another run.\n\n Blocking run: %s\n' .. + ' Generation: %d\n State: %s\n\nWait until that result is ' .. + 'consumed, or choose a different result destination.'):format( + rejection.blocking_run_id, rejection.blocking_generation, + rejection.state) + end return 'DwarfSpec bootstrap rejected: ' .. rejection.message end diff --git a/src/dwarfspec/host/execution/host.lua b/src/dwarfspec/host/execution/host.lua index f62b8cc..bdf7721 100644 --- a/src/dwarfspec/host/execution/host.lua +++ b/src/dwarfspec/host/execution/host.lua @@ -11,6 +11,7 @@ local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') local SchedulerFailureKind = require('dwarfspec.protocol.enums.scheduler_failure_kinds') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local service = require('dwarfspec.host.service.service') local module_environment_module = require('dwarfspec.host.environment.module_environment') @@ -569,6 +570,29 @@ local function service_selection(specs) return identities end +local ADMISSION_MESSAGES = { + [SchedulerFailureKind.PROJECT_BUSY]= + 'This project already has an outstanding DwarfSpec run.', + [SchedulerFailureKind.REQUEST_KEY_CONFLICT]= + 'This request identity is already bound to a different DwarfSpec run.', + [SchedulerFailureKind.RESULT_PATH_BUSY]= + 'The configured result destination is reserved by another DwarfSpec run.', +} + +---Constructs a public structured rejection for one expected admission conflict. +---@param outcome table +---@return table +local function admission_rejection(outcome) + local message = ADMISSION_MESSAGES[outcome.kind] + if not message then return nil end + return adapter_errors.domain(outcome.kind, message, { + blocking_run_id=outcome.identity.run_id, + blocking_generation=outcome.identity.generation, + state=outcome.snapshot.state, + reason=outcome.reason, + }) +end + ---Starts one service-owned nonblocking automation run. ---@param package_root string ---@param project_root string @@ -619,12 +643,9 @@ function M.start(package_root, project_root, options) selection={identities=service_selection(options.specs)}, }, dependencies) if not outcome.accepted then - if outcome.snapshot.terminal then - error(('automation run %s has an unobserved %s result') - :format(outcome.identity.run_id, outcome.snapshot.state)) - end - error(('automation run %s is already %s') - :format(outcome.identity.run_id, outcome.snapshot.state)) + local rejection = admission_rejection(outcome) + if rejection then error(rejection, 0) end + error(outcome.reason or 'DwarfSpec scheduler rejected the run', 0) end local registry = get_registry() local run = assert(registry.runs[outcome.identity.run_id], diff --git a/src/dwarfspec/protocol/adapter_errors.lua b/src/dwarfspec/protocol/adapter_errors.lua index d83ead1..debcc4e 100644 --- a/src/dwarfspec/protocol/adapter_errors.lua +++ b/src/dwarfspec/protocol/adapter_errors.lua @@ -3,6 +3,8 @@ local events = require('dwarfspec.protocol.events') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') local M = { protocol=2, @@ -34,6 +36,33 @@ local KNOWN_CODES = { requested_version='string', }, }, + [SchedulerFailureKind.PROJECT_BUSY]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, + [SchedulerFailureKind.REQUEST_KEY_CONFLICT]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, + [SchedulerFailureKind.RESULT_PATH_BUSY]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, } local APPROVED_KINDS = { diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index d98a548..95af3dd 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -9,6 +9,8 @@ local EventType = require('dwarfspec.protocol.enums.event_types') local ErrorFormat = require('dwarfspec.protocol.configuration.error_formats') local ResultState = require('dwarfspec.protocol.enums.result_states') local RunState = require('dwarfspec.protocol.enums.run_states') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') local RUN_STATE_TERMINAL = { [RunState.QUEUED]=false, @@ -1078,6 +1080,76 @@ describe('DwarfSpec external runner', function() assert.is_nil(outcome.report) end) + it('renders every admission conflict from its structured subtype without recovery', + function() + local cases = { + { + code=SchedulerFailureKind.PROJECT_BUSY, + phrase='this project already has an outstanding run', + action='Wait for that run to finish and consume its result', + }, + { + code=SchedulerFailureKind.REQUEST_KEY_CONFLICT, + phrase='this request identity is already bound to a different run', + action='submit this work with a new run identity', + }, + { + code=SchedulerFailureKind.RESULT_PATH_BUSY, + phrase='configured result destination is reserved by another run', + action='choose a different result destination', + }, + } + for _, case in ipairs(cases) do + local bootstrap_calls = 0 + local recovery_calls = 0 + local run_options = options('admission-' .. case.code) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + bootstrap_calls = bootstrap_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code=case.code, + message='opaque host wording that must not be parsed', + blocking_run_id='blocking-run', + blocking_generation=9, + state='queued', + reason='scheduler classification detail', + })}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(1, bootstrap_calls) + assert.equals(0, recovery_calls) + assert.equals(5, outcome.exit_code) + assert.equals(runner.failure_kinds.REGISTRATION, + outcome.error.kind) + assert.equals(ResultState.REGISTRATION_ERROR, + outcome.result.state) + assert.equals(outcome.error.message, + outcome.result.error.message) + assert.matches(case.phrase, outcome.error.message, 1, true) + assert.matches(case.action, outcome.error.message, 1, true) + assert.matches('Blocking run: blocking-run', + outcome.error.message, 1, true) + assert.matches('Generation: 9', outcome.error.message, 1, true) + assert.matches('State: queued', outcome.error.message, 1, true) + assert.is_nil(outcome.error.message:find( + 'opaque host wording', 1, true)) + assert.is_nil(outcome.error.message:find( + 'selected specification', 1, true)) + assert.is_nil(outcome.report) + end + end) + it('does not infer version guidance from generic registration text or code', function() local cases = { diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index 8e15e76..f8ef474 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -127,6 +127,77 @@ describe('version 2 automation entrypoint contract', function() assert.is_nil(dfhack.dwarfspec) end) + it('serializes admission conflicts with exact safe blocking fields', + function() + local root = require('lfs').currentdir():gsub('\\', '/') + local result_path = root .. '/shared-result.json' + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path) + local registry = dfhack.dwarfspec + local owner = registry.runs['admission-owner'] + + lines = {} + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path) + assert.equals('dwarfspec.transport.v2', encoded[2].schema) + assert.matches('DWARFSPEC_OWNER ', lines[2], 1, true) + assert.equals(1, registry.generation) + + lines = {} + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path, '--spec=different.ds.lua') + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.same({ + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='request_key_conflict', + message='This request identity is already bound to a different ' .. + 'DwarfSpec run.', + blocking_run_id=owner.run_id, + blocking_generation=owner.generation, + state=owner.state, + reason='request key is already bound to a different request', + }, encoded[3]) + + lines = {} + load_host_script('bootstrap')( + 'admission-project-busy', '--project-root=.', + '--result-policy=file', '--result-path=' .. result_path) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('project_busy', encoded[4].code) + assert.equals('registration', encoded[4].kind) + assert.equals(owner.run_id, encoded[4].blocking_run_id) + assert.equals(owner.generation, encoded[4].blocking_generation) + assert.equals(owner.state, encoded[4].state) + assert.equals('project already owns an outstanding run', + encoded[4].reason) + assert.is_string(encoded[4].message) + assert.is_true(encoded[4].message ~= '') + + lines = {} + load_host_script('bootstrap')( + 'admission-result-busy', '--project-root=tests', + '--result-policy=file', '--result-path=' .. result_path) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('result_path_busy', encoded[5].code) + assert.equals('registration', encoded[5].kind) + assert.equals(owner.run_id, encoded[5].blocking_run_id) + assert.equals(owner.generation, encoded[5].blocking_generation) + assert.equals(owner.state, encoded[5].state) + assert.equals('result path is owned by another outstanding run', + encoded[5].reason) + assert.is_nil(encoded[5].project_root) + assert.is_nil(encoded[5].result_path) + assert.is_nil(encoded[5].owner_capability) + assert.equals(1, registry.generation) + assert.is_nil(registry.runs['admission-project-busy']) + assert.is_nil(registry.runs['admission-result-busy']) + end) + it('starts and aborts through version 2 transport entrypoints', function() local root = require('lfs').currentdir() diff --git a/tests/unit/host/execution/host_spec.lua b/tests/unit/host/execution/host_spec.lua index 1bb9482..d188fd9 100644 --- a/tests/unit/host/execution/host_spec.lua +++ b/tests/unit/host/execution/host_spec.lua @@ -246,9 +246,35 @@ describe('automation host ownership', function() it('rejects overlap and ignores a callback after abort', function() local run = host.start('.', '.', options('owner')) assert.equals('starting', run.state) - assert.has_error(function() + local reused = host.start('.', '.', options('owner')) + assert.equals(run, reused) + assert.equals(1, dfhack.dwarfspec.generation) + + local changed = options('owner') + changed.specs = {'different.ds.lua'} + local conflict_accepted, conflict = pcall(function() + host.start('.', '.', changed) + end) + assert.is_false(conflict_accepted) + assert.equals(SchedulerFailureKind.REQUEST_KEY_CONFLICT, conflict.code) + assert.equals('owner', conflict.blocking_run_id) + assert.equals(1, conflict.blocking_generation) + assert.equals('starting', conflict.state) + assert.equals('request key is already bound to a different request', + conflict.reason) + + local accepted, rejection = pcall(function() host.start('.', '.', options('overlap')) - end, 'automation run owner is already starting') + end) + assert.is_false(accepted) + assert.same({ + code=SchedulerFailureKind.PROJECT_BUSY, + message='This project already has an outstanding DwarfSpec run.', + blocking_run_id='owner', + blocking_generation=1, + state='starting', + reason='project already owns an outstanding run', + }, rejection) local cleaned = false run.cleanup_module.push(run.cleanup_registry, 'abort proof', function() @@ -302,9 +328,16 @@ describe('automation host ownership', function() local retained = host.start('.', '.', options('retained')) local aborted = host.abort(retained.run_id, retained.owner_capability) - assert.has_error(function() + local accepted, rejection = pcall(function() host.start('.', '.', options('replacement')) - end, 'automation run retained has an unobserved aborted result') + end) + assert.is_false(accepted) + assert.equals(SchedulerFailureKind.PROJECT_BUSY, rejection.code) + assert.equals('retained', rejection.blocking_run_id) + assert.equals(retained.generation, rejection.blocking_generation) + assert.equals('aborted', rejection.state) + assert.equals('project already owns an outstanding run', + rejection.reason) host.acknowledge(aborted.run_id, aborted.generation, aborted.owner_capability) diff --git a/tests/unit/host/service/service_scheduler_spec.lua b/tests/unit/host/service/service_scheduler_spec.lua index 32aae2d..051a515 100644 --- a/tests/unit/host/service/service_scheduler_spec.lua +++ b/tests/unit/host/service/service_scheduler_spec.lua @@ -488,6 +488,7 @@ describe('multi-project automation service scheduler', function() assert.is_nil(first.snapshot.owner_capability) assert.is_nil(service.events(first.identity.run_id, 0, dependencies).events[1].owner_capability) + local after_reuse = service.summary(dependencies) local mismatched_retry = submission('alpha') mismatched_retry.selection.identities = {'tests/live/other.ds.lua'} @@ -497,12 +498,15 @@ describe('multi-project automation service scheduler', function() assert.equals(SchedulerFailureKind.REQUEST_KEY_CONFLICT, conflict.kind) assert.equals(first.identity.run_id, conflict.identity.run_id) + assert.same(after_reuse, service.summary(dependencies)) + local before_busy = service.summary(dependencies) local busy = service.submit(projects[1].project_id, submission('alpha-other'), dependencies) assert.is_false(busy.accepted) assert.equals(SchedulerFailureKind.PROJECT_BUSY, busy.kind) assert.equals(first.identity.run_id, busy.identity.run_id) + assert.same(before_busy, service.summary(dependencies)) local second = service.submit(projects[2].project_id, submission('alpha'), dependencies) diff --git a/tests/unit/protocol/adapter_errors_spec.lua b/tests/unit/protocol/adapter_errors_spec.lua index 3015f46..a807c01 100644 --- a/tests/unit/protocol/adapter_errors_spec.lua +++ b/tests/unit/protocol/adapter_errors_spec.lua @@ -4,6 +4,8 @@ local adapter_errors = require('dwarfspec.protocol.adapter_errors') local reports = require('dwarfspec.controller.reporting.report') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') ---Encodes one value through the controller test JSON implementation. ---@param value table @@ -58,6 +60,46 @@ describe('adapter error protocol', function() end end) + it('validates every admission conflict with only safe blocking context', + function() + for _, code in ipairs({ + SchedulerFailureKind.PROJECT_BUSY, + SchedulerFailureKind.REQUEST_KEY_CONFLICT, + SchedulerFailureKind.RESULT_PATH_BUSY, + }) do + local rejection = adapter_errors.domain(code, 'admission conflict', { + blocking_run_id='blocking-run', + blocking_generation=7, + state='queued', + reason='scheduler classification detail', + }) + local envelope = adapter_errors.envelope(rejection, + RunnerFailureKind.REGISTRATION) + assert.equals(RunnerFailureKind.REGISTRATION, envelope.kind) + assert.equals(code, envelope.code) + assert.equals('blocking-run', envelope.blocking_run_id) + assert.equals(7, envelope.blocking_generation) + assert.equals('queued', envelope.state) + assert.is_nil(envelope.project_root) + assert.is_nil(envelope.result_path) + + for _, missing in ipairs({ + 'blocking_run_id', 'blocking_generation', 'state', 'reason', + }) do + local fields = { + blocking_run_id='blocking-run', + blocking_generation=7, + state='queued', + reason='scheduler classification detail', + } + fields[missing] = nil + assert.has_error(function() + adapter_errors.domain(code, 'admission conflict', fields) + end) + end + end + end) + it('rejects non-JSON-safe and forbidden domain fields', function() for _, fields in ipairs({ {operation=function() end}, From 78dca77a83195787b082de30ad0bcb90f73d1040 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 03:54:45 -0700 Subject: [PATCH 15/18] [Phase 6]: Structure mutation and recovery rejections --- docs/package-version-mismatch-rejection.todo | 50 +++++------ docs/test-runner-service-design.md | 22 ++++- .../controller/execution/run_recovery.lua | 17 +++- src/dwarfspec/controller/execution/runner.lua | 7 +- .../controller/execution/transport_client.lua | 78 +++++++++++++++- src/dwarfspec/host/entrypoints/abort.lua | 16 ++-- .../host/entrypoints/acknowledge.lua | 18 ++-- src/dwarfspec/host/entrypoints/cancel.lua | 12 ++- src/dwarfspec/host/entrypoints/discard.lua | 12 ++- .../host/entrypoints/operation_response.lua | 30 +++++++ src/dwarfspec/host/entrypoints/recover.lua | 12 ++- .../host/entrypoints/recover_executor.lua | 15 ++-- src/dwarfspec/host/execution/host.lua | 47 ++++++---- .../host/service/scheduler/queue.lua | 18 +++- .../host/service/scheduler/recovery.lua | 90 ++++++++++++++----- .../service/scheduler/request_validation.lua | 66 +++++++++++--- src/dwarfspec/protocol/adapter_errors.lua | 22 +++++ .../execution/run_recovery_spec.lua | 28 ++++++ .../unit/controller/execution/runner_spec.lua | 81 +++++++++++++++++ .../execution/transport_client_spec.lua | 58 ++++++++++++ .../entrypoints/entrypoint_contract_spec.lua | 54 ++++++++++- tests/unit/host/execution/host_spec.lua | 14 ++- .../host/service/scheduler/recovery_spec.lua | 29 +++++- .../host/service/service_scheduler_spec.lua | 83 ++++++++++++++++- tests/unit/protocol/adapter_errors_spec.lua | 35 ++++++++ 25 files changed, 787 insertions(+), 127 deletions(-) create mode 100644 src/dwarfspec/host/entrypoints/operation_response.lua diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index c9d08e3..7fdb10a 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -353,77 +353,77 @@ Completion criteria: ☒ No admission conflict is reduced to generic run-state prose. Phase 6: Structure mutation and recovery rejections - ☐ Exit with abort, cancel, recover, acknowledge, discard, and executor + ☒ Exit with abort, cancel, recover, acknowledge, discard, and executor recovery adapters returning actionable domain rejections instead of only subprocess exit codes. 6.1 Operation subtype contracts: - ☐ Define stable codes and required safe fields for `service_not_loaded`, + ☒ Define stable codes and required safe fields for `service_not_loaded`, `run_not_found`, `generation_mismatch`, `invalid_run_state`, `owner_capability_rejected`, `quarantine_mismatch`, and `clean_state_unverified`, adjusting names only when the contract review identifies an existing canonical term. - ☐ Map each code only to expected operator- or orchestration-triggerable + ☒ Map each code only to expected operator- or orchestration-triggerable conditions; keep malformed internal requests and impossible service invariants unclassified. - ☐ Define broad failure-kind, result-state, and exit-code mappings for + ☒ Define broad failure-kind, result-state, and exit-code mappings for direct operator commands and secondary recovery or acknowledgement failures. - ☐ Preserve the original run failure as primary when a structured + ☒ Preserve the original run failure as primary when a structured recovery or acknowledgement rejection is appended as secondary context. 6.2 Service and host boundaries: - ☐ Replace expected assertion-only rejections in the approved operation + ☒ Replace expected assertion-only rejections in the approved operation paths with structured domain values at the layer that owns the decision. - ☐ Preserve exact run, project, service-instance, generation, capability, + ☒ Preserve exact run, project, service-instance, generation, capability, state, quarantine, and clean-state authorization checks. - ☐ Ensure owner capabilities and authorization proofs are never copied + ☒ Ensure owner capabilities and authorization proofs are never copied into error payloads, logs, persisted results, or CLI output. - ☐ Preserve successful operation state transitions, native cleanup, + ☒ Preserve successful operation state transitions, native cleanup, acknowledgement, discard, quarantine clearing, and subsequent queue activation exactly as before. - ☐ Prove rejected operations do not renew leases, mutate journals, + ☒ Prove rejected operations do not renew leases, mutate journals, release ownership, clear quarantine, discard results, or invoke native cleanup. 6.3 Entrypoint adoption: - ☐ Adopt the shared serializer in `abort.lua`, `cancel.lua`, `recover.lua`, + ☒ Adopt the shared serializer in `abort.lua`, `cancel.lua`, `recover.lua`, `acknowledge.lua`, `discard.lua`, and `recover_executor.lua`. - ☐ Ensure each adapter emits exactly one canonical JSON response for + ☒ Ensure each adapter emits exactly one canonical JSON response for either success or a modeled domain rejection. - ☐ Retain nonzero or fallback behavior for module-load, malformed + ☒ Retain nonzero or fallback behavior for module-load, malformed argument, serialization, and unexpected internal failures according to the settled migration contract. - ☐ Keep entrypoints thin and free of duplicated business classification + ☒ Keep entrypoints thin and free of duplicated business classification or remediation logic. 6.4 Controller and recovery consumption: - ☐ Extend transport and recovery clients to return structured operation + ☒ Extend transport and recovery clients to return structured operation rejections without collapsing them to generic `exited with` messages. - ☐ Render run identifiers, generations, current states, and remediation + ☒ Render run identifiers, generations, current states, and remediation only from validated subtype fields. - ☐ Preserve direct abort and executor-recovery command exit behavior. - ☐ Preserve recovery error precedence and append structured secondary + ☒ Preserve direct abort and executor-recovery command exit behavior. + ☒ Preserve recovery error precedence and append structured secondary detail without replacing the original timeout, host, interruption, or test failure. - ☐ Preserve successful transport validation and cleanup confirmation + ☒ Preserve successful transport validation and cleanup confirmation requirements. 6.5 Mutation and recovery tests: - ☐ Add service tests for every modeled rejection and its no-mutation + ☒ Add service tests for every modeled rejection and its no-mutation guarantee. - ☐ Add entrypoint tests for structured success-versus-error exclusivity + ☒ Add entrypoint tests for structured success-versus-error exclusivity and forbidden sensitive fields. - ☐ Add transport-client and recovery tests for each subtype, broad kind, + ☒ Add transport-client and recovery tests for each subtype, broad kind, exit code, primary-error precedence, and exact useful context. - ☐ Retain success coverage for queued cancellation, active abort with + ☒ Retain success coverage for queued cancellation, active abort with cleanup, terminal acknowledgement, explicit discard, and verified executor recovery. Completion criteria: - ☐ Every expected mutation or recovery rejection crosses the adapter + ☒ Every expected mutation or recovery rejection crosses the adapter boundary as a validated object with safe actionable context. - ☐ No successful behavior, authorization rule, state transition, cleanup + ☒ No successful behavior, authorization rule, state transition, cleanup requirement, or error precedence changes. Phase 7: Structure polling and event transport rejections diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index 542c58a..b9e1511 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -977,8 +977,9 @@ protocol code; admission conflicts reuse the public immutable `SchedulerFailureKind` values `project_busy`, `request_key_conflict`, and `result_path_busy` so the scheduler classification is preserved verbatim. -The common optional fields are `operation`, `run_id`, `generation`, `state`, -`blocking_run_id`, and `blocking_generation`. Identifiers and states are +The common optional fields are `operation`, `run_id`, `generation`, +`current_generation`, `state`, `blocking_run_id`, `blocking_generation`, and +`reason`. Identifiers, states, and reasons are non-empty strings; generations are positive integers. Subtype contracts add only fields needed for remediation. Owner capabilities, authorization proofs, package or project roots, result paths, and unrelated machine paths are @@ -1006,6 +1007,23 @@ generator failures remain internal host faults. All three expected conflicts retain the registration failure kind, `registration_error` persisted state, exit code 5, a single bootstrap attempt, and no recovery attempt. +Mutation adapters use the same envelope for expected orchestration rejections. +`abort`, `cancel`, `recover`, `acknowledge`, `discard`, and executor recovery +may report `service_not_loaded`, `run_not_found`, `generation_mismatch`, +`invalid_run_state`, `owner_capability_rejected`, `quarantine_mismatch`, or +`clean_state_unverified`. These codes retain broad kind `host` and direct +command exit code 5. Their required fields contain only the operation and the +minimum applicable run identifier, requested or current generation, state, +blocking quarantine identity, or clean-state reason. + +The controller selects remediation by `code` and renders identifiers, +generations, and states only from validated subtype fields. A recovery or +acknowledgement rejection is appended as secondary detail when an earlier run +failure exists; it never replaces the original timeout, interruption, host, or +test failure. Rejected service decisions complete before lease, journal, +ownership, quarantine, result-retention, or native-cleanup mutation. Owner +capabilities and authorization proofs never cross the error boundary. + Adapters may emit a valid error envelope with either a zero or nonzero process exit during migration. The controller inspects and validates the envelope before interpreting the process exit. A valid structured rejection is retained; diff --git a/src/dwarfspec/controller/execution/run_recovery.lua b/src/dwarfspec/controller/execution/run_recovery.lua index 65df4d3..59197a4 100644 --- a/src/dwarfspec/controller/execution/run_recovery.lua +++ b/src/dwarfspec/controller/execution/run_recovery.lua @@ -45,6 +45,12 @@ function M.new(dependencies) return nil, 'recovery bridge failed: ' .. clean_message(result) end if result.exit_code ~= 0 then + if client.parse_transport_response then + local parsed, transport, rejection = pcall( + client.parse_transport_response, result.lines, + expected or {run_id=run_id}, options.decode_json) + if parsed and rejection then return nil, rejection.message end + end return nil, 'recovery exited with ' .. result.exit_code end local parse_expected = {} @@ -52,9 +58,16 @@ function M.new(dependencies) parse_expected[name] = value end parse_expected.after_sequence = after_sequence - local ok, transport = pcall(client.parse_transport, result.lines, - parse_expected, options.decode_json) + local ok, transport, rejection + if client.parse_transport_response then + ok, transport, rejection = pcall(client.parse_transport_response, + result.lines, parse_expected, options.decode_json) + else + ok, transport = pcall(client.parse_transport, result.lines, + parse_expected, options.decode_json) + end if not ok then return nil, tostring(transport) end + if rejection then return nil, rejection.message end local report = transport.snapshot if not report.terminal then return transport, 'recovery left the run nonterminal' end if report.state == RunState.ABORTED and not report.cleanup_confirmed then diff --git a/src/dwarfspec/controller/execution/runner.lua b/src/dwarfspec/controller/execution/runner.lua index 153a4a5..9be4497 100644 --- a/src/dwarfspec/controller/execution/runner.lua +++ b/src/dwarfspec/controller/execution/runner.lua @@ -82,6 +82,9 @@ end ---@param value any ---@return string local function clean_message(value) + if type(value) == 'table' and type(value.message) == 'string' then + return value.message + end return tostring(value):gsub('^.-:%d+: ', '') end @@ -423,11 +426,11 @@ function M.run(options) event_cursor) if not acknowledge_ok and not runner_error then runner_error = failure(RunnerFailureKind.HOST, - tostring(acknowledge_error)) + clean_message(acknowledge_error)) elseif not acknowledge_ok then runner_error.message = runner_error.message .. '; could not acknowledge terminal result: ' .. - tostring(acknowledge_error) + clean_message(acknowledge_error) end end diff --git a/src/dwarfspec/controller/execution/transport_client.lua b/src/dwarfspec/controller/execution/transport_client.lua index f3fc3f5..9d90873 100644 --- a/src/dwarfspec/controller/execution/transport_client.lua +++ b/src/dwarfspec/controller/execution/transport_client.lua @@ -12,6 +12,48 @@ local MAX_OUTPUT_BYTES = 2048 local LINE_TRUNCATED = '...' local OUTPUT_TRUNCATED = ' ' +---Renders remediation using only validated structured rejection fields. +---@param value table +---@return string +local function operation_rejection_message(value) + local messages = { + service_not_loaded=function() + return ('%s Bootstrap DwarfSpec with a run before retrying %s.') + :format(value.message, value.operation) + end, + run_not_found=function() + return ('%s Run %s is no longer retained; refresh status before retrying %s.') + :format(value.message, value.run_id, value.operation) + end, + generation_mismatch=function() + return ('%s Run %s requested generation %d, current generation %d; refresh status and retry.') + :format(value.message, value.run_id, value.generation, + value.current_generation) + end, + invalid_run_state=function() + return ('%s Run %s generation %d is %s; refresh status and choose an operation valid for that state.') + :format(value.message, value.run_id, value.generation, + value.state) + end, + owner_capability_rejected=function() + return ('%s Run %s generation %d is %s; retry from its owning DwarfSpec process or use an authorized operator command.') + :format(value.message, value.run_id, value.generation, + value.state) + end, + quarantine_mismatch=function() + return ('%s Requested run %s generation %d, but executor quarantine belongs to run %s generation %d; refresh status and recover that exact generation.') + :format(value.message, value.run_id, value.generation, + value.blocking_run_id, value.blocking_generation) + end, + clean_state_unverified=function() + return ('%s Run %s generation %d: %s Resolve remaining live resources, then retry executor recovery.') + :format(value.message, value.run_id, value.generation, + value.reason) + end, + } + return messages[value.code] and messages[value.code]() or value.message +end + ---Converts an arbitrary captured value without allowing tostring errors to escape. ---@param value any ---@return string @@ -208,6 +250,19 @@ function M.new(dependencies) local clean_message = assert(dependencies.clean_message, 'transport error cleaner is required') local client = {} + ---Converts a validated wire rejection into a classified controller error. + ---@param value table + ---@return table + local function controller_rejection(value) + local detail = failure(value.kind, operation_rejection_message(value)) + for name, field_value in pairs(value) do + if name ~= 'message' and name ~= 'kind' then + detail[name] = field_value + end + end + return detail + end + ---Resolves the configured dfhack-run executable. ---@param options table ---@return string|nil, table|nil @@ -302,10 +357,15 @@ function M.new(dependencies) if result.exit_code ~= 0 then local adapter_error = adapter_error_from_result( result, expected, options.decode_json) - if adapter_error then error(adapter_error, 0) end + if adapter_error then + error(controller_rejection(adapter_error), 0) + end error(failure(kinds.HOST, nonzero_message(operation, result)), 0) end - return reports.parse_transport(result.lines, expected, options.decode_json) + local transport, _, adapter_error = reports.parse_transport_response( + result.lines, expected, options.decode_json) + if adapter_error then error(controller_rejection(adapter_error), 0) end + return transport end ---Invokes and parses a bootstrap response that may contain a rejection. @@ -395,6 +455,20 @@ function M.new(dependencies) return reports.parse_transport(lines, expected, decoder) end + ---Parses a raw transport-or-rejection response for recovery workflows. + ---@param lines string[] + ---@param expected table + ---@param decoder function|nil + ---@return table|nil, table|nil + function client.parse_transport_response(lines, expected, decoder) + local transport, _, adapter_error = reports.parse_transport_response( + lines, expected, decoder) + if adapter_error then + return nil, controller_rejection(adapter_error) + end + return transport, nil + end + ---Returns the report authority's event formatter for polling composition. ---@return function function client.event_formatter() diff --git a/src/dwarfspec/host/entrypoints/abort.lua b/src/dwarfspec/host/entrypoints/abort.lua index ce5d80d..48364c0 100644 --- a/src/dwarfspec/host/entrypoints/abort.lua +++ b/src/dwarfspec/host/entrypoints/abort.lua @@ -47,9 +47,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.abort(run_id, owner_capability) -local transport = host.transport(run.run_id, after_sequence) -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.abort(run_id, owner_capability) + return host.transport(run.run_id, after_sequence) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/acknowledge.lua b/src/dwarfspec/host/entrypoints/acknowledge.lua index 1a41b81..40fafab 100644 --- a/src/dwarfspec/host/entrypoints/acknowledge.lua +++ b/src/dwarfspec/host/entrypoints/acknowledge.lua @@ -51,10 +51,14 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.acknowledge(run_id, generation, owner_capability) -local transport = host.transport(run.run_id, after_sequence) -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d ' .. - 'acknowledged=true') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.acknowledge(run_id, generation, owner_capability) + return host.transport(run.run_id, after_sequence) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d ' .. + 'acknowledged=true') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/cancel.lua b/src/dwarfspec/host/entrypoints/cancel.lua index a8da329..dda93a4 100644 --- a/src/dwarfspec/host/entrypoints/cancel.lua +++ b/src/dwarfspec/host/entrypoints/cancel.lua @@ -48,7 +48,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.cancel(run_id, owner_capability, - reason or 'external runner cancellation') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.cancel(run_id, owner_capability, + reason or 'external runner cancellation') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/discard.lua b/src/dwarfspec/host/entrypoints/discard.lua index e77e3e7..12b2fab 100644 --- a/src/dwarfspec/host/entrypoints/discard.lua +++ b/src/dwarfspec/host/entrypoints/discard.lua @@ -43,7 +43,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.discard(run_id, generation, - reason or 'local operator discarded retained result') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.discard(run_id, generation, + reason or 'local operator discarded retained result') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/operation_response.lua b/src/dwarfspec/host/entrypoints/operation_response.lua new file mode 100644 index 0000000..c1d839d --- /dev/null +++ b/src/dwarfspec/host/entrypoints/operation_response.lua @@ -0,0 +1,30 @@ +-- Canonical success-or-rejection emission for mutation adapters. + +local adapter_errors = require('dwarfspec.protocol.adapter_errors') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') + +local M = {} + +---Runs one adapter operation and emits exactly one canonical JSON response. +---@param operation function +---@param emit_success function +---@param encoder function +---@return boolean, any +function M.execute(operation, emit_success, encoder) + local succeeded, value = pcall(operation) + if not succeeded then + if type(value) ~= 'table' or type(value.code) ~= 'string' or + value.code == '' then + error(value, 0) + end + local encoded = adapter_errors.serialize( + value, RunnerFailureKind.HOST, encoder) + print('DWARFSPEC_JSON ' .. encoded) + return false, value + end + emit_success(value) + return true, value +end + +return M diff --git a/src/dwarfspec/host/entrypoints/recover.lua b/src/dwarfspec/host/entrypoints/recover.lua index 593bdc7..b00b75a 100644 --- a/src/dwarfspec/host/entrypoints/recover.lua +++ b/src/dwarfspec/host/entrypoints/recover.lua @@ -48,7 +48,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.recover(run_id, owner_capability, - reason or 'external runner recovery') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.recover(run_id, owner_capability, + reason or 'external runner recovery') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/recover_executor.lua b/src/dwarfspec/host/entrypoints/recover_executor.lua index 89c2a3b..5b41891 100644 --- a/src/dwarfspec/host/entrypoints/recover_executor.lua +++ b/src/dwarfspec/host/entrypoints/recover_executor.lua @@ -50,8 +50,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -host.recover_executor(run_id, generation, - reason or 'local operator verified executor clean state') -local transport = host.transport(run_id, after_sequence) -transport.scheduler = host.scheduler_snapshot() -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + host.recover_executor(run_id, generation, + reason or 'local operator verified executor clean state') + local transport = host.transport(run_id, after_sequence) + transport.scheduler = host.scheduler_snapshot() + return transport +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/execution/host.lua b/src/dwarfspec/host/execution/host.lua index bdf7721..92391c6 100644 --- a/src/dwarfspec/host/execution/host.lua +++ b/src/dwarfspec/host/execution/host.lua @@ -147,9 +147,17 @@ local function validate_run_id(run_id) end ---Returns the compatible process-wide service registry. +---@param operation string|nil ---@return table -local function get_registry() +local function get_registry(operation) local registry = dfhack.dwarfspec + if operation and (type(registry) ~= 'table' or + registry.protocol_version ~= M.protocol_version or + registry.schema ~= service.schema) then + error(adapter_errors.domain('service_not_loaded', + 'The compatible DwarfSpec service is not loaded.', + {operation=operation}), 0) + end assert(type(registry) == 'table' and registry.protocol_version == M.protocol_version and registry.schema == service.schema, @@ -660,9 +668,10 @@ end ---Returns any retained service run by exact identifier. ---@param run_id string +---@param operation string|nil ---@return table|nil -function M.find(run_id) - local registry = get_registry() +function M.find(run_id, operation) + local registry = get_registry(operation) return registry.runs[run_id] end @@ -682,10 +691,16 @@ end ---Observes one retained run without renewing or transferring ownership. ---@param run_id string +---@param operation string|nil ---@return table -function M.observe(run_id) - local run = M.find(run_id) - if not run then error('automation run not found: ' .. run_id) end +function M.observe(run_id, operation) + operation = operation or 'observe' + local run = M.find(run_id, operation) + if not run then + error(adapter_errors.domain('run_not_found', + 'DwarfSpec run was not found.', + {operation=operation, run_id=run_id}), 0) + end return run end @@ -732,7 +747,7 @@ end ---@param owner_capability string ---@return table function M.acknowledge(run_id, generation, owner_capability) - local run = M.observe(run_id) + local run = M.observe(run_id, 'acknowledgement') local request = owner_request(run, owner_capability) request.generation = generation request.persistence = { @@ -750,9 +765,7 @@ end ---@param reason string|nil ---@return table function M.cancel(run_id, owner_capability, reason) - local run = M.observe(run_id) - assert(run.state == RunState.QUEUED, - 'only a queued automation run can be cancelled') + local run = M.observe(run_id, 'cancel') local request = owner_request(run, owner_capability) request.reason = reason or 'by request' service.cancel(request, service_dependencies()) @@ -767,7 +780,7 @@ end ---@param reason string|nil ---@return table function M.recover(run_id, owner_capability, reason) - local run = M.observe(run_id) + local run = M.observe(run_id, 'recover') if M.is_terminal(run) then return run end if run.state == RunState.QUEUED then return M.cancel(run_id, owner_capability, @@ -785,7 +798,7 @@ end ---@param reason string ---@return table function M.discard(run_id, generation, reason) - local run = M.observe(run_id) + local run = M.observe(run_id, 'discard') service.discard({ service_instance_id=run.service_instance_id, project_id=run.project_id, @@ -838,7 +851,7 @@ end ---@param reason string ---@return table function M.recover_executor(run_id, generation, reason) - local registry = get_registry() + local registry = get_registry('recover executor') local outcome = service.recover_executor({ service_instance_id=registry.service_instance_id, run_id=run_id, @@ -855,9 +868,13 @@ end ---@param owner_capability string|nil ---@return table function M.abort(run_id, owner_capability) - local registry = get_registry() + local registry = get_registry('abort') local run = registry.runs[run_id] - if not run then error('automation run not found: ' .. run_id) end + if not run then + error(adapter_errors.domain('run_not_found', + 'DwarfSpec run was not found.', + {operation='abort', run_id=run_id}), 0) + end if M.is_terminal(run) then return run end local reason = 'by request' if run.state == RunState.QUEUED then diff --git a/src/dwarfspec/host/service/scheduler/queue.lua b/src/dwarfspec/host/service/scheduler/queue.lua index a11991f..83be04b 100644 --- a/src/dwarfspec/host/service/scheduler/queue.lua +++ b/src/dwarfspec/host/service/scheduler/queue.lua @@ -77,8 +77,13 @@ function M.cancel(registry, request, context) assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'cancel reason must be a nonempty bounded string') - assert(run.state == RunState.QUEUED and not run.terminal, - 'only a queued run can be cancelled') + if run.state ~= RunState.QUEUED or run.terminal then + validation.reject('invalid_run_state', + 'Only a queued run can be cancelled.', { + operation='cancel', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end return transitions.cancel_queued(registry, run, request.reason, run.owner_kind, validation.current_time(context)) end @@ -86,8 +91,13 @@ end ---Cancels one queued run through operator authority. function M.operator_cancel(registry, request, context) local run = validation.exact_run(registry, request, 'operator cancel') - assert(run.state == RunState.QUEUED and not run.terminal, - 'only a queued run can be force-cancelled') + if run.state ~= RunState.QUEUED or run.terminal then + validation.reject('invalid_run_state', + 'Only a queued run can be force-cancelled.', { + operation='operator cancel', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'operator cancel reason must be a nonempty bounded string') diff --git a/src/dwarfspec/host/service/scheduler/recovery.lua b/src/dwarfspec/host/service/scheduler/recovery.lua index 0139a49..2d41061 100644 --- a/src/dwarfspec/host/service/scheduler/recovery.lua +++ b/src/dwarfspec/host/service/scheduler/recovery.lua @@ -18,23 +18,40 @@ function M.authorize_abort(registry, request) assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'abort reason must be a nonempty bounded string') - assert(registry.active_run_id == run.run_id and ACTIVE[run.state] and - not run.terminal, 'only the active run can be aborted') + if registry.active_run_id ~= run.run_id or not ACTIVE[run.state] or + run.terminal then + validation.reject('invalid_run_state', + 'Only the active run can be aborted.', { + operation='abort', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end return run end ---Clears executor quarantine after authoritative clean-state verification. function M.recover_executor(registry, request, context) - assert(registry.quarantine.active, - 'automation executor is not quarantined') assert(type(request) == 'table', 'executor recovery request must be a table') assert(request.service_instance_id == registry.service_instance_id, 'executor recovery service identity does not match') - assert(request.run_id == registry.quarantine.run_id, - 'executor recovery run identity does not match quarantine') - assert(request.generation == registry.quarantine.generation, - 'executor recovery generation does not match quarantine') + if not registry.quarantine.active then + validation.reject('invalid_run_state', + 'The DwarfSpec executor is not quarantined.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, state='not_quarantined', + }) + end + if request.run_id ~= registry.quarantine.run_id or + request.generation ~= registry.quarantine.generation then + validation.reject('quarantine_mismatch', + 'The requested generation does not own executor quarantine.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, + blocking_run_id=registry.quarantine.run_id, + blocking_generation=registry.quarantine.generation, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'executor recovery reason must be a nonempty bounded string') @@ -44,8 +61,15 @@ function M.recover_executor(registry, request, context) assert(type(context.verify_clean_state) == 'function', 'executor recovery requires an authoritative verifier') local verified, detail = context.verify_clean_state(request.proof) - assert(verified == true, detail or - 'executor clean-state proof was rejected') + if verified ~= true then + validation.reject('clean_state_unverified', + 'Executor clean state could not be verified.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, + reason=validation.safe_reason(detail, + 'clean-state proof was rejected'), + }) + end transitions.clear_quarantine(registry) return {recovered=true} end @@ -53,10 +77,20 @@ end ---Acknowledges one exact owner-retained terminal result after persistence. function M.acknowledge(registry, request, context) local run = validation.authorize_owner(registry, request, 'acknowledgement') - assert(run.terminal and TERMINAL[run.state], - 'only a terminal run can be acknowledged') - assert(run.acknowledged ~= true and run.discarded ~= true, - 'terminal run has already been released') + if not run.terminal or not TERMINAL[run.state] then + validation.reject('invalid_run_state', + 'Only a terminal run can be acknowledged.', { + operation='acknowledgement', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end + if run.acknowledged == true or run.discarded == true then + validation.reject('invalid_run_state', + 'The terminal run has already been released.', { + operation='acknowledgement', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end local persistence = request.persistence assert(type(persistence) == 'table' and persistence.succeeded == true, 'acknowledgement requires successful persistence') @@ -73,10 +107,20 @@ end ---Releases one exact retained terminal result through operator authority. function M.discard(registry, request, context) local run = validation.exact_run(registry, request, 'discard') - assert(run.terminal and TERMINAL[run.state], - 'only a terminal run can be discarded') - assert(run.acknowledged ~= true and run.discarded ~= true, - 'terminal run has already been released') + if not run.terminal or not TERMINAL[run.state] then + validation.reject('invalid_run_state', + 'Only a terminal run can be discarded.', { + operation='discard', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end + if run.acknowledged == true or run.discarded == true then + validation.reject('invalid_run_state', + 'The terminal run has already been released.', { + operation='discard', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'discard reason must be a nonempty bounded string') @@ -93,8 +137,14 @@ end ---Authorizes an operator recovery abort without owner impersonation. function M.authorize_operator_abort(registry, request, context) local run = validation.exact_run(registry, request, 'operator abort') - assert(registry.active_run_id == run.run_id and ACTIVE[run.state] and - not run.terminal, 'only the active run can be force-aborted') + if registry.active_run_id ~= run.run_id or not ACTIVE[run.state] or + run.terminal then + validation.reject('invalid_run_state', + 'Only the active run can be force-aborted.', { + operation='operator abort', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'operator abort reason must be a nonempty bounded string') diff --git a/src/dwarfspec/host/service/scheduler/request_validation.lua b/src/dwarfspec/host/service/scheduler/request_validation.lua index 04d4287..d155f88 100644 --- a/src/dwarfspec/host/service/scheduler/request_validation.lua +++ b/src/dwarfspec/host/service/scheduler/request_validation.lua @@ -4,11 +4,29 @@ local events = require('dwarfspec.protocol.events') local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local projects = require('dwarfspec.host.service.projects') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local M = {} local SUBMISSION_FIELDS = {selection=true, request_key=true, owner_kind=true, queue_lease_ms=true, execution_lease_ms=true, lease_check_frames=true} +---Raises one expected scheduler mutation rejection as a structured value. +---@param code string +---@param message string +---@param fields table +function M.reject(code, message, fields) + error(adapter_errors.domain(code, message, fields), 0) +end + +---Returns one non-empty bounded reason safe for an adapter rejection. +---@param value any +---@param fallback string +---@return string +function M.safe_reason(value, fallback) + if type(value) ~= 'string' or value == '' then value = fallback end + return adapter_errors.safe_message(value) +end + ---Returns one validated monotonic timestamp. function M.current_time(context) local value = context.now_ms() @@ -88,17 +106,30 @@ function M.authorize_owner(registry, request, operation) operation .. ' project id must be a nonempty string') assert(type(request.run_id) == 'string' and request.run_id ~= '', operation .. ' run id must be a nonempty string') - local run = assert(registry.runs[request.run_id], - 'automation run was not found: ' .. tostring(request.run_id)) + local run = registry.runs[request.run_id] + if not run then + M.reject('run_not_found', 'DwarfSpec run was not found.', + {operation=operation, run_id=request.run_id}) + end assert(run.project_id == request.project_id, operation .. ' project identity does not match run') - assert(run.generation == request.generation, - operation .. ' generation does not match run') - assert(type(request.owner_capability) == 'string' and - request.owner_capability ~= '', - operation .. ' owner capability must be a nonempty string') - assert(run.owner_capability == request.owner_capability, - operation .. ' owner capability does not match run') + if run.generation ~= request.generation then + M.reject('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=request.generation, + current_generation=run.generation, + }) + end + if type(request.owner_capability) ~= 'string' or + request.owner_capability == '' or + run.owner_capability ~= request.owner_capability then + M.reject('owner_capability_rejected', + 'The run owner capability was rejected.', { + operation=operation, run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end M.run_identity(registry, run) return run end @@ -108,12 +139,21 @@ function M.exact_run(registry, request, operation) assert(type(request) == 'table', operation .. ' request must be a table') assert(request.service_instance_id == registry.service_instance_id, operation .. ' service identity does not match') - local run = assert(registry.runs[request.run_id], - 'automation run was not found: ' .. tostring(request.run_id)) + local run = registry.runs[request.run_id] + if not run then + M.reject('run_not_found', 'DwarfSpec run was not found.', + {operation=operation, run_id=request.run_id}) + end assert(run.project_id == request.project_id, operation .. ' project identity does not match run') - assert(run.generation == request.generation, - operation .. ' generation does not match run') + if run.generation ~= request.generation then + M.reject('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=request.generation, + current_generation=run.generation, + }) + end M.run_identity(registry, run) return run end diff --git a/src/dwarfspec/protocol/adapter_errors.lua b/src/dwarfspec/protocol/adapter_errors.lua index debcc4e..c2c881e 100644 --- a/src/dwarfspec/protocol/adapter_errors.lua +++ b/src/dwarfspec/protocol/adapter_errors.lua @@ -18,6 +18,8 @@ local COMMON_FIELDS = { state='string', blocking_run_id='string', blocking_generation='positive_integer', + current_generation='positive_integer', + reason='string', } local FORBIDDEN_FIELDS = { @@ -63,6 +65,26 @@ local KNOWN_CODES = { reason='string', }, }, + service_not_loaded={kind=RunnerFailureKind.HOST, + required={operation='string'}}, + run_not_found={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string'}}, + generation_mismatch={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', current_generation='positive_integer'}}, + invalid_run_state={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string'}}, + owner_capability_rejected={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string'}}, + quarantine_mismatch={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', blocking_run_id='string', + blocking_generation='positive_integer'}}, + clean_state_unverified={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', reason='string'}}, } local APPROVED_KINDS = { diff --git a/tests/unit/controller/execution/run_recovery_spec.lua b/tests/unit/controller/execution/run_recovery_spec.lua index 2b409d1..d466707 100644 --- a/tests/unit/controller/execution/run_recovery_spec.lua +++ b/tests/unit/controller/execution/run_recovery_spec.lua @@ -39,6 +39,12 @@ local function fixture(behavior) if behavior.parse_error then error(behavior.parse_error) end return behavior.recovery_transport end, + parse_transport_response=function(_, expected) + record.parse_expected=expected + if behavior.parse_error then error(behavior.parse_error) end + if behavior.rejection then return nil, behavior.rejection end + return behavior.recovery_transport, nil + end, transport=function(_, _, arguments, expected, operation) record.transport={arguments=arguments, expected=expected, operation=operation} @@ -109,6 +115,28 @@ describe('controller run recovery', function() original.message) end) + it('appends a structured recovery rejection without replacing the primary', + function() + for _, code in ipairs({ + 'service_not_loaded', 'run_not_found', 'generation_mismatch', + 'invalid_run_state', 'owner_capability_rejected', + 'quarantine_mismatch', 'clean_state_unverified', + }) do + local rejection_message = 'structured ' .. code .. ' guidance' + local service = fixture({rejection={kind='host', code=code, + message=rejection_message}}) + local _, detail = service.after_failure({}, 'runner', 'run-1', + 'secret-owner', {run_id='run-1', generation=2}, 4) + local original = {kind='timeout', exit_code=7, + message='original timeout'} + service.preserve_error(original, detail) + assert.equals('timeout', original.kind) + assert.equals(7, original.exit_code) + assert.equals('original timeout; recovery failed: ' .. + rejection_message, original.message) + end + end) + it('classifies queued and active explicit abort cleanup outcomes', function() local service = fixture({transport={snapshot={state=RunState.CANCELLED}, events={'cancelled'}}}) diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index 95af3dd..d7ac6c4 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -1610,4 +1610,85 @@ describe('DwarfSpec external runner', function() }) assert.is_false(outcome.scheduler.quarantine.active) end) + + it('returns exit 5 for structured direct mutation rejections', function() + local cases = { + { + name='abort', + invoke=function(run_options) + return runner.abort(run_options, 'direct-run') + end, + response={code='invalid_run_state', operation='abort', + run_id='direct-run', generation=2, state='passed'}, + expected='is passed', + }, + { + name='recover-executor', + invoke=function(run_options) + return runner.recover_executor(run_options, + 'direct-run', 2, 'verified clean') + end, + response={code='clean_state_unverified', + operation='recover executor', run_id='direct-run', + generation=2, reason='owned screen remains active'}, + expected='Resolve remaining live resources', + }, + } + for _, case in ipairs(cases) do + local run_options = options('direct-' .. case.name) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + end + local response = {schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + message='structured direct rejection'} + for name, value in pairs(case.response) do + response[name] = value + end + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(response), + }} + end + local outcome = case.invoke(run_options) + assert.equals(5, outcome.exit_code, case.name) + assert.equals(runner.failure_kinds.HOST, outcome.error.kind) + assert.equals(case.response.code, outcome.error.code) + assert.matches(case.expected, outcome.error.message, 1, true) + end + end) + + it('appends structured acknowledgement detail to the original failure', + function() + local run_options = options('acknowledgement-secondary') + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.STARTING, false)} + elseif arguments[3]:match('status%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.FAILED, true)} + end + assert.matches('acknowledge%.lua$', arguments[3]) + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='owner_capability_rejected', + message='owner rejected', operation='acknowledgement', + run_id=run_options.run_id, generation=1, state='failed', + })}} + end + local outcome = runner.run(run_options) + assert.equals(runner.failure_kinds.TEST, outcome.error.kind) + assert.equals(6, outcome.exit_code) + assert.matches('could not acknowledge terminal result:', + outcome.error.message, 1, true) + assert.matches('owning DwarfSpec process', outcome.error.message, + 1, true) + assert.is_falsy(outcome.error.message:find('table:', 1, true)) + end) end) diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index ec1757f..756040c 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -290,6 +290,64 @@ describe('controller transport client', function() assert.equals('package_version_mismatch', bootstrap_error.code) end) + it('classifies zero-exit mutation rejections with actionable safe context', + function() + local cases = { + {code='service_not_loaded', operation='abort', + guidance='Bootstrap DwarfSpec'}, + {code='run_not_found', operation='cancel', run_id='run-1', + guidance='refresh status before retrying cancel'}, + {code='generation_mismatch', operation='acknowledgement', + run_id='run-1', generation=2, current_generation=3, + guidance='requested generation 2, current generation 3'}, + {code='invalid_run_state', operation='discard', run_id='run-1', + generation=3, state='running', guidance='is running'}, + {code='owner_capability_rejected', operation='recover', + run_id='run-1', generation=3, state='running', + guidance='owning DwarfSpec process'}, + {code='quarantine_mismatch', operation='recover executor', + run_id='run-1', generation=3, blocking_run_id='run-2', + blocking_generation=4, + guidance='belongs to run run-2 generation 4'}, + {code='clean_state_unverified', operation='recover executor', + run_id='run-1', generation=3, reason='cleanup remains active', + guidance='Resolve remaining live resources'}, + } + for _, case in ipairs(cases) do + local response = {schema='dwarfspec.error.v1', protocol=2, + kind='host', message='opaque host prose'} + for name, value in pairs(case) do + if name ~= 'guidance' then response[name] = value end + end + local transport = client() + local ok, detail = pcall(transport.transport, { + invoke=function() + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(response), + }} + end, + }, 'runner', {}, {}, case.operation) + assert.is_false(ok, case.code) + assert.equals(case.code, detail.code) + assert.equals('host', detail.kind) + assert.equals(9, detail.exit_code) + assert.is_truthy(detail.message:find('opaque host prose', 1, true)) + assert.is_nil(detail.owner_capability) + assert.is_nil(detail.authorization_proof) + assert.is_truthy(detail.message:find(case.guidance, 1, true), + case.code) + for name, value in pairs(case) do + if name ~= 'guidance' then + assert.equals(value, detail[name], case.code .. '.' .. name) + end + end + if case.run_id then + assert.is_truthy(detail.message:find(case.run_id, 1, true), + case.code) + end + end + end) + it('classifies nonzero exits without valid JSON using bounded output', function() local transport = client() local ok, detail = pcall(transport.transport, { diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index f8ef474..6c3c281 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -110,6 +110,46 @@ describe('version 2 automation entrypoint contract', function() assert.is_nil(dfhack.dwarfspec) end) + it('emits one structured rejection from every unloaded mutation adapter', + function() + local cases = { + {name='abort', arguments={'missing-run', ''}}, + {name='cancel', arguments={'missing-run', 'owner-secret', '0'}}, + {name='recover', arguments={'missing-run', 'owner-secret', '0'}}, + {name='acknowledge', arguments={ + 'missing-run', '1', 'owner-secret', '0'}}, + {name='discard', arguments={'missing-run', '1', '0'}}, + {name='recover_executor', arguments={'missing-run', '1', '0'}}, + } + for _, case in ipairs(cases) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines, case.name) + local rejection = encoded[#encoded] + assert.equals('dwarfspec.error.v1', rejection.schema) + assert.equals('host', rejection.kind) + assert.equals('service_not_loaded', rejection.code) + assert.is_nil(rejection.owner_capability) + assert.is_nil(rejection.authorization_proof) + assert.is_falsy(rejection.message:find('owner-secret', 1, true)) + end + assert.is_nil(dfhack.dwarfspec) + end) + + it('keeps unexpected adapter faults on the subprocess failure path', + function() + local response = + require('dwarfspec.host.entrypoints.operation_response') + local ok, detail = pcall(response.execute, + function() error('unexpected invariant failure', 0) end, + function() error('success must not be emitted') end, + require('json').encode) + assert.is_false(ok) + assert.equals('unexpected invariant failure', detail) + assert.same({}, lines) + assert.same({}, encoded) + end) + it('preserves generic string bootstrap rejections', function() load_host_script('bootstrap')( 'entrypoint-generic-rejection', '--unknown=value') @@ -391,7 +431,19 @@ describe('version 2 automation entrypoint contract', function() assert.equals('dwarfspec.transport.v2', encoded[#encoded].schema) - dfhack.dwarfspec.quarantine = {active=false} + dfhack.dwarfspec.quarantine = { + active=true, run_id=active.run_id, + generation=active.generation, reason='fixture quarantine', + } + lines = {} + load_host_script('recover_executor')( + active.run_id, tostring(active.generation), + tostring(#active.event_journal.events), 'fixture verified clean') + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('dwarfspec.transport.v2', encoded[#encoded].schema) + assert.is_false(encoded[#encoded].scheduler.quarantine.active) + assert.is_false(dfhack.dwarfspec.quarantine.active) + lines = {} load_host_script('scheduler_status')() assert.equals('DWARFSPEC_JSON {"encoded":true}', lines[1]) diff --git a/tests/unit/host/execution/host_spec.lua b/tests/unit/host/execution/host_spec.lua index d188fd9..1a93b07 100644 --- a/tests/unit/host/execution/host_spec.lua +++ b/tests/unit/host/execution/host_spec.lua @@ -407,10 +407,13 @@ describe('automation host ownership', function() assert.equals(1, aborted.mount_cleanup_state.active_screen_count) assert.matches('mount lifecycle verification failed', aborted.failure_details[1].message, 1, true) - assert.has_error(function() + local recovered, rejection = pcall(function() host.recover_executor(aborted.run_id, aborted.generation, 'unsafe fixture recovery') - end, 'quarantined mount state is not clean') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('quarantined mount state is not clean', rejection.reason) end) it('refuses cleanup confirmation for retained ownership evidence', @@ -434,10 +437,13 @@ describe('automation host ownership', function() assert.is_false(aborted.cleanup_confirmed) assert.is_false(aborted.mount_cleanup_state.verified) - assert.has_error(function() + local recovered, rejection = pcall(function() host.recover_executor(aborted.run_id, aborted.generation, 'unsafe retained ownership recovery') - end, 'quarantined mount state is not clean') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('quarantined mount state is not clean', rejection.reason) end) it('never confirms cleanup after an earlier cleanup action failed', diff --git a/tests/unit/host/service/scheduler/recovery_spec.lua b/tests/unit/host/service/scheduler/recovery_spec.lua index 1f097b2..a9ee632 100644 --- a/tests/unit/host/service/scheduler/recovery_spec.lua +++ b/tests/unit/host/service/scheduler/recovery_spec.lua @@ -30,11 +30,34 @@ describe('scheduler recovery policy', function() local dependencies, controls = support.environment() controls.registry.quarantine = {active=true, run_id='failed-proof', generation=3, reason='unclean'} - assert.has_error(function() recovery.recover_executor(controls.registry, + local ok, rejection = pcall(function() + recovery.recover_executor(controls.registry, {service_instance_id=controls.registry.service_instance_id, run_id='failed-proof', generation=3, - reason='not clean', proof={clean=false}}, dependencies) end, - 'clean-state proof rejected') + reason='not clean', proof={clean=false}}, dependencies) + end) + assert.is_false(ok) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('clean-state proof rejected', rejection.reason) + assert.is_true(controls.registry.quarantine.active) + end) + + it('normalizes empty verifier detail without clearing quarantine', function() + local dependencies, controls = support.environment() + controls.registry.quarantine = {active=true, + run_id='empty-detail', generation=4, reason='unclean'} + dependencies.verify_clean_state = function() + return false, '' + end + local ok, rejection = pcall(recovery.recover_executor, + controls.registry, { + service_instance_id=controls.registry.service_instance_id, + run_id='empty-detail', generation=4, + reason='not clean', proof={clean=false}, + }, dependencies) + assert.is_false(ok) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('clean-state proof was rejected', rejection.reason) assert.is_true(controls.registry.quarantine.active) end) diff --git a/tests/unit/host/service/service_scheduler_spec.lua b/tests/unit/host/service/service_scheduler_spec.lua index 051a515..0bd02da 100644 --- a/tests/unit/host/service/service_scheduler_spec.lua +++ b/tests/unit/host/service/service_scheduler_spec.lua @@ -255,6 +255,73 @@ describe('multi-project automation service scheduler', function() renewed.execution_lease.expires_at_ms) end) + it('returns structured mutation rejections without changing service state', + function() + local dependencies = environment() + local project = register_project(dependencies, 1) + local admitted = service.submit(project.project_id, + submission('structured-mutation'), dependencies) + + ---Captures and verifies one rejected operation atomically. + ---@param expected_code string + ---@param operation function + ---@return table + local function rejected(expected_code, operation) + local scheduler_before = service.scheduler_snapshot(dependencies) + local run_before = service.snapshot(admitted.identity.run_id, + dependencies) + local ok, detail = pcall(operation) + assert.is_false(ok) + assert.equals(expected_code, detail.code) + assert.same(scheduler_before, + service.scheduler_snapshot(dependencies)) + assert.same(run_before, service.snapshot( + admitted.identity.run_id, dependencies)) + assert.is_nil(detail.owner_capability) + assert.is_nil(detail.authorization_proof) + return detail + end + + local missing = owner_request(admitted, {run_id='missing-run'}) + missing.reason = 'cancel' + rejected('run_not_found', function() + service.cancel(missing, dependencies) + end) + local stale = owner_request(admitted, { + generation=admitted.identity.generation + 1, reason='cancel', + }) + rejected('generation_mismatch', function() + service.cancel(stale, dependencies) + end) + local unauthorized = owner_request(admitted, { + owner_capability='wrong-owner-capability-00000000001', + reason='cancel', + }) + rejected('owner_capability_rejected', function() + service.cancel(unauthorized, dependencies) + end) + + service.activate_next(dependencies) + local active_request = owner_request(admitted, {reason='cancel'}) + local invalid = rejected('invalid_run_state', function() + service.cancel(active_request, dependencies) + end) + assert.equals(RunState.STARTING, invalid.state) + + local cleanup_called = false + dependencies.abort_active = function() + cleanup_called = true + end + local rejected_abort = owner_request(admitted, { + owner_capability='wrong-owner-capability-00000000001', + reason='abort', + }) + rejected('owner_capability_rejected', function() + service.abort(rejected_abort, dependencies) + end) + assert.is_false(cleanup_called) + end) + it('expires queued owners without cleanup and blocks only their project', function() local dependencies, clock = environment() @@ -706,7 +773,8 @@ describe('multi-project automation service scheduler', function() assert.equals(1, service.snapshot(third.identity.run_id, dependencies).queue_position) - assert.has_error(function() + local before_recovery = service.scheduler_snapshot(dependencies) + local recovered, rejection = pcall(function() service.recover_executor({ service_instance_id=scheduler.service_instance_id, run_id=scheduler.quarantine.run_id, @@ -714,8 +782,11 @@ describe('multi-project automation service scheduler', function() reason='stale recovery', proof={clean=true}, }, dependencies) - end, 'executor recovery generation does not match quarantine') - assert.has_error(function() + end) + assert.is_false(recovered) + assert.equals('quarantine_mismatch', rejection.code) + assert.same(before_recovery, service.scheduler_snapshot(dependencies)) + recovered, rejection = pcall(function() service.recover_executor({ service_instance_id=scheduler.service_instance_id, run_id=scheduler.quarantine.run_id, @@ -723,7 +794,11 @@ describe('multi-project automation service scheduler', function() reason='unverified recovery', proof={clean=true}, }, dependencies) - end, 'fixture clean-state proof was rejected') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('fixture clean-state proof was rejected', rejection.reason) + assert.same(before_recovery, service.scheduler_snapshot(dependencies)) assert.is_true(service.scheduler_snapshot( dependencies).quarantine.active) diff --git a/tests/unit/protocol/adapter_errors_spec.lua b/tests/unit/protocol/adapter_errors_spec.lua index a807c01..646a12a 100644 --- a/tests/unit/protocol/adapter_errors_spec.lua +++ b/tests/unit/protocol/adapter_errors_spec.lua @@ -100,6 +100,41 @@ describe('adapter error protocol', function() end end) + it('validates every mutation rejection subtype and safe required fields', + function() + local cases = { + service_not_loaded={operation='abort'}, + run_not_found={operation='cancel', run_id='run-1'}, + generation_mismatch={operation='acknowledgement', run_id='run-1', + generation=2, current_generation=3}, + invalid_run_state={operation='discard', run_id='run-1', + generation=3, state='running'}, + owner_capability_rejected={operation='recover', run_id='run-1', + generation=3, state='running'}, + quarantine_mismatch={operation='recover executor', run_id='run-1', + generation=3, blocking_run_id='run-2', blocking_generation=4}, + clean_state_unverified={operation='recover executor', run_id='run-1', + generation=3, reason='cleanup remains active'}, + } + for code, fields in pairs(cases) do + local rejection = adapter_errors.domain(code, 'rejected', fields) + local envelope = adapter_errors.envelope(rejection, + RunnerFailureKind.HOST) + assert.equals(RunnerFailureKind.HOST, envelope.kind) + assert.equals(code, envelope.code) + assert.is_nil(envelope.owner_capability) + assert.is_nil(envelope.authorization_proof) + for name in pairs(fields) do + local incomplete = {} + for field, value in pairs(fields) do incomplete[field] = value end + incomplete[name] = nil + assert.has_error(function() + adapter_errors.domain(code, 'rejected', incomplete) + end) + end + end + end) + it('rejects non-JSON-safe and forbidden domain fields', function() for _, fields in ipairs({ {operation=function() end}, From a852d58271309f06c92cc48ed0abe6034dfe520f Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 04:10:03 -0700 Subject: [PATCH 16/18] [Phase 7]: Structure polling and event transport rejections --- docs/package-version-mismatch-rejection.todo | 38 ++++++------- docs/test-runner-service-design.md | 26 +++++++++ .../controller/execution/command_builder.lua | 12 +++- .../controller/execution/run_poller.lua | 3 +- .../controller/execution/transport_client.lua | 5 ++ src/dwarfspec/host/entrypoints/event_read.lua | 12 +++- .../host/entrypoints/scheduler_status.lua | 16 ++++-- src/dwarfspec/host/entrypoints/status.lua | 21 ++++--- src/dwarfspec/host/execution/host.lua | 57 +++++++++++++++++-- src/dwarfspec/protocol/adapter_errors.lua | 23 ++++++++ .../execution/command_builder_spec.lua | 2 + .../controller/execution/run_poller_spec.lua | 45 ++++++++++++--- .../execution/run_recovery_spec.lua | 1 + .../unit/controller/execution/runner_spec.lua | 43 ++++++++++++++ .../execution/transport_client_spec.lua | 4 ++ .../entrypoints/entrypoint_contract_spec.lua | 48 ++++++++++++++++ tests/unit/host/execution/host_spec.lua | 50 ++++++++++++++++ tests/unit/protocol/adapter_errors_spec.lua | 16 ++++++ 18 files changed, 371 insertions(+), 51 deletions(-) diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index 7fdb10a..e660a38 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -427,57 +427,57 @@ Completion criteria: requirement, or error precedence changes. Phase 7: Structure polling and event transport rejections - ☐ Exit with status polling, event reading, and run-specific scheduler + ☒ Exit with status polling, event reading, and run-specific scheduler transport preserving expected stale-state details while retaining existing read-only response schemas. 7.1 Polling and event subtype contracts: - ☐ Map expected missing-service, missing-run, stale-generation, + ☒ Map expected missing-service, missing-run, stale-generation, capability, cursor, and invalid-state conditions to the shared codes or define narrowly scoped additional codes when their remediation differs. - ☐ Distinguish an operator-addressable stale run from malformed transport, + ☒ Distinguish an operator-addressable stale run from malformed transport, corrupt registry state, impossible event-journal state, or an unexpected host exception. - ☐ Define which polling rejections remain primary host failures and which + ☒ Define which polling rejections remain primary host failures and which trigger the runner's existing state-aware recovery path. 7.2 Entrypoint adoption: - ☐ Replace `status.lua`'s modeled-domain `qerror` path with canonical + ☒ Replace `status.lua`'s modeled-domain `qerror` path with canonical structured serialization while retaining an internal-fault fallback. - ☐ Adopt the shared serializer for modeled failures in `event_read.lua` + ☒ Adopt the shared serializer for modeled failures in `event_read.lua` and the run-specific branch of `scheduler_status.lua`. - ☐ Ensure status and event adapters emit exactly one canonical JSON + ☒ Ensure status and event adapters emit exactly one canonical JSON response and do not emit a partial success envelope before a failure. - ☐ Keep the service-wide `dwarfspec.status.v1` response and its + ☒ Keep the service-wide `dwarfspec.status.v1` response and its `service_loaded` state unchanged. - ☐ Keep `history`, `show`, and `logs` using their existing + ☒ Keep `history`, `show`, and `logs` using their existing `service_loaded` and `found` fields rather than converting normal absence into adapter errors. 7.3 Controller behavior: - ☐ Parse validated error envelopes from poll, event-read, and run-status + ☒ Parse validated error envelopes from poll, event-read, and run-status operations before applying generic subprocess failure handling. - ☐ Preserve safe structured context in primary and recovery diagnostics. - ☐ Preserve retry, timeout, event-cursor advancement, lease renewal, + ☒ Preserve safe structured context in primary and recovery diagnostics. + ☒ Preserve retry, timeout, event-cursor advancement, lease renewal, acknowledgement, and state-aware recovery behavior. - ☐ Ensure a rejected or malformed poll never advances the event cursor or + ☒ Ensure a rejected or malformed poll never advances the event cursor or fabricates a newer transport generation. 7.4 Polling and event tests: - ☐ Add host and entrypoint tests for every modeled polling or event + ☒ Add host and entrypoint tests for every modeled polling or event rejection, exactly-one-response behavior, and no lease or cursor mutation. - ☐ Add transport-client and runner tests proving structured details are + ☒ Add transport-client and runner tests proving structured details are retained for direct polling failures and secondary recovery failures. - ☐ Retain healthy polling, unrelated-output, terminal observation, + ☒ Retain healthy polling, unrelated-output, terminal observation, service-unloaded status, missing read-only run, and malformed transport coverage. - ☐ Verify unexpected internal failures remain distinguishable and include + ☒ Verify unexpected internal failures remain distinguishable and include bounded captured output rather than being assigned a domain code. Completion criteria: - ☐ Every approved polling and event rejection retains safe structured + ☒ Every approved polling and event rejection retains safe structured context across the host/controller boundary. - ☐ Existing cursor, lease, retry, timeout, recovery, and read-only query + ☒ Existing cursor, lease, retry, timeout, recovery, and read-only query semantics remain unchanged. Phase 8: Lock down documentation, regression, and package integrity diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index b9e1511..c080422 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -1024,6 +1024,32 @@ test failure. Rejected service decisions complete before lease, journal, ownership, quarantine, result-retention, or native-cleanup mutation. Owner capabilities and authorization proofs never cross the error boundary. +Polling and event adapters also use the shared envelope without changing the +healthy transport or read-only query schemas. `status`, `event_read`, and the +run-specific scheduler-status branch report `service_not_loaded`, +`run_not_found`, `generation_mismatch`, and +`owner_capability_rejected` where applicable. A cursor beyond the retained +journal reports `event_cursor_ahead` with the run identifier, generation, +state, requested `after_sequence`, and retained `last_sequence`. + +Expected generation and cursor validation happens before status polling renews +the owner lease. Therefore a rejected poll cannot advance a lease, event +cursor, journal, observation, or persisted report. Malformed cursors, corrupt +event journals, impossible run states, and unexpected host exceptions remain +uncoded internal failures. Terminal observation remains successful, including +its existing no-renewal behavior. The service-wide `dwarfspec.status.v1` +response continues to expose `service_loaded`; history, inspection, and logs +continue to represent normal absence with `service_loaded` and `found` rather +than adapter errors. + +The controller validates polling envelopes before generic subprocess handling +and selects remediation from subtype fields. A primary polling rejection still +uses the existing host-failure and state-aware recovery path. Any structured +recovery rejection is appended without replacing that primary failure. +Transports are consumed only after validation, preserving retry, timeout, +cursor advancement, lease renewal, cleanup confirmation, and acknowledgement +semantics. + Adapters may emit a valid error envelope with either a zero or nonzero process exit during migration. The controller inspects and validates the envelope before interpreting the process exit. A valid structured rejection is retained; diff --git a/src/dwarfspec/controller/execution/command_builder.lua b/src/dwarfspec/controller/execution/command_builder.lua index a79d1f6..65c996b 100644 --- a/src/dwarfspec/controller/execution/command_builder.lua +++ b/src/dwarfspec/controller/execution/command_builder.lua @@ -123,10 +123,16 @@ function M.new(dependencies) ---@param run_id string ---@param owner_capability string ---@param after_sequence integer + ---@param generation integer|nil ---@return string[] - function builder.poll(options, run_id, owner_capability, after_sequence) - return {'lua', '-f', builder.host_script(options, 'status'), run_id, - owner_capability, tostring(after_sequence)} + function builder.poll(options, run_id, owner_capability, after_sequence, + generation) + local arguments = {'lua', '-f', builder.host_script(options, 'status'), + run_id, owner_capability, tostring(after_sequence)} + if generation ~= nil then + table.insert(arguments, tostring(generation)) + end + return arguments end ---Builds a scheduler-status command vector. diff --git a/src/dwarfspec/controller/execution/run_poller.lua b/src/dwarfspec/controller/execution/run_poller.lua index 9faddfe..9bd6e82 100644 --- a/src/dwarfspec/controller/execution/run_poller.lua +++ b/src/dwarfspec/controller/execution/run_poller.lua @@ -83,7 +83,8 @@ function M.new(dependencies) local expected = scope.expectation(cursor) local transport = client.transport(scope.options, scope.runner, builder.poll(scope.options, scope.run_id, - scope.owner_capability, cursor), expected, 'status') + scope.owner_capability, cursor, expected.generation), + expected, 'status') scope.execution_started_at = execution_started_at local consumed = poller.consume(scope, transport, true) report = consumed.report diff --git a/src/dwarfspec/controller/execution/transport_client.lua b/src/dwarfspec/controller/execution/transport_client.lua index 9d90873..baf4913 100644 --- a/src/dwarfspec/controller/execution/transport_client.lua +++ b/src/dwarfspec/controller/execution/transport_client.lua @@ -50,6 +50,11 @@ local function operation_rejection_message(value) :format(value.message, value.run_id, value.generation, value.reason) end, + event_cursor_ahead=function() + return ('%s Run %s generation %d is %s; requested cursor %d, retained cursor %d. Restart observation from the retained cursor without advancing local state.') + :format(value.message, value.run_id, value.generation, + value.state, value.after_sequence, value.last_sequence) + end, } return messages[value.code] and messages[value.code]() or value.message end diff --git a/src/dwarfspec/host/entrypoints/event_read.lua b/src/dwarfspec/host/entrypoints/event_read.lua index 4738a7b..ad669e1 100644 --- a/src/dwarfspec/host/entrypoints/event_read.lua +++ b/src/dwarfspec/host/entrypoints/event_read.lua @@ -1,9 +1,11 @@ -- Production adapter that reads events without renewing a run lease. -local run_id, after_sequence_text = ... +local run_id, after_sequence_text, generation_text = ... assert(run_id, 'run id argument is required') local after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -41,5 +43,9 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local transport = host.transport(run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + return host.transport(run_id, after_sequence, 'event read', generation) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/scheduler_status.lua b/src/dwarfspec/host/entrypoints/scheduler_status.lua index fbad396..440335d 100644 --- a/src/dwarfspec/host/entrypoints/scheduler_status.lua +++ b/src/dwarfspec/host/entrypoints/scheduler_status.lua @@ -1,11 +1,13 @@ -- Production adapter for scheduler state and retained-run transport. -local run_id, after_sequence_text = ... +local run_id, after_sequence_text, generation_text = ... local after_sequence if run_id ~= nil then after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') end +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -53,7 +55,13 @@ if run_id == nil then scheduler=loaded and host.scheduler_snapshot() or nil, })) else - local transport = host.transport(run_id, after_sequence) - transport.scheduler = host.scheduler_snapshot() - print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) + local response = require('dwarfspec.host.entrypoints.operation_response') + response.execute(function() + local transport = host.transport(run_id, after_sequence, + 'run scheduler status', generation) + transport.scheduler = host.scheduler_snapshot() + return transport + end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) + end, require('json').encode) end diff --git a/src/dwarfspec/host/entrypoints/status.lua b/src/dwarfspec/host/entrypoints/status.lua index e4ef968..1d92026 100644 --- a/src/dwarfspec/host/entrypoints/status.lua +++ b/src/dwarfspec/host/entrypoints/status.lua @@ -1,10 +1,12 @@ -- Production adapter that polls a run through cursor-based transport. -local run_id, owner_capability, after_sequence_text = ... +local run_id, owner_capability, after_sequence_text, generation_text = ... assert(run_id, 'run id argument is required') assert(owner_capability, 'owner capability argument is required') local after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua module lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -48,10 +50,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local poll_ok, transport = pcall(host.poll_transport, run_id, - owner_capability, after_sequence) -if not poll_ok then qerror(transport) end -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + return host.poll_transport(run_id, owner_capability, after_sequence, + generation) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/execution/host.lua b/src/dwarfspec/host/execution/host.lua index 92391c6..fbc942a 100644 --- a/src/dwarfspec/host/execution/host.lua +++ b/src/dwarfspec/host/execution/host.lua @@ -704,12 +704,47 @@ function M.observe(run_id, operation) return run end +---Validates expected polling identity and cursor before any lease mutation. +---@param run table +---@param operation string +---@param after_sequence integer|nil +---@param expected_generation integer|nil +local function validate_transport_request(run, operation, after_sequence, + expected_generation) + if expected_generation ~= nil and + expected_generation ~= run.generation then + error(adapter_errors.domain('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=expected_generation, + current_generation=run.generation, + }), 0) + end + if after_sequence == nil then return end + assert(type(after_sequence) == 'number' and after_sequence >= 0 and + after_sequence % 1 == 0, + 'event cursor must be a nonnegative integer') + events.validate_journal(run.event_journal) + local last_sequence = #run.event_journal.events + if after_sequence > last_sequence then + error(adapter_errors.domain('event_cursor_ahead', + 'The requested event cursor is ahead of the retained journal.', { + operation=operation, run_id=run.run_id, + generation=run.generation, state=run.state, + after_sequence=after_sequence, + last_sequence=last_sequence, + }), 0) + end +end + ---Renews an owned nonterminal run and returns its current state. ---@param run_id string ---@param owner_capability string +---@param expected_generation integer|nil ---@return table -function M.poll(run_id, owner_capability) - local run = M.observe(run_id) +function M.poll(run_id, owner_capability, expected_generation) + local run = M.observe(run_id, 'status poll') + validate_transport_request(run, 'status poll', nil, expected_generation) assert(type(owner_capability) == 'string' and owner_capability ~= '', 'status poll requires the owner capability') if not M.is_terminal(run) then @@ -726,8 +761,13 @@ end ---Returns canonical transport data after one event sequence cursor. ---@param run_id string ---@param after_sequence integer +---@param operation string|nil +---@param expected_generation integer|nil ---@return table -function M.transport(run_id, after_sequence) +function M.transport(run_id, after_sequence, operation, expected_generation) + local label = operation or 'event read' + local run = M.observe(run_id, label) + validate_transport_request(run, label, after_sequence, expected_generation) return service.transport(run_id, after_sequence, service_dependencies()) end @@ -735,10 +775,15 @@ end ---@param run_id string ---@param owner_capability string ---@param after_sequence integer +---@param expected_generation integer|nil ---@return table -function M.poll_transport(run_id, owner_capability, after_sequence) - M.poll(run_id, owner_capability) - return M.transport(run_id, after_sequence) +function M.poll_transport(run_id, owner_capability, after_sequence, + expected_generation) + local run = M.observe(run_id, 'status poll') + validate_transport_request(run, 'status poll', after_sequence, + expected_generation) + M.poll(run_id, owner_capability, expected_generation) + return service.transport(run_id, after_sequence, service_dependencies()) end ---Acknowledges successful persistence for one exact terminal owner. diff --git a/src/dwarfspec/protocol/adapter_errors.lua b/src/dwarfspec/protocol/adapter_errors.lua index c2c881e..d15eb85 100644 --- a/src/dwarfspec/protocol/adapter_errors.lua +++ b/src/dwarfspec/protocol/adapter_errors.lua @@ -20,6 +20,8 @@ local COMMON_FIELDS = { blocking_generation='positive_integer', current_generation='positive_integer', reason='string', + after_sequence='nonnegative_integer', + last_sequence='nonnegative_integer', } local FORBIDDEN_FIELDS = { @@ -85,6 +87,11 @@ local KNOWN_CODES = { clean_state_unverified={kind=RunnerFailureKind.HOST, required={operation='string', run_id='string', generation='positive_integer', reason='string'}}, + event_cursor_ahead={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string', + after_sequence='nonnegative_integer', + last_sequence='nonnegative_integer'}}, } local APPROVED_KINDS = { @@ -100,6 +107,13 @@ local function is_positive_integer(value) return type(value) == 'number' and value > 0 and value % 1 == 0 end +---Returns whether a value is a nonnegative integer. +---@param value any +---@return boolean +local function is_nonnegative_integer(value) + return type(value) == 'number' and value >= 0 and value % 1 == 0 +end + ---Validates one field against its public adapter-error type. ---@param value any ---@param field_type string @@ -109,6 +123,10 @@ local function validate_field(value, field_type, field_name) assert(is_positive_integer(value), 'DwarfSpec adapter error field ' .. field_name .. ' must be a positive integer') + elseif field_type == 'nonnegative_integer' then + assert(is_nonnegative_integer(value), + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a nonnegative integer') else assert(type(value) == field_type and value ~= '', 'DwarfSpec adapter error field ' .. field_name .. @@ -253,6 +271,11 @@ function M.validate(response) end allowed[name] = true end + if response.code == 'event_cursor_ahead' then + assert(response.after_sequence > response.last_sequence, + 'DwarfSpec event cursor rejection requires requested cursor ' .. + 'to be ahead of retained cursor') + end elseif response.kind == RunnerFailureKind.EXECUTOR_QUARANTINED and response.code == nil then assert(type(response.blocking_run_id) == 'string' and diff --git a/tests/unit/controller/execution/command_builder_spec.lua b/tests/unit/controller/execution/command_builder_spec.lua index 8b49b18..2deea65 100644 --- a/tests/unit/controller/execution/command_builder_spec.lua +++ b/tests/unit/controller/execution/command_builder_spec.lua @@ -36,6 +36,8 @@ describe('controller command builder', function() }, builder.bootstrap(value, 'run')) assert.same({'lua', '-f', value.host_scripts.status, 'run', 'owner', '7'}, builder.poll(value, 'run', 'owner', 7)) + assert.same({'lua', '-f', value.host_scripts.status, 'run', 'owner', + '7', '3'}, builder.poll(value, 'run', 'owner', 7, 3)) assert.same({'lua', '-f', value.host_scripts.run_query, 'show', 'run'}, builder.query(value, 'show', 'run')) assert.same({'lua', '-f', value.host_scripts.scheduler_status}, diff --git a/tests/unit/controller/execution/run_poller_spec.lua b/tests/unit/controller/execution/run_poller_spec.lua index 4095bdb..ff58c70 100644 --- a/tests/unit/controller/execution/run_poller_spec.lua +++ b/tests/unit/controller/execution/run_poller_spec.lua @@ -8,8 +8,10 @@ local RunState = require('dwarfspec.protocol.enums.run_states') ---@return table, table local function fixture(transports) local record = {polls={}, invocations={}, formatted={}} - local builder = {poll=function(_, run_id, owner_capability, cursor) - local arguments = {'poll', run_id, owner_capability, tostring(cursor)} + local builder = {poll=function(_, run_id, owner_capability, cursor, + generation) + local arguments = {'poll', run_id, owner_capability, tostring(cursor), + tostring(generation)} table.insert(record.polls, arguments) return arguments end} @@ -43,7 +45,9 @@ local function scope(overrides) report={state=RunState.QUEUED, terminal=false}, cursor=4, queue_started_at=0, now=function() return 1 end, sleep=function() end, - expectation=function(cursor) return {after_sequence=cursor} end, + expectation=function(cursor) + return {after_sequence=cursor, generation=3} + end, journal={}, activated_at=function() return activated_at end, entered_executor=function(report) return report.activated_at_ms ~= nil end, activate=function() @@ -77,11 +81,13 @@ describe('controller run poller', function() assert.same(RunState.PASSED, outcome.report.state) assert.same(6, outcome.cursor) assert.same({ - {'poll', 'run', 'owner', '4'}, - {'poll', 'run', 'owner', '5'}, + {'poll', 'run', 'owner', '4', '3'}, + {'poll', 'run', 'owner', '5', '3'}, }, calls.polls) - assert.same({after_sequence=4}, calls.invocations[1].expected) - assert.same({after_sequence=5}, calls.invocations[2].expected) + assert.same({after_sequence=4, generation=3}, + calls.invocations[1].expected) + assert.same({after_sequence=5, generation=3}, + calls.invocations[2].expected) assert.same(1, observed.activated) assert.same({'first', 'second'}, observed.emitted) assert.same({RunState.RUNNING, RunState.PASSED}, observed.persisted) @@ -121,6 +127,31 @@ describe('controller run poller', function() assert.same({}, calls.invocations) end) + it('does not advance cursor or observations after a rejected poll', function() + local detail = {kind='host', code='event_cursor_ahead', + message='requested cursor 8, retained cursor 7'} + local poller = module.new({ + builder={poll=function() return {'poll'} end}, + client={transport=function() error(detail, 0) end}, + format_events=function() return {} end, + fail=function(kind, message) + error({kind=kind, message=message}, 0) + end, + failure_kinds={HOST='host'}, clean_message=tostring, + }) + local value, observed = scope() + local original_report = value.report + local ok, rejection = pcall(poller.until_terminal, value) + assert.is_false(ok) + assert.equals('event_cursor_ahead', rejection.code) + assert.equals(4, value.cursor) + assert.equals(original_report, value.report) + assert.same({}, value.journal) + assert.same({}, observed.persisted) + assert.same({}, observed.observed) + assert.same({}, observed.emitted) + end) + it('classifies formatting failures before persistence', function() local poller = module.new({builder={}, client={}, format_events=function() error('formatter failed') end, diff --git a/tests/unit/controller/execution/run_recovery_spec.lua b/tests/unit/controller/execution/run_recovery_spec.lua index d466707..6d17ea5 100644 --- a/tests/unit/controller/execution/run_recovery_spec.lua +++ b/tests/unit/controller/execution/run_recovery_spec.lua @@ -121,6 +121,7 @@ describe('controller run recovery', function() 'service_not_loaded', 'run_not_found', 'generation_mismatch', 'invalid_run_state', 'owner_capability_rejected', 'quarantine_mismatch', 'clean_state_unverified', + 'event_cursor_ahead', }) do local rejection_message = 'structured ' .. code .. ' guidance' local service = fixture({rejection={kind='host', code=code, diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index d7ac6c4..600215f 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -1520,6 +1520,49 @@ describe('DwarfSpec external runner', function() outcome.error.message) end) + it('preserves structured poll context when recovery is also rejected', + function() + local run_options = options('structured-poll-failure') + local status_calls, recovery_calls = 0, 0 + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.STARTING, false)} + elseif arguments[3]:match('status%.lua$') then + status_calls = status_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='event_cursor_ahead', message='cursor rejected', + operation='status poll', run_id=run_options.run_id, + generation=1, state='starting', after_sequence=4, + last_sequence=3, + })}} + end + assert.matches('recover%.lua$', arguments[3]) + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='run_not_found', message='run disappeared', + operation='recover', run_id=run_options.run_id, + })}} + end + local outcome = runner.run(run_options) + assert.equals(5, outcome.exit_code) + assert.equals(runner.failure_kinds.HOST, outcome.error.kind) + assert.matches('requested cursor 4, retained cursor 3', + outcome.error.message, 1, true) + assert.matches('recovery failed:', outcome.error.message, 1, true) + assert.matches('no longer retained', outcome.error.message, 1, true) + assert.equals(1, status_calls) + assert.equals(1, recovery_calls) + assert.equals(0, outcome.report.last_sequence) + end) + it('attributes a selected path only when subprocess output emitted it', function() local run_options = options('emitted-selection') local identity = 'tests/private-emitted-selection.ds.lua' diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index 756040c..de9faf9 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -312,6 +312,10 @@ describe('controller transport client', function() {code='clean_state_unverified', operation='recover executor', run_id='run-1', generation=3, reason='cleanup remains active', guidance='Resolve remaining live resources'}, + {code='event_cursor_ahead', operation='status poll', + run_id='run-1', generation=3, state='running', + after_sequence=8, last_sequence=7, + guidance='requested cursor 8, retained cursor 7'}, } for _, case in ipairs(cases) do local response = {schema='dwarfspec.error.v1', protocol=2, diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index 6c3c281..ee73b60 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -136,6 +136,54 @@ describe('version 2 automation entrypoint contract', function() assert.is_nil(dfhack.dwarfspec) end) + it('serializes polling and event rejections with one safe response', + function() + for _, case in ipairs({ + {name='status', arguments={'missing-run', 'owner-secret', '0', '1'}}, + {name='event_read', arguments={'missing-run', '0', '1'}}, + {name='scheduler_status', arguments={'missing-run', '0', '1'}}, + }) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('service_not_loaded', encoded[#encoded].code) + assert.is_falsy(encoded[#encoded].message:find( + 'owner-secret', 1, true)) + end + + local root = require('lfs').currentdir():gsub('\\', '/') + lines = {} + load_host_script('bootstrap')('poll-entrypoint', + '--project-root=' .. root, '--defer-frames=1') + local run = assert(dfhack.dwarfspec.runs['poll-entrypoint']) + local last_sequence = #run.event_journal.events + local cases = { + {name='status', arguments={run.run_id, run.owner_capability, + '0', tostring(run.generation + 1)}, + code='generation_mismatch'}, + {name='status', arguments={run.run_id, 'owner-secret', '0', + tostring(run.generation)}, code='owner_capability_rejected'}, + {name='event_read', arguments={run.run_id, + tostring(last_sequence + 1), tostring(run.generation)}, + code='event_cursor_ahead'}, + {name='event_read', arguments={'missing-run', '0', '1'}, + code='run_not_found'}, + {name='scheduler_status', arguments={run.run_id, '0', + tostring(run.generation + 1)}, code='generation_mismatch'}, + } + for _, case in ipairs(cases) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines, case.name) + local rejection = encoded[#encoded] + assert.equals('dwarfspec.error.v1', rejection.schema) + assert.equals(case.code, rejection.code) + assert.is_nil(rejection.owner_capability) + assert.is_nil(rejection.authorization_proof) + assert.is_falsy(rejection.message:find('owner-secret', 1, true)) + end + end) + it('keeps unexpected adapter faults on the subprocess failure path', function() local response = diff --git a/tests/unit/host/execution/host_spec.lua b/tests/unit/host/execution/host_spec.lua index 1a93b07..4389715 100644 --- a/tests/unit/host/execution/host_spec.lua +++ b/tests/unit/host/execution/host_spec.lua @@ -243,6 +243,56 @@ describe('automation host ownership', function() outstanding_run_id) end) + it('rejects stale polling context before lease or cursor mutation', + function() + local service = require('dwarfspec.host.service.service') + local run = host.start('.', '.', options('poll-rejection')) + + ---Captures the complete detached service state. + ---@return table + local function summary() + return service.summary({namespace=dfhack}) + end + + ---Asserts one polling rejection leaves all service state unchanged. + ---@param expected_code string + ---@param operation function + ---@return table + local function rejected(expected_code, operation) + local before = summary() + local ok, detail = pcall(operation) + assert.is_false(ok) + assert.equals(expected_code, detail.code) + assert.same(before, summary()) + return detail + end + + local stale = rejected('generation_mismatch', function() + host.poll_transport(run.run_id, run.owner_capability, 0, + run.generation + 1) + end) + assert.equals(run.generation + 1, stale.generation) + assert.equals(run.generation, stale.current_generation) + + local cursor = rejected('event_cursor_ahead', function() + host.poll_transport(run.run_id, run.owner_capability, + #run.event_journal.events + 1, run.generation) + end) + assert.equals(#run.event_journal.events + 1, cursor.after_sequence) + assert.equals(#run.event_journal.events, cursor.last_sequence) + + local unauthorized = rejected('owner_capability_rejected', function() + host.poll_transport(run.run_id, 'wrong-owner-capability', 0, + run.generation) + end) + assert.equals(run.state, unauthorized.state) + + local missing = rejected('run_not_found', function() + host.transport('missing-poll-run', 0, 'event read', 1) + end) + assert.equals('missing-poll-run', missing.run_id) + end) + it('rejects overlap and ignores a callback after abort', function() local run = host.start('.', '.', options('owner')) assert.equals('starting', run.state) diff --git a/tests/unit/protocol/adapter_errors_spec.lua b/tests/unit/protocol/adapter_errors_spec.lua index 646a12a..8c4c2f3 100644 --- a/tests/unit/protocol/adapter_errors_spec.lua +++ b/tests/unit/protocol/adapter_errors_spec.lua @@ -115,6 +115,9 @@ describe('adapter error protocol', function() generation=3, blocking_run_id='run-2', blocking_generation=4}, clean_state_unverified={operation='recover executor', run_id='run-1', generation=3, reason='cleanup remains active'}, + event_cursor_ahead={operation='status poll', run_id='run-1', + generation=3, state='running', after_sequence=8, + last_sequence=7}, } for code, fields in pairs(cases) do local rejection = adapter_errors.domain(code, 'rejected', fields) @@ -150,6 +153,19 @@ describe('adapter error protocol', function() end end) + it('requires an event cursor rejection to describe an ahead cursor', + function() + for _, after_sequence in ipairs({6, 7}) do + assert.has_error(function() + adapter_errors.domain('event_cursor_ahead', 'stale cursor', { + operation='status poll', run_id='run-1', generation=3, + state='running', after_sequence=after_sequence, + last_sequence=7, + }) + end) + end + end) + it('retains generic and unknown compatibility without known fields', function() local generic = adapter_errors.envelope('generic rejection', RunnerFailureKind.REGISTRATION) From d788a4e3cc016b70829df3d6637dd8ed7e74d316 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 04:26:36 -0700 Subject: [PATCH 17/18] [Phase 8]: Lock down documentation, regression, and package integrity --- CHANGELOG.md | 10 ++++++ docs/command-line.md | 33 ++++++++++++++++++ docs/package-version-mismatch-rejection.todo | 36 ++++++++++---------- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688dc04..1417dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `Subject:search(query)` find literal final rendered text in the active mount and return zero-based inclusive screen-cell bounds. Subject and rectangle areas are spatial filters, not render-ownership claims. +- Structured `dwarfspec.error.v1` rejection codes now cover package admission, + scheduler conflicts, run mutation and recovery, polling, and event cursors + while preserving their existing runner classifications and exit codes. + +### Changed + +- Package-version mismatch diagnostics label the process-wide + `running_version` and current command's `requested_version` explicitly and + require a complete Dwarf Fortress/DFHack restart, removing the ambiguous + `expected` and `found` wording. ## [0.2.2] - 2026-08-03 diff --git a/docs/command-line.md b/docs/command-line.md index 9bc3f10..190a4ad 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -215,6 +215,39 @@ the title screen or unloading the world does not unload the process-wide DwarfSpec service. This remains a registration rejection with `registration_error` result state and exit code 5. +### Structured host rejections + +Expected service decisions use a validated `dwarfspec.error.v1` response. The +command selects guidance from its stable code and safe context fields; it does +not parse the human-readable message. These decisions preserve the existing +runner classification, persisted result state, and exit code 5. + +| Code | When it is reported | Operator action | +|---|---|---| +| `package_version_mismatch` | DFHack retained a different process-wide DwarfSpec package version | Fully exit and relaunch Dwarf Fortress/DFHack, then retry with the current command package | +| `project_busy` | Another retained run owns the project | Wait for and consume the blocking run's result | +| `request_key_conflict` | A request identity was reused for different work | Retry the identical request or choose a new run identity | +| `result_path_busy` | Another retained run owns the result destination | Wait for result consumption or choose another result destination | +| `service_not_loaded` | The requested mutating, recovery, polling, or event operation has no loaded service | Start a run to load the service; for an in-flight command, treat the service as unavailable and retry only after checking its result | +| `run_not_found` | The named retained run does not exist | Verify the run identity; do not fabricate local run state | +| `generation_mismatch` | The run exists at a newer generation | Refresh authoritative run state and retry with its current generation | +| `invalid_run_state` | The operation is not legal in the run's current state | Inspect the reported state and choose the matching status, recovery, acknowledgement, or discard workflow | +| `owner_capability_rejected` | The caller no longer owns the requested run operation | Stop retrying with the rejected ownership context and recover or inspect through the command that created the run | +| `quarantine_mismatch` | Executor recovery targeted a different quarantined run generation | Refresh scheduler state and recover the reported blocking run | +| `clean_state_unverified` | Executor recovery could not prove native cleanup | Keep the executor quarantined and resolve the reported cleanup condition before retrying recovery | +| `event_cursor_ahead` | Polling requested events beyond the retained journal | Restart observation from the reported retained cursor without advancing the local cursor | + +Normal read-only absence is not a rejection. Service-wide status reports +`service_loaded`, and history, inspection, and log queries report `found` when +the service or run is unavailable. A structured domain rejection means the +host understood a valid operation and declined it without performing the +forbidden state change. A bridge failure means the external `dfhack-run` +process failed, timed out, or returned no valid response; its diagnostic may +include only bounded, sanitized subprocess output. Malformed responses, +corrupt service state, impossible invariants, and unexpected host exceptions +remain internal faults instead of being mislabeled as operator-correctable +rejection codes. + On timeout, interruption, or malformed transport after bootstrap, the command asks the service to recover from authoritative current state. A queued run is cancelled without native cleanup; an active run is aborted with cleanup. If diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo index e660a38..5bf415c 100644 --- a/docs/package-version-mismatch-rejection.todo +++ b/docs/package-version-mismatch-rejection.todo @@ -481,53 +481,53 @@ Completion criteria: semantics remain unchanged. Phase 8: Lock down documentation, regression, and package integrity - ☐ Exit with user documentation, complete source validation, and one + ☒ Exit with user documentation, complete source validation, and one internally consistent package artifact. 8.1 Documentation and release surfaces: - ☐ Update `docs/command-line.md` to describe the package-mismatch + ☒ Update `docs/command-line.md` to describe the package-mismatch diagnostic, process-wide lifetime, complete-restart requirement, and preserved exit code 5. - ☐ Add a changelog entry describing structured version labels and removal + ☒ Add a changelog entry describing structured version labels and removal of ambiguous `expected`/`found` wording. - ☐ Apply the settled package-version decision consistently to the + ☒ Apply the settled package-version decision consistently to the rockspec, host package version, CLI version, documentation examples, and artifact name when a bump is required. - ☐ Search user-facing documentation and tests for the old mismatch + ☒ Search user-facing documentation and tests for the old mismatch wording and retain it only where explicitly testing backward input or historical behavior. - ☐ Document admission, mutation, recovery, polling, and event rejection + ☒ Document admission, mutation, recovery, polling, and event rejection codes that have an operator-facing remediation path. - ☐ Document the distinction between a structured domain rejection, an + ☒ Document the distinction between a structured domain rejection, an unavailable service or run represented by read-only state, a bridge failure, and an unexpected internal host fault. 8.2 Source validation: - ☐ Run focused service, scheduler, host-entrypoint, report-parser, + ☒ Run focused service, scheduler, host-entrypoint, report-parser, transport-client, recovery, and runner unit specifications through build/test subagents. - ☐ Run the complete recursive unit suite through a build/test subagent. - ☐ Run Lua syntax, formatting, and declaration checks for every changed + ☒ Run the complete recursive unit suite through a build/test subagent. + ☒ Run Lua syntax, formatting, and declaration checks for every changed source and test file through a build/test subagent. - ☐ Run `git diff --check` and inspect the focused diff without altering + ☒ Run `git diff --check` and inspect the focused diff without altering unrelated worktree or index state. - ☐ Record focused and complete-suite evidence separately. + ☒ Record focused and complete-suite evidence separately. 8.3 Package integrity: - ☐ Build the LuaRocks artifact with the repository publishing workflow + ☒ Build the LuaRocks artifact with the repository publishing workflow through a build/test subagent. - ☐ Inspect the archive manifest and contents to prove the matching + ☒ Inspect the archive manifest and contents to prove the matching service, shared error contract, all migrated entrypoints, report parser, transport and recovery clients, runner, and version metadata are present. - ☐ Install the artifact into a disposable LuaRocks tree and verify the + ☒ Install the artifact into a disposable LuaRocks tree and verify the command and bundled host modules resolve from that same artifact. - ☐ Remove the disposable tree and confirm cleanup without modifying the + ☒ Remove the disposable tree and confirm cleanup without modifying the operator's normal LuaRocks installation. Completion criteria: - ☐ Focused, recursive, syntax, formatting, declaration, and diff checks + ☒ Focused, recursive, syntax, formatting, declaration, and diff checks pass with evidence recorded independently. - ☐ Documentation and package metadata describe the same structured + ☒ Documentation and package metadata describe the same structured behavior shipped in the inspected artifact. Phase 9: Prove installed rejection and restart workflows From 114820ce36ce0e251788dd8a06086e5c76ec59d8 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 3 Aug 2026 04:47:24 -0700 Subject: [PATCH 18/18] chore: remove completed task list --- docs/connection-probe-error-reporting.todo | 351 ---------- docs/package-version-mismatch-rejection.todo | 642 ------------------- 2 files changed, 993 deletions(-) delete mode 100644 docs/connection-probe-error-reporting.todo delete mode 100644 docs/package-version-mismatch-rejection.todo diff --git a/docs/connection-probe-error-reporting.todo b/docs/connection-probe-error-reporting.todo deleted file mode 100644 index 182f29a..0000000 --- a/docs/connection-probe-error-reporting.todo +++ /dev/null @@ -1,351 +0,0 @@ -DwarfSpec Connection Probe Error Reporting -=========================================== - -Source proposal: - ☐ The error-reporting proposal in the conversation that identified the - strict `DWARFSPEC_PROBE` final-line comparison as the source of the - misleading "DFHack is not running or did not provide a healthy core Lua - context" diagnostic. - -Goal: - ☐ Preserve the lightweight DFHack connection preflight while reporting the - specific subprocess, protocol, context, or capability condition that - prevented a run. - ☐ Keep canonical live-spec discovery and selection independent from DFHack - connection diagnosis. - ☐ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the existing - connection-error result state for all probe failures. - ☐ Keep the probe dependency-free so it remains usable when DwarfSpec module - loading or the DFHack Lua environment is incomplete. - ☐ Treat source, unit, package, and live-runtime evidence as distinct - completion requirements. - -Non-goals: - ☐ Do not change project-root resolution, test discovery, selector glob - semantics, runner lookup order, run admission, bootstrap transport, or - scheduler behavior. - ☐ Do not accept an incompatible protocol, a non-core Lua context, or a - missing `dfhack.timeout` capability. - ☐ Do not introduce a new public failure kind or change established process - exit codes solely to improve diagnostic detail. - ☐ Do not make the probe depend on JSON libraries, project modules, the - consumer's Lua path, or an already-loaded DwarfSpec host service. - -Assumptions and open questions: - ☐ Confirm that protocol 2 remains the controller-to-host compatibility - contract for this change. - ☐ Decide whether the diagnostic probe should report `dfhack.VERSION` as an - optional field; it may improve supportability but must not become a - health requirement. - ☐ Decide the exact bounded-output limits before implementation. The proposed - default is at most eight non-empty lines and 2 KiB of rendered text. - ☐ Decide whether a package version bump is required for distributing the - changed probe and controller together; record the decision and rationale. - -Phase 1: Establish the probe response and diagnostic contracts - ☒ Exit with one documented probe grammar, one classification table, and - explicit compatibility invariants before changing runtime behavior. - - Evidence: `docs/connection-probe-contract.md`. - -1.1 Probe response grammar: - ☒ Define `DWARFSPEC_PROBE` as a single line with whitespace-separated - `name=value` fields. - ☒ Require exactly one probe marker in the complete subprocess output. - ☒ Require `protocol`, `core`, and `timeout` fields. - ☒ Define the accepted healthy values as `protocol=2`, `core=true`, and - `timeout=function`. - ☒ Define unknown fields as forward-compatible diagnostics that the current - parser ignores after validating the required fields. - ☒ Define duplicate required fields, missing values, invalid booleans, and - malformed tokens as malformed probe reports. - ☒ Define whether `dfhack` or other optional diagnostic field values require - escaping or are restricted to safe non-whitespace tokens. - -1.2 Failure classification and messages: - ☒ Specify distinct messages for every controller-observable condition: - ☒ Process invocation throws before returning a result. - ☒ The probe process exits nonzero. - ☒ The process succeeds but emits no probe marker. - ☒ The process emits a malformed probe marker. - ☒ The process emits multiple probe markers. - ☒ The probe protocol differs from the controller protocol. - ☒ The probe reports a non-core Lua context. - ☒ The probe reports `dfhack.timeout` with a type other than `function`. - ☒ Include the resolved runner path in invocation failures without implying - that test selection or the selected spec caused the failure. - ☒ Make the protocol-mismatch message identify expected and observed values - and recommend checking for mixed installed DwarfSpec package versions. - ☒ Make missing-marker and nonzero-exit messages include a bounded excerpt of - captured output when output is available. - ☒ Define a stable placeholder for empty output so the diagnostic never ends - with an unexplained blank suffix. - ☒ Preserve `RunnerFailureKind.CONNECTION`, exit code 4, and the connection - result state for every classified probe failure. - -1.3 Bounded subprocess output contract: - ☒ Define deterministic selection of non-empty output lines. - ☒ Define per-line and total-length truncation behavior. - ☒ Add an explicit truncation marker when content is omitted. - ☒ Normalize line endings and control characters that would corrupt a - one-line CLI diagnostic while preserving useful DFHack error text. - ☒ Do not expose command arguments or environment variables that were not - already present in captured subprocess output. - -Completion criteria: - ☒ Every condition currently collapsed into the generic health message maps - to one documented, objectively testable diagnostic. - ☒ The contract explicitly preserves existing failure kinds, result states, - exit codes, and selection boundaries. - -Phase 2: Make the host probe safe and self-describing - ☒ Exit with a dependency-free probe that reports observable context state - instead of crashing while inspecting an incomplete DFHack environment. - - Evidence: `src/dwarfspec/host/entrypoints/probe.lua`, - `tests/unit/host/entrypoints/probe_spec.lua`, 8 focused successes, - 776 recursive unit successes, and Lua checks for 295 files. - -2.1 Safe capability inspection: - ☒ Update `src/dwarfspec/host/entrypoints/probe.lua` to inspect the global - `dfhack` value without indexing it unless it is a table. - ☒ Report `core` using `tostring(dfhack.is_core_context)` when the table is - available and a deterministic unavailable value otherwise. - ☒ Report `timeout` using `type(dfhack.timeout)` when the table is available - and a deterministic unavailable type otherwise. - ☒ Emit the protocol supported by the probe script. - ☒ Emit the approved optional DFHack version field without making it part of - the healthy-context predicate. - ☒ Keep the entrypoint free of `require`, `reqscript`, JSON, project - configuration, and host-service dependencies. - ☒ Add language-standard documentation comments for any new helper methods. - -2.2 Entrypoint contract tests: - ☒ Extend the host entrypoint unit fixtures to capture the exact probe line. - ☒ Verify the healthy core-context response. - ☒ Verify that an absent `dfhack` global produces a parseable unhealthy - response instead of an indexing exception. - ☒ Verify missing `is_core_context`, missing `timeout`, and incorrectly typed - capability values independently. - ☒ Verify optional DFHack version presence and absence according to the - settled response grammar. - ☒ Verify the probe does not load DwarfSpec or third-party modules. - -Completion criteria: - ☒ The probe emits exactly one parseable marker for every modeled Lua-context - shape and does not throw while gathering its required fields. - ☒ The healthy response remains compatible with the controller protocol - settled in Phase 1. - -Phase 3: Parse and classify probe results in the controller - ☒ Exit with controller-side parsing that accepts unrelated DFHack output but - rejects ambiguous or unhealthy probe reports with precise messages. - - Evidence: `src/dwarfspec/controller/execution/transport_client.lua`, - `tests/unit/controller/execution/transport_client_spec.lua`, focused - 12 successes, 782 recursive unit successes, and Lua checks for 295 - files. - -3.1 Probe parsing: - ☒ Add a focused private parser in - `src/dwarfspec/controller/execution/transport_client.lua` or a narrowly - scoped controller module if the parser and formatting responsibilities - would otherwise obscure transport invocation. - ☒ Scan every captured line for the exact `DWARFSPEC_PROBE` marker instead of - assuming the marker is the final output line. - ☒ Reject zero markers and multiple markers as distinct conditions. - ☒ Parse required fields by name rather than by positional whole-line - equality. - ☒ Reject malformed tokens, missing required fields, duplicate required - fields, and invalid required values with field-specific context. - ☒ Ignore approved unknown fields without weakening required-field checks. - ☒ Add language-standard documentation comments for every new parser or - formatter method. - -3.2 Failure construction: - ☒ Handle process invocation exceptions separately from returned subprocess - failures. - ☒ Check `exit_code` before interpreting a successful probe response. - ☒ Format nonzero exits with the numeric exit code and bounded output. - ☒ Format missing and malformed reports with bounded output or the offending - marker as established in Phase 1. - ☒ Compare the parsed protocol to the controller protocol and report both - values on mismatch. - ☒ Report `core` and `timeout` health failures independently. - ☒ Return success only for exactly one well-formed response with every - required healthy value. - ☒ Remove the generic - `DFHack is not running or did not provide a healthy core Lua context` - fallback after every observable condition has a precise replacement. - ☒ Keep all failures classified as `RunnerFailureKind.CONNECTION` so runner - orchestration and result interpretation remain compatible. - -3.3 Bounded output formatting: - ☒ Implement the settled line and byte limits deterministically. - ☒ Preserve useful stderr text already merged into the subprocess result. - ☒ Make truncation visible. - ☒ Verify output formatting cannot itself throw on absent, empty, sparse, or - non-string fixture values. - -Completion criteria: - ☒ A valid marker can appear before or after unrelated output and still pass. - ☒ Every invalid subprocess result produces its specific Phase 1 diagnostic. - ☒ No connection-probe branch retains the old generic fallback. - -Phase 4: Lock down controller and runner compatibility - ☒ Exit with focused unit coverage proving the new detail does not change - established orchestration outcomes. - - Evidence: focused probe 8 successes, transport 18 successes, runner - 32 successes, and result-interpreter 4 successes; 789 recursive unit - successes; Lua checks for 295 files; LuaLS valid declarations with no - diagnostics and invalid declarations with six expected warnings. - -4.1 Transport client tests: - ☒ Replace the combined process-exception/unhealthy-probe test with separate - cases that assert the exact classification and meaningful message detail. - ☒ Verify a healthy marker as the only output line. - ☒ Verify a healthy marker with unrelated output before it. - ☒ Verify a healthy marker with unrelated output after it. - ☒ Verify invocation exceptions include the runner path and original error. - ☒ Verify nonzero exits with empty and non-empty output. - ☒ Verify empty successful output and successful output without a marker. - ☒ Verify malformed, missing-field, duplicate-field, and multiple-marker - responses. - ☒ Verify protocol mismatch reports expected and observed protocol values. - ☒ Verify `core=false` and every non-function `timeout` value independently. - ☒ Verify unknown optional fields are ignored. - ☒ Verify line, per-line, and total-output truncation boundaries. - ☒ Verify every case retains the connection failure kind. - -4.2 Runner and result tests: - ☒ Verify `runner.run()` returns exit code 4 for each representative probe - failure category. - ☒ Verify the persisted result remains in the connection-error state where - result persistence applies. - ☒ Verify run bootstrap is never attempted after probe failure. - ☒ Verify selected identities and spec paths do not appear in the connection - explanation unless they were independently part of subprocess output. - ☒ Verify abort, status, history, show, logs, and executor-recovery commands - preserve their existing connection-failure behavior while surfacing the - improved detail. - -4.3 Regression suite: - ☒ Run the focused host-entrypoint, transport-client, runner, and result - interpreter unit specifications. - ☒ Run the complete recursive unit suite. - ☒ Run Lua syntax, formatting, and declaration checks for all changed files. - ☒ Run `git diff --check` and inspect the final focused diff. - -Completion criteria: - ☒ Focused tests cover every classification row and boundary condition. - ☒ The complete unit and static-analysis suites pass without changing public - failure kinds, result states, or exit codes. - -Phase 5: Document and package the improved diagnostics - ☒ Exit with user-facing guidance and package artifacts that cannot mix the - new controller with an obsolete probe unnoticed. - - Evidence: release documentation and version surfaces, successful - package build and archive inspection, disposable LuaRocks install, - installed command/layout/hash verification, and confirmed cleanup. - -5.1 Documentation: - ☒ Update `docs/command-line.md` to describe connection exit code 4 and the - actionable probe diagnostics. - ☒ Document that test selection completes before the DFHack connection - preflight, so a connection error does not implicate the selected file. - ☒ Document protocol-mismatch remediation in terms of controller/probe - package alignment without prescribing project-specific paths. - ☒ Update other connection-error examples that quote or promise the removed - generic message. - - Evidence: `docs/command-line.md`; repository search found no other - user-facing example retaining the removed generic diagnostic. - -5.2 Package integrity: - ☒ Apply the settled package-version decision consistently to package - metadata, CLI version output, and changelog entries. - ☒ Build the LuaRocks artifact using the repository packaging workflow. - ☒ Inspect the artifact manifest and archive contents to prove the updated - controller parser and probe entrypoint are both present. - - Evidence: `dwarfspec-0.2.2-1.rockspec`, CLI and host package version - `0.2.2`, `CHANGELOG.md`, successful `tools/Publish.ps1`, and - `dist/dwarfspec-0.2.2-1.all.rock` with controller and probe protocol - `2` entries listed in `rock_manifest`. - ☒ Install the artifact into a disposable LuaRocks tree. - ☒ Verify the disposable command resolves its controller and probe from the - same package version and layout. - ☒ Remove the disposable installation and confirm cleanup. - - Evidence: the read-only mounted `0.2.2-1` artifact installed into a - container-local LuaRocks tree; its command reported `0.2.2`, the - controller, probe, and host resolved from the same tree and matched - archive hashes, and the `--rm` container was confirmed absent. - -Completion criteria: - ☒ Documentation distinguishes runner invocation, subprocess exit, missing - report, malformed report, protocol mismatch, and capability failures. - ☒ The packaged controller and probe implement the same response protocol - and the disposable package smoke checks pass. - -Phase 6: Validate installed and live-runtime behavior - ☐ Exit with bounded evidence that the packaged CLI reports real DFHack - connection failures accurately and still accepts a healthy core context. - -6.1 Installed consumer preparation: - ☐ Select one consumer project with a canonical nested `.ds.lua` identity. - ☐ Record the exact installed DwarfSpec package version, resolved - `dwarfspec` command, resolved `dfhack-run`, project root, and selected - identity before execution. - ☐ Confirm the controller and probe resolve from the same installed artifact. - ☐ Preserve the consumer worktree and do not substitute a source-tree module - path for installed-package evidence. - -6.2 Failure-path evidence: - ☐ With no reachable DFHack process, verify the CLI reports the actual - nonzero connection-probe exit and bounded runner output. - ☐ Using a controlled protocol-mismatch fixture or disposable mismatched - package layout, verify expected and observed protocol values are reported. - ☐ Using controlled probe fixtures, verify non-core and missing-timeout - diagnostics without weakening the real health predicate. - ☐ Confirm each failure returns exit code 4, does not attempt bootstrap, and - does not attribute the failure to the selected spec path. - -6.3 Healthy live evidence: - ☐ Start from a known responsive DFHack process and run one exact nested - project-relative identity through the installed CLI. - ☐ Confirm unrelated DFHack output does not invalidate the single healthy - marker. - ☐ Confirm the run proceeds beyond preflight into bootstrap and reaches a - terminal DwarfSpec result. - ☐ Record terminal status, exit code, result artifact status, and - `cleanup_confirmed` independently from the connection result. - ☐ Confirm the DFHack executor is idle, the queue is empty, quarantine is - absent, and no test-owned resources remain after the run. - -6.4 Followup review: - ☐ Review the implementation against every requirement and non-goal in this - plan after source, package, and live validation are complete. - ☐ Recheck that no generic fallback still hides captured probe information. - ☐ Recheck that healthy validation did not become permissive while making - error messages more detailed. - ☐ Recheck that package-skew guidance is supported by the final installed - layout and does not claim a mismatch when none was observed. - ☐ Record any deferred item with its rationale and a concrete follow-up owner - or removal condition. - -Completion criteria: - ☐ Installed failure scenarios produce specific actionable diagnostics. - ☐ One installed healthy run reaches a terminal result with cleanup - confirmed and final executor state verified. - ☐ The followup review finds every requirement satisfied or explicitly - deferred with rationale. - -Final acceptance: - ☐ Every proposal requirement is implemented or explicitly deferred with a - recorded rationale. - ☐ The probe is dependency-free, safe against incomplete DFHack globals, and - emits exactly one parseable response. - ☐ The controller parses the response by fields, tolerates unrelated output, - and reports every failure condition precisely. - ☐ Connection failures retain their existing kind, result state, and exit - code while exposing bounded subprocess evidence. - ☐ Source, focused unit, complete unit, static-analysis, package, installed, - and live-runtime evidence are recorded separately. - ☐ Documentation and package metadata describe the shipped behavior. - ☐ Temporary fixtures, disposable installations, result artifacts, and live - test resources are removed with cleanup confirmed. diff --git a/docs/package-version-mismatch-rejection.todo b/docs/package-version-mismatch-rejection.todo deleted file mode 100644 index 5bf415c..0000000 --- a/docs/package-version-mismatch-rejection.todo +++ /dev/null @@ -1,642 +0,0 @@ -DwarfSpec Structured Host Error Responses -========================================== - -Source proposal: - ☐ The conversation that identified the ambiguous bootstrap diagnostic - `incompatible automation package version: expected ..., found ...` and - proposed carrying the running and requested package versions as fields in - the existing `dwarfspec.error.v1` rejection envelope. - ☐ The follow-up boundary audit that identified bootstrap admission - conflicts, mutation and recovery rejections, and polling or event - transport failures whose structured host context is currently discarded - or reduced to a subprocess exit code. - -Goal: - ☐ Make a bootstrap package-version mismatch identify which version is - already loaded by the running DFHack process and which version the - current DwarfSpec command requested. - ☐ Tell the operator that DwarfSpec is process-wide, that returning to the - title screen or unloading a world is insufficient, and that fully exiting - and relaunching Dwarf Fortress/DFHack is required before retrying. - ☐ Represent the mismatch as a structured registration subtype so the - controller does not infer machine-readable meaning from human text. - ☐ Preserve the existing `dwarfspec.error.v1` transport, - `RunnerFailureKind.REGISTRATION`, registration-error result state, exit - code 5, rejection atomicity, and no-recovery behavior. - ☐ Reuse one validated error-envelope contract across host entrypoints so - expected domain rejections retain stable codes and safe structured - context from the service boundary through the controller. - ☐ Preserve scheduler admission classifications for project, request-key, - and result-path conflicts instead of collapsing them into run-state text. - ☐ Preserve actionable run, generation, state, ownership, quarantine, and - clean-state verification context for mutation, recovery, polling, and - event operations without exposing capabilities or unrelated paths. - ☐ Keep source, focused unit, complete unit, package, installed, and live - DFHack evidence distinct. - -Non-goals: - ☐ Do not introduce a new top-level error schema, runner failure kind, - result state, or process exit code. - ☐ Do not change package compatibility rules, protocol compatibility, - project registration semantics, scheduler admission decisions, - quarantine rules, retained service state, or recovery authority. - ☐ Do not add an in-process service unload or hot-reload path. - ☐ Do not generalize internal programming assertions, malformed registry - invariants, failed module loads, impossible transition assertions, or - developer-only dependency failures into public domain rejection codes. - ☐ Do not replace structured `service_loaded`, `found`, transport - snapshots, event journals, test failures, or the dependency-free - `DWARFSPEC_PROBE` grammar with adapter-error responses. - ☐ Do not silently select, install, downgrade, or remove a DwarfSpec - package on the operator's behalf. - -Assumptions and open questions: - ☒ Treat `code='package_version_mismatch'`, `running_version`, and - `requested_version` as the stable wire names proposed in the - conversation. - ☒ Decide whether `package_version_mismatch` should be centralized in a - shared immutable error-code module with the other accepted domain codes. - ☒ Confirm that broadening the accepted error kinds and codes remains an - additive `dwarfspec.error.v1` change. If backward compatibility cannot be - proved, stop and revise this plan instead of silently introducing a new - top-level schema. - ☒ Define the boundary between expected domain rejections and unexpected - host faults before converting any auxiliary entrypoint; unknown faults - must remain distinguishable from stable public rejection codes. - ☒ Decide whether adapters emit a structured error with subprocess exit - code zero, emit structured JSON alongside a nonzero exit, or support both - during migration. The controller must not discard a valid error envelope - solely because the bridge returned nonzero. - ☒ Decide whether the canonical multi-line remediation text is stored - verbatim in persisted result errors or whether persistence retains a - single-line message while the CLI renderer adds layout. Verify existing - result consumers before settling this boundary. - ☒ Decide whether shipping the changed host and controller requires a - package version bump, and record the compatibility rationale. - -Phase 1: Establish the additive rejection contract - ☒ Exit with one documented package-mismatch subtype and exact user-facing - semantics before changing service or controller behavior. - -1.1 Wire contract: - ☒ Extend the existing `dwarfspec.error.v1` registration envelope with the - optional `code` field rather than creating another schema or changing the - broad `kind='registration'` classification. - ☒ Define `code='package_version_mismatch'` as requiring non-empty string - fields `running_version` and `requested_version`. - ☒ Define `running_version` as the package version retained by the - process-wide DFHack service registry. - ☒ Define `requested_version` as the package version supplied by the host - loaded from the current DwarfSpec command's package. - ☒ Keep `message` required and independently meaningful so diagnostics - remain useful to consumers that only display the base error text. - ☒ Preserve generic registration envelopes without `code` and preserve - the existing structured executor-quarantine envelope unchanged. - ☒ Define unknown future registration codes to fall back to their - supplied message rather than being mistaken for a version mismatch. - -1.2 User-facing diagnostic: - ☒ Settle and test wording that labels both values without the ambiguous - `expected` and `found` terms: - ☒ `Running DFHack service: `. - ☒ `Current DwarfSpec command: `. - ☒ Explain that DFHack already has a different DwarfSpec version loaded. - ☒ Direct the operator to save, fully exit Dwarf Fortress/DFHack, - relaunch it, and retry the command. - ☒ State that returning to the title screen or unloading the world does - not unload the process-wide DwarfSpec service. - ☒ Keep the diagnostic independent of project paths, selected specs, - installation-tree paths, and assumptions about how DFHack was launched. - -Completion criteria: - ☒ The contract distinguishes machine-readable classification from - human-readable wording and defines every new field unambiguously. - ☒ Existing registration, quarantine, failure-kind, result-state, and - exit-code contracts remain explicitly preserved. - -Phase 2: Produce structured mismatch rejections in the host - ☒ Exit with the service and bootstrap entrypoint carrying version fields - without mutating retained service state or weakening compatibility. - -2.1 Service rejection: - ☒ Replace only the incompatible bootstrap package-version assertion in - `src/dwarfspec/host/service/service.lua` with a structured error value - containing the stable code and both version fields. - ☒ Preserve request validation before registry access and preserve the - current protocol-version validation order. - ☒ Raise the structured value without an incidental Lua source prefix or - string coercion that would discard its fields. - ☒ Keep successful first bootstrap and compatible repeated bootstrap - behavior unchanged. - ☒ Verify an incompatible bootstrap creates no project, run, queue, - scheduler, ownership, timestamp, or registry mutation. - ☒ Add language-standard documentation comments for every new helper or - public contract surface. - -2.2 Bootstrap adapter serialization: - ☒ Extend `src/dwarfspec/host/entrypoints/bootstrap.lua` to recognize the - structured mismatch value and emit its `code`, `running_version`, and - `requested_version` fields in `dwarfspec.error.v1` JSON. - ☒ Retain `kind='registration'`, protocol 2, and a non-empty fallback - message for the mismatch response. - ☒ Preserve generic string-error serialization for all unrelated - bootstrap failures. - ☒ Preserve executor-quarantine classification and its structured fields - without routing it through version-mismatch formatting. - ☒ Avoid exposing package roots or other machine-specific service data. - -2.3 Host-focused tests: - ☒ Update `tests/unit/host/service/service_spec.lua` to assert the exact - structured mismatch value and unchanged retained registry snapshot. - ☒ Update - `tests/unit/host/entrypoints/entrypoint_contract_spec.lua` to assert the - exact JSON schema, protocol, registration kind, code, running version, - requested version, and non-empty message. - ☒ Retain independent coverage for generic registration errors and - executor quarantine. - ☒ Verify matching versions still bootstrap normally and emit no error - envelope. - -Completion criteria: - ☒ A version mismatch crosses the host entrypoint as structured JSON with - both correctly oriented version values. - ☒ Host-focused tests prove rejection atomicity and no regression in - compatible bootstrap or quarantine behavior. - -Phase 3: Validate and consume the structured rejection in the controller - ☒ Exit with strict field validation and code-based diagnostic formatting - that no longer parses human message text. - -3.1 Controller response validation: - ☒ Extend the adapter-error validator in - `src/dwarfspec/controller/reporting/report.lua` to accept optional - registration codes while retaining JSON-safety validation. - ☒ Require non-empty `running_version` and `requested_version` strings - when `code='package_version_mismatch'`. - ☒ Reject missing, empty, or incorrectly typed required mismatch fields - as malformed adapter responses instead of rendering misleading guidance. - ☒ Continue accepting generic registration errors without a code. - ☒ Continue validating executor-quarantine fields exactly as before. - ☒ Preserve unknown future registration codes as generic message-bearing - rejections unless the settled contract requires stricter handling. - ☒ Add language-standard documentation comments for every changed or new - validation and formatting method. - -3.2 Runner formatting: - ☒ Change the registration formatter in - `src/dwarfspec/controller/execution/runner.lua` to receive the validated - rejection object rather than only its message string. - ☒ Branch on `code == 'package_version_mismatch'` and format the settled - labels and remediation from the structured version fields. - ☒ Remove the substring match on - `incompatible automation package version` after structured coverage is - complete. - ☒ Keep generic registration rejections prefixed consistently and do not - append restart advice to unrelated errors or unknown codes. - ☒ Preserve one bootstrap attempt, no bootstrap retry, no recovery call, - registration failure classification, result persistence, and exit code 5 - for an explicit mismatch rejection. - -3.3 Controller-focused tests: - ☒ Add report-parser cases for a valid structured mismatch and every - missing, empty, or incorrectly typed required field. - ☒ Verify generic registration and executor-quarantine envelopes remain - accepted and retain their existing fields. - ☒ Update `tests/unit/controller/execution/runner_spec.lua` to assert the - exact running/current labels, values, full-exit guidance, title-screen or - world-unload clarification, classification, result state, and exit code. - ☒ Verify the diagnostic contains no ambiguous `expected` or `found` - labels. - ☒ Verify a generic registration message that happens to contain the old - mismatch phrase receives no special restart guidance. - ☒ Verify unknown registration codes fall back to the supplied message - and do not receive version-mismatch formatting. - ☒ Verify malformed structured responses fail through the existing - invalid-bootstrap-response path without attempting recovery. - -Completion criteria: - ☒ No controller behavior depends on parsing the host's human mismatch - message. - ☒ Valid structured rejections render both versions and precise recovery - instructions while all adjacent rejection behavior remains compatible. - -Phase 4: Establish one shared adapter-error boundary - ☒ Exit with reusable construction, serialization, parsing, and validation - rules that later entrypoints can adopt without duplicating bootstrap's - special-case logic. - ☒ Treat the package-version work in Phases 2 and 3 as the first complete - vertical slice, then extract its proven contract into shared machinery - before migrating additional error families. - -4.1 Error taxonomy and field policy: - ☒ Inventory every error-producing host entrypoint and classify each - failure as an expected domain rejection, structured state already - represented by another schema, subprocess or connection failure, or - unexpected internal fault. - ☒ Define `kind` as the existing broad runner classification and `code` as - the stable domain subtype; document which layer owns each value. - ☒ Define common optional fields such as `operation`, `run_id`, - `generation`, `state`, `blocking_run_id`, and `blocking_generation`. - ☒ Define subtype-specific required fields and forbid fields whose values - would expose owner capabilities, authorization proofs, package roots, or - unrelated machine-specific paths. - ☒ Define compatibility behavior for generic errors without `code`, known - codes, unknown future codes, malformed known-code payloads, and - unexpected internal exceptions. - ☒ Preserve existing failure kinds, result states, exit codes, primary - versus secondary error precedence, and recovery decisions unless a later - task explicitly documents an approved mapping. - -4.2 Shared host construction and serialization: - ☒ Add one narrowly scoped protocol or host-support abstraction for - constructing JSON-safe domain rejection objects with required `code`, - `message`, and subtype fields. - ☒ Add one shared entrypoint serializer for canonical adapter errors so - bootstrap, mutation, recovery, polling, and event adapters do not each - implement their own field-copy rules. - ☒ Preserve bootstrap's executor-quarantine payload and the structured - package-version mismatch as compatibility fixtures for the shared path. - ☒ Migrate package-version mismatch and executor quarantine onto the - shared constructor and serializer, then remove any temporary - mismatch-only field-copy or dispatch scaffolding after parity is proved. - ☒ Ensure error serialization itself cannot throw on a malformed or - non-string internal exception; retain a bounded generic host-fault - fallback without falsely assigning a public code. - ☒ Add language-standard documentation comments for every new class, - method, constructor, validator, and public contract surface. - -4.3 Shared controller parsing: - ☒ Generalize adapter-error validation in - `src/dwarfspec/controller/reporting/report.lua` so all approved broad - kinds and codes are validated by the same contract. - ☒ Update transport-client operations to inspect and validate a canonical - error envelope before replacing a response with generic - ` exited with ` text. - ☒ Preserve bounded captured output when a subprocess fails without a - valid structured response. - ☒ Return validated error objects to runner and recovery orchestration - without flattening them to strings prematurely. - ☒ Preserve generic registration fallback, executor quarantine, healthy - transport parsing, read-only response schemas, and connection-probe - behavior. - -4.4 Shared contract tests: - ☒ Add round-trip tests from domain rejection construction through JSON - serialization, controller validation, and retained fields. - ☒ Verify known codes require their exact fields and reject missing, - empty, incorrectly typed, non-JSON-safe, or forbidden values. - ☒ Verify unknown codes and generic uncoded messages follow the settled - compatibility policy without receiving known-code guidance. - ☒ Verify nonzero subprocess results with valid structured JSON preserve - the structured error under the settled migration contract. - ☒ Verify nonzero results without valid JSON remain classified as bridge - or host failures with bounded diagnostic output. - -Completion criteria: - ☒ One documented and tested error-envelope path serves bootstrap and is - ready for every approved auxiliary adapter. - ☒ Structured domain rejections and unexpected internal faults remain - observably distinct end to end. - -Phase 5: Preserve bootstrap admission conflicts - ☒ Exit with scheduler admission classifications reaching the CLI as - actionable structured registration rejections without changing whether - a run is accepted, reused, or rejected. - -5.1 Admission subtype contracts: - ☒ Define structured registration codes for `project_busy`, - `request_key_conflict`, and `result_path_busy` using the existing - `SchedulerFailureKind` values where compatible. - ☒ Define safe blocking context for each subtype, including the blocking - run identity, generation, and state when available. - ☒ Decide whether project identity or normalized result-path identity is - necessary for remediation; omit raw paths and internal identifiers when - the blocking run identity is sufficient. - ☒ Define actionable messages that name the actual conflict rather than - reporting only that another run is queued, active, or terminal. - -5.2 Host propagation: - ☒ Update `src/dwarfspec/host/execution/host.lua` to preserve - `outcome.kind`, `outcome.reason`, identity, and snapshot context from - `service.submit()` when admission is rejected. - ☒ Route each expected admission outcome through the shared structured - rejection constructor and bootstrap serializer. - ☒ Preserve accepted first submissions and identical request-key retries - as successful, idempotent transport responses. - ☒ Preserve rejection atomicity, outstanding-run ownership, queue order, - generation, leases, and result-path reservations. - ☒ Leave invalid scheduler invariants and generator failures as internal - faults rather than assigning them admission codes. - -5.3 Controller formatting and behavior: - ☒ Validate every admission subtype and its required blocking context. - ☒ Format project-busy, request-key-conflict, and result-path-busy - guidance from structured fields without parsing `reason` or `message`. - ☒ Preserve registration failure classification, registration-error - persistence, exit code 5, no bootstrap retry, and no recovery attempt. - ☒ Ensure conflict messages do not blame the selected specification or - disclose unrelated consumer configuration. - -5.4 Admission tests: - ☒ Add focused scheduler and service fixtures for all three rejection - outcomes and for accepted idempotent reuse. - ☒ Add bootstrap-entrypoint tests for exact error schema, code, broad - kind, safe blocking fields, and non-empty message. - ☒ Add controller tests for exact subtype classification, actionable - rendering, persisted result state, exit code, and no recovery. - ☒ Verify each rejection leaves the complete registry and scheduler state - unchanged except for state that legitimately predates the attempted run. - -Completion criteria: - ☒ Every expected admission conflict retains its scheduler classification - and safe blocking context through the CLI. - ☒ No admission conflict is reduced to generic run-state prose. - -Phase 6: Structure mutation and recovery rejections - ☒ Exit with abort, cancel, recover, acknowledge, discard, and executor - recovery adapters returning actionable domain rejections instead of only - subprocess exit codes. - -6.1 Operation subtype contracts: - ☒ Define stable codes and required safe fields for `service_not_loaded`, - `run_not_found`, `generation_mismatch`, `invalid_run_state`, - `owner_capability_rejected`, `quarantine_mismatch`, and - `clean_state_unverified`, adjusting names only when the contract review - identifies an existing canonical term. - ☒ Map each code only to expected operator- or orchestration-triggerable - conditions; keep malformed internal requests and impossible service - invariants unclassified. - ☒ Define broad failure-kind, result-state, and exit-code mappings for - direct operator commands and secondary recovery or acknowledgement - failures. - ☒ Preserve the original run failure as primary when a structured - recovery or acknowledgement rejection is appended as secondary context. - -6.2 Service and host boundaries: - ☒ Replace expected assertion-only rejections in the approved operation - paths with structured domain values at the layer that owns the decision. - ☒ Preserve exact run, project, service-instance, generation, capability, - state, quarantine, and clean-state authorization checks. - ☒ Ensure owner capabilities and authorization proofs are never copied - into error payloads, logs, persisted results, or CLI output. - ☒ Preserve successful operation state transitions, native cleanup, - acknowledgement, discard, quarantine clearing, and subsequent queue - activation exactly as before. - ☒ Prove rejected operations do not renew leases, mutate journals, - release ownership, clear quarantine, discard results, or invoke native - cleanup. - -6.3 Entrypoint adoption: - ☒ Adopt the shared serializer in `abort.lua`, `cancel.lua`, `recover.lua`, - `acknowledge.lua`, `discard.lua`, and `recover_executor.lua`. - ☒ Ensure each adapter emits exactly one canonical JSON response for - either success or a modeled domain rejection. - ☒ Retain nonzero or fallback behavior for module-load, malformed - argument, serialization, and unexpected internal failures according to - the settled migration contract. - ☒ Keep entrypoints thin and free of duplicated business classification - or remediation logic. - -6.4 Controller and recovery consumption: - ☒ Extend transport and recovery clients to return structured operation - rejections without collapsing them to generic `exited with` messages. - ☒ Render run identifiers, generations, current states, and remediation - only from validated subtype fields. - ☒ Preserve direct abort and executor-recovery command exit behavior. - ☒ Preserve recovery error precedence and append structured secondary - detail without replacing the original timeout, host, interruption, or - test failure. - ☒ Preserve successful transport validation and cleanup confirmation - requirements. - -6.5 Mutation and recovery tests: - ☒ Add service tests for every modeled rejection and its no-mutation - guarantee. - ☒ Add entrypoint tests for structured success-versus-error exclusivity - and forbidden sensitive fields. - ☒ Add transport-client and recovery tests for each subtype, broad kind, - exit code, primary-error precedence, and exact useful context. - ☒ Retain success coverage for queued cancellation, active abort with - cleanup, terminal acknowledgement, explicit discard, and verified - executor recovery. - -Completion criteria: - ☒ Every expected mutation or recovery rejection crosses the adapter - boundary as a validated object with safe actionable context. - ☒ No successful behavior, authorization rule, state transition, cleanup - requirement, or error precedence changes. - -Phase 7: Structure polling and event transport rejections - ☒ Exit with status polling, event reading, and run-specific scheduler - transport preserving expected stale-state details while retaining - existing read-only response schemas. - -7.1 Polling and event subtype contracts: - ☒ Map expected missing-service, missing-run, stale-generation, - capability, cursor, and invalid-state conditions to the shared codes or - define narrowly scoped additional codes when their remediation differs. - ☒ Distinguish an operator-addressable stale run from malformed transport, - corrupt registry state, impossible event-journal state, or an unexpected - host exception. - ☒ Define which polling rejections remain primary host failures and which - trigger the runner's existing state-aware recovery path. - -7.2 Entrypoint adoption: - ☒ Replace `status.lua`'s modeled-domain `qerror` path with canonical - structured serialization while retaining an internal-fault fallback. - ☒ Adopt the shared serializer for modeled failures in `event_read.lua` - and the run-specific branch of `scheduler_status.lua`. - ☒ Ensure status and event adapters emit exactly one canonical JSON - response and do not emit a partial success envelope before a failure. - ☒ Keep the service-wide `dwarfspec.status.v1` response and its - `service_loaded` state unchanged. - ☒ Keep `history`, `show`, and `logs` using their existing - `service_loaded` and `found` fields rather than converting normal absence - into adapter errors. - -7.3 Controller behavior: - ☒ Parse validated error envelopes from poll, event-read, and run-status - operations before applying generic subprocess failure handling. - ☒ Preserve safe structured context in primary and recovery diagnostics. - ☒ Preserve retry, timeout, event-cursor advancement, lease renewal, - acknowledgement, and state-aware recovery behavior. - ☒ Ensure a rejected or malformed poll never advances the event cursor or - fabricates a newer transport generation. - -7.4 Polling and event tests: - ☒ Add host and entrypoint tests for every modeled polling or event - rejection, exactly-one-response behavior, and no lease or cursor mutation. - ☒ Add transport-client and runner tests proving structured details are - retained for direct polling failures and secondary recovery failures. - ☒ Retain healthy polling, unrelated-output, terminal observation, - service-unloaded status, missing read-only run, and malformed transport - coverage. - ☒ Verify unexpected internal failures remain distinguishable and include - bounded captured output rather than being assigned a domain code. - -Completion criteria: - ☒ Every approved polling and event rejection retains safe structured - context across the host/controller boundary. - ☒ Existing cursor, lease, retry, timeout, recovery, and read-only query - semantics remain unchanged. - -Phase 8: Lock down documentation, regression, and package integrity - ☒ Exit with user documentation, complete source validation, and one - internally consistent package artifact. - -8.1 Documentation and release surfaces: - ☒ Update `docs/command-line.md` to describe the package-mismatch - diagnostic, process-wide lifetime, complete-restart requirement, and - preserved exit code 5. - ☒ Add a changelog entry describing structured version labels and removal - of ambiguous `expected`/`found` wording. - ☒ Apply the settled package-version decision consistently to the - rockspec, host package version, CLI version, documentation examples, and - artifact name when a bump is required. - ☒ Search user-facing documentation and tests for the old mismatch - wording and retain it only where explicitly testing backward input or - historical behavior. - ☒ Document admission, mutation, recovery, polling, and event rejection - codes that have an operator-facing remediation path. - ☒ Document the distinction between a structured domain rejection, an - unavailable service or run represented by read-only state, a bridge - failure, and an unexpected internal host fault. - -8.2 Source validation: - ☒ Run focused service, scheduler, host-entrypoint, report-parser, - transport-client, recovery, and runner unit specifications through - build/test subagents. - ☒ Run the complete recursive unit suite through a build/test subagent. - ☒ Run Lua syntax, formatting, and declaration checks for every changed - source and test file through a build/test subagent. - ☒ Run `git diff --check` and inspect the focused diff without altering - unrelated worktree or index state. - ☒ Record focused and complete-suite evidence separately. - -8.3 Package integrity: - ☒ Build the LuaRocks artifact with the repository publishing workflow - through a build/test subagent. - ☒ Inspect the archive manifest and contents to prove the matching - service, shared error contract, all migrated entrypoints, report parser, - transport and recovery clients, runner, and version metadata are present. - ☒ Install the artifact into a disposable LuaRocks tree and verify the - command and bundled host modules resolve from that same artifact. - ☒ Remove the disposable tree and confirm cleanup without modifying the - operator's normal LuaRocks installation. - -Completion criteria: - ☒ Focused, recursive, syntax, formatting, declaration, and diff checks - pass with evidence recorded independently. - ☒ Documentation and package metadata describe the same structured - behavior shipped in the inspected artifact. - -Phase 9: Prove installed rejection and restart workflows - ☐ Exit with installed, live DFHack evidence for both the mismatch - diagnostic and the healthy post-restart path, followed by an independent - implementation review. - -9.1 Reproducible installed setup: - ☐ Prepare separate disposable installed trees for two known DwarfSpec - package versions without replacing the operator's default installation. - ☐ Record both package versions, command paths, module roots, the selected - consumer project, the exact project-relative spec identity, the resolved - `dfhack-run`, and the target DFHack process before execution. - ☐ Confirm each command resolves its controller and bootstrap entrypoint - from its own single package artifact. - ☐ Ensure the selected live test can terminate and clean up normally - before intentionally introducing package skew. - -9.2 Live mismatch evidence: - ☐ Start a clean DFHack process and use the older installed command to - bootstrap the process-wide DwarfSpec service. - ☐ Confirm the initial run reaches a terminal result with cleanup - confirmed and leaves the service idle, the queue empty, and quarantine - absent. - ☐ Without restarting DFHack, invoke the newer installed command against - the same process and exact consumer identity. - ☐ Verify the rejection labels the older registry value as the running - service and the newer package value as the current command. - ☐ Verify the complete-exit/relaunch guidance and the clarification that - title-screen return or world unload is insufficient. - ☐ Verify registration failure classification, registration-error result - state where persisted, exit code 5, one rejected bootstrap attempt, no - recovery attempt, no admitted run, and unchanged service state. - ☐ Capture bounded command output and service status without claiming the - selected spec itself failed. - -9.3 Installed operational rejection evidence: - ☐ Exercise one safe controlled admission conflict and verify its exact - structured code, blocking context, registration classification, exit - code, no recovery, and no state mutation. - ☐ Exercise representative missing-run or stale-generation failures for - direct operator mutation and executor recovery without altering a valid - retained run. - ☐ Exercise one controlled polling or event stale-state rejection and - verify the cursor, lease, journal, and scheduler remain unchanged. - ☐ Verify each installed diagnostic retains useful validated fields and - does not expose capabilities, authorization proofs, package roots, or - unrelated paths. - ☐ Keep artificial fixtures distinct from real installed DFHack evidence - and record exactly which conditions were induced versus naturally - observed. - -9.4 Post-restart healthy evidence and cleanup: - ☐ Save and fully exit the test Dwarf Fortress/DFHack process, confirm it - is no longer running, then relaunch it. - ☐ Re-run the same exact consumer identity with the newer installed - command and confirm it proceeds beyond bootstrap to a terminal result. - ☐ Record exit code, result artifact, test outcome, cleanup confirmation, - executor idle state, empty queue, and absent quarantine independently. - ☐ Remove disposable installed trees, live result artifacts, and - test-owned resources, and confirm cleanup. - ☐ Restore the operator's original running-process and command-selection - state if the validation workflow changed either one. - -9.5 Followup review: - ☐ Perform an independent review of the implementation against every - requirement, non-goal, assumption, and completion criterion in this plan. - ☐ Recheck field orientation from the retained registry through JSON, - controller validation, rendered CLI text, and persisted result output. - ☐ Recheck that no substring-based version-mismatch classification - remains and no unrelated registration rejection receives restart advice. - ☐ Recheck that no new unload, downgrade, installation, scheduler, - recovery, or compatibility behavior entered the final diff. - ☐ Recheck every migrated entrypoint emits one success or structured - domain rejection response, never both, for modeled outcomes. - ☐ Recheck internal assertions were not mislabeled as public domain codes - and structured state schemas were not unnecessarily converted to errors. - ☐ Recheck sensitive capabilities, proofs, roots, and unrelated paths are - absent from error payloads, output, and persisted results. - ☐ Record every deferred item with rationale and a concrete follow-up or - removal condition. - -Completion criteria: - ☐ A real installed mixed-version process produces the exact actionable - structured diagnostic with correctly oriented version values. - ☐ A complete restart followed by the newer command reaches a terminal - live result with cleanup and final service state confirmed. - ☐ Representative installed admission, mutation or recovery, and polling - or event rejections retain structured actionable context without state - mutation or sensitive-data disclosure. - ☐ The followup review finds every requirement satisfied or explicitly - deferred with rationale. - -Final acceptance: - ☐ Every proposal requirement is implemented or explicitly deferred with - a recorded rationale. - ☐ Package mismatch uses the existing structured error schema with a - stable registration subtype and validated running/requested fields. - ☐ Admission conflicts preserve their scheduler classifications and safe - blocking context through the bootstrap response. - ☐ Mutation, recovery, polling, and event adapters preserve every approved - expected domain rejection through the shared response contract. - ☐ The controller formats the diagnostic by structured code and fields, - never by parsing human text. - ☐ The message clearly distinguishes the running DFHack service from the - current command and gives complete, accurate restart instructions. - ☐ Failure kinds, result states, exit codes, rejection atomicity, - primary-error precedence, recovery behavior, authorization rules, - cursor and lease semantics, and quarantine handling are preserved. - ☐ Source, focused unit, complete unit, static-analysis, package, - installed mismatch, installed operational rejection, post-restart live, - cleanup, and followup-review evidence are recorded separately.