[object Object]

← back to Ticket System

TK-11565: board Run-Now enqueues to ~/.claude/ticket-runner-queue.jsonl; com.steve.ticket-runner LaunchAgent drains it -> each ticket auto-opens its own iTerm2 window

1f5a9e8522eba889a9ebc66e37b7290556f11a84 · 2026-09-13 00:21:17 -0700 · Steve Abrams

Root cause: server.js Run-Now execFile(run-ticket.sh) fired from the pm2 daemon,
which has no Aqua session / Automation TCC -> osascript to iTerm always failed.
Fix: daemon appends {ts,id,cwd,profile} to the queue; the GUI-session LaunchAgent
(ticket-runner.sh) launches each via run-ticket.sh (at-most-once offset, mkdir
single-flight lock, 1.5s stagger). Verified end-to-end: enqueued TK-11427 ->
window opened -> offset advanced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvjJKqUEzTMfgQeD5NF2Jb

Files touched

Diff

commit 1f5a9e8522eba889a9ebc66e37b7290556f11a84
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 00:21:17 2026 -0700

    TK-11565: board Run-Now enqueues to ~/.claude/ticket-runner-queue.jsonl; com.steve.ticket-runner LaunchAgent drains it -> each ticket auto-opens its own iTerm2 window
    
    Root cause: server.js Run-Now execFile(run-ticket.sh) fired from the pm2 daemon,
    which has no Aqua session / Automation TCC -> osascript to iTerm always failed.
    Fix: daemon appends {ts,id,cwd,profile} to the queue; the GUI-session LaunchAgent
    (ticket-runner.sh) launches each via run-ticket.sh (at-most-once offset, mkdir
    single-flight lock, 1.5s stagger). Verified end-to-end: enqueued TK-11427 ->
    window opened -> offset advanced.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CvjJKqUEzTMfgQeD5NF2Jb
---
 .gitignore       |  2 ++
 server.js        | 19 ++++++++++------
 ticket-runner.sh | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 80 insertions(+), 7 deletions(-)

diff --git a/.gitignore b/.gitignore
index a0a513c7..89a8cc8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,5 @@ dist/
 build/
 .next/
 desktop-bar/bin/
+server.js.bak-TK11565
+ticket-runner.launchd.log
diff --git a/server.js b/server.js
index 2926f5f6..28ad4e23 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ const DATA_DIR = path.join(os.homedir(), '.claude', 'tickets');
 const VERDICTS = path.join(DATA_DIR, 'dtd-verdicts.json');       // last batched dtd run-now verdicts
 const DTD_RUNNING = path.join(DATA_DIR, 'dtd-verdicts.running'); // present while a sweep is in flight
 const RUN_SH = path.join(__dirname, 'run-ticket.sh');           // opens an iTerm2 Claude session
+const RUNNER_QUEUE = path.join(os.homedir(), '.claude', 'ticket-runner-queue.jsonl'); // TK-11565: GUI-session drainer (com.steve.ticket-runner) picks this up
 const DTD_RUN = path.join(__dirname, 'dtd-run.js');             // batched panel.sh sweep
 const RUN_PROFILES = new Set(['claude-sonnet', 'claude-opus', 'claude-haiku', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable', 'codex', 'codex-gpt6', 'codex-gpt52', 'local-qwen-27b', 'local-qwen-14b']);
 const DEFAULT_RUN_PROFILE = 'codex';
@@ -524,14 +525,18 @@ http.createServer((req, res) => {
         let cwd = os.homedir();
         const proj = t && t.project;
         if (proj && /^[a-z0-9._-]+$/i.test(proj)) { const p = path.join(os.homedir(), 'Projects', proj); if (fs.existsSync(p)) cwd = p; }
-        setTimeout(() => execFile('bash', [RUN_SH, id, cwd, profile], { timeout: 25000 }, (err) => {
-          // Log the REAL outcome from the launch callback — never an optimistic "launched" before the window opens.
-          // If osascript/iTerm fails (app quit, Automation permission denied), surface it on the board instead of a false success.
+        // TK-11565: the pm2 daemon can't open iTerm (no Aqua session / Automation TCC),
+        // so ENQUEUE for the GUI-session drainer (LaunchAgent com.steve.ticket-runner),
+        // which launches each ticket in its own iTerm2 window via run-ticket.sh.
+        try {
+          fs.appendFileSync(RUNNER_QUEUE, JSON.stringify({ ts: new Date().toISOString(), id, cwd, profile }) + '\n');
+          withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
+            text: `▶ RUN NOW — queued for ticket-runner (own iTerm2 window) · profile=${profile}` }));
+        } catch (e2) {
           withLock(() => append({ ts: new Date().toISOString(), type: 'action', id, agent: 'board',
-            text: err ? ('⚠ RUN NOW failed to launch iTerm2 session — ' + String(err.message || err).split('\n')[0])
-                      : `▶ RUN NOW — launched iTerm2 session from the board · profile=${profile}` }));
-          invalidateViews();
-        }), i * 1300); // stagger ~1.3s
+            text: '⚠ RUN NOW enqueue failed — ' + String(e2.message || e2).split('\n')[0] }));
+        }
+        invalidateViews();
         launched.push(id);
       });
       json(res, 200, { launched, skipped, profile });
diff --git a/ticket-runner.sh b/ticket-runner.sh
new file mode 100644
index 00000000..a61ba1bb
--- /dev/null
+++ b/ticket-runner.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# ticket-runner.sh — drains the board's Run-Now queue from Steve's LOGIN (Aqua)
+# session, launching each ticket in its OWN iTerm2 window via run-ticket.sh.
+#
+# WHY THIS EXISTS (TK-11565): the pm2 `ticket-board` daemon cannot open iTerm —
+# a background/non-Aqua process has no Automation (Apple Events) TCC grant, so its
+# Run-Now osascript always fails ("⚠ RUN NOW failed to launch iTerm2 session").
+# The patched board now ENQUEUES {ts,id,cwd,profile} to the queue file below; THIS
+# script runs in Steve's GUI session (via LaunchAgent com.steve.ticket-runner) and
+# actually launches the window. Steve's standing rule: "run each ticket in its own
+# terminal in iTerm2, ALWAYS."
+#
+# Idempotent per-tick processor (launchd StartInterval=5, RunAtLoad): processes
+# only queue lines past the stored offset, staggers launches, advances the offset.
+set -euo pipefail
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RUN_SH="$DIR/run-ticket.sh"
+QUEUE="$HOME/.claude/ticket-runner-queue.jsonl"
+OFFSET="$HOME/.claude/ticket-runner.offset"
+DONELOG="$HOME/.claude/ticket-runner.done.log"
+LOCKDIR="$HOME/.claude/ticket-runner.lock.d"
+
+[ -f "$QUEUE" ] || exit 0
+[ -x "$RUN_SH" ] || [ -f "$RUN_SH" ] || { echo "$(date -u +%FT%TZ) FATAL run-ticket.sh missing at $RUN_SH" >> "$DONELOG"; exit 0; }
+
+# single-flight: portable mkdir lock (macOS has no flock). Stale-lock guard: if the
+# lock dir is >10 min old, a prior tick died mid-launch — reclaim it.
+if ! mkdir "$LOCKDIR" 2>/dev/null; then
+  if [ -d "$LOCKDIR" ] && [ -n "$(find "$LOCKDIR" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then
+    rmdir "$LOCKDIR" 2>/dev/null || true
+    mkdir "$LOCKDIR" 2>/dev/null || exit 0
+  else
+    exit 0
+  fi
+fi
+trap 'rmdir "$LOCKDIR" 2>/dev/null || true' EXIT
+
+total=$(wc -l < "$QUEUE" | tr -d ' ')
+last=$(cat "$OFFSET" 2>/dev/null || echo 0)
+case "$last" in ''|*[!0-9]*) last=0 ;; esac
+[ "$total" -le "$last" ] && exit 0
+
+# Process new lines only. Advance the offset LINE-BY-LINE so a crash mid-batch
+# never re-launches an already-launched ticket (at-most-once).
+n="$last"
+while IFS= read -r line; do
+  n=$((n+1))
+  [ -z "$line" ] && { echo "$n" > "$OFFSET"; continue; }
+  id=$(printf '%s' "$line"      | jq -r '.id // empty'                 2>/dev/null || true)
+  cwd=$(printf '%s' "$line"     | jq -r '.cwd // empty'                2>/dev/null || true)
+  profile=$(printf '%s' "$line" | jq -r '.profile // "claude-sonnet"' 2>/dev/null || true)
+  if [ -z "$id" ]; then
+    echo "$(date -u +%FT%TZ) SKIP unparseable line $n: $line" >> "$DONELOG"
+    echo "$n" > "$OFFSET"; continue
+  fi
+  [ -z "$cwd" ] && cwd="$HOME"
+  [ -z "$profile" ] && profile="claude-sonnet"
+  if bash "$RUN_SH" "$id" "$cwd" "$profile" >>"$DONELOG" 2>&1; then
+    echo "$(date -u +%FT%TZ) launched $id ($profile) cwd=$cwd" >> "$DONELOG"
+  else
+    echo "$(date -u +%FT%TZ) FAILED $id (exit $?) — see run-ticket.sh output above" >> "$DONELOG"
+  fi
+  echo "$n" > "$OFFSET"   # advance AFTER this line is handled (at-most-once)
+  sleep 1.5              # stagger so iTerm doesn't drop windows (mirrors board's old 1.3s)
+done < <(tail -n +"$((last+1))" "$QUEUE")

← be158f96 auto-data-snapshot: 2026-09-12T06:45:13 (1 data files) — pac  ·  back to Ticket System  ·  auto-data-snapshot: 2026-09-13T23:18:39 (1 data files) — con ff5f20c0 →