diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04103a8..020882b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: run: bash skills/delegate-kit/tests/route.sh - name: Safety gate run: bash skills/delegate-kit/tests/gate.sh + - name: Worktree inspect for reruns + run: bash skills/delegate-kit/tests/inspect.sh - name: SKILL.md stays a policy, not a manual run: | size=$(wc -c < skills/delegate-kit/SKILL.md) diff --git a/skills/delegate-kit/references/external.md b/skills/delegate-kit/references/external.md index e7de64f..685ea0b 100644 --- a/skills/delegate-kit/references/external.md +++ b/skills/delegate-kit/references/external.md @@ -22,7 +22,7 @@ agent-run wait | status | list | log [out|err|prompt] | kill - **One worker, nothing else to do** → `run` blocks and prints the result. - **Several staggered workers, or a coordinator that must stay responsive** → `--detach`, then either `wait ` or `--on-finish CMD`. The hook fires exactly once per run on any terminal state, with the result on stdin and `DK_RUN_ID`, `DK_STATUS`, `DK_RESULT_PATH` in the environment; a non-zero exit is retried, delivery survives a dead supervisor. Point it where the coordinator will actually look — a log it tails, a desktop notifier. Without a hook, an external worker is invisible until polled. -- **Run states.** `status` is how a run ended; `lifecycle` is whether `resume` can still revive it (`parked`) or not (`done`). `orphaned` (supervisor died) and `timeout` are terminal but the worker has usually committed before dying — read the worktree before rerunning. +- **Run states.** `status` is how a run ended; `lifecycle` is whether `resume` can still revive it (`parked`) or not (`done`). `orphaned` (supervisor died) and `timeout` are terminal but the worker has usually committed before dying — `agent-run inspect ` shows what it left before you rerun. - **Timeouts are a fuse.** Writers and planners 90 min, reviewers and researchers 45; `--timeout` raises one run. Set it up front for a brief you expect to be long. ### Under a Codex coordinator @@ -58,7 +58,7 @@ Per-role defaults for one user, in the same file — the shipped table in `scrip - **Writer cap 3, ceiling 8; workers = writers + 3**, so a `panel` or `led` review fits beside a full set of writers. `agent-run run` counts external writers machine-wide plus the native locks (`agent-wt lock`) of the repository it writes into; `agent-wt lock` counts the locked worktrees of its repository. The cap is raised per task: the coordinator states the **partition** — one ticket per writer, disjoint write scopes — the user says yes, and `--max-writers N` on the run or the lock carries it (`DELEGATE_KIT_MAX_WRITERS` for the session; `DELEGATE_KIT_MAX_WORKERS` overrides the total, floored at writers + 1). The ceiling holds against every override; past it the work goes in waves. A run refused by a cap or a locked worktree fails before anything is spawned, `--detach` included: the parent prints the reason. A refusal that lands inside the supervisor (a race) is recorded as `failed`, so `status` shows it. Counting and taking a slot happen under one mutex, so concurrent starts respect the cap too. N sessions on one subscription hit the rate limit together; `--fallback none` keeps a fleet from all retrying on the other family at once. - **Writers** run in a worktree under the backend's own sandbox (`workspace-write` / `acceptEdits`); the dangerous modes are outside this skill. `agent-run` refuses a worktree locked for a native writer, and the reverse. - **Read-only roles** run under `codex -s read-only` / `claude --permission-mode plan` — the enforced boundary a native role lacks. When it matters (an untrusted diff, a risk zone), dispatch that role externally even inside the family. -- **Quota fallback.** On a usage or rate limit `agent-run` retries the brief once on the other family and marks the result `fallback_from`. For a reviewer that can land the review on the author's family — the result says so; report it or re-run later. `--fallback none` disables it; resumes never fall back. +- **Quota fallback.** On a usage or rate limit `agent-run` retries the brief once on the other family and marks the result `fallback_from`. A writer's rerun gets a `PREVIOUS ATTEMPT` note at the top of the brief — commits since base and uncommitted files the first worker left (`agent-run inspect` prints the same) — so it continues instead of starting blind; `ultra` becomes `xhigh` when the rerun lands on Claude. For a reviewer that can land the review on the author's family — the result says so; report it or re-run later. `--fallback none` disables it; resumes never fall back. - `hooks/gate.sh` makes dangerous shell commands need the user's confirmation in the coordinator (Claude: the approval prompt; Codex: denied with instructions to confirm and re-run prefixed `DELEGATE_KIT_CONFIRMED=1`). - The ledger `~/.delegate-kit/ledger.jsonl` records model, effort, preset, lens, tokens, duration and outcome per external run. Read it before changing a default. diff --git a/skills/delegate-kit/scripts/agent-run b/skills/delegate-kit/scripts/agent-run index 4df20ea..3436b63 100755 --- a/skills/delegate-kit/scripts/agent-run +++ b/skills/delegate-kit/scripts/agent-run @@ -239,6 +239,37 @@ function nativeWriterLocks(cwd) { }); } +/* + * Что осталось в worktree от прерванного прогона. + * + * Quota fallback перезапускал тот же бриф в том же worktree, а лимит может + * сработать после коммитов и незакоммиченных правок первого воркера — второй + * начинал вслепую и переделывал или ломал сделанное. База берётся из + * delegate-kit.base, который пишет `agent-wt create`; без него считаются + * только грязные файлы. Тот же снимок печатает `agent-run inspect` — команда + * под давнее «read the worktree before rerunning» из документации. + */ +function inspectWorktree(cwd) { + const g = gitDirOf(cwd); + if (!g) return { cwd, git: false, base: null, commits: [], dirty: [], partial: false, note: null }; + let base = null; try { base = fs.readFileSync(path.join(g, "delegate-kit.base"), "utf8").trim() || null; } catch {} + const commits = base ? (sh("git", ["log", "--format=%h%x09%s", `${base}..HEAD`], { cwd }).stdout || "").split("\n").filter(Boolean).map((l) => { const [sha, ...rest] = l.split("\t"); return { sha, subject: rest.join("\t") }; }) : []; + // --untracked-files=all: a new directory is listed file by file, not as one `?? dir/` entry + const dirty = (sh("git", ["status", "--porcelain", "--untracked-files=all"], { cwd }).stdout || "").split("\n").filter(Boolean).map((l) => ({ status: l.slice(0, 2).trim(), path: l.slice(3) })); + const partial = commits.length > 0 || dirty.length > 0; + return { cwd, git: true, base, commits, dirty, partial, note: partial ? previousAttemptNote({ commits, dirty }) : null }; +} +function previousAttemptNote({ commits, dirty }) { + const c = commits.length ? commits.map((x) => `${x.sha} ${x.subject}`).join("; ") : "none"; + const d = dirty.length ? dirty.map((x) => `${x.status} ${x.path}`).join(", ") : "none"; + return `PREVIOUS ATTEMPT: a worker on this same brief stopped mid-run in this worktree. Commits it made since base: ${c}. Uncommitted changes it left: ${d}. Read that work first (git log, git diff) and continue from it: keep what is done, finish what is not, and for the uncommitted changes decide deliberately — keep, finish or revert — and say which in \`summary\`.`; +} +function cmdInspect(cwd) { + const p = path.resolve(cwd || process.cwd()); + if (!fs.existsSync(p)) die(`no such directory: ${p}`); + process.stdout.write(JSON.stringify(inspectWorktree(p), null, 2) + "\n"); +} + function resolveCaps(opts = {}) { const raw = opts["max-writers"] !== undefined ? { v: opts["max-writers"], from: "--max-writers" } : process.env.DELEGATE_KIT_MAX_WRITERS ? { v: process.env.DELEGATE_KIT_MAX_WRITERS, from: "DELEGATE_KIT_MAX_WRITERS" } : null; @@ -906,6 +937,7 @@ async function cmdRun(opts, resumeOf = null) { let prompt = opts.prompt; if (!prompt && opts.brief) { if (!fs.existsSync(opts.brief)) die(`brief not found: ${opts.brief}`); prompt = fs.readFileSync(opts.brief, "utf8"); } if (!prompt) die("provide --brief FILE or --prompt TEXT"); + const rawBrief = prompt; const lens = opts.lens && opts.lens !== true ? String(opts.lens) : null; if (lens && !LENSES[lens]) die(`--lens must be ${Object.keys(LENSES).join("|")} (got ${lens})`); if (lens && role !== "reviewer") die("--lens applies to --role reviewer only"); @@ -1001,7 +1033,13 @@ async function cmdRun(opts, resumeOf = null) { const nb = otherBackend(backend); process.stderr.write(`agent-run: ${backend} reported a usage/rate limit; retrying once on ${nb} (fallback). Use --fallback none to disable.\n`); m.status = "failed-quota"; saveMeta(m); - const next = { ...opts, backend: nb, model: undefined, effort: opts.effort, _fallbackFrom: id, detach: false, id: undefined }; + // A writer may have committed or left edits before the limit hit: the rerun is told what + // it inherits. `ultra` exists on Codex only; the nearest Claude effort is xhigh. + const state = write ? inspectWorktree(cwd) : null; + const brief = state && state.partial ? `${state.note}\n\n${rawBrief}` : rawBrief; + if (state && state.partial) process.stderr.write(`agent-run: the fallback worker inherits ${state.commits.length} commit(s) and ${state.dirty.length} uncommitted file(s) from ${id}; the brief says so.\n`); + const effort = opts.effort === "ultra" && nb === "claude" ? "xhigh" : opts.effort; + const next = { ...opts, backend: nb, model: undefined, effort, prompt: brief, brief: undefined, _fallbackFrom: id, detach: false, id: undefined }; await cmdRun(next, null); return; } @@ -1061,6 +1099,7 @@ switch (sub) { case "kill": cmdKill(argv._[1]); break; case "log": cmdLog(argv._[1], argv._[2]); break; case "notify": cmdNotify(argv); break; + case "inspect": cmdInspect(argv._[1]); break; default: console.log(`agent-run — headless workers for Claude Code / Codex with role defaults, worktree locks and a ledger @@ -1078,6 +1117,8 @@ switch (sub) { agent-run resume (--brief FILE | --prompt TEXT) [--detach] agent-run list | status | wait [--timeout MIN] | kill | log [out|err|prompt] agent-run notify [] [--force] deliver any completion still pending (retries due ones); no id drains all + agent-run inspect [] what a stopped worker left: base, commits since base, uncommitted files, and + the note a fallback rerun of a writer is given about them --timeout on \`run\` is a fuse against a hung worker, not a schedule; without it the role decides: ${Object.entries(ROLE_TIMEOUT_MIN).map(([r, t]) => `${r} ${t}m`).join(", ")}. A run killed by it ends as diff --git a/skills/delegate-kit/tests/inspect.sh b/skills/delegate-kit/tests/inspect.sh new file mode 100755 index 0000000..6e12e43 --- /dev/null +++ b/skills/delegate-kit/tests/inspect.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Стенд `agent-run inspect`: что прерванный писатель оставил в worktree и какую +# заметку получит его fallback-перезапуск. Та же функция кормит quota fallback, +# поэтому проверяется здесь, без запуска моделей. +# +# ./inspect.sh +set -u +SCRIPTS="$(cd "$(dirname "$0")/../scripts" && pwd)" +AR="$SCRIPTS/agent-run"; WT="$SCRIPTS/agent-wt" +BASE="${TMPDIR:-/tmp}/dk-inspect-test.$$" +trap 'rm -rf "$BASE"' EXIT +export DELEGATE_KIT_HOME="$BASE/state" +PASS=0; FAIL=0 +ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; } +G="git -c user.email=t@t -c user.name=t" + +mkdir -p "$BASE/repo"; cd "$BASE/repo" || exit 1 +git init -q -b main . && $G commit -q --allow-empty -m init +"$WT" create slice >/dev/null 2>&1 +W="$BASE/repo.worktrees/slice" + +echo "── чистый worktree" +ok "git=true, partial=false" "$(node "$AR" inspect "$W" | jq -r '[.git,.partial,(.commits|length),(.dirty|length)]|join(" ")')" "true false 0 0" +ok "база записана agent-wt create" "$(node "$AR" inspect "$W" | jq -r '.base == "'"$(git rev-parse HEAD)"'"')" "true" +ok "note пустой" "$(node "$AR" inspect "$W" | jq -r '.note')" "null" + +echo "── коммит и грязный файл после базы" +echo a > "$W/a.txt"; $G -C "$W" add a.txt; $G -C "$W" commit -q -m "add a" +echo b > "$W/b.txt"; echo a2 > "$W/a.txt" +OUT=$(node "$AR" inspect "$W") +ok "partial=true" "$(jq -r '.partial' <<<"$OUT")" "true" +ok "один коммит с темой" "$(jq -r '.commits|length|tostring' <<<"$OUT") $(jq -r '.commits[0].subject' <<<"$OUT")" "1 add a" +ok "грязные: изменённый и новый" "$(jq -r '[.dirty[]|.status+":"+.path]|sort|join(" ")' <<<"$OUT")" "??:b.txt M:a.txt" +ok "note называет коммит" "$(jq -r '.note' <<<"$OUT" | grep -c 'add a')" "1" +ok "note называет грязные файлы" "$(jq -r '.note' <<<"$OUT" | grep -c 'M a.txt, ?? b.txt')" "1" +ok "note велит читать и продолжать" "$(jq -r '.note' <<<"$OUT" | grep -c 'continue from it')" "1" + +echo "── файлы в новом каталоге перечислены поимённо" +mkdir -p "$W/src/new"; echo c > "$W/src/new/c.txt"; echo d > "$W/src/new/d.txt" +ok "два файла, не один каталог" "$(node "$AR" inspect "$W" | jq -r '[.dirty[]|select(.path|startswith("src/new/"))|.path]|sort|join(" ")')" "src/new/c.txt src/new/d.txt" +rm -rf "$W/src" + +echo "── без базы считаются только грязные файлы" +rm "$BASE/repo/.git/worktrees/slice/delegate-kit.base" +ok "base=null, коммиты не считаются, грязные видны" "$(node "$AR" inspect "$W" | jq -r '[(.base|tostring),(.commits|length),(.dirty|length),.partial]|join(" ")')" "null 0 2 true" + +echo "── не git" +mkdir -p "$BASE/plain" +ok "git=false, partial=false" "$(node "$AR" inspect "$BASE/plain" | jq -r '[.git,.partial]|join(" ")')" "false false" +ok "нет каталога — отказ" "$(node "$AR" inspect "$BASE/nope" 2>&1 | grep -c 'no such directory')" "1" + +echo; echo "Пройдено: $PASS, провалено: $FAIL" +exit $((FAIL > 0))