Skip to content

Channel B completion sends SIGTERM to a live worker before continuation #5283

Closed

Description

@Trecek

Fix target and integration order — mandatory routing

Repository: TalonT-Org/AutoSkillit.
Fix PR base branch: develop, baseline 9684c4e0e2ce195fdce69502e552f0bc9cfb08ff or its descendant.

This is an existing managed-runner/runtime defect. Its source files already exist on develop and do not depend on the unmerged E2E collector. Open the fix against develop, merge it there, then integrate that commit into parent PR #5255 before commissioning two fresh implementation attempts. Do not target the parent branch just because its verification exposed this defect. This blocks successful commissioning; deferred product-observer follow-up #5275 is separate.

Defect and observed consequence

A live Claude worker can put its per-invocation completion marker in an assistant transcript record, then receive Stop-hook error/feedback. Channel B treats the assistant marker as a completion candidate. With lifecycle observation enabled and no pending native-child or scheduled-wake obligation, the runner settles that candidate while the process is still live. The B-only path resolves NATURAL_EXIT and selects NO_KILL, but evidence settlement calls owned-group cleanup, which sends SIGTERM. The runner can therefore suppress a delayed continuation and return -15 while reporting kill_reason=natural_exit.

The source path and a no-model process probe reproduce that combination. The two live failures match it, but their retained diagnostics do not directly identify the signal sender; attribution of those particular kills to this path remains source/probe-supported inference.

Live evidence at an integrated, current source revision

Both MiniMax implementation attempts used parent head 96a18e72922040f4951b343195aff1188503d7d5, containing develop9684 plus all #5270–#5274 fixes:

  • Run 38746791327, attempt 1, artifact e2e-implementation-38746791327-1. Planning worker e57ba2fc-3e6d-4e68-9752-06ba34cd12c3, about 81 seconds, exit -15, unparseable, retry_reason resume, kill_reason natural_exit. Raw stdout records 290–293 contain final plan tokens/ORDER_UP followed by Stop-hook feedback and a stop-hook-error notification. No native type=result follows.
  • Run 38746802415, attempt 1, artifact e2e-implementation-38746802415-1. Planning worker f704c80e-173e-40c1-9bbd-715996d26921, about 103 seconds, same exit/classification. Raw stdout records 352–355 contain the analogous sequence.

In each artifact, worker evidence is under logs/sessions/<worker>/raw_stdout.jsonl, audit_log.json, summary.json and turn_usage.jsonl. The audit stderr includes Session terminated, killing shell.... The plans are retained under out/pipeline-artifacts/<clone>/make-plan/; they were actually written. Root callers passed an empty plan_parts to salvage and reported the plans unsalvageable. That recovery behavior is not evidence that the files were absent.

The workers received explicit required-join instructions and had connected declaration/exploration tools and registered child roles, but made no declaration or child call. In both artifacts, logs/join_diagnostics.jsonl record 1 reports a valid binding and guard status=block, with join-bearing skill loaded but no declared wave for this parent. This model-contract failure must remain enforced.

The CLI transcript's stop_hook_summary also contains hookErrors for that guard, preventedContinuation=false, and level=suggestion (run1 worker transcript record 72; run2 record 100). The actual hook exit status and subsequent CLI continuation are not retained. Do not use these fields alone to claim the full guard-to-CLI continuation sequence was verified or to identify the signal sender. The source contract and synthetic process probe establish the runtime boundary; a real guard-composition regression is required below.

These workers' observed context-window values are unknown because no final CLI result/telemetry arrived. Do not classify this as a new proven 200K-window regression. Lifecycle coverage is complete and peak two is only the truncated planning phase. Regular Tests 38746768152 and canary/clean-install 38746768349 passed at the same head.

Deterministic no-model reproduction

Use the pinned develop checkout with its configured task install-worktree environment. Save the following as .autoskillit/temp/investigate/channel_b_stop_probe.py, then run .venv/bin/python .autoskillit/temp/investigate/channel_b_stop_probe.py from the repository root. It starts only its own temporary child processes, uses the real production run_managed_async and ClaudeStreamParser, and makes no model/network calls. No test runner is invoked.

The child first creates its transcript, pauses so the monitor can attach, appends an assistant marker and synthetic Stop-rejection feedback, then waits three seconds before recording that continuation ran and emitting a native terminal result. The synthetic producer does not invoke the actual SDK or guard; it isolates the runner boundary. The live transcripts supply the real guard sequence.

"""No-model process-runner probe; launches and cleans up only its own child."""
import json
import sys
import time
from pathlib import Path
from uuid import uuid4

import anyio

from autoskillit.execution.backends._claude_parse import ClaudeStreamParser
from autoskillit.execution.process import run_managed_async


def child(root: Path, sid: str, marker: str) -> None:
    print(json.dumps({"type": "system", "subtype": "init", "session_id": sid}), flush=True)
    transcript = root / "logs" / (sid + ".jsonl")
    transcript.write_text(json.dumps({"type": "system", "sessionId": sid}) + "\n")
    time.sleep(0.5)
    records = [
        {"type": "assistant", "sessionId": sid,
         "message": {"content": [{"type": "text", "text": "plan_path = example.md\n" + marker}]}},
        {"type": "user", "sessionId": sid,
         "message": {"content": [{"type": "text", "text": "Stop hook feedback: join-bearing skill loaded but no declared wave for this parent"}]}},
    ]
    with transcript.open("a") as stream:
        stream.write("".join(json.dumps(r) + "\n" for r in records))
    (root / "feedback_written").write_text("yes")
    time.sleep(3)
    (root / "continuation_ran").write_text("yes")
    print(json.dumps({"type": "result", "subtype": "success", "session_id": sid,
                      "result": "Continuation completed\n" + marker}), flush=True)


async def probe() -> None:
    project = Path(__file__).resolve().parents[3]
    base = Path(__file__).resolve().parent / ("channel-b-stop-probe-" + uuid4().hex)
    base.mkdir()
    for channel_b in (False, True):
        root = base / ("channel-b" if channel_b else "native-result-control")
        (root / "logs").mkdir(parents=True)
        sid = str(uuid4())
        marker = "%%ORDER_UP::" + uuid4().hex[:8] + "%%"
        start = time.monotonic()
        result = await run_managed_async(
            [sys.executable, str(Path(__file__).resolve()), "child", str(root), sid, marker],
            cwd=project,
            timeout=10,
            completion_marker=marker,
            session_log_dir=root / "logs" if channel_b else None,
            stream_parser=ClaudeStreamParser(completion_marker=marker),
            lifecycle_observation_enabled=True,
            natural_exit_grace_seconds=4,
            completion_drain_timeout=2,
            child_deferral_ceiling=5,
            _phase1_poll=0.05,
            _phase2_poll=0.05,
            _heartbeat_poll=0.05,
        )
        summary = {
            "channel_b": channel_b,
            "elapsed": round(time.monotonic() - start, 3),
            "feedback_written": (root / "feedback_written").exists(),
            "continuation_ran": (root / "continuation_ran").exists(),
            **{key: getattr(result, key, None) for key in
               ("returncode", "kill_reason", "termination", "channel_confirmation",
                "lifecycle_observation_complete", "pending_task_ids")},
        }
        print(json.dumps(summary, default=str), flush=True)
    print("probe_dir =", base)


if len(sys.argv) > 1 and sys.argv[1] == "child":
    child(Path(sys.argv[2]), sys.argv[3], sys.argv[4])
else:
    anyio.run(probe)

Verified output, with elapsed values varying:

control: channel_a, returncode=0, continuation_ran=true
Channel B: channel_b, returncode=-15, kill_reason=natural_exit,
           termination=natural_exit, continuation_ran=false,
           lifecycle_observation_complete=true, pending_task_ids=[]

The final recorded probe finished the control in about 4.3 seconds and terminated the Channel B child in about 1.1 seconds, before the promised continuation. An initial probe that wrote its marker before the monitor attached did not take this path: fresh monitors intentionally skip pre-existing transcript content. Keep the attach-before-marker ordering above.

