Skip to content

feat: structured error envelope and deterministic exit on SIGTERM/SIGINT #130

Description

@rshade

Overview

Make ax.Execute install SIGTERM/SIGINT handling so that a killed process
emits one final structured error envelope on stderr and returns a documented,
deterministic exit code — instead of a bare 130 with truncated logs. The
"stderr always carries a structured error" invariant should hold even in death.

User Story

As an agent host that routinely kills hung or over-budget child processes, I
want a terminated ax-go CLI to leave one final machine-readable error envelope
and a deterministic exit code, So that my parser contract has no hole exactly
when things go wrong.

Problem Statement

Agent hosts kill processes routinely (over budget, hung, cancelled turn). Today
ax-go installs no signal handling: ax.Execute runs root.ExecuteContext(ctx)
with no signal.NotifyContext, so SIGINT/SIGTERM produce Go's default behavior
— a bare exit (130 for SIGINT) and whatever logs happened to flush. The central
ax-go invariant that stderr always carries a structured error is violated
precisely at termination, the moment an agent most needs a parseable signal.

Proposed Solution

Wrap the execution context in signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) inside ax.Execute. When a signal cancels the context,
root.ExecuteContext returns a cancellation error; normalize it to a structured
ax.Error (e.g. error_code: "interrupted"/"terminated") written via
WriteError to stderr, and return a documented deterministic exit code. The OTel
flush-on-exit path already deferred in Execute still runs so telemetry is not
truncated.

Technical Approach

  • In Execute (execute.go), derive a signal-aware context with
    signal.NotifyContext and defer its stop, before root.ExecuteContext.
  • Distinguish signal-induced cancellation from ordinary errors in
    normalizeExecuteError and produce the signal envelope with a fixed
    error_code and exit code.
  • Decide the deterministic mapping in the spec. Two candidates: (a) reuse the
    existing context.Canceled → ExitInternal (1) mapping in
    contract/error.go for the structured path, or (b) adopt a documented
    128+signum convention (130 for SIGINT, 143 for SIGTERM). The AX contract
    favors the fixed 0-4 codes, so (a) with a clearly documented envelope is the
    likely default; the spec pins it.
  • Ensure the deferred OTel shutdown and any Loki flush still complete within the
    shutdown budget so the final envelope and telemetry are not truncated.

Files Likely Affected

  • execute.go - install signal.NotifyContext; detect signal cancellation; emit the structured death envelope; map the exit code
  • contract/error.go - signal error_code construction and the deterministic exit mapping (reuse context.Canceled → ExitInternal or a documented convention)
  • contract/exit.go - documentation of the signal-death exit mapping if a new constant/convention is chosen
  • README.md, docs/ - document signal behavior and the exit mapping
  • examples/integration/ - optionally demonstrate/verify graceful termination

Acceptance Criteria

  • SIGINT and SIGTERM each cause ax.Execute to write exactly one structured ax.Error envelope to stderr
  • The exit code on signal death is deterministic and documented
  • The deferred OTel shutdown (and Loki flush) still runs so telemetry/the final envelope are not truncated
  • Nothing leaks to stdout on signal death
  • Tests pass with adequate coverage (per AGENTS.md testing-first discipline); the concurrent signal path passes -race
  • Documentation updated (signal behavior + exit mapping)

Out of Scope

  • In-command checkpoint/resume on interrupt (resume-token convention, F15) — F10 only guarantees a structured final envelope, not resumability.
  • Per-command custom signal handlers; F10 provides the library-level invariant.

Technical Notes

Additive behavior change: adds a structured envelope + documented exit on a path
that currently produces none; no existing output field is removed or retyped
(Constitution XI). The signal error_code and exit mapping belong in the
import-isolated contract package (source of truth), never the runtime. Concurrency
is central — signal delivery races with normal completion and with the OTel/Loki
flush goroutines, so -race coverage is required (AGENTS.md mandates the race
detector). The final envelope must have no non-deterministic content beyond the
documented fields (trace_id). Routes through Spec Kit (issue → spec → plan →
tasks).

Testing Strategy

  • Unit/integration test that sends SIGINT/SIGTERM to a running Execute and asserts a single structured envelope + deterministic exit
  • Golden-file test for the signal-death envelope JSON shape
  • -race test exercising signal-vs-completion and signal-vs-flush ordering
  • stdout/stderr separation assertion on the death path

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/mediumMedium effort - half day to 1 dayenhancementNew feature or requestroadmap/futureFuture milestones - Future Vision

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions