Просмотр исходного кода

feat(skills): fleetflow live monitor + codex worktree-commit fix

ff-status.sh (run status as JSON; --watch feeds the monitor) +
assets/ff-monitor.html (native-/workflows-style agent grid: pips, state
dots, elapsed/tools/commits/tokens, expandable detail incl. error tail).
ff-spawn passes --add-dir <git-dir> for codex worktree lanes - a worktree's
git metadata lives in the main .git, outside the codex sandbox, so commits
failed with index.lock permission denied (found live in the shakedown run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 2 недель назад
Родитель
Сommit
b16fa2442e

+ 10 - 0
skills/fleetflow/SKILL.md

@@ -173,6 +173,16 @@ The ones to actually use:
 | [scripts/ff-doctor.sh](scripts/ff-doctor.sh) | `--offline` structural preflight; `--live` probes GLM endpoint, Codex auth, Anthropic models, reports orchestrator tier (fable/opus) |
 | [scripts/ff-spawn.sh](scripts/ff-spawn.sh) | uniform spawner: worktree lane + guard preamble + journal + per-brain launch (GLM via fleet-worker, Codex via `codex exec`, Anthropic via `claude -p`) |
 | [scripts/ff-collect.sh](scripts/ff-collect.sh) | per-brain result gate; `--check-main-clean` escape guard |
+| [scripts/ff-status.sh](scripts/ff-status.sh) | run status as JSON (lane state, elapsed, commits, tools, tokens, activity); `--watch N --out status.json` feeds the live monitor |
+
+**Live monitor** ([assets/ff-monitor.html](assets/ff-monitor.html)): a
+zero-dependency page reproducing the native /workflows progress surface — run
+header with per-lane pips, an agent grid with state dots, elapsed/tools/
+commits/tokens, and expandable per-agent detail (activity, last commit, error
+tail, artifact). Wire-up: copy it into the run dir as `index.html`, run
+`ff-status --watch 3 --out <rundir>/status.json`, serve the run dir with any
+static server, open in a browser/preview panel. It polls `status.json` every
+2.5s.
 
 All follow the Skill Resource Protocol: stdout is data, chatter on stderr,
 semantic exit codes (`0` ok, `2` usage, `3` cached/missing, `7` unreachable,

+ 98 - 0
skills/fleetflow/assets/ff-monitor.html

@@ -0,0 +1,98 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>fleetflow monitor</title>
+<style>
+  :root { color-scheme: light dark;
+    --bg:#fafaf8; --card:#fff; --border:#e4e2dd; --text:#1a1a17; --muted:#8a887f;
+    --run:#378add; --ok:#1d9e75; --bad:#e24b4a; --cache:#b4b2a9; }
+  @media (prefers-color-scheme: dark) { :root {
+    --bg:#1c1c1a; --card:#262624; --border:#3a3936; --text:#ececea; --muted:#8f8d85; } }
+  * { box-sizing:border-box; margin:0; }
+  body { background:var(--bg); color:var(--text); padding:20px;
+    font:14px/1.5 "Segoe UI", "Helvetica Neue", ui-sans-serif, sans-serif; }
+  .shell { max-width:760px; margin:0 auto; }
+  .head { background:var(--card); border:1px solid var(--border); border-radius:12px;
+    padding:14px 18px; margin-bottom:14px; }
+  .head h1 { font-size:16px; font-weight:500; }
+  .meta { color:var(--muted); font-size:13px; margin-top:2px; display:flex; gap:14px; }
+  .pips { display:flex; gap:4px; margin-top:10px; }
+  .pip { width:22px; height:6px; border-radius:3px; background:var(--border); }
+  .pip.run { background:var(--run); animation:pulse 1.2s ease-in-out infinite; }
+  .pip.done { background:var(--ok); } .pip.failed { background:var(--bad); }
+  @keyframes pulse { 50% { opacity:.35 } }
+  .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; }
+  .agent { background:var(--card); border:1px solid var(--border); border-radius:12px; }
+  .agent summary { list-style:none; cursor:pointer; padding:12px 14px; }
+  .agent summary::-webkit-details-marker { display:none; }
+  .row1 { display:flex; align-items:center; gap:8px; }
+  .dot { width:9px; height:9px; border-radius:50%; flex:none; }
+  .dot.running { background:var(--run); animation:pulse 1.2s ease-in-out infinite; }
+  .dot.done { background:var(--ok); } .dot.failed { background:var(--bad); }
+  .id { font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .brain { margin-left:auto; font-size:11px; color:var(--muted); border:1px solid var(--border);
+    border-radius:9px; padding:1px 8px; flex:none; }
+  .stats { display:flex; gap:14px; color:var(--muted); font-size:12px; margin-top:6px; }
+  .detail { border-top:1px solid var(--border); padding:10px 14px; font-size:12px;
+    color:var(--muted); display:grid; gap:6px; }
+  .detail b { color:var(--text); font-weight:500; }
+  .act { font-family:ui-monospace, Consolas, monospace; font-size:11px;
+    overflow-wrap:anywhere; }
+  .err { color:var(--bad); }
+  .foot { color:var(--muted); font-size:12px; margin-top:12px; text-align:right; }
+</style>
+</head>
+<body>
+<div class="shell">
+  <div class="head">
+    <h1 id="run">fleetflow</h1>
+    <div class="meta"><span id="agents"></span><span id="elapsed"></span></div>
+    <div class="pips" id="pips"></div>
+  </div>
+  <div class="grid" id="grid"></div>
+  <div class="foot" id="foot">waiting for status.json…</div>
+</div>
+<script>
+const fmt = s => s >= 3600 ? Math.floor(s/3600)+"h "+Math.floor(s%3600/60)+"m"
+  : s >= 60 ? Math.floor(s/60)+"m "+(s%60)+"s" : s+"s";
+const esc = t => (t||"").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+const open = new Set();
+async function tick() {
+  let d;
+  try { d = await (await fetch("status.json?t="+Date.now())).json(); }
+  catch (e) { document.getElementById("foot").textContent = "status.json unreachable — is ff-status --watch running?"; return; }
+  document.getElementById("run").textContent = "fleetflow — " + d.run;
+  const lanes = d.lanes || [];
+  const running = lanes.filter(l => l.state === "running").length;
+  document.getElementById("agents").textContent =
+    lanes.length + " agent" + (lanes.length === 1 ? "" : "s") + (running ? " · " + running + " running" : " · all finished");
+  const oldest = Math.min(...lanes.map(l => l.started).filter(Boolean));
+  document.getElementById("elapsed").textContent = oldest ? fmt(d.generated_at - oldest) : "";
+  document.getElementById("pips").innerHTML =
+    lanes.map(l => `<div class="pip ${l.state === "running" ? "run" : l.state}"></div>`).join("");
+  document.getElementById("grid").innerHTML = lanes.map(l => `
+    <details class="agent" data-id="${esc(l.id)}" ${open.has(l.id) ? "open" : ""}>
+      <summary>
+        <div class="row1"><span class="dot ${l.state}"></span>
+          <span class="id">${esc(l.id)}</span><span class="brain">${esc(l.brain)}</span></div>
+        <div class="stats"><span>${fmt(l.elapsed_s)}</span>
+          <span>${l.tools || 0} tools</span><span>${l.commits || 0} commits</span>
+          ${l.tokens ? `<span>${l.tokens >= 1e6 ? (l.tokens/1e6).toFixed(1)+"M" : (l.tokens/1000).toFixed(1)+"k"} tok</span>` : ""}</div>
+      </summary>
+      <div class="detail">
+        <div class="act"><b>activity</b> ${esc(l.activity)}</div>
+        ${l.last_commit ? `<div><b>last commit</b> ${esc(l.last_commit)}</div>` : ""}
+        ${l.err_tail ? `<div class="err">${esc(l.err_tail)}</div>` : ""}
+        <div><b>artifact</b> ${esc((l.artifact||"pending").split(/[\\/]/).pop())}</div>
+      </div>
+    </details>`).join("");
+  document.querySelectorAll("details.agent").forEach(el =>
+    el.addEventListener("toggle", () => el.open ? open.add(el.dataset.id) : open.delete(el.dataset.id)));
+  document.getElementById("foot").textContent = "updated " + new Date().toLocaleTimeString();
+}
+tick(); setInterval(tick, 2500);
+</script>
+</body>
+</html>

+ 6 - 0
skills/fleetflow/references/worker-contracts.md

@@ -52,6 +52,12 @@ OpenAI's agent harness, non-interactive. Flag map (codex-cli 0.125.0):
 - **Character**: a genuinely different model *and* toolchain — its highest
   value in fleetflow is dissent (refuter/judge lanes) and independent second
   implementations, not bulk mechanical work (GLM is cheaper there).
+- **Worktree-lane commit gotcha** (observed 2026-07-05): a git worktree's
+  metadata (index, refs) lives in the MAIN repo's `.git/` — outside the codex
+  sandbox's writable root — so `git commit` inside a lane fails with
+  `index.lock: Permission denied` under `--full-auto`. `ff-spawn` fixes this
+  by passing `--add-dir <absolute-git-dir>` for codex worktree lanes; if you
+  launch codex by hand, add it yourself (or use a full clone as the lane).
 - **Skill-loading quirk** (observed 2026-07-05): codex reads Claude-format
   skills from `~/.agents/skills/` at session start and rejects any whose
   description exceeds **1024 chars** (`failed to load skill … exceeds maximum

+ 1 - 1
skills/fleetflow/scripts/ff-doctor.sh

@@ -49,7 +49,7 @@ if command -v codex >/dev/null; then say "bin-codex" ok "$(codex --version 2>/de
 FW="${FLEETFLOW_FLEET_WORKER:-$HOME/.claude/skills/fleet-worker/scripts/fleet-worker}"
 if [ -f "$FW" ]; then say "fleet-worker" ok "$FW"; else say "fleet-worker" advisory "not installed - glm brain unavailable"; fi
 
-for s in ff-spawn.sh ff-collect.sh; do
+for s in ff-spawn.sh ff-collect.sh ff-status.sh; do
   if bash -n "$HERE/$s" 2>/dev/null; then say "syntax-$s" ok "parses"; else say "syntax-$s" fail "syntax error"; FAIL=1; fi
 done
 [ -f "$HERE/../assets/guard-preamble.txt" ] && say "guard-preamble" ok "present" || { say "guard-preamble" fail "missing"; FAIL=1; }

+ 4 - 0
skills/fleetflow/scripts/ff-spawn.sh

@@ -143,8 +143,12 @@ else
     codex)
       command -v codex >/dev/null || { err "codex CLI not found"; exit 5; }
       ART="$RUNDIR/$ID.last.txt"
+      # a worktree's git metadata lives in the MAIN repo's .git - outside the
+      # codex sandbox's writable root - so git commit fails without this carve-out
+      GITDIR=""; [ "$WORKTREE" = 1 ] && GITDIR="$(git -C "$REPO" rev-parse --absolute-git-dir)"
       ( cd "$WORKDIR" && \
         codex exec --full-auto --ephemeral --color never --json \
+          ${GITDIR:+--add-dir "$GITDIR"} \
           ${FLEETFLOW_CODEX_MODEL:+-m "$FLEETFLOW_CODEX_MODEL"} \
           ${SCHEMA:+--output-schema "$SCHEMA"} \
           -o "$ART" - < "$SENT" \

+ 107 - 0
skills/fleetflow/scripts/ff-status.sh

@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# ff-status.sh - emit a fleetflow run's live status as JSON (the data feed
+# behind assets/ff-monitor.html, and a machine-readable run summary on its own).
+#
+# Reads the run journal + artifacts; never modifies anything. Lane state is
+# derived from journal records (started-without-result = running), timings
+# from artifact mtimes, activity from lane commits (claude brains) or the
+# codex event stream (item.completed counts + last item).
+# stdout: the JSON document (data only). stderr: chatter.
+#
+# Exit codes: 0 ok | 2 usage
+set -u
+
+usage() {
+  cat <<'EOF'
+Usage: ff-status.sh --run NAME [--repo PATH] [--out FILE] [--watch SECONDS]
+
+  --run NAME       run name under <repo>/.fleetflow/
+  --repo PATH      repo root (default: git toplevel of cwd)
+  --out FILE       write JSON to FILE instead of stdout
+  --watch SECONDS  loop forever, rewriting --out every SECONDS (requires --out)
+
+EXAMPLES
+  ff-status.sh --run currency | jq '.lanes[] | {id, state, elapsed_s}'
+  ff-status.sh --run currency --out .fleetflow/currency/status.json --watch 3
+EOF
+}
+
+err() { echo "ff-status: $*" >&2; }
+
+RUN="" REPO="" OUT="" WATCH=""
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --run) RUN="${2:-}"; shift 2 ;;
+    --repo) REPO="${2:-}"; shift 2 ;;
+    --out) OUT="${2:-}"; shift 2 ;;
+    --watch) WATCH="${2:-}"; shift 2 ;;
+    -h|--help) usage; exit 0 ;;
+    *) err "unknown argument: $1"; usage >&2; exit 2 ;;
+  esac
+done
+[ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; }
+command -v jq >/dev/null || { err "jq required"; exit 2; }
+[ -z "$WATCH" ] || [ -n "$OUT" ] || { err "--watch requires --out"; exit 2; }
+[ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
+RUNDIR="$REPO/.fleetflow/$RUN"
+[ -f "$RUNDIR/journal.jsonl" ] || { err "no journal at $RUNDIR"; exit 2; }
+
+mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
+
+emit() {
+  local now lanes id brain state started finished elapsed art commits last_c tools activity tokens etail rc
+  now=$(date +%s)
+  lanes="[]"
+  for id in $(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" | awk '!seen[$0]++'); do
+    brain="$(jq -r --arg id "$id" 'select(.id==$id) | .brain' "$RUNDIR/journal.jsonl" | head -1)"
+    rc="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .rc' "$RUNDIR/journal.jsonl" | tail -1)"
+    art="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .artifact' "$RUNDIR/journal.jsonl" | tail -1)"
+    if [ -z "$rc" ]; then state="running"; finished=0
+    elif [ "$rc" = "0" ]; then state="done"; finished=$(mtime "$art")
+    else state="failed"; finished=$(mtime "$art"); fi
+    started=$(mtime "$RUNDIR/$id.prompt.txt")
+    if [ "$finished" -gt 0 ]; then elapsed=$((finished - started)); else elapsed=$((now - started)); fi
+    [ "$elapsed" -ge 0 ] || elapsed=0
+
+    commits=0; last_c=""
+    if [ -d "$RUNDIR/wt-$id" ]; then
+      commits="$(git -C "$RUNDIR/wt-$id" rev-list --count "main..HEAD" 2>/dev/null || echo 0)"
+      last_c="$(git -C "$RUNDIR/wt-$id" log -1 --format=%s "main..HEAD" -- 2>/dev/null | head -c 90)"
+    fi
+
+    tools=0; activity=""; tokens=0
+    if [ "$brain" = "codex" ] && [ -f "$RUNDIR/$id.events.jsonl" ]; then
+      tools="$(jq -r 'select(.type=="item.completed" and .item.type=="command_execution") | 1' "$RUNDIR/$id.events.jsonl" 2>/dev/null | wc -l | tr -d ' ')"
+      activity="$(jq -r 'select(.type=="item.completed") | .item | (.type + ": " + ((.command // .text // "") | gsub("\n";" ") | .[0:70]))' "$RUNDIR/$id.events.jsonl" 2>/dev/null | tail -1)"
+      tokens="$(jq -r 'select(.usage != null) | .usage.total_tokens // (.usage.input_tokens + .usage.output_tokens) // 0' "$RUNDIR/$id.events.jsonl" 2>/dev/null | tail -1)"
+      [ -n "$tokens" ] || tokens=0
+    elif [ "$state" != "running" ] && [ -f "$RUNDIR/$id.result.json" ]; then
+      tokens="$(jq -r '.usage.output_tokens // 0' "$RUNDIR/$id.result.json" 2>/dev/null | head -1)"
+      tools="$(jq -r '.num_turns // 0' "$RUNDIR/$id.result.json" 2>/dev/null | head -1)"
+      [ -n "$tokens" ] || tokens=0; [ -n "$tools" ] || tools=0
+    fi
+    [ -n "$activity" ] || activity="${last_c:-working}"
+    etail="$(grep -v '^\s*$' "$RUNDIR/$id.err" 2>/dev/null | tail -1 | head -c 160)"
+
+    lanes="$(jq -nc --argjson L "$lanes" \
+      --arg id "$id" --arg brain "$brain" --arg state "$state" --arg activity "$activity" \
+      --arg last_c "$last_c" --arg etail "$etail" --arg art "${art:-}" \
+      --argjson started "$started" --argjson elapsed "$elapsed" \
+      --argjson commits "${commits:-0}" --argjson tools "${tools:-0}" --argjson tokens "${tokens:-0}" \
+      '$L + [{id:$id,brain:$brain,state:$state,started:$started,elapsed_s:$elapsed,
+              commits:$commits,tools:$tools,tokens:$tokens,activity:$activity,
+              last_commit:$last_c,artifact:$art,err_tail:$etail}]')"
+  done
+  jq -nc --arg run "$RUN" --arg repo "$REPO" --argjson now "$now" --argjson lanes "$lanes" \
+    '{run:$run,repo:$repo,generated_at:$now,lanes:$lanes}'
+}
+
+if [ -n "$WATCH" ]; then
+  err "watching every ${WATCH}s -> $OUT (ctrl-c to stop)"
+  while :; do emit > "$OUT.tmp" && mv -f "$OUT.tmp" "$OUT"; sleep "$WATCH"; done
+elif [ -n "$OUT" ]; then
+  emit > "$OUT"
+else
+  emit
+fi
+exit 0

+ 11 - 1
skills/fleetflow/tests/run.sh

@@ -26,7 +26,7 @@ git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
 PKT="$TMP/packet.txt"; echo "Do the thing. FINAL REPLY: one line." > "$PKT"
 
 # --- syntax + help ------------------------------------------------------------
-for s in ff-spawn.sh ff-collect.sh ff-doctor.sh; do
+for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh; do
   bash -n "$S/$s" 2>/dev/null && ok "syntax $s" || bad "syntax $s"
   bash "$S/$s" --help 2>/dev/null | grep -q "EXAMPLES" && ok "$s --help has EXAMPLES" || bad "$s --help lacks EXAMPLES"
   check "$s --help exits 0" 0 bash "$S/$s" --help
@@ -78,6 +78,16 @@ check "collect: codex schema-valid JSON" 0 bash "$S/ff-collect.sh" --run r1 --id
 printf 'not json' > "$REPO/.fleetflow/r1/cx.last.txt"
 check "collect: codex schema-invalid fails" 10 bash "$S/ff-collect.sh" --run r1 --id cx --repo "$REPO" --schema
 
+# --- status feed --------------------------------------------------------------------
+check "status: no args" 2 bash "$S/ff-status.sh"
+check "status: watch without out" 2 bash "$S/ff-status.sh" --run r1 --repo "$REPO" --watch 3
+bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes | length >= 2' >/dev/null \
+  && ok "status: emits lanes JSON" || bad "status: JSON invalid"
+bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes[] | select(.id=="a") | .state=="done"' >/dev/null \
+  && ok "status: dry-run lane state done" || bad "status: lane state wrong"
+[ -f "$HERE/../assets/ff-monitor.html" ] && grep -q "status.json" "$HERE/../assets/ff-monitor.html" \
+  && ok "monitor asset present + polls status.json" || bad "monitor asset missing"
+
 # --- escape guard ------------------------------------------------------------------
 check "escape guard: clean main" 0 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
 echo rogue > "$REPO/rogue.txt"