lifecycle_observation_complete=true describes the observed native lifecycle; it does not establish that the required skill wave exists or that Stop accepted completion.

Personally checked source path

The official Stop decision-control documentation, read 2026-10-15, also specifies that exit code 2 returns the reason to Claude so it can continue. That is the continuation opportunity the current runner path can preempt.

Required correction and meaningful regression checks

Preserve a live worker's opportunity to continue when the Stop guard blocks completion. An assistant Channel B marker alone must not settle a required-join session whose completion remains unapproved. Keep bounded timeout/cancellation and owned-process cleanup, retain valid Channel B fallback behavior, including when process exit precedes marker detection, and report any actual runner-initiated termination honestly.

Add a production-shaped managed-runner regression with an attached transcript and assistant marker that invokes the actual join Stop guard against a required binding with no declared wave. Capture its block/exit-2 result, then model CLI continuation and delayed native terminal result in the runner fixture. Assert continuation is not preempted and the worker can complete the required work. Synthetic feedback text alone is insufficient for this regression. Cover approved completion, a real natural exit, existing pending-child/ScheduleWakeup deferral, and bounded timeout/cancellation with truthful kill/cleanup evidence. Reuse existing process fixtures and completion policy; no new generic supervisor or registry is requested. Keep the required-join guard intact. Run pre-commit run --all-files and configured task test-local-gate.

Related history and separate findings

Closed #4233 addressed already-running asynchronous children. This reproduction has no children and missing required wave, so that fix's native-child deferral does not protect it. Closed #3318 concerned cross-dispatch reaper attribution; closed #4641 concerned cleanup/reap verification after successful implementation. Open #2350 concerns recovery when a marker is missing; this marker is present. Dedup searches found no exact open match.

Run2 also retried an invalid-label claim, which the strict harness flags as claim_reentry. That caller/policy finding is not assigned to this runtime ticket. Both failure reporters repeat existing #5249. The unknown worker context windows and empty salvage captures are secondary observations, not independently confirmed new source bugs here.

Classification: bug, recipe:remediation. Commissioning #5255 remains incomplete until this runtime fix is integrated and two fresh implementation attempts are accepted.

Adversarial validation — 2026-10-15

Two independent reviewers read this full issue body, inspected the actual source and retained evidence, and validated the reproduction. The parent personally checked their source/record citations. Review corrections were incorporated before filing: the live signal sender and exact SDK Stop outcome remain unproven, observed native lifecycle completion is distinguished from semantic join approval, and the required regression must invoke the actual join guard and capture exit 2. The existing synthetic probe is deliberately limited to the managed-runner boundary. Preserve valid Channel B fallback and bounded cleanup.

Live integration verification — 2026-10-16

Fix #5284 is included in published PR5255 head 20ee39237e8e89a8d21f403c3a825273fcac6bf7 through develop 75141403a04ca98bb4c7e6c3af9ce03eaf838f03. The original no-model control and transcript-monitoring reproducer both now continue and exit0 with native Channel A completion. Focused configured task verification passed326 tests; all-files pre-commit passed. Full same-head Tests38960042144, including execution-channel-b, passed.

In fresh MiniMax-M3 implementation run38960061259, make-plan session 57b29067-2161-458a-827a-306c2a57d5c6 completed success/exit0/natural_exit in312.429s. In run38960069882, make-plan 0b73e059-2657-4815-8bfe-65b83eec02be completed success/exit0/natural_exit in79.155s. The prior -15/unfinished-worker failure did not recur. Every retained managed-session turn_usage row reports a1000000-token context window:149 rows in the first attempt and33 in the second, with no missing values.

Both attempts have complete lifecycle coverage but fail later for separate reasons. This validates this runner repair live and does not claim two accepted full implementation runs. Keep this issue closed; PR5255 records the remaining commissioning work.

Activity

  1. added
    bugExisting behavior is broken
    recipe:remediationRoute: investigate/decompose before implementation
    on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions