Skip to content

fix(cli): settle signal-terminated shell commands as 128 + signum - #12698

Merged
marius-kilocode merged 1 commit into
mainfrom
complete-asterisk
Jul 30, 2026
Merged

fix(cli): settle signal-terminated shell commands as 128 + signum#12698
marius-kilocode merged 1 commit into
mainfrom
complete-asterisk

Conversation

@marius-kilocode

Copy link
Copy Markdown
Collaborator

When a command run through the Bash tool dies from a signal, the spawned process produces no numeric exit code (Node reports code: null, signal: 'SIGSEGV'). This is the common case, not an edge case: bash -c execs a single simple command, so any segfaulting binary kills the spawned process by signal.

The spawner's exitCode effect failed in that situation, and the shell tool's Effect.raceAll only settles on the first success, so the call kept waiting for the abort or timeout branches. The tool looked hung until the full Bash timeout elapsed, and other consumers of exitCode (AppProcess, command timeout handling, project git helpers) surfaced an opaque Unknown: ChildProcess.exitCode error instead of a status.

Report signal termination the way every POSIX shell does, as 128 + signum (139 for SIGSEGV, 143 for SIGTERM). The mapping lives in a Kilo-owned helper (packages/core/src/kilocode/exit-code.ts) and the shared spawner keeps only two kilocode_change marked lines, so all exitCode consumers settle with a meaningful code at once. Regression coverage sits in Kilo-owned test files: a mapping and live-spawner test in packages/core, and an end-to-end Bash tool test that terminates the shell with SIGSEGV and asserts the call settles promptly with exit 139 instead of waiting out its configured timeout.

Supersedes #12679, which mapped the failure to exit code 1 inside the shell tool only. That left the other exitCode consumers hanging and discarded the signal information that a 128 + signum code preserves.

Fixes #12677

@@ -443,13 +444,7 @@ export const make = Effect.gen(function* () {
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: This null check is now duplicated inside settle, so the settle([code, signal]) branch below can never observe a non-null code.

Since settle already handles the numeric case and returns an Effect, the whole callback could collapse to exitCode: Effect.flatMap(Deferred.await(signal), settle). That removes the dead branch and shrinks the diff against upstream in this shared file to a single changed line, which matches the stated intent of the PR. Your call.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

command,
),
)
return settle([code, signal]) // kilocode_change - signal termination settles as 128 + signum

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: Settling immediately on signal termination also changes process-group cleanup, and the release handler above isn't updated for it.

The acquireRelease release path only group-kills when the leader exited with a non-null, non-zero code (if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))). For a signal death code stays null, so no killGroup runs. Previously the shell tool never reached scope close promptly: it waited out its timeout branch and then called handle.kill({ forceKillAfter }), which does killGroup and reaped descendants of the shell. Now the scope closes as soon as the leader dies, so background descendants of a signal-killed shell (e.g. sleep 300 & kill -SEGV $$) survive with their pipes still open.

Extending the release condition to also group-kill when the leader died from a signal would preserve the old cleanup guarantee. Not a blocker for the hang fix itself.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread packages/core/src/kilocode/exit-code.ts Outdated
// bash tool waiting on a numeric code that never arrived.
export const settle = ([code, signal]: readonly [code: number | null, signal: NodeJS.Signals | null]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
if (Predicate.isNotNull(signal)) return Effect.succeed(ExitCode(128 + constants.signals[signal]))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: constants.signals is typed as carrying every NodeJS.Signals key, but at runtime it only contains the signals the current platform defines (SIGINFO is absent on Linux, and Windows exposes a small subset). An unmapped name evaluates to 128 + undefined, i.e. ExitCode(NaN), which then flows into metadata.exit and out over the SDK as a non-numeric exit code — a quieter failure mode than the error this replaces.

Node normally only reports signal names that exist on the platform, so this is defensive rather than a live bug, but a guarded lookup (check the value is a number, otherwise fall back to 128) would keep the result numeric in all cases. No suggestion block here because ?? on the non-nullable typed lookup would itself trip the typechecker.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Incremental review of 1d360c8c -> 4d093bb6. All previously raised suggestions are resolved:

  • The duplicated null-code check in cross-spawn-spawner.ts is gone; exitCode now pipes Deferred.await(signal) straight into settle, keeping the shared-file diff to a single marked line.
  • settle now guards the constants.signals lookup, so an unknown/platform-absent signal falls back to exit code 1 instead of producing ExitCode(NaN), with a regression assertion covering it.
  • The process-group cleanup concern raised earlier is pre-existing behavior in the unchanged acquireRelease release path (a null code already skipped killGroup before this PR), so it is not carried forward as a finding on this diff.
Files Reviewed (3 files)
  • packages/core/src/cross-spawn-spawner.ts
  • packages/core/src/kilocode/exit-code.ts
  • packages/core/test/kilocode/exit-code.test.ts
Previous Review Summary (commit 1d360c8)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 1d360c8)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 3
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/core/src/cross-spawn-spawner.ts 446 Null-code check is duplicated inside settle; passing settle directly reduces the shared-file diff to one line and drops a dead branch
packages/core/src/cross-spawn-spawner.ts 447 Immediate settle means the release handler skips killGroup for signal deaths (code === null), so background descendants of a signal-killed shell are no longer reaped
packages/core/src/kilocode/exit-code.ts 12 constants.signals[signal] can be undefined at runtime despite its type, yielding ExitCode(NaN); a guarded lookup keeps the code numeric
Files Reviewed (5 files)
  • packages/core/src/cross-spawn-spawner.ts - 2 issues
  • packages/core/src/kilocode/exit-code.ts - 1 issue
  • packages/core/test/kilocode/exit-code.test.ts - 0 issues
  • packages/opencode/test/kilocode/tool/shell-signal.test.ts - 0 issues
  • .changeset/fuzzy-otters-signal.md - 0 issues

The core approach looks right: mapping signal termination to 128 + signum settles every exitCode consumer at once, the Kilo-specific logic lives in a Kilo-owned file, the shared spawner keeps a minimal marked diff, and both regression tests exercise the real spawner/tool rather than mocks. All findings are non-blocking suggestions.

Fix these issues in Kilo Cloud


Reviewed by claude-opus-5 · Input: 26 · Output: 3.9K · Cached: 506.8K

Review guidance: REVIEW.md from base branch main

@marius-kilocode
marius-kilocode merged commit 1d1630b into main Jul 30, 2026
43 of 48 checks passed
@marius-kilocode
marius-kilocode deleted the complete-asterisk branch July 30, 2026 15:26
t7tran pushed a commit to t7tran/kilocode that referenced this pull request Aug 14, 2026
teknium1 added a commit to NousResearch/hermes-agent that referenced this pull request Aug 17, 2026
Port from Kilo-Org/kilocode#12698: report signal-terminated commands with
a human-readable note instead of a bare numeric exit code.

Kilo's fix settles a signal-killed process as the conventional 128+signum
exit code so its bash tool stops hanging. Hermes already produces numeric
codes for signal deaths (subprocess -signum, or the shell's 128+signum),
but the model saw a bare exit_code=-9 or 137 and burned turns
mis-diagnosing (137 = OOM kill being the most common). This adapts the
idea to Hermes' existing exit-code semantics tier:

- _interpret_signal_exit(): maps negative codes (definite signal death)
  and the 128+signum band (hedged with 'usually') to a note naming the
  signal and its likely cause, wired into _interpret_exit_code() ahead of
  the per-command semantics table.
- Curated signal table (SIGKILL/SIGSEGV/SIGTERM/SIGABRT/...) so ambiguous
  application exit codes are never mislabeled; uncurated 128+N codes stay
  silent, SIGINT is excluded (executor's interrupt-marker path owns
  rc=130).
- Notes surface via the existing exit_code_meaning result field.

E2E verified against real SIGSEGV/SIGKILL processes.
teknium1 added a commit to NousResearch/hermes-agent that referenced this pull request Aug 17, 2026
Port from Kilo-Org/kilocode#12698: report signal-terminated commands with
a human-readable note instead of a bare numeric exit code.

Kilo's fix settles a signal-killed process as the conventional 128+signum
exit code so its bash tool stops hanging. Hermes already produces numeric
codes for signal deaths (subprocess -signum, or the shell's 128+signum),
but the model saw a bare exit_code=-9 or 137 and burned turns
mis-diagnosing (137 = OOM kill being the most common). This adapts the
idea to Hermes' existing exit-code semantics tier:

- _interpret_signal_exit(): maps negative codes (definite signal death)
  and the 128+signum band (hedged with 'usually') to a note naming the
  signal and its likely cause, wired into _interpret_exit_code() ahead of
  the per-command semantics table.
- Curated signal table (SIGKILL/SIGSEGV/SIGTERM/SIGABRT/...) so ambiguous
  application exit codes are never mislabeled; uncurated 128+N codes stay
  silent, SIGINT is excluded (executor's interrupt-marker path owns
  rc=130).
- Notes surface via the existing exit_code_meaning result field.

E2E verified against real SIGSEGV/SIGKILL processes.
mikeholownych added a commit to mikeholownych/charterforge that referenced this pull request Aug 17, 2026
…ite (#8)

* fix(config): parse list/mapping literals in hermes config set

Fold the list/mapping parser INSIDE the existing string-typed-value coercion guard (the `not isinstance(_default_value_for_key(key), str)` block from e4ea0a0) instead of running it unconditionally, so a genuinely string-typed setting whose value merely starts with '[' or '{' is left untouched while non-string keys get JSON/YAML flow literals parsed to real lists/dicts.

Update website/docs/user-guide/configuring-models.md: the `config set only writes scalar values` note is no longer accurate; document the list/mapping support with a quoted example.

Fixes NousResearch#40545 NousResearch#50168

* fix(config): extend structured-value parsing to multi-line YAML blocks with a conservative trigger

Consolidation follow-up on top of NousResearch#59182's cherry-picked base:

- Add _looks_structured_value(): triggers a yaml.safe_load structured
  parse only when the value starts with '[' / '{' or spans multiple
  lines with YAML list-item ('- x') or mapping-entry ('key: v') shaped
  lines. Deliberately avoids the over-broad leading '-' trigger from
  NousResearch#88066 so '-5' and '--flag' stay strings.
- Stays folded INSIDE the string-typed-key guard: keys whose
  DEFAULT_CONFIG type is str (e.g. approvals.mode) are never coerced.
- Tests: multi-line YAML list/dict, string-typed key given '[x]' and
  '-5' stays string, dash-prefixed scalars stay strings, plain
  multi-line prose stays a string, load_config round-trip.
  Sabotage-verified: 7 of the suite's tests fail on main without the fix.

* Port from can1357/oh-my-pi#7553: allow quoted shell metacharacters in allowlist matching

command_allowlist glob rules (e.g. 'cargo *') rejected any command whose
quoted arguments contained shell metacharacters — a cargo benchmark
regex filter like '^layer3/write/(a|b)$' disqualified the whole command
even though those characters are literal to the shell.

_has_allowlist_shell_operator is now quote-aware:
- metacharacters inside single/double quotes or behind a backslash are
  treated as literal arguments;
- $ and backtick inside DOUBLE quotes still disqualify (expansion is
  active there);
- quoted/escaped control characters still disqualify when the command
  carries a -c/-e/--command/--eval-style option that hands the payload
  to another interpreter (sh -c '...', git -c alias.x='!...' x);
- unterminated quotes disqualify (shape can't be reasoned about).

Compound commands (unquoted ; & | < > backtick $( newline) are rejected
exactly as before. hermes_cli/approvals_suggest.derive_glob picks up the
same semantics via its existing import.

* feat(terminal): interpret signal-termination exit codes for the model

Port from Kilo-Org/kilocode#12698: report signal-terminated commands with
a human-readable note instead of a bare numeric exit code.

Kilo's fix settles a signal-killed process as the conventional 128+signum
exit code so its bash tool stops hanging. Hermes already produces numeric
codes for signal deaths (subprocess -signum, or the shell's 128+signum),
but the model saw a bare exit_code=-9 or 137 and burned turns
mis-diagnosing (137 = OOM kill being the most common). This adapts the
idea to Hermes' existing exit-code semantics tier:

- _interpret_signal_exit(): maps negative codes (definite signal death)
  and the 128+signum band (hedged with 'usually') to a note naming the
  signal and its likely cause, wired into _interpret_exit_code() ahead of
  the per-command semantics table.
- Curated signal table (SIGKILL/SIGSEGV/SIGTERM/SIGABRT/...) so ambiguous
  application exit codes are never mislabeled; uncurated 128+N codes stay
  silent, SIGINT is excluded (executor's interrupt-marker path owns
  rc=130).
- Notes surface via the existing exit_code_meaning result field.

E2E verified against real SIGSEGV/SIGKILL processes.

* Port from MoonshotAI/kimi-code#2596/NousResearch#2600: surface MCP tool-result _meta to the model, minus protocol-reserved keys

MCP tool results carry a server _meta mapping (exposed as .meta by the
Python SDK) alongside structuredContent. Servers return namespaced
machine-readable contracts there (validated payloads, browser-handoff
URLs); Hermes previously dropped the field entirely, so that data was
invisible to the agent.

Now _meta is included in the JSON tool output, after filtering
protocol-reserved keys per the MCP spec's key-name rules: a prefix is
reserved when a modelcontextprotocol or mcp label is followed by at
least one more label (modelcontextprotocol.io/..., tools.mcp.com/...).
Vendor namespaces with a trailing reserved word (com.example.mcp/...)
and unprefixed keys pass through. Non-serializable metadata drops the
extras rather than failing the call.

* fix(telegram): rebind TypeHandler in the deferred SDK import

`check_telegram_requirements()` re-imports python-telegram-bot after a
lazy install and rebinds the module-level aliases that the top-level
`except ImportError` block set to `typing.Any`. TypeHandler was left out
of all three places: the `global` declaration, the
`from telegram.ext import (...)` list, and the assignments.

So whenever the top-level import fails and the deferred path runs, every
other alias is restored and TELEGRAM_AVAILABLE flips to True, while
TypeHandler stays `Any`. Handler registration then raises
`TypeError: Any cannot be instantiated` and the gateway reports:

    [Telegram] Failed to connect to Telegram: Any cannot be instantiated
    Gateway started with no connected platforms

The 22.6 -> 22.8 pin bump named in NousResearch#85272 is the trigger rather than the
defect: it makes the top-level import fail, which is what routes the
module through the deferred path where the omission has always been.

* Port from aaif-goose/goose#10746: strip invisible Unicode TAG chars from MCP content

Unicode TAG characters (U+E0000-U+E007F) render as nothing in terminals
and chat UIs but are fully visible to LLM tokenizers, making them an
ASCII-smuggling prompt-injection channel for untrusted MCP servers.

- tools/ansi_strip.py: new strip_unicode_tags() with fast path; unlike
  goose we preserve valid emoji tag sequences (U+1F3F4 base + tag spec +
  U+E007F cancel), so regional flags survive.
- tools/mcp_tool.py: applied at every MCP text ingestion point — tool
  result text blocks, embedded resource text, read_resource contents,
  get_prompt message content, and tool descriptions entering the schema.
- tests/tools/test_unicode_tag_strip.py: smuggled-instruction vectors,
  goose's test vector, emoji-tag-sequence preservation, ZWJ untouched.

* feat(business-os): implement Waves 1-33 strategic enhancements and full E2E QA suite

- Add Waves 1-33 capabilities across finance, governance, GTM, SEO, and top-tier web/graphic design.
- Implement Design System Tokens, WebGL Shaders, Motion Architecture, and Skeleton Shimmer Loaders.
- Add WCAG 2.1 AAA Accessible Keyboard Focus Rings, Adaptive Breakpoints, and Fluid Typography Scalers.
- Fix pre-existing unit test timing issues and model catalog mocking in test_models, test_objective_worker, and authority_integrity.
- Create comprehensive E2E functional test suite (test_full_qa_e2e_workflow.py) verified across 381 unit & integration tests.

* chore(contributors): add contributor mapping for paul.lesyuk@gmail.com

---------

Co-authored-by: Sam Liu <sam7894604@gmail.com>
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Co-authored-by: Pavel Lesyuk <paul.lesyuk@gmail.com>
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.

Bash tool hangs when called command segfaults

2 participants