Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions skills/delegate-kit/references/external.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ agent-run wait <id> | status <id> | list | log <id> [out|err|prompt] | kill <id>

- **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 <id>` 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 <worktree>` 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
Expand Down Expand Up @@ -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.

Expand Down
43 changes: 42 additions & 1 deletion skills/delegate-kit/scripts/agent-run
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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

Expand All @@ -1078,6 +1117,8 @@ switch (sub) {
agent-run resume <id> (--brief FILE | --prompt TEXT) [--detach]
agent-run list | status <id> | wait <id> [--timeout MIN] | kill <id> | log <id> [out|err|prompt]
agent-run notify [<id>] [--force] deliver any completion still pending (retries due ones); no id drains all
agent-run inspect [<worktree>] 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
Expand Down
53 changes: 53 additions & 0 deletions skills/delegate-kit/tests/inspect.sh
Original file line number Diff line number Diff line change
@@ -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))
Loading