From 3fdb4cb5c353ed20a38cab3bca8d3be4b4b7e937 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 2 Sep 2026 08:57:31 +0900 Subject: [PATCH 1/2] desk: wire Fable 5.1 model id and effort max (uncommitted desk edits) --- .../sume-main-agent-orchestration/SKILL.md | 24 ++--- .../bin/agent-human-stream.sh | 92 ++++++++++++++++++- .../bin/claude-human-stream.sh | 2 +- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/sume-desk/skills/sume-main-agent-orchestration/SKILL.md b/sume-desk/skills/sume-main-agent-orchestration/SKILL.md index 53a51ad..b768fc8 100644 --- a/sume-desk/skills/sume-main-agent-orchestration/SKILL.md +++ b/sume-desk/skills/sume-main-agent-orchestration/SKILL.md @@ -175,13 +175,14 @@ rules. **Chase lock (2026-08-23) — default author is Opus.** Coding models are only **Fable / Opus / Grok**. If Chase does **not** name a model, launch **Opus** (`claude-human-stream`, default `--model opus`). Use **Fable** only when Chase -says Fable / `--model fable`. **Grok** is land/ops (and explicit Grok author -asks). Composer remains explore-only. +says Fable / `--model fable`. The wrapper **must** pin **Fable 5.1** +(`claude-fable-5-1`), not the generic `fable` alias. **Grok** is land/ops +(and explicit Grok author asks). Composer remains explore-only. | Role | Model | Transport (from a Cursor main agent) | |------|-------|--------------------------------------| | Opus (default author: design / RCA / mega-issue / **code → MQ enqueue**) | Claude Opus via Claude Code subscription | **`claude-human-stream`**. Do **not** use Cursor `Task` with `claude-opus-*`. | -| Fable (author only if Chase named Fable) | Claude Fable via same wrapper | **`claude-human-stream --model fable`**. Same Graphite enqueue path as Opus. | +| Fable (author only if Chase named Fable) | Claude **Fable 5.1** (`claude-fable-5-1`) | **`claude-human-stream --model fable`** (wrapper rewrites to `--model claude-fable-5-1`). Same Graphite enqueue path as Opus. | | Grok (**MQ land babysit / deploy / ops**; author only if Chase said Grok) | Grok Build CLI (`grok` on PATH) | **`agent-human-stream --backend grok`**. Do **not** use Cursor `Task` with `cursor-grok-*`. | | Composer (explore only) | Composer | Cursor `Task` with `composer-*` / `explore` | @@ -196,7 +197,7 @@ Pass `--effort` from the **job lane**. Cursor rule: | Lane | Opus | Fable | Grok | |------|------|-------|------| -| **Code** (implement, PR, land, deploy, RCA that edits) | `medium` | `high` | `xhigh` | +| **Code** (implement, PR, land, deploy, RCA that edits) | `medium` | `max` | `xhigh` | | **Research** (조사 / open-source scan / no code change) | `low` | `low` | `medium` | **Default research owner is Grok.** Most 조사 goes to @@ -204,15 +205,16 @@ Pass `--effort` from the **job lane**. Cursor rule: only when Chase names them — still `--effort low`. Claude Code CLI enum is `low|medium|high|xhigh|max` (Fable / Opus 4.7+). -When Chase names **max** or **xhigh**, pass that string through — -**do not** remap Fable `max` → `high`. Wrapper aliases: `maximum` → `max`, -`mid` → `medium`, `x-high` → `xhigh`. +Fable 5.1 may use the **full** Claude effort enum +(`low|medium|high|xhigh|max`). Do **not** clamp Fable at `high`. +When Chase names **max** or **xhigh**, pass that string through. +Wrapper aliases: `maximum` → `max`, `mid` → `medium`, `x-high` → `xhigh`. Grok CLI enum is `xhigh|high|medium|low` (no `max` / `mid`). The wrapper maps `mid` → `medium` and `max`/`maximum` → `xhigh` (Grok ceiling). Wrapper default when `--effort` is omitted = **code lane** (Opus -`medium`, Fable `high`, Grok `xhigh`). Named `max` is not that default. +`medium`, Fable `max`, Grok `xhigh`). Research **must** pass `--effort`. Mixed research-then-implement in one worker → code lane. @@ -238,9 +240,9 @@ When the main agent needs an **Opus** worker/subagent: Default local config already targets Opus (`~/.claude/settings.json` → `"model": "opus[1m]"`, `"effortLevel": "medium"`). `claude-human-stream` / `agent-human-stream --backend claude` injects - `--effort` when omitted: **Opus → medium**, **Fable → high** - (code lane). Named `--effort max` / `xhigh` pass through to Claude - Code (do not remap to `high`). Research → pass `--effort low`. See + `--effort` when omitted: **Opus → medium**, **Fable → max** + (code lane; Fable 5.1 may use low through max). Named `--effort` + `high` / `xhigh` / `max` pass through. Research → pass `--effort low`. See § "Worker reasoning effort". 2. Prefer the human-readable wrapper (stream-json under the hood, printable lines). Canonical launcher: `agent-human-stream` (Claude + Grok Build). diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh index 3e0278d..97165c5 100755 --- a/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh @@ -9,6 +9,7 @@ # # Extra backend flags go after the prompt: # agent-human-stream "…" --model opus +# agent-human-stream "…" --model fable # → claude-fable-5-1 (Fable 5.1) # agent-human-stream --backend grok --prompt-file job.md --effort high set -euo pipefail @@ -28,6 +29,8 @@ LIVE_DIR="${AGENT_HUMAN_STREAM_LIVE_DIR:-${CLAUDE_HUMAN_STREAM_LIVE_DIR:-$CSTACK # Claude Code CLI 2.1.x (SDK + changelog): low|medium|high|xhigh|max. # Fable / Opus 4.7+ take `max` natively. Do NOT remap max → high. # Desk aliases: mid → medium, maximum → max, x-high → xhigh. +# Desk --model fable → claude-fable-5-1 (Fable 5.1). Bare `fable` is not enough +# after Claude Code 2.1.258 (help still lists the alias; wire the full id). _claude_effort_wire() { local lc lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') @@ -40,6 +43,18 @@ _claude_effort_wire() { esac } +# Chase lock 2026-09-02: desk "Fable" is Fable 5.1, not the generic alias. +_claude_model_wire() { + local lc + lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + case "$lc" in + fable|fable-5|fable5|fable-5.1|fable-5-1|claude-fable|claude-fable-5|claude-fable-5.1|claude-fable-5-1) + printf '%s' claude-fable-5-1 + ;; + *) printf '%s' "$1" ;; + esac +} + # Grok Build CLI: xhigh|high|medium|low (no max / mid). # Aliases: mid → medium, max|maximum → xhigh (Grok ceiling). _grok_effort_wire() { @@ -69,15 +84,16 @@ Auto backend (default): --backend grok / --grok / AGENT_HUMAN_STREAM_BACKEND=grok --model grok* → grok --model opus|fable|sonnet|haiku|claude* → claude + --model fable → claude-fable-5-1 (Fable 5.1; do not leave bare fable) --resume uses the last backend recorded for that session otherwise → claude (Opus/Fable path unchanged) Claude (tokenmaxxing `claude` on this desk): claude -p … --output-format stream-json --verbose --permission-mode bypassPermissions - Default --effort when omitted (code lane): opus → medium, fable → high, + Default --effort when omitted (code lane): opus → medium, fable → max, grok → xhigh. Research must pass --effort (grok medium / opus+fable low). - Named Claude levels pass through: low|medium|high|xhigh|max. - Chase "Fable max" / --effort max → claude --effort max (not high). + Fable 5.1 may use the full Claude enum: low|medium|high|xhigh|max. + Named levels pass through. Do not clamp Fable at high. Aliases: mid → medium, maximum → max, x-high → xhigh. Grok Build (`grok` on PATH): @@ -270,6 +286,21 @@ if [[ "$SELF_TEST" -eq 1 ]]; then echo "self-test: claude effort high must stay high (got ${_got})" >&2 exit 1 fi + _got="$(_claude_model_wire fable)" + if [[ "$_got" != "claude-fable-5-1" ]]; then + echo "self-test: --model fable must wire to claude-fable-5-1 (got ${_got})" >&2 + exit 1 + fi + _got="$(_claude_model_wire claude-fable-5-1)" + if [[ "$_got" != "claude-fable-5-1" ]]; then + echo "self-test: claude-fable-5-1 must pass through (got ${_got})" >&2 + exit 1 + fi + _got="$(_claude_model_wire opus)" + if [[ "$_got" != "opus" ]]; then + echo "self-test: --model opus must stay opus (got ${_got})" >&2 + exit 1 + fi _tmp=$(mktemp -d) cat > "$_tmp/grok" <<'EOF' #!/usr/bin/env bash @@ -363,6 +394,29 @@ EOF rm -rf "$_tmp" exit 1 fi + if ! grep -qx 'claude-fable-5-1' "$_tmp/claude-argv.txt"; then + echo "self-test: claude argv missing model claude-fable-5-1 from --model fable:" >&2 + cat "$_tmp/claude-argv.txt" >&2 + rm -rf "$_tmp" + exit 1 + fi + if grep -qx 'fable' "$_tmp/claude-argv.txt"; then + echo "self-test: claude argv still has bare --model fable:" >&2 + cat "$_tmp/claude-argv.txt" >&2 + rm -rf "$_tmp" + exit 1 + fi + CLAUDE_ARGV_FILE="$_tmp/claude-argv-fable-default.txt" \ + AGENT_HUMAN_STREAM_REGISTRY="$_tmp/reg.jsonl" \ + AGENT_HUMAN_STREAM_LIVE_DIR="$_tmp/live" \ + PATH="$_tmp:$PATH" \ + "$SOURCE" --backend claude --name self-test-fable-default "self-test fable default effort" --model fable >/dev/null + if ! grep -qx 'max' "$_tmp/claude-argv-fable-default.txt"; then + echo "self-test: fable omitted --effort must default to max:" >&2 + cat "$_tmp/claude-argv-fable-default.txt" >&2 + rm -rf "$_tmp" + exit 1 + fi rm -rf "$_tmp" exec python3 "$ROOT/agent-human-stream.test.py" fi @@ -496,6 +550,13 @@ print(found) if [[ -z "$_resolved_model" && -n "$RESUME" ]]; then _resolved_model=$(lookup_session_field "$RESUME" model) fi +if [[ -n "$_resolved_model" ]]; then + _wired_model="$(_claude_model_wire "$_resolved_model")" + if [[ "$_wired_model" != "$_resolved_model" ]]; then + echo "note: mapping claude --model ${_resolved_model} → ${_wired_model} (Fable 5.1)" >&2 + _resolved_model="$_wired_model" + fi +fi if [[ "$BACKEND" == "auto" && -n "$RESUME" ]]; then _reg_backend=$(lookup_session_field "$RESUME" backend) @@ -524,7 +585,7 @@ if [[ "$BACKEND" == "claude" && "$_has_effort" -eq 0 ]]; then _model_lc=$(printf '%s' "${_resolved_model:-opus}" | tr '[:upper:]' '[:lower:]') case "$_model_lc" in fable|fable*|claude-fable*) - _effort="${CLAUDE_HUMAN_STREAM_EFFORT_FABLE:-${AGENT_HUMAN_STREAM_EFFORT_FABLE:-high}}" + _effort="${CLAUDE_HUMAN_STREAM_EFFORT_FABLE:-${AGENT_HUMAN_STREAM_EFFORT_FABLE:-max}}" ;; *) _effort="${CLAUDE_HUMAN_STREAM_EFFORT_OPUS:-${AGENT_HUMAN_STREAM_EFFORT_OPUS:-medium}}" @@ -545,6 +606,29 @@ if [[ "$BACKEND" == "claude" && ${#EXTRA[@]} -gt 0 ]]; then while [[ $_i -lt ${#EXTRA[@]} ]]; do _a="${EXTRA[$_i]}" case "$_a" in + --model|-m) + _val="${EXTRA[$((_i + 1))]:-}" + _wire="$(_claude_model_wire "$_val")" + if [[ "$_wire" != "$_val" ]]; then + echo "note: mapping claude --model ${_val} → ${_wire} (Fable 5.1)" >&2 + fi + _filtered+=("$_a" "$_wire") + _resolved_model="$_wire" + _i=$((_i + 2)) + continue + ;; + --model=*|-m=*) + _flag="${_a%%=*}" + _val="${_a#*=}" + _wire="$(_claude_model_wire "$_val")" + if [[ "$_wire" != "$_val" ]]; then + echo "note: mapping claude --model ${_val} → ${_wire} (Fable 5.1)" >&2 + fi + _filtered+=("${_flag}=${_wire}") + _resolved_model="$_wire" + _i=$((_i + 1)) + continue + ;; --effort|--reasoning-effort) _val="${EXTRA[$((_i + 1))]:-}" _wire="$(_claude_effort_wire "$_val")" diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/claude-human-stream.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/claude-human-stream.sh index ad1edb3..ad8d974 100755 --- a/sume-desk/skills/sume-main-agent-orchestration/bin/claude-human-stream.sh +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/claude-human-stream.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Compat alias: Opus/Fable keep calling `claude-human-stream`. # Implementation lives in agent-human-stream.sh (--backend claude). -# Default --effort is code lane: Opus medium, Fable high. Named max/xhigh pass through. +# Default --effort is code lane: Opus medium, Fable max (5.1 full enum through max). # Research → --effort low. set -euo pipefail SOURCE=${BASH_SOURCE[0]} From 020a8849efc73b5aafac5fa916e2d4bca293a841 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 2 Sep 2026 09:03:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?desk:=20fix=20Grok=20steer-kill=20on=20macO?= =?UTF-8?q?S=20=E2=80=94=20agent-holders=20kills=20the=20whole=20session?= =?UTF-8?q?=20group=20and=20waits=20(#5706=20PR-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sume-bg-launch --resume: pgrep -a on macOS means include-ancestors and prints pids only, so the old awk filter never matched and the steer never killed anything; two grok -p --resume on one uuid then hung the second in session_create (empty live log). - new bin/agent-holders.sh: list|kill holders keyed on --resume/--session-id argv tokens (darwin pgrep -lf / linux -af), excludes ancestors and gt merge/submit, TERMs the process group so the tokenmaxxing supervisor and the orphan-prone raw grok child both die, waits, KILL escalates, exit 3 if still held. - agent-human-stream: refuse grok --resume while a live holder exists (AGENT_HUMAN_STREAM_FORCE_RESUME=1 overrides) and refuse grok --continue while another grok job is open in this cwd. - bin/sume-bg-launch.test.sh: offline fakes for pgrep/ps/kill. Refs sumelabs/sume#5706 --- .../bin/agent-holders.sh | 225 ++++++++++++++++++ .../bin/agent-human-stream.sh | 48 ++++ .../bin/sume-bg-launch.sh | 33 ++- .../bin/sume-bg-launch.test.sh | 164 +++++++++++++ 4 files changed, 457 insertions(+), 13 deletions(-) create mode 100755 sume-desk/skills/sume-main-agent-orchestration/bin/agent-holders.sh create mode 100755 sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.test.sh diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/agent-holders.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-holders.sh new file mode 100755 index 0000000..c86a4f4 --- /dev/null +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-holders.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# List or stop every process that still holds an agent session uuid: the +# wrapper bash, the tokenmaxxing supervisor, and the raw grok / claude child. +# +# agent-holders list # pidpgidcommand, one per holder +# agent-holders kill # TERM the holders' process groups, wait, +# # KILL leftovers; exit 0 clear, 3 still held +# agent-holders close # no holder alive: append synthetic +# # task_completed / turn_completed records so +# # Grok can resume a session killed mid-turn +# +# Rules (issue sumelabs/sume#5706): +# - Key on `--resume ` / `--session-id ` argv tokens, never on a +# bare uuid: steer prompts mention the uuid they are steering. +# - macOS `pgrep -a` means "include ancestors" and prints PIDs only; the argv +# listing is `pgrep -lf`. Linux procps uses `-af`. +# - Never touch a process running `gt merge` / `gt submit`, this process, or +# its ancestors (the shell that launched us has the uuid in its argv too). +# - Killing the tokenmaxxing supervisor alone orphans the raw grok child +# (no SIGTERM forward), so the raw child is matched and killed by argv. +set -euo pipefail + +usage() { + echo "usage: agent-holders list|kill|close " >&2 + exit 2 +} + +cmd="${1:-}" +uuid="${2:-}" +[[ -n "$cmd" && -n "$uuid" ]] || usage +case "$uuid" in + *[!a-zA-Z0-9_.-]*) echo "agent-holders: bad uuid: $uuid" >&2; exit 2 ;; +esac + +WAIT_SECS="${AGENT_HOLDERS_WAIT:-15}" +# `kill` is a bash builtin; tests point this at a fake binary. +KILL_BIN="${AGENT_HOLDERS_KILL:-kill}" + +argv_list() { + if [[ "$(uname -s)" == "Darwin" ]]; then + pgrep -lf -- "$uuid" 2>/dev/null || true + else + pgrep -af -- "$uuid" 2>/dev/null || true + fi +} + +ancestors() { + local p=$$ pp + while [[ -n "$p" && "$p" != "0" && "$p" != "1" ]]; do + printf '%s\n' "$p" + pp=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ' || true) + [[ -n "$pp" && "$pp" != "$p" ]] || break + p=$pp + done +} + +pgid_of() { + ps -o pgid= -p "$1" 2>/dev/null | tr -d ' ' || true +} + +# pidpgidcommand +holders() { + local skip + skip=$(ancestors | tr '\n' ' ') + argv_list | awk -v u="$uuid" -v skip=" $skip " ' + { + pid = $1 + if (index(skip, " " pid " ") > 0) next + line = $0 + if (line ~ /gt merge|gt submit/) next + if (line ~ /agent-holders/) next + if (line !~ /agent-human-stream|sume-bg-launch|supervise|grok|claude/) next + if (line ~ ("(^|[ \t])(--resume|--session-id|-r|-s)[= ]" u "([ \t]|$)")) print pid "\t" line + }' | while IFS=$'\t' read -r pid line; do + [[ -n "$pid" ]] || continue + printf '%s\t%s\t%s\n' "$pid" "$(pgid_of "$pid")" "$line" + done +} + +signal_holders() { + local sig="$1" my_pgid pid pgid line + my_pgid=$(pgid_of $$) + local -a groups=() + while IFS=$'\t' read -r pid pgid line; do + [[ -n "$pid" ]] || continue + if [[ -n "$pgid" && "$pgid" != "$my_pgid" ]]; then + case " ${groups[*]-} " in + *" $pgid "*) ;; + *) + groups+=("$pgid") + echo "agent-holders: $sig group $pgid (${line:0:80})" >&2 + "$KILL_BIN" "-$sig" -- "-$pgid" 2>/dev/null || true + ;; + esac + fi + "$KILL_BIN" "-$sig" "$pid" 2>/dev/null || true + done < <(holders) +} + +wait_clear() { + local deadline=$(( $(date +%s) + $1 )) + while :; do + [[ -z "$(holders)" ]] && return 0 + [[ $(date +%s) -ge $deadline ]] && return 1 + sleep 0.5 + done +} + +# Grok 1.0.11 will not resume a session whose last turn never closed (an +# in-flight model call or a backgrounded task with no task_completed): the +# resume blocks forever in session_create. After the holder is dead, close +# the turn on disk. Verified 2026-09-02 (sumelabs/sume#5706): append +# task_completed for open task_backgrounded records + turn_completed +# (stop_reason interrupted) to updates.jsonl and turn_ended to events.jsonl. +close_session() { + if [[ -n "$(holders)" ]]; then + echo "agent-holders: refusing to close $uuid — a live holder still exists" >&2 + return 3 + fi + python3 - "$uuid" <<'PYCLOSE' +import glob, json, os, shutil, sys, time +uuid = sys.argv[1] +home = os.path.expanduser(os.environ.get("GROK_HOME") or "~/.grok") +dirs = glob.glob(os.path.join(home, "sessions", "*", uuid)) +if not dirs: + print(f"agent-holders: close: no session dir for {uuid} under {home}/sessions", file=sys.stderr) + sys.exit(0) +d = dirs[0] +upd = os.path.join(d, "updates.jsonl"); ev = os.path.join(d, "events.jsonl") +if not os.path.exists(upd): + sys.exit(0) +rows = [] +for line in open(upd, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue +sid = uuid; open_tasks = {}; open_turn = False; last_meta = 0 +for r in rows: + p = r.get("params") or {}; u = p.get("update") or {} + k = u.get("sessionUpdate") + if k == "task_backgrounded": + open_tasks[u.get("task_id")] = u + elif k == "task_completed": + open_tasks.pop((u.get("task_snapshot") or {}).get("task_id"), None) + elif k == "user_message_chunk": + open_turn = True + elif k == "turn_completed": + open_turn = False + m = (u.get("_meta") or {}).get("eventId") or "" + try: + last_meta = max(last_meta, int(m.rsplit("-", 1)[-1])) + except ValueError: + pass +if not open_turn and not open_tasks: + print(f"agent-holders: close: {uuid} already closed (nothing to do)", file=sys.stderr) + sys.exit(0) +stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) +for f in (upd, ev): + if os.path.exists(f): + shutil.copy2(f, f + ".pre-close-" + stamp) +prompt_id = None +try: + prompt_id = json.load(open(os.path.join(d, "summary.json"), encoding="utf-8")).get("request_id") +except (OSError, ValueError): + pass +now = int(time.time()); ms = now * 1000 +def emit(update): + global last_meta + last_meta += 1 + update = dict(update) + update["_meta"] = {"eventId": f"{sid}-{last_meta}", "agentTimestampMs": ms} + with open(upd, "a", encoding="utf-8") as fh: + fh.write(json.dumps({"timestamp": now, "method": "_x.ai/session/update", + "params": {"sessionId": sid, "update": update}}) + "\n") +for tid, u in open_tasks.items(): + emit({"sessionUpdate": "task_completed", + "task_snapshot": {"task_id": tid, "tool_call_id": u.get("tool_call_id"), + "command": u.get("command") or "", "status": "killed", + "exit_code": 143, "output": "(killed by desk steer: agent-holders close)"}}) +if open_turn: + emit({"sessionUpdate": "turn_completed", "prompt_id": prompt_id, + "stop_reason": "interrupted", "usage": {"inputTokens": 0, "outputTokens": 0}}) + with open(ev, "a", encoding="utf-8") as fh: + fh.write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()), + "type": "turn_ended", "outcome": "interrupted"}) + "\n") +print(f"agent-holders: closed open turn for {uuid} (tasks={len(open_tasks)}, turn={open_turn}); backups *.pre-close-{stamp}", file=sys.stderr) +PYCLOSE +} + +case "$cmd" in + list) + holders + ;; + close) + close_session + ;; + kill) + if [[ -z "$(holders)" ]]; then + exit 0 + fi + signal_holders TERM + if wait_clear "$WAIT_SECS"; then + echo "agent-holders: session $uuid released" >&2 + close_session || true + exit 0 + fi + echo "agent-holders: holders survived TERM for ${WAIT_SECS}s; sending KILL" >&2 + signal_holders KILL + if wait_clear 3; then + echo "agent-holders: session $uuid released (KILL)" >&2 + close_session || true + exit 0 + fi + echo "agent-holders: session $uuid is still held:" >&2 + holders >&2 + exit 3 + ;; + *) + usage + ;; +esac diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh index 97165c5..ef8b0f2 100755 --- a/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/agent-human-stream.sh @@ -580,6 +580,54 @@ if [[ "$BACKEND" == "auto" ]]; then esac fi +# Grok session guards (sumelabs/sume#5706): a second `grok -p --resume` against +# a live session blocks forever in session_create (empty live log), and +# `--continue` picks whichever job in this cwd started last. +if [[ "$BACKEND" == "grok" && -n "$RESUME" && "${AGENT_HUMAN_STREAM_FORCE_RESUME:-0}" != "1" ]]; then + _held=$("$ROOT/agent-holders.sh" list "$RESUME" 2>/dev/null || true) + if [[ -n "$_held" ]]; then + echo "error: session $RESUME is still held by a live process:" >&2 + printf '%s\n' "$_held" >&2 + echo "steer with: sume-bg-launch --backend grok --name --resume $RESUME --prompt-file " >&2 + exit 3 + fi +fi +if [[ "$BACKEND" == "grok" && "$CONTINUE" -eq 1 && "${AGENT_HUMAN_STREAM_ALLOW_CONTINUE:-0}" != "1" ]]; then + _open=$(python3 - "$REGISTRY" "$PWD" <<'PY2' || true +import json, sys, time +path, cwd = sys.argv[1], sys.argv[2] +starts, closed = {}, set() +cutoff = time.time() - 24 * 3600 +try: + for line in open(path, encoding="utf-8"): + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + if rec.get("backend") != "grok" or rec.get("cwd") != cwd: + continue + pid = rec.get("pid") + ev = rec.get("event") + try: + ts = time.mktime(time.strptime(rec.get("ts", ""), "%Y-%m-%dT%H:%M:%SZ")) - time.timezone + except Exception: + ts = 0 + if ev == "start" and ts >= cutoff: + starts[pid] = rec.get("name") or "?" + elif ev in ("end", "abort", "exit"): + closed.add(pid) +except OSError: + pass +print(" ".join(f"{pid}:{name}" for pid, name in starts.items() if pid not in closed)) +PY2 +) + if [[ -n "$_open" ]]; then + echo "error: --continue is ambiguous for grok: open job(s) in this cwd: $_open" >&2 + echo "use --resume (registry: agent-human-stream --sessions)" >&2 + exit 3 + fi +fi + _effort="$_passed_effort" if [[ "$BACKEND" == "claude" && "$_has_effort" -eq 0 ]]; then _model_lc=$(printf '%s' "${_resolved_model:-opus}" | tr '[:upper:]' '[:lower:]') diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.sh index a0072ac..a508c43 100755 --- a/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.sh +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Cursor background-worker launcher. Reads the prompt from a file (no $(cat) -# race) and, on --resume, stops older wrappers holding that session. +# race) and, on --resume, stops every process still holding that session +# (wrapper, tokenmaxxing supervisor, raw grok/claude child) before relaunching. # # sume-bg-launch --backend grok --name job-slug --resume \ # --prompt-file /tmp/sume-grok-prompts/job.md -- --effort high @@ -9,6 +10,14 @@ # After spawn: read the terminal once for 📎 session_id= or exit_code. set -euo pipefail +SOURCE=${BASH_SOURCE[0]} +while [[ -L "$SOURCE" ]]; do + DIR=$(cd "$(dirname "$SOURCE")" && pwd) + SOURCE=$(readlink "$SOURCE") + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +ROOT="$(cd "$(dirname "$SOURCE")" && pwd)" + BACKEND="grok" NAME="" RESUME="" @@ -74,19 +83,17 @@ if [[ ! -s "$PROMPT_FILE" ]]; then exit 2 fi -# Steer: one live wrapper per session. Do not touch gt merge. +# Steer: one live holder per session. agent-holders keys on the +# `--resume ` / `--session-id ` argv tokens, kills the whole +# process group (wrapper + tokenmaxxing supervisor + raw grok/claude child) +# and waits for release. gt merge / gt submit processes are never touched. +# Refuse to launch a second concurrent resume: that is the empty-live-log +# hang (sumelabs/sume#5706 R1-R3). if [[ -n "$RESUME" ]]; then - _self=$$ - while read -r _pid _rest; do - [[ -z "${_pid:-}" ]] && continue - [[ "$_pid" == "$_self" ]] && continue - case "$_rest" in - *gt\ merge*|*gt\ submit*) continue ;; - esac - echo "steer: stopping pid ${_pid} (same --resume ${RESUME})" >&2 - kill "$_pid" 2>/dev/null || true - done < <(pgrep -af -- "$RESUME" | awk '/agent-human-stream|\/grok |grok -p/{print $1, $0}') - sleep 0.2 + if ! "$ROOT/agent-holders.sh" kill "$RESUME"; then + echo "error: session $RESUME is still held by a live process; not launching a second resume" >&2 + exit 3 + fi fi WRAPPER="${SUME_BG_LAUNCH_WRAPPER:-$(command -v agent-human-stream)}" diff --git a/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.test.sh b/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.test.sh new file mode 100755 index 0000000..c09f883 --- /dev/null +++ b/sume-desk/skills/sume-main-agent-orchestration/bin/sume-bg-launch.test.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Offline tests for agent-holders (the steer-kill behind sume-bg-launch +# --resume). Fakes pgrep/ps/kill/uname on PATH; no real processes touched. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOLDERS="$ROOT/agent-holders.sh" +U="01a05de0-e4d4-7471-a074-891cf3ec7c94" +OTHER="01a05ddf-55ee-7ea2-8251-deb3960c8ef3" +THIRD="01a05e1f-90bb-7561-b9a9-8f5e4f9558c6" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +FAKE="$TMP/bin" +mkdir -p "$FAKE" +export FAKE_PROCS="$TMP/procs" # pidppidpgidcommand +export FAKE_KILL_LOG="$TMP/kill.log" +export FAKE_IMMORTAL="${FAKE_IMMORTAL:-}" +export FAKE_SELF_PARENT=$$ +: > "$FAKE_KILL_LOG" + +cat > "$FAKE/uname" <<'SH' +#!/usr/bin/env bash +echo Darwin +SH +# macOS shape: -lf prints "pid command", -af prints pids only (ancestors flag). +cat > "$FAKE/pgrep" <<'SH' +#!/usr/bin/env bash +mode=""; pat="" +while [[ $# -gt 0 ]]; do + case "$1" in + -lf|-fl) mode=lf; shift ;; + -af|-fa) mode=af; shift ;; + --) shift; pat="$1"; shift ;; + *) pat="$1"; shift ;; + esac +done +while IFS=$'\t' read -r pid ppid pgid cmd; do + [[ "$cmd" == *"$pat"* ]] || continue + if [[ "$mode" == "lf" ]]; then printf '%s %s\n' "$pid" "$cmd"; else printf '%s\n' "$pid"; fi +done < "$FAKE_PROCS" +SH +cat > "$FAKE/ps" <<'SH' +#!/usr/bin/env bash +# ps -o ppid= -p PID | ps -o pgid= -p PID +field="$2"; pid="$4" +while IFS=$'\t' read -r p ppid pgid cmd; do + [[ "$p" == "$pid" ]] || continue + case "$field" in ppid=) echo "$ppid" ;; pgid=) echo "$pgid" ;; esac + exit 0 +done < "$FAKE_PROCS" +# Unknown pid = the agent-holders process itself → its parent is the test shell. +[[ "$field" == "ppid=" ]] && echo "${FAKE_SELF_PARENT:-1}" +SH +cat > "$FAKE/kill" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FAKE_KILL_LOG" +sig="${1#-}"; shift +[[ "$1" == "--" ]] && shift +target="$1" +tmp="$FAKE_PROCS.tmp"; : > "$tmp" +while IFS=$'\t' read -r pid ppid pgid cmd; do + dead=0 + if [[ "$target" == -* ]]; then + [[ "$pgid" == "${target#-}" ]] && dead=1 + else + [[ "$pid" == "$target" ]] && dead=1 + fi + if [[ "$dead" == 1 && "$pid" == "$FAKE_IMMORTAL" && "$sig" == "TERM" ]]; then dead=0; fi + if [[ "$dead" == 1 && "$pid" == "$FAKE_IMMORTAL" && "$sig" == "KILL" && "${FAKE_TRULY_IMMORTAL:-0}" == 1 ]]; then dead=0; fi + [[ "$dead" == 1 ]] || printf '%s\t%s\t%s\t%s\n' "$pid" "$ppid" "$pgid" "$cmd" >> "$tmp" +done < "$FAKE_PROCS" +mv "$tmp" "$FAKE_PROCS" +SH +chmod +x "$FAKE"/* +export PATH="$FAKE:$PATH" +export AGENT_HOLDERS_WAIT=1 +export AGENT_HOLDERS_KILL="$FAKE/kill" + +seed() { + # Our own ancestor chain must be excluded: the launching zsh (pid 100) + # has the uuid in its argv too. The test shell is $$ with ppid $PPID; map + # them onto pid 100 so ancestors() walks 100 → 1. + cat > "$FAKE_PROCS" <&2; exit 1; } + +# 1. list: wrapper, supervisor, raw child and --session-id=uuid match; +# prompt mention (504), gt merge (505), and our ancestors (100) do not. +seed +got=$("$HOLDERS" list "$U" | cut -f1 | sort | tr '\n' ' ') +[[ "$got" == "501 502 503 506 " ]] || fail "list holders: got '$got' want '501 502 503 506 '" +pg=$("$HOLDERS" list "$U" | awk -F'\t' '$1==503{print $2}') +[[ "$pg" == "501" ]] || fail "pgid of raw child: got '$pg'" + +# 2. kill: TERM the foreign process groups (501, 506) once each, then clear. +seed +"$HOLDERS" kill "$U" 2>/dev/null || fail "kill should succeed" +grep -q -- '-TERM -- -501' "$FAKE_KILL_LOG" || fail "no TERM to group 501: $(cat "$FAKE_KILL_LOG")" +grep -q -- '-TERM -- -506' "$FAKE_KILL_LOG" || fail "no TERM to group 506" +[[ $(grep -c -- '-TERM -- -501' "$FAKE_KILL_LOG") -eq 1 ]] || fail "group 501 signalled more than once" +grep -q -- '-TERM -- -100' "$FAKE_KILL_LOG" && fail "our own group 100 was signalled" +grep -q -- ' 504' "$FAKE_KILL_LOG" && fail "prompt-mention pid 504 was signalled" +grep -q -- ' 505' "$FAKE_KILL_LOG" && fail "gt merge pid 505 was signalled" +[[ -z "$("$HOLDERS" list "$U")" ]] || fail "holders remain after kill" + +# 3. raw child survives TERM (supervisor orphan) → KILL escalates, still clears. +seed; : > "$FAKE_KILL_LOG" +FAKE_IMMORTAL=503 "$HOLDERS" kill "$U" 2>/dev/null || fail "kill with TERM-immune child should still clear via KILL" +grep -q -- '-KILL' "$FAKE_KILL_LOG" || fail "no KILL escalation" + +# 4. truly immortal → exit 3, launcher must not proceed. +seed; : > "$FAKE_KILL_LOG" +set +e +FAKE_IMMORTAL=503 FAKE_TRULY_IMMORTAL=1 "$HOLDERS" kill "$U" 2>/dev/null +rc=$? +set -e +[[ "$rc" -eq 3 ]] || fail "immortal holder: want exit 3 got $rc" + +# 5. nothing held → exit 0, no signals. +seed; : > "$FAKE_KILL_LOG" +"$HOLDERS" kill "$OTHER" 2>/dev/null || fail "kill of unheld uuid should be a no-op success" +# 504 mentions $OTHER only via --session-id; it is a holder of OTHER, so it dies. Check a fresh uuid: +: > "$FAKE_KILL_LOG" +"$HOLDERS" kill "deadbeef-0000-0000-0000-000000000000" 2>/dev/null || fail "unknown uuid should exit 0" +[[ ! -s "$FAKE_KILL_LOG" ]] || fail "unknown uuid signalled something: $(cat "$FAKE_KILL_LOG")" + +# 6. close: no holder → synthetic task_completed + turn_completed appended once; idempotent. +seed; : > "$FAKE_KILL_LOG" +export GROK_HOME="$TMP/grokhome" +SD="$GROK_HOME/sessions/%2Ftmp%2Fx/$THIRD"; mkdir -p "$SD" +printf '%s\n' '{"timestamp":1,"method":"_x.ai/session/update","params":{"sessionId":"'"$THIRD"'","update":{"sessionUpdate":"user_message_chunk","_meta":{"eventId":"'"$THIRD"'-1"}}}}' \ + '{"timestamp":2,"method":"_x.ai/session/update","params":{"sessionId":"'"$THIRD"'","update":{"sessionUpdate":"task_backgrounded","task_id":"call-1","tool_call_id":"call-1","command":"sleep 90","_meta":{"eventId":"'"$THIRD"'-2"}}}}' > "$SD/updates.jsonl" +printf '%s\n' '{"ts":"t","type":"turn_started"}' > "$SD/events.jsonl" +printf '{"request_id":"p1"}' > "$SD/summary.json" +"$HOLDERS" close "$THIRD" 2>/dev/null || fail "close should succeed with no holder" +grep -q '"task_completed"' "$SD/updates.jsonl" || fail "close did not append task_completed" +grep -q '"turn_completed"' "$SD/updates.jsonl" || fail "close did not append turn_completed" +grep -q '"turn_ended"' "$SD/events.jsonl" || fail "close did not append turn_ended" +ls "$SD"/updates.jsonl.pre-close-* >/dev/null 2>&1 || fail "no backup written" +n=$(grep -c . "$SD/updates.jsonl") +"$HOLDERS" close "$THIRD" 2>/dev/null || fail "second close should be a no-op" +[[ $(grep -c . "$SD/updates.jsonl") -eq $n ]] || fail "close is not idempotent" +# holder alive → close refuses (exit 3) and appends nothing +cat >> "$FAKE_PROCS" <> "$SD/updates.jsonl" +set +e; "$HOLDERS" close "$THIRD" 2>/dev/null; rc=$?; set -e +[[ "$rc" -eq 3 ]] || fail "close with live holder: want 3 got $rc" +unset GROK_HOME + +echo "sume-bg-launch/agent-holders test: ok (darwin argv listing, group kill, orphan escalation, refuse, close)"