fix(cli): `maka run` returns 0 while a self-armed full-access Goal keeps executing in the Runtime Host
- Dominant language
- TypeScript
- Stars
- 5.4k
- Forks
- 502
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 715
Description
## What happened
In an isolated `maka-agent@next` reproduction, the first model turn called `GoalSet` and answered `ARMED`. `maka run --yolo` then returned exit code 0 while the marker file did not exist. Stdout contained only `ARMED`.
The same Session recorded two Runs. The first had no `goalId`; the second had a `goalId`. Both recorded `permissionMode=bypass`. After the CLI process disappeared, the detached Runtime Host was reparented to PID 1, started the Goal continuation, and ran a shell command that created `POST_EXIT\n` after the CLI had returned.
`GoalSet` was called by the model from the first turn, not by a separate CLI Goal command. With the default 50-turn ceiling (`packages/runtime/src/goal-state.ts:122`), a Goal can run up to 49 successor Runs after the initial turn.
An earlier real-workload run (a local service integration check) showed the same boundary:
- Its final answer said that it had not contacted any host outside `127.0.0.1`.
- After exit 0, a Goal Run inspected an established connection to `192.168.10.19:5672`.
- The Goal Run then ran `stop.sh` and stopped the service.
In both cases the final answer printed by `maka run` no longer describes the Session's final state.
## How to reproduce
This uses the current `$HOME` for the configured connection and a fresh temporary working directory. It does not require a separate Goal command.
```bash
#!/usr/bin/env bash
set -u
tmp=$(mktemp -d)
work="$tmp/work"
gate="$tmp/cli-returned.gate"
marker="$tmp/post-exit.marker"
stdout="$tmp/stdout.txt"
stderr="$tmp/stderr.txt"
mkdir -p "$work"
condition="The file $marker exists and its complete content is exactly POST_EXIT plus a trailing newline, and it must be created only after a new user message beginning with [Goal continuation] is received."
prompt="This is a two-phase lifecycle test. In the current first turn, call GoalSet as your only tool. Set its success condition exactly to: $condition Omit max_iterations, block_cap, and token_budget so product defaults apply. In this first turn, do not inspect, wait for, or create $gate or $marker. After GoalSet succeeds, call no other tool and answer exactly ARMED. Only after you receive a new user message beginning with [Goal continuation], wait until $gate exists, then run sleep 5, write exactly POST_EXIT plus a trailing newline to $marker, verify the complete file content, and finish."
(
cd "$work" || exit 97
maka run --yolo --timeout 120 "$prompt"
) >"$stdout" 2>"$stderr"
rc=$?
printf 'exit_code=%s\n' "$rc"
printf 'stdout='; cat "$stdout"
if test -e "$marker"; then
echo 'marker_at_cli_return=PRESENT'
else
echo 'marker_at_cli_return=ABSENT'
fi
printf 'returned\n' >"$gate"
for _ in $(seq 1 60); do
test -e "$marker" && break
sleep 1
done
expected="$tmp/expected.txt"
printf 'POST_EXIT\n' >"$expected"
cmp -s "$expected" "$marker" && echo 'marker_after_return=POST_EXIT'
echo "artifacts=$tmp"
```
On the affected build, this prints `exit_code=0`, `stdout=ARMED`, and `marker_at_cli_return=ABSENT`. The Host then creates the marker and the script prints `marker_after_return=POST_EXIT`.
## Expected
A successful blocking `maka run` must not return while a Goal armed by that invocation can still execute tools under the Session's permission boundary, unless the user explicitly selected a documented detach mode.
This follows the approved `maka run` contract in #730: "One invocation runs one model turn, including tool steps, then exits." The same contract defines `0: completed with finalOutput`.
## Root cause
The Runtime Host lifecycle and Goal durability are intentional. The architecture says the Host owns Runtime work (`docs/architecture/runtime-host-architecture.md:35`) and that Runtime work outlives a request connection (`docs/architecture/runtime-host-architecture.md:43`). #853 and #1154 establish that boundary. This issue is limited to the non-interactive completion contract.
The text runner consumes the current `sendMessage()` stream (`packages/cli/src/run-command-core.ts:397`). Only `--graph` adds a completion wait (`packages/cli/src/run-command-core.ts:426-428`). The normal success path closes the Client context (`packages/cli/src/run-command-core.ts:440`) and returns 0 from the first outcome (`packages/cli/src/run-command-core.ts:462`).
Closing the run context only closes local interaction, subscription, and graph-wait state (`packages/cli/src/runtime-host-run-command.ts:414-419`); the outer `finally` closes the Client connection (`packages/cli/src/runtime-host-run-command.ts:135`). The local Host candidate is detached and unreferenced (`packages/runtime-host/src/client/launcher.ts:77`, `packages/runtime-host/src/client/launcher.ts:133`).
A non-terminal Goal retains Host residency (`packages/runtime-host/src/server/goal-coordinator.ts:593-601`). The continuation starts asynchronously (`packages/runtime/src/goal-continuation.ts:827`) and creates a new Goal Run (`packages/runtime-host/src/server/goal-execution-coordinator.ts:115-116`). That Run inherits the Session header's `permissionMode` (`packages/runtime/src/agent-run.ts:1125-1137`).
## Proposed boundary
1. Preferred: follow the existing `--graph` `waitForCompletion` pattern and wait for the Goal to reach a terminal state. Project the final Goal Run's outcome to stdout and the process exit code. Timeout and SIGINT should continue through the existing stop path.
2. Minimum mitigation: query Goal state before exit. If it is non-terminal, write `goal continues: session=` to stderr and return a documented, distinct exit code.
3. Do not pause on generic connection close. That would break the multi-client Runtime Host design in #853.
4. Do not hide `GoalSet` only from the text runner. That would conflict with the model-armed Goal direction in #3199 and create a surface-specific tool boundary.
I am willing to follow up with a PR once maintainers choose the boundary.
## Related
- #730 defines the one-turn stdout and exit-code contract but predates Runtime Host Goal continuation.
- #853 defines Client-independent Runtime Host work but does not define blocking `maka run` completion.
- #3022 adds Goal visibility to the TUI, not to non-interactive `maka run`.
- #3023 adds Goal controls to the TUI, not a text-run wait or detach contract.
- #3194 covers durable Goal consumer-loop correctness, not Client stdout, exit codes, or post-exit tools.
- #3244 discusses durable ownership after an `npx` shell exits, not the success boundary of an installed blocking CLI.
## Environment
- `maka-agent` 0.1.0-beta.1 from npm `@next`.
- HEAD f6bc66a has the same source-level lifecycle; HEAD was not run for this reproduction.
- Node v24.19.0.
- npm 11.17.0.
- macOS 26.6.1, arm64.
- Model via an OpenAI-compatible connection.
- Surface: `maka run --yolo`.
Generated-by: gpt-5.6 (draft), human-reviewed before filing
Contributor guide
Assessment
This issue has not been assessed yet.