Skip to content

feat(core): structured execution outcomes and configurable exit codes - #85

Open
carldebilly wants to merge 1 commit into
mainfrom
dev/cdb/issue-81-exit-codes
Open

feat(core): structured execution outcomes and configurable exit codes#85
carldebilly wants to merge 1 commit into
mainfrom
dev/cdb/issue-81-exit-codes

Conversation

@carldebilly

Copy link
Copy Markdown
Member

Summary

  • classify every run that reaches the core pipeline into a structured ReplExecutionOutcome (ReplExecutionOutcomeKind: Success, Help, UsageError, BindingError, HandlerError, HandlerExitCode, HandlerException, Cancelled, Interrupted, FrameworkError)
  • map the kind to an integer exactly once, at the top of CoreReplApp.ExecuteCoreAsync, through the new ReplOptions.ExitCodes table (ExitCodeOptions) and then through the optional ExitCodes.Resolver hook
  • change the default codes: framework refusals and binding failures exit 2; handler failures stay 1; help and success stay 0; Results.Exit(n) keeps its code verbatim
  • add ExitCodes.Cancelled (int?, unset by default) to turn a caller-token cancellation into an exit code instead of letting OperationCanceledException escape RunAsync
  • expose the handler return value to middleware through ReplExecutionContext.Result (readable and replaceable after await next(), settable by a short-circuiting middleware); ReplNext and Use are unchanged
  • route interactive shell-integration mark codes (including Ctrl+C) through the same table and resolver
  • keep MCP sub-invocations on the built-in defaults, ignoring the resolver
  • keep Repl.Testing's per-command TimeoutException working when the app under test maps Cancelled
  • document the exit-code contract for headless child processes and state explicitly that a handler's int return value is data, never an exit code

Fixes #81.

Design notes

  • The single policy point lives in Repl.Core; src/Repl.Defaults/ReplApp.cs, ReplRunOptions.cs and ReplAppProfileExtensions.cs are intentionally untouched so this does not collide with fix(runtime): cancel standalone runs on process signals #80. ReplExecutionOutcomeKind.Interrupted and CoreReplApp.ResolveExitCode(ExecutionOutcome, bool) are the seam for the process-signal bridge to route SIGINT/SIGTERM through the same resolver.
  • Cancelled defaults to null (rethrow) for the same reason: fix(runtime): cancel standalone runs on process signals #80 catches the OperationCanceledException in ReplApp to produce 130/143, and a core default would make that catch unreachable. Consumers opt in with o.ExitCodes.Cancelled = 130.
  • Binder failures are told apart from handler failures with a bound flag around HandlerArgumentBinder.Bind, so a handler-thrown InvalidOperationException is HandlerException while conversion/[FromServices] failures are BindingError.
  • Known bypass, documented: hosted-service start/stop failures in ReplApp.RunAsync still return 1 directly (same file as fix(runtime): cancel standalone runs on process signals #80; to be routed through the policy afterwards).

Breaking changes

See CHANGELOG.mdChanged — breaking: framework exit codes:

  • unknown command, ambiguous prefix, invalid/colliding option, context validation failure, unknown --output format, ambient misuse in one-shot mode, unrenderable help → 2 (was 1); unbindable/unconvertible arguments → 2 (was 1)
  • every Run/RunAsync overload now checks the caller's token before doing any work (previously only CoreReplApp.RunAsync did)
  • Repl.Testing.CommandExecution.ExitCode follows the policy; MCP failure text reads "exit code 2" for those cases

Validation

  • dotnet build src/Repl.slnx -c Release -warnaserror — 0 warnings, 0 errors
  • test suites: Repl.Tests 688, Repl.IntegrationTests 554, Repl.McpTests 224 (+1 pre-existing opt-in skip), Repl.SpectreTests 17, Repl.ProtocolTests 6, samples/06-testing 11 — all green
  • TDD: Given_ExitCodes (34 tests) written first and observed red (29/30) before the implementation; 53 integration assertions flipped Be(1)Be(2), each verified to be a usage/binding refusal
  • new coverage: interactive marks honour the table, the resolver and a configured Cancelled; MCP ignores both table and resolver; Repl.Testing timeout with Cancelled mapped; enum exhaustiveness guard on ExitCodeOptions.Map
  • real-process smoke on samples/01-core-basics: unknown command → 2, --help0
  • markdownlint 0 issues, git diff --check clean
  • review agents skeptic, quality, contract run before commit; all findings addressed

Every run that reaches the core pipeline now ends in a ReplExecutionOutcome
(ReplExecutionOutcomeKind: Success, Help, UsageError, BindingError,
HandlerError, HandlerExitCode, HandlerException, Cancelled, Interrupted,
FrameworkError). The kind is mapped to an integer once, at the top of
CoreReplApp.ExecuteCoreAsync, through the new ReplOptions.ExitCodes table
(ExitCodeOptions) and then handed to the optional ExitCodes.Resolver hook.

Default codes change: framework refusals (unknown command, ambiguous prefix,
invalid or colliding option, context validation, unknown output format,
ambient misuse) and binding failures exit 2 instead of 1; handler failures
stay 1, help and success stay 0; Results.Exit keeps its code verbatim.
ExitCodes.Cancelled (int?, unset by default) turns a caller-token
cancellation into an exit code instead of letting the exception escape.

ReplExecutionContext.Result exposes the handler return value to middleware,
readable and replaceable after next(); ReplNext and Use are unchanged.

The interactive loop resolves shell-integration mark codes through the same
table and resolver, including Ctrl+C (conventional 130 unless remapped).
MCP sub-invocations keep the built-in defaults and skip the resolver.
Repl.Testing still raises TimeoutException when the app under test maps
Cancelled. Interrupted is reserved for the process-signal bridge (#79/#80).

Closes #81.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T17:54:22.487553Z 71c0042 Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71c0042168

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +209 to +211
var ambientExecution = ambientOutcome == AmbientCommandOutcome.HandledError
? ExecutionOutcome.Usage()
: ExecutionOutcome.Success;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Classify interactive help as Help

When an interactive user enters the built-in help or ? ambient command, a successful render reaches this blanket Success branch rather than ReplExecutionOutcomeKind.Help. Consequently, applications that configure ExitCodes.Help differently from Success, or whose resolver switches on Kind, emit the wrong shell-integration command-end code for interactive help; distinguish the help ambient entry and resolve it as ExecutionOutcome.Help.

Useful? React with 👍 / 👎.

cancellationToken.ThrowIfCancellationRequested();
var outcome = await ExecuteCoreOutcomeAsync(args, serviceProvider, isSubInvocation, cancellationToken)
.ConfigureAwait(false);
return ResolveExitCode(outcome, isSubInvocation);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the resolver outside the cancellation catch

When a configured resolver throws OperationCanceledException—for example, because it closes over a cancellation token—this call is still inside the catch's protected region, so the exception is reclassified as a cancelled run and ResolveExitCode invokes the resolver a second time from the catch block. This violates the documented once-per-run contract, can duplicate resolver side effects, and may replace a resolver failure with the configured cancellation code; narrow the cancellation catch to execution of the pipeline and invoke the final resolver afterward.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose structured execution outcomes and configurable framework exit codes

1 participant