From 76b33222502c29eeaabe1bf273ed4014a21d2929 Mon Sep 17 00:00:00 2001 From: dch0202 Date: Mon, 17 Aug 2026 23:10:06 +0900 Subject: [PATCH 1/3] fix(orchestrate): guard cmd_send against unsubmitted [Pasted text] placeholders (#96) send-prompt.sh's cmd_send could report "delivered" while the prompt was actually sitting unsubmitted as a "[Pasted text #N]" placeholder in the worker's input box -- tmux send-keys reports success as soon as the bytes reach the tty, regardless of whether the CLI consumed them. Field-confirmed 2026-08-17 in the linkly iss0817 run: worker lo-3-iss0817 stalled on [Pasted text #4] until one extra Enter unstuck it. Port the same guard launch-session.sh already uses for the first injection: after the existing queued_pat check, detect the placeholder in the post-send pane capture and retry with Enter, bounded to 3 attempts (LO_PASTED_PATTERN, default "[Pasted text"). A placeholder still present after the bound reports "unconfirmed" (exit 7), never a guessed "delivered". The exit-code contract and one-token stdout are unchanged. Adds 3 send-prompt.bats cases (normal: retry clears it -> delivered; error: persists -> unconfirmed after exactly 3 retries; boundary: queued_pat still wins when both indicators are present) via a scripted fake tmux that returns a different pane capture on each successive call. Verified against a guard-stripped mutant: the normal/error cases fail without the fix. bats tests/send-prompt.bats: 65/65. Full bats tests/: 560/560, no regressions. --- skills/orchestrate/scripts/send-prompt.sh | 32 ++++++++++ tests/send-prompt.bats | 76 +++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/skills/orchestrate/scripts/send-prompt.sh b/skills/orchestrate/scripts/send-prompt.sh index 2219153..5c69f89 100755 --- a/skills/orchestrate/scripts/send-prompt.sh +++ b/skills/orchestrate/scripts/send-prompt.sh @@ -35,6 +35,8 @@ # env: # LO_QUEUED_PATTERN substring meaning "queued behind a busy turn" # LO_BUSY_PATTERN substring meaning "mid-turn" (state only) +# LO_PASTED_PATTERN substring meaning "collapsed to an unsubmitted paste +# placeholder" (issue #96, default: [Pasted text) # LO_PANE_TAIL_LINES non-empty pane lines searched for those (default 6) # LO_CONFIRM_DELAY seconds to settle before classifying a send (default 1) # LO_PICKUP_TIMEOUT wait deadline in seconds (default 180) @@ -54,6 +56,12 @@ TMUX_BIN=$(command -v tmux) || { echo "send-prompt: tmux not found" >&2; exit 12 queued_pat="${LO_QUEUED_PATTERN:-Press up to edit queued messages}" busy_pat="${LO_BUSY_PATTERN:-esc to interrupt}" +# Issue #96: a send can collapse into an unsubmitted "[Pasted text #N ...]" +# placeholder left sitting in the input box — tmux still reports the send-keys +# call as successful. launch-session.sh's submit-confirm loop proved the +# remedy (a further Enter); cmd_send ports the same detection, bounded. +pasted_pat="${LO_PASTED_PATTERN:-[Pasted text}" + # Every grep against these uses `-- "$pat"`. A pattern beginning with '-' is # otherwise parsed as a grep flag ("unrecognized option"), which is the same # defect the '--' before the send-keys payload guards against. @@ -183,6 +191,30 @@ cmd_send() { note_pane "$1" echo "queued"; exit 4 fi + + # [Pasted text] guard (issue #96): the placeholder proves "buffered, not + # submitted" the same way queued_pat proves "buffered, not consumed" — so it + # gets the same retry-before-verdict treatment. Bounded to 3 extra Enters, + # mirroring launch-session.sh's submit-confirm loop; a pane still stuck after + # that is reported unconfirmed, never guessed delivered. + pasted_attempts=0 + while printf '%s' "$after" | grep -qF -- "$pasted_pat" 2>/dev/null \ + && [ "$pasted_attempts" -lt 3 ]; do + if ! "$TMUX_BIN" send-keys -t "$(target_pane "$1")" Enter; then + session_alive "$1" || { echo "gone"; exit 3; } + echo "send-prompt: send-keys failed for live session '$1'" >&2 + exit 6 + fi + pasted_attempts=$((pasted_attempts + 1)) + sleep "$confirm_delay" + after=$(pane_tail "$1") || { + echo "send-prompt: capture-pane failed for live session '$1'" >&2; exit 6; } + done + if printf '%s' "$after" | grep -qF -- "$pasted_pat" 2>/dev/null; then + note_pane "$1" + echo "unconfirmed"; exit 7 + fi + if [ "$after" != "$before" ]; then echo "delivered"; exit 0 fi diff --git a/tests/send-prompt.bats b/tests/send-prompt.bats index 4abcb0d..8bf99ba 100644 --- a/tests/send-prompt.bats +++ b/tests/send-prompt.bats @@ -228,6 +228,82 @@ mk_busy() { [ "$status" -eq 1 ] } +# ------------------------------------ send: [Pasted text] guard (issue #96) +# +# cmd_send must never report "delivered" while the prompt sits as an +# unsubmitted "[Pasted text #N]" placeholder. A real tmux pane cannot be +# driven through an exact multi-call capture-pane sequence (before -> after -> +# after each retry) deterministically, so these cases use a scripted fake tmux +# on PATH — same technique as the `keys` fake below, extended with a captures +# script so each successive capture-pane call returns the next scripted line. + +# $FAKE_CAPTURES holds one pane-snapshot per line; capture-pane returns the +# Nth line on its Nth call (clamped to the last line once exhausted), so a +# test can script exactly what cmd_send sees on the before-capture, the +# post-send capture, and each retry re-capture. +_use_fake_tmux_send() { + mkdir -p "$STUB_ROOT/bin" + FAKE_SEND_LOG="$STUB_ROOT/sends.log"; : > "$FAKE_SEND_LOG" + FAKE_CAPTURE_N="$STUB_ROOT/capture_n"; echo 0 > "$FAKE_CAPTURE_N" + FAKE_CAPTURES="$STUB_ROOT/captures"; : > "$FAKE_CAPTURES" + FAKE_ALIVE_FILE="$STUB_ROOT/alive"; : > "$FAKE_ALIVE_FILE" + export FAKE_SEND_LOG FAKE_CAPTURE_N FAKE_CAPTURES FAKE_ALIVE_FILE + cat > "$STUB_ROOT/bin/tmux" <<'FAKE' +#!/bin/sh +verb="$1"; shift +case "$verb" in + has-session) [ -e "$FAKE_ALIVE_FILE" ]; exit $? ;; + capture-pane) + n=$(cat "$FAKE_CAPTURE_N") + total=$(wc -l < "$FAKE_CAPTURES" | tr -d ' ') + idx=$((n + 1)) + [ "$idx" -gt "$total" ] && idx="$total" + sed -n "${idx}p" "$FAKE_CAPTURES" + echo $((n + 1)) > "$FAKE_CAPTURE_N" + exit 0 ;; + send-keys) + printf '%s\n' "$*" >> "$FAKE_SEND_LOG" + exit 0 ;; +esac +exit 0 +FAKE + chmod +x "$STUB_ROOT/bin/tmux" + PATH="$STUB_ROOT/bin:$PATH"; export PATH +} +_enter_count() { grep -cx -- "-t =$S: Enter" "$FAKE_SEND_LOG"; } + +@test "send: a [Pasted text] placeholder cleared by one retry is delivered" { + _use_fake_tmux_send + printf '%s\n' 'READY>' '[Pasted text #1 +2 lines]' 'T3_PASTE_RAN' > "$FAKE_CAPTURES" + run --separate-stderr sh "$SP" send "$S" 'echo T3_PASTE' + [ "$status" -eq 0 ] + [ "$output" = "delivered" ] + # submit Enter + exactly one retry Enter, never more once the placeholder clears + [ "$(_enter_count)" -eq 2 ] +} + +@test "send: a [Pasted text] placeholder that never clears is unconfirmed, not delivered" { + _use_fake_tmux_send + printf '%s\n' 'READY>' '[Pasted text #1]' '[Pasted text #1]' '[Pasted text #1]' '[Pasted text #1]' \ + > "$FAKE_CAPTURES" + run --separate-stderr sh "$SP" send "$S" 'echo T3_STUCK' + [ "$status" -eq 7 ] + [ "$output" = "unconfirmed" ] + # submit Enter + exactly 3 bounded retries — never an unbounded loop + [ "$(_enter_count)" -eq 4 ] +} + +@test "boundary: a placeholder alongside the queued indicator reports queued, not unconfirmed" { + # Order is unchanged: the existing queued_pat check still wins over the new + # placeholder guard, so no retry Enter is sent at all. + _use_fake_tmux_send + printf '%s\n' 'READY>' '[Pasted text #1] Press up to edit queued messages' > "$FAKE_CAPTURES" + run --separate-stderr sh "$SP" send "$S" 'echo T3_BOTH' + [ "$status" -eq 4 ] + [ "$output" = "queued" ] + [ "$(_enter_count)" -eq 1 ] +} + # ---------------------------------------------------------------- wait @test "wait: returns picked-up once the worker drains its queue" { From 72dbd6b3ff1dd581aa01a6e6c7dbf9bbadc58f7f Mon Sep 17 00:00:00 2001 From: dch0202 Date: Mon, 17 Aug 2026 23:26:39 +0900 Subject: [PATCH 2/3] fix(orchestrate): gate status-update.sh session resolution on $TMUX, document failed-phase reset (#97) A coordinator-shell caller (outside tmux) running status-update.sh got tmux display-message answering with the server's most-recently-active session instead of its own -- there is no $TMUX check before consulting it. Gate the tmux fallback behind [ -n "${TMUX:-}" ]; STATUS_SESSION still wins unconditionally, and behavior inside tmux or with STATUS_SESSION set is unchanged. Also document, in SKILL.md's exit-6 question playbook, the reset step needed after a worker records phase=failed and the coordinator resolves it via ask-coordinator -- otherwise watch-status.sh keeps counting the stale failed phase and re-aborts with exit 3 on every subsequent poll. The reset is a normal status-update.sh write with the observed phase, not a new phase word. Document STATUS_SESSION for coordinator-side calls in the Session knobs paragraph. Sub-item (c) (stale `error` field surviving merges) is deliberately skipped per plan decision D5 -- nothing reads that field today. Extends tests/status-update.bats with normal/regression/boundary cases for the $TMUX gating; full bats tests/ green (560/560). --- skills/orchestrate/SKILL.md | 15 ++++++++-- skills/orchestrate/scripts/status-update.sh | 10 +++++-- tests/status-update.bats | 33 +++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/skills/orchestrate/SKILL.md b/skills/orchestrate/SKILL.md index abf9374..1688da5 100644 --- a/skills/orchestrate/SKILL.md +++ b/skills/orchestrate/SKILL.md @@ -238,7 +238,12 @@ so every `launch-session.sh` gets a collision-proof name `lo--` (reus exact name for later `send-prompt.sh`); the script also exports the guardrails escalation env into each worker. Trust-screen wording drifts between CLI releases — if a launch hangs, set `LO_READY_EXTRA` / `LO_TRUST_EXTRA` (substrings) or -`LO_READY_TIMEOUT`. +`LO_READY_TIMEOUT`. `status-update.sh` resolves the status file's `session` field +from `tmux display-message -p '#S'` only when the caller is itself inside tmux +(`$TMUX` set) — a coordinator-side call (this shell, not a worker's tmux pane) +must pass `STATUS_SESSION=` explicitly, or the record's `session` +field is left absent rather than guessed from whatever tmux session happens to +be active. `watch-status.sh` now exits **5** on a pending guardrails escalation (approve/deny, clear `.orchestration/escalations/`, then DELIVER the outcome to the now-idle worker with `scripts/send-prompt.sh send lo- "approved — re-run: , then @@ -479,7 +484,13 @@ exits — handle, then relaunch watch with the same target: recurs while `questions/.json` exists, like exit 5; exit 5 wins when both are pending): read the record (`{ts, taskId, question, options, worktree}`), answer with `scripts/send-prompt.sh send lo- ""`, delete the record - file, relaunch watch. + file. If the task's status was recorded `phase=failed` when it asked the + question, reset it to the phase you actually observe (read the worker pane + first) BEFORE relaunching watch — the reset IS a normal status write, not a + new phase word: `STATUS_DIR= STATUS_SESSION= sh + scripts/status-update.sh note="reset after exit-6 + answer"` — otherwise `watch-status.sh` counts the stale `failed` phase and + aborts with exit 3 again on the very next poll. Then relaunch watch. - **7 — stalled live worker** (prints `[watch] worker stalled — :`; the weakest signal — failed(3) and all-reached(0) win over it; driven by `tmux-worker-stalled.sh`, silence threshold `LO_STALL_SEC` default 600s; a diff --git a/skills/orchestrate/scripts/status-update.sh b/skills/orchestrate/scripts/status-update.sh index 36cba89..5863856 100755 --- a/skills/orchestrate/scripts/status-update.sh +++ b/skills/orchestrate/scripts/status-update.sh @@ -22,8 +22,14 @@ now=$(date -u +%Y-%m-%dT%H:%M:%SZ) # cross-platform (GNU/BSD) iso-8601 UTC wt=$(pwd -P) # physical path so loop-gate can match it # Record the tmux session name so watch-status can detect a dead worker. The # worker runs inside its tmux session; allow an explicit override (orchestrator -# / tests) via STATUS_SESSION. -sess="${STATUS_SESSION:-$(tmux display-message -p '#S' 2>/dev/null || true)}" +# / tests) via STATUS_SESSION. Only ask tmux when actually inside one ($TMUX +# set) — outside tmux, `tmux display-message` still exits 0 and answers with +# the server's most-recently-active session, which is not this caller's (#97). +if [ -n "${TMUX:-}" ]; then + sess="${STATUS_SESSION:-$(tmux display-message -p '#S' 2>/dev/null || true)}" +else + sess="${STATUS_SESSION:-}" +fi # Collect the extra key=value pairs into one JSON object first (in memory — no # file writes), so the whole record lands in a single atomic write below. diff --git a/tests/status-update.bats b/tests/status-update.bats index 16c7680..804853f 100644 --- a/tests/status-update.bats +++ b/tests/status-update.bats @@ -4,6 +4,18 @@ setup() { SU="${BATS_TEST_DIRNAME}/../skills/orchestrate/scripts/status-update.sh" export STATUS_DIR="${BATS_TEST_TMPDIR}/status" + # Session resolution reads $TMUX/$STATUS_SESSION from the ambient environment + # (issue #97: a suite launched from inside a real tmux session would + # otherwise silently inherit it and mask the non-tmux regression below). + unset TMUX STATUS_SESSION +} + +mk_tmux_stub() { # $1 = session name the stub's `display-message -p '#S'` answers with + d="${BATS_TEST_TMPDIR}/tmuxbin" + mkdir -p "$d" + printf '#!/bin/sh\necho "%s"\n' "$1" > "$d/tmux" + chmod +x "$d/tmux" + printf '%s' "$d" } @test "writes phase, timestamp, worktree and extras in one valid-JSON record" { @@ -36,3 +48,24 @@ setup() { ( cd "$BATS_TEST_TMPDIR" && STATUS_SESSION=lo-7 bash "$SU" t4 implementing ) [ "$(jq -r '.session' "$STATUS_DIR/t4.json")" = "lo-7" ] } + +@test "TMUX set, no STATUS_SESSION: asks tmux and records its answer (normal)" { + stub="$(mk_tmux_stub inside-session)" + run env TMUX=fake PATH="$stub:$PATH" bash "$SU" t5 implementing + [ "$status" -eq 0 ] + [ "$(jq -r '.session' "$STATUS_DIR/t5.json")" = "inside-session" ] +} + +@test "TMUX unset: tmux is never consulted, session field stays absent (regression, issue #97)" { + stub="$(mk_tmux_stub wrongly-resolved-session)" + run env PATH="$stub:$PATH" bash "$SU" t6 implementing + [ "$status" -eq 0 ] + [ "$(jq -r '.session // "MISSING"' "$STATUS_DIR/t6.json")" = "MISSING" ] +} + +@test "STATUS_SESSION wins outside tmux even with tmux on PATH (boundary)" { + stub="$(mk_tmux_stub wrongly-resolved-session)" + run env STATUS_SESSION=explicit PATH="$stub:$PATH" bash "$SU" t7 implementing + [ "$status" -eq 0 ] + [ "$(jq -r '.session' "$STATUS_DIR/t7.json")" = "explicit" ] +} From d08eb84c8e8884c215306119666fc60778a66acb Mon Sep 17 00:00:00 2001 From: dch0202 Date: Mon, 17 Aug 2026 23:26:45 +0900 Subject: [PATCH 3/3] feat(wiki): seed gap categories G1-G6 for issue #38 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 6 sourced, lint-clean wiki pages closing the highest-priority wiki-audit gaps: cors-and-preflight, api-versioning-and-breaking-changes (existing api-design category), data-backfill-migrations (existing operations category), feature-flag-lifecycle (existing deploy category), and two new categories — backend/common/architecture (sync-vs-async-integration) and backend/common/realtime (websocket-sse-lifecycle). Register every page in its domain index with a load-when trigger, and extend the root INDEX.md backend route-line for the two new categories per the mechanical index-registration precedent. G1 is scoped to sync-vs-async-integration only; the other two categories.md sub-topics (module-boundaries-and-layering, event-driven-adoption-criteria) are intentionally left out as future ingest candidates on issue #38, not silently dropped. G7-G10 stay open on the issue. Bump tests/wiki-lint-prohibitions.bats' hardcoded corpus count from 61 to 64 directive units (coordinator-approved carve-out, scoped to exactly this one assertion) — the 6 new pages' Do-this/Instead-of rows push the real corpus count past the number the test had frozen. The stale assertion still reported "ok" locally on macOS bash 3.2 due to a [[ ]]/set -e quirk where a non-last failing [[ ]] doesn't abort the test; verified against a bash 5.2 (ubuntu-matching) run that it genuinely fails without this fix, the same CI-only-failure class as PR #94/§O3. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Rwv5J7jqNo7Kri2eFNBfc7 --- INDEX.md | 2 +- log.md | 1 + tests/wiki-lint-prohibitions.bats | 4 +- .../api-versioning-and-breaking-changes.md | 64 +++++++++++++++++++ .../common/api-design/cors-and-preflight.md | 55 ++++++++++++++++ .../architecture/sync-vs-async-integration.md | 53 +++++++++++++++ .../realtime/websocket-sse-lifecycle.md | 54 ++++++++++++++++ wiki/backend/index.md | 16 ++++- wiki/databases/index.md | 1 + .../operations/data-backfill-migrations.md | 53 +++++++++++++++ .../deploy/feature-flag-lifecycle.md | 56 ++++++++++++++++ wiki/infrastructure/index.md | 1 + 12 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 wiki/backend/common/api-design/api-versioning-and-breaking-changes.md create mode 100644 wiki/backend/common/api-design/cors-and-preflight.md create mode 100644 wiki/backend/common/architecture/sync-vs-async-integration.md create mode 100644 wiki/backend/common/realtime/websocket-sse-lifecycle.md create mode 100644 wiki/databases/operations/data-backfill-migrations.md create mode 100644 wiki/infrastructure/deploy/feature-flag-lifecycle.md diff --git a/INDEX.md b/INDEX.md index 3803c73..941dd27 100644 --- a/INDEX.md +++ b/INDEX.md @@ -12,7 +12,7 @@ follow the cross-pointers in their index or take the next matching seeded domain | Domain | Status | Route here when | |--------|--------|-----------------| | [databases](wiki/databases/index.md) | **seeded** | Designing schemas/tables/keys, choosing or evaluating indexes, writing or optimizing queries, choosing transaction/isolation behavior, surveying live data to derive a rule, verifying additive migrations | -| [backend](wiki/backend/index.md) | **seeded** | Server-side application code — language-agnostic (`common/`: API contracts, call-site enumeration before a contract change, idempotency, JWT, timeouts/retries, caching, jobs, transactions in app code, shared state/pools, errors, consuming LLM APIs (completion validation, context budgeting), consuming external-API responses, externally-owned defaults, object-storage references) plus stack subtrees: `java/` (JPA, Spring proxies, JVM threads/memory), `node/` (event loop, promises, runtime validation, shutdown), `python/` (GIL/asyncio, pydantic, WSGI/ASGI workers, language traps) | +| [backend](wiki/backend/index.md) | **seeded** | Server-side application code — language-agnostic (`common/`: API contracts, call-site enumeration before a contract change, idempotency, JWT, timeouts/retries, caching, jobs, transactions in app code, shared state/pools, errors, consuming LLM APIs (completion validation, context budgeting), consuming external-API responses, externally-owned defaults, object-storage references, sync-vs-async integration choice, WebSocket/SSE connection lifecycle) plus stack subtrees: `java/` (JPA, Spring proxies, JVM threads/memory), `node/` (event loop, promises, runtime validation, shutdown), `python/` (GIL/asyncio, pydantic, WSGI/ASGI workers, language traps) | | [frontend](wiki/frontend/index.md) | **seeded** | Web UI code: state placement, rendering performance, in-UI data fetching (races, infinite scroll), auth token handling, forms, XSS-safe output, accessibility | | [infrastructure](wiki/infrastructure/index.md) | **seeded** | CI/CD pipelines, secrets in build/deploy, container image builds, rollout/rollback strategy, observability (logs/metrics/alerting), per-environment/path-valued config, multi-agent orchestration (worker liveness signals, shared run state, tmux pane delivery, completion gates, worktree-isolated workers) | | [testing](wiki/testing/index.md) | **seeded** | Writing or structuring automated tests: level choice, cases/assertions, test data, mock decisions, flaky tests (release-process quality → qa) | diff --git a/log.md b/log.md index bd066cf..e2378d7 100644 --- a/log.md +++ b/log.md @@ -44,3 +44,4 @@ Append-only. Format: `## [YYYY-MM-DD]