From 7c1340daab34dad4e51cb7e36cf013083e4cfd89 Mon Sep 17 00:00:00 2001 From: Kirill Kit Date: Wed, 2 Sep 2026 00:40:49 +0300 Subject: [PATCH 1/2] =?UTF-8?q?delegate-kit:=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=20=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=203/8,=20=D1=84=D0=BB=D0=B0=D0=B3=20--max-writers,?= =?UTF-8?q?=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=20=D0=BF=D1=80=D0=B8=20--detach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лимит писателей был константой 2 в четырёх несогласных местах: таблица PARALLEL разрешала 2–3 слайса, строка Limits — 2 писателя, README обещал «larger fleets» без механизма, а env-переопределение в agent-run нигде не документировалось. - agent-run: cap 3 по умолчанию, ceiling 8; workers = writers + 3 (раньше константа 4 упиралась в panel/led при живых писателях); --max-writers N, DELEGATE_KIT_MAX_WRITERS, DELEGATE_KIT_MAX_WORKERS не ниже writers + 1; нативные lock'и репозитория входят в счёт. - agent-run: проверки потолка и lock'а worktree до fork супервизора — при --detach отказ печатается родителю; отказ внутри супервизора пишет meta со статусом failed вместо тихого исчезновения. - agent-wt lock: тот же потолок по lock'ам репозитория, --max-writers N. - SKILL.md, README, external.md, hosts.md: одно правило вместо четырёх; SKILL.md ужат до 7962 байт — CI на main падал на 8406. - tests/caps.sh + шаг CI. Closes #3 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 + README.md | 10 +- skills/delegate-kit/SKILL.md | 48 ++++---- skills/delegate-kit/references/external.md | 1 + skills/delegate-kit/references/hosts.md | 2 +- skills/delegate-kit/scripts/agent-run | 124 ++++++++++++++++++-- skills/delegate-kit/scripts/agent-wt | 36 +++++- skills/delegate-kit/tests/caps.sh | 125 +++++++++++++++++++++ 8 files changed, 305 insertions(+), 43 deletions(-) create mode 100755 skills/delegate-kit/tests/caps.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0133cc..6e24f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: shellcheck -S warning $files - name: Node syntax run: node --check skills/delegate-kit/scripts/agent-run + - name: Writer caps and detach refusal + run: bash skills/delegate-kit/tests/caps.sh - name: SKILL.md stays a policy, not a manual run: | size=$(wc -c < skills/delegate-kit/SKILL.md) diff --git a/README.md b/README.md index b2748db..10d852c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ One skill for Claude Code, T3 Code and Codex CLI. Your logins, your subscription Capable models already delegate. What they do inconsistently is *when* and *how*: a one-line fix spawns a 50k-token worker, a ten-module feature starts with no plan, three subagents share one working tree, and the author approves its own diff. delegate-kit makes one decision the same every time: -> **Do it here, send one scout, plan first, one worker, two or three in parallel, or a sequence — and who reviews the result.** +> **Do it here, send one scout, plan first, one worker, a few in parallel, or a sequence — and who reviews the result.** | Without | With | |---|---| @@ -78,10 +78,10 @@ Two kinds of worker, never confused: | **SCOUT** | the hard part is *finding*: a large repo, several plausible causes, docs to quote | one read-only worker, then decide again | | **PLAN** | prose requirements, ambiguity, > 1 module or > ~10 files; any question that ends in a verdict | planner, read-only, strongest model | | **SINGLE** | one vertical slice too big for DIRECT | one implementer in its own worktree | -| **PARALLEL** | 2–3 slices with disjoint write scopes and stable interfaces | one implementer per slice, each in a worktree | +| **PARALLEL** | 2+ slices with disjoint write scopes and stable interfaces | one implementer per slice, each in a worktree | | **SEQUENTIAL** | one result changes the next task's assumptions (schema → API → UI) | one worker at a time, resumed | -Limits: 2 writers, 4 workers, delegation depth 1. Repository size changes the cost of *finding* context, not the number of writers. +Limits: writer cap 3, ceiling 8, workers = writers + 3, delegation depth 1. Raising the cap is a per-task decision: the coordinator names the partition (one ticket per writer, disjoint write scopes), you say yes, and `--max-writers N` carries it. Repository size changes the cost of *finding* context, not the number of writers. ## Review @@ -183,7 +183,7 @@ agent-run resume --prompt "Fix findings 1 and 3: ..." agent-run list | status | wait | kill | log | notify [] ``` -Two writers in parallel: `--detach` on each, then `agent-run wait ` or `--on-finish CMD` so each reports for itself. A native writer: `agent-wt lock ` before dispatch, `agent-wt release ` after. `--help` on either script is the flag reference. +Several writers in parallel: `--detach` on each, then `agent-run wait ` or `--on-finish CMD` so each reports for itself. A native writer: `agent-wt lock ` before dispatch, `agent-wt release ` after. Both commands refuse a writer past the cap (`--max-writers N` or `DELEGATE_KIT_MAX_WRITERS` raise it, up to the ceiling of 8); a refused `--detach` run is reported to the parent. `--help` on either script is the flag reference. @@ -212,7 +212,7 @@ skills/delegate-kit/ ## What it will not do - **Steer an external worker mid-run** — headless sessions run to completion; you read the result and resume. That is why native is preferred inside the family, and why an external worker is never called a subagent. -- **Run a swarm** — two writers, four workers. Larger fleets need an unusually clear partition and your explicit ask. +- **Run a swarm** — writer cap 3, ceiling 8, and the ceiling holds against every flag. Between the two it takes a stated partition and your yes, passed as `--max-writers N`. - **Make delegation cheap** — a worker is a full session; the shapes exist so you pay for it only for independence, parallelism or a clean context. - **Sandbox by itself** — the gate is a list of dangerous command shapes; the real isolation is the CLIs' own sandboxes plus the worktree. diff --git a/skills/delegate-kit/SKILL.md b/skills/delegate-kit/SKILL.md index 6049da9..da5df0d 100644 --- a/skills/delegate-kit/SKILL.md +++ b/skills/delegate-kit/SKILL.md @@ -7,7 +7,7 @@ compatibility: Policy runs anywhere. Native workers need a host with subagents ( # delegate-kit -The current session is the **coordinator**: it owns the user's intent, the plan, every brief, integration, verification and the final answer. Workers are fresh sessions that start from a brief and read the repository themselves. Two kinds exist, and the policy names which one it means: +The current session is the **coordinator**: it owns intent, plan, every brief, integration, verification and the final answer. Workers are fresh sessions that start from a brief and read the repository themselves. Two kinds: - **native** — a subagent of the host, same model family as the coordinator: steerable, visible, cheap. - **external** — a headless CLI session of the other family (`claude -p` / `codex exec`) through `scripts/agent-run`: launched once, collected once, never steered. `references/external.md`. @@ -18,44 +18,44 @@ Triage before acting. Pick the first shape that fits; state it in one line. | Shape | When | Who | |---|---|---| -| **DIRECT** | ≤ ~3 files with clear requirements; an explanation or diagnosis; a micro-fix after review; anything destructive or production-adjacent (`sudo`, deletes, services, secrets, prod DB, SSH) | coordinator, in the foreground | -| **SCOUT** | the hard part is *finding*: code spread across a large repo, several plausible causes, current docs to quote | one read-only worker (researcher for docs, otherwise the host's explorer) — then triage again | -| **PLAN** | prose requirements with business rules, ambiguity that reading code cannot settle, > 1 module or > ~10 files; any reading that ends in a *verdict* ("which do we adopt", "is this upgrade safe") | planner, read-only, strongest model | +| **DIRECT** | ≤ ~3 files with clear requirements; an explanation or diagnosis; a micro-fix after review; anything destructive or production-adjacent (`sudo`, deletes, secrets, prod DB, SSH) | coordinator, in the foreground | +| **SCOUT** | the hard part is *finding*: code spread across a large repo, several plausible causes, current docs to quote | one read-only worker (researcher for docs, else the host's explorer), then triage again | +| **PLAN** | prose requirements with business rules, ambiguity code cannot settle, > 1 module or > ~10 files; any reading that ends in a *verdict* ("which do we adopt", "is this upgrade safe") | planner, read-only, strongest model | | **SINGLE** | one well-specified vertical slice too large for DIRECT | one implementer in its own worktree | -| **PARALLEL** | 2–3 slices with disjoint write scopes, stable interfaces between them, each verifiable alone | one implementer per slice, each in its own worktree | -| **SEQUENTIAL** | a result changes the assumptions of the next task: schema → API → UI, diagnosis → fix | one worker at a time; the same worker resumed keeps its context | +| **PARALLEL** | 2+ slices with disjoint write scopes, stable interfaces, each verifiable alone; beyond the cap only as the limits below allow | one implementer per slice, each in its own worktree | +| **SEQUENTIAL** | a result changes the assumptions of the next task: schema → API → UI, diagnosis → fix | one worker at a time; resumed, it keeps its context | -Worker count equals the number of independent outcomes. Coupled edits stay in one pair of hands: split, they return as merge conflicts and two designs. Limits: 2 writers, 4 workers at once, delegation depth 1. +Worker count equals the number of independent outcomes. Coupled edits stay in one pair of hands: split, they return as merge conflicts and two designs. Limits: writer cap 3, ceiling 8; workers = writers + 3; delegation depth 1. Raising the cap is proposed like a review panel — partition stated, user's yes — and passed as `--max-writers N` (`references/external.md`). -**Routing trap:** "research" that ends in a recommendation is PLAN, not SCOUT. A quote is research; a verdict is planning, and a wrong verdict propagates downstream. +**Routing trap:** "research" that ends in a recommendation is PLAN, not SCOUT. A quote is research; a verdict is planning. ## 2. Spec, tickets and preset -- A grill/interview output or requirements longer than a paragraph → `.scratch//spec.md`; workers are pointed at it. An interview is not finished until the coordinator restates it in 5–8 lines — outcome, who it is for, what success looks like, the binding constraint, **out of scope** — and the user says yes explicitly; "sounds good" is not yes. -- Multi-slice work gets **tickets**: `/to-tickets` (mattpocock/skills) writes one file per tracer-bullet slice to `.scratch//issues/NN-slug.md` with `Blocked by`, `Status` and acceptance checkboxes. One ticket = one brief. The **frontier** — tickets whose blockers are all done — is the next work; a fresh session reads the directory and continues without being told where things stand. -- **Never overwrite a plan with unchecked items.** Replanning the same work edits the files in place; different work arriving while a plan is open is a question for the user, not a silent replacement. -- The user's words set the **preset**: any phrasing naming who carries the bulk of the work ("main model Claude", "let Codex implement") is `main-claude` / `main-codex`; naming a model for one role ("plan with Fable") is a per-call override. Presets move planner, implementer, researcher; the reviewer follows the author. `references/external.md`. +- A grill/interview output or requirements longer than a paragraph → `.scratch//spec.md`; workers are pointed at it. An interview ends only when the coordinator restates it in 5–8 lines — outcome, who it is for, what success looks like, the binding constraint, **out of scope** — and the user says yes explicitly; "sounds good" is not yes. +- Multi-slice work gets **tickets**: `/to-tickets` (mattpocock/skills) writes one file per slice to `.scratch//issues/NN-slug.md` with `Blocked by`, `Status` and acceptance checkboxes. One ticket = one brief. The **frontier** — tickets whose blockers are all done — is the next work; a fresh session continues from the directory alone. +- **Never overwrite a plan with unchecked items.** Replanning edits the files in place; different work while a plan is open is a question for the user, not a silent replacement. +- The user's words set the **preset**: naming who carries the bulk ("main model Claude", "let Codex implement") is `main-claude` / `main-codex`; naming a model for one role ("plan with Fable") is a per-call override. Presets move planner, implementer, researcher; the reviewer follows the author. `references/external.md`. ## 3. Route -Once per role: `agent-run route --role [--preset P]`. Keep its answer — family, model, effort, native or external, exact invocation — for the whole task. `native` → dispatch through the host (`references/hosts.md`); `external` → `references/external.md`. +Once per role: `agent-run route --role [--preset P]`. Keep its answer for the whole task. `native` → dispatch through the host (`references/hosts.md`); `external` → `references/external.md`. ## 4. Brief -Write every brief from `references/brief-template.md`: goal, spec, acceptance criteria as commands, where to look, constraints, worktree path, what to return. Done when a stranger with the repository and nothing else could start. An external worker cannot ask mid-run, so its brief also says: on ambiguity return `status: blocked` with precise `questions`. +Write every brief from `references/brief-template.md`: goal, spec, acceptance criteria as commands, where to look, constraints, worktree path, what to return. Done when a stranger with only the repository could start. An external worker cannot ask mid-run: its brief says to return `status: blocked` with precise `questions` on ambiguity. ## 5. Worktree -Every writer gets one: `agent-wt create `. External writer → `--cwd ` locks it; native writer → `agent-wt lock ` and the path in the brief. One writer per worktree. The worktree branches from **HEAD**: uncommitted work is invisible to the worker — if `git status` is dirty and the task touches it, tell the user and commit or stash first. +Every writer gets one: `agent-wt create `. External writer → `--cwd ` locks it; native writer → `agent-wt lock ` and the path in the brief. One writer per worktree. The worktree branches from **HEAD**, so uncommitted work is invisible to the worker: if the tree is dirty and the task touches it, tell the user and commit or stash first. ## 6. Review -The author of a non-trivial change does not certify it. A review is **independent** when a fresh read-only worker gets the frozen diff and the spec. Independence, in order of preference: +The author of a non-trivial change does not certify it. A review is **independent** when a fresh read-only worker gets the frozen diff and the spec. Independence, by preference: 1. **the other family** than the author — `route` picks it when that family's CLI is installed; -2. **a fresh worker of the author's family** — what `route` returns when the other CLI is missing (`independent: false`, with a note). Report which one ran. +2. **a fresh worker of the author's family** — `route`'s answer when the other CLI is missing (`independent: false`). Report which one ran. -Review when: any delegated implementation; any change in a risk zone (auth, payments, migrations, prod config); a coordinator-written diff > ~50 lines; the user asks. Freeze and size it: +Review when: any delegated implementation; a risk zone (auth, payments, migrations, prod config); a coordinator-written diff > ~50 lines; the user asks. Freeze and size it: ``` agent-wt diff > review.diff @@ -64,20 +64,20 @@ agent-run route --role reviewer --diff review.diff [--author-backend self] `single` runs straight away. `panel` / `led` are **proposed with the printed numbers and run on the user's yes**. Reviewers run in parallel and blind to each other; merge by `references/review.md`. -**Findings.** Mechanical ones the coordinator fixes. Substantive ones go back to the same implementer (`agent-run resume `, or continue the native subagent). A dispute is settled by a command first — a test, a typecheck, `npm ls`, a grep; spend a **verifier** only on intent, severity or design. A behavior-changing fix re-runs the affected checks and review. +**Findings.** Mechanical ones the coordinator fixes. Substantive ones go back to the same implementer (`agent-run resume `, or continue the native subagent). A dispute is settled by a command first — a test, a typecheck, a grep; a **verifier** only for intent, severity or design. A behavior-changing fix re-runs the affected checks and review. ## 7. Integrate and report -Merge or open a PR per repo conventions; `agent-wt release `, `agent-wt remove `. Report: what was done, what was checked, what was not, open questions, which preset ran, which family reviewed at which depth. A check that did not run is reported as not run; a worker's "done" is evidence to inspect. +Merge or open a PR per repo conventions; `agent-wt release `, `agent-wt remove `. Report: done, checked, not checked, open questions, which preset ran, which family reviewed at which depth. A check that did not run is reported as not run; a worker's "done" is evidence to inspect. -When the brief came from a ticket: once the acceptance commands have run in the coordinator's own hands, tick the ticket's checkboxes and set `Status: done`. The report then ends with the frontier — `closed X of Y, next: ` — so the ticket files and the answer never disagree. +A ticket's brief: once the coordinator itself has run the acceptance commands, tick its checkboxes and set `Status: done`. The report ends with the frontier — `closed X of Y, next: ` — so tickets and answer never disagree. ## 8. Handoff -Switching machine, harness or model family mid-task: write `.scratch/handoff/-.md` — where the work stands, what is blocked and on whom, which skills the next session should call, and pointers (spec, tickets, commits, diffs) instead of copies of them. No secrets. The next session starts from that file plus the tickets directory. A handoff transfers ownership; nothing comes back to integrate. +Switching machine, harness or model family mid-task: write `.scratch/handoff/-.md` — where the work stands, what is blocked and on whom, which skills to call next, and pointers (spec, tickets, commits, diffs) instead of copies. No secrets. The next session starts from that file and the tickets. A handoff transfers ownership; nothing comes back to integrate. ## Worker contract -Every worker returns one JSON object (`references/result-schema.json`): `status` (`done` | `blocked` | `failed`), `summary`, `changes`, `checks_run`, `not_verified`, `findings`, `plan`, `questions`, `sources`, `next_steps`. `blocked` + `questions` → the coordinator asks the user and resumes the same worker. +Every worker returns one JSON object per `references/result-schema.json`; `status` is `done` | `blocked` | `failed`. `blocked` + `questions` → the coordinator asks the user and resumes the same worker. -Roles, models and prompt hints: `references/roles.md`. Native definitions: `agents/dk-*.md` (Claude Code) and `references/codex-agents.toml` (Codex), installed by `hooks/install.sh`. `--help` on `scripts/agent-run` and `scripts/agent-wt` is the flag reference. +Roles, models and prompt hints: `references/roles.md`. Native definitions: `agents/dk-*.md` (Claude Code), `references/codex-agents.toml` (Codex), installed by `hooks/install.sh`. `--help` on both scripts is the flag reference. diff --git a/skills/delegate-kit/references/external.md b/skills/delegate-kit/references/external.md index 9279350..d281749 100644 --- a/skills/delegate-kit/references/external.md +++ b/skills/delegate-kit/references/external.md @@ -55,6 +55,7 @@ Per-role defaults for one user, in the same file — the shipped table in `scrip ## Limits and safety - **Delegation depth is 1.** `agent-run` disables subagents on both CLIs; the shipped `dk-*` definitions carry no `Agent` tool. +- **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 and the run is recorded as `failed`. 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. diff --git a/skills/delegate-kit/references/hosts.md b/skills/delegate-kit/references/hosts.md index dfa1917..0020642 100644 --- a/skills/delegate-kit/references/hosts.md +++ b/skills/delegate-kit/references/hosts.md @@ -17,7 +17,7 @@ Choose external even inside the family when one matters: - **An enforced sandbox.** External read-only roles run under `claude --permission-mode plan` / `codex -s read-only`. A native role is read-only by instruction and tool list — fine for a reviewer you dispatched yourself, thin as the boundary around an untrusted diff. - **The strict result schema, ledger, run id, timeout, quota fallback.** All live in `agent-run`. -- **The write-lock for free.** `agent-run --cwd` takes it; a native writer needs `agent-wt lock` before and `agent-wt release` after. +- **The write-lock for free.** `agent-run --cwd` takes it; a native writer needs `agent-wt lock` before and `agent-wt release` after — the lock is also what the writer cap counts (`external.md`). ## Git with parallel writers diff --git a/skills/delegate-kit/scripts/agent-run b/skills/delegate-kit/scripts/agent-run index 2a2b7fa..df5e097 100755 --- a/skills/delegate-kit/scripts/agent-run +++ b/skills/delegate-kit/scripts/agent-run @@ -15,8 +15,30 @@ const SCHEMA_PATH = path.join(SKILL_DIR, "references", "result-schema.json"); const STATE_DIR = process.env.DELEGATE_KIT_HOME || path.join(os.homedir(), ".delegate-kit"); const RUNS_DIR = path.join(STATE_DIR, "runs"); const LEDGER = path.join(STATE_DIR, "ledger.jsonl"); -const MAX_WORKERS = Number(process.env.DELEGATE_KIT_MAX_WORKERS || 4); -const MAX_WRITERS = Number(process.env.DELEGATE_KIT_MAX_WRITERS || 2); +/* + * Потолки одновременных воркеров. + * + * Писатели: 3 по умолчанию — ровно столько, сколько разрешает форма PARALLEL + * в SKILL.md; жёсткий потолок 8, выше которого не поднимает ни флаг, ни env. + * Между ними — решение на задачу: `--max-writers N` на этот запуск или + * `DELEGATE_KIT_MAX_WRITERS` на сессию, после того как координатор назвал + * партицию (один тикет на писателя, непересекающиеся write scopes) и + * пользователь сказал «да». Число, а не правило, потому что ограничение по + * сути — не количество, а способность координатора проинтегрировать и + * проревьюить N веток; 8 — предохранитель от разгона, не план. + * + * Воркеры всего: писатели + 3 места под ревьюеров, исследователя и + * верификатора рядом с ними. Раньше это была отдельная константа 4, и она + * упиралась в собственные глубины ревью: два писателя плюс `panel` из двух + * ревьюеров — впритык, плюс `led` из трёх — уже за потолком. + * `DELEGATE_KIT_MAX_WORKERS` по-прежнему переопределяет, но не ниже писателей + 1. + * + * Считаются внешние прогоны на всей машине и нативные lock'и + * (`agent-wt lock`) в репозитории, где запускается писатель. + */ +const WRITERS_DEFAULT = 3; +const WRITERS_CEILING = 8; +const WORKERS_HEADROOM = 3; // ---------- role matrix (defaults; override with --preset/--backend/--model/--effort) ---------- // Model and effort per role, per model family. `--backend` picks the family; which family @@ -89,7 +111,25 @@ const LENS_TAIL = "Report high-severity problems outside your lens too; another const RESULT_CONTRACT = `\n\nRETURN FORMAT: your final answer must be a single JSON object matching the delegate-kit result schema: {"status":"done|blocked|failed","summary":string,"changes":[{"file","note"}],"checks_run":[string],"not_verified":[string],"plan":[{"step","files","risk"}],"findings":[{"severity","kind","lens","file","line","claim","evidence","suggested_fix","verdict","raised_by"}],"questions":[string],"sources":[{"url","title","date","verified"}],"next_steps":[string]}. Emit every top-level key: use [] for arrays you have nothing for, and null for optional scalars inside items. Do not omit keys — strict output schemas reject partial objects. No prose outside the JSON.`; // ---------- helpers ---------- -const die = (msg, code = 1) => { process.stderr.write(`agent-run: ${msg}\n`); process.exit(code); }; +/* + * Отсоединённый супервизор запущен со stdio «ignore»: всё, что он пишет в + * stderr, исчезает. До этой правки провал до записи meta.json — упор в лимит, + * занятый worktree — оставлял вызывающего с `{"status":"starting"}` для + * прогона, которого не существует. Теперь такой провал сам записывает meta со + * статусом failed, и родитель, ждущий meta.json, видит причину. + */ +let SUPERVISED = null; // { id, role, backend, model, effort, cwd, write } while running as --_supervise +const die = (msg, code = 1) => { + process.stderr.write(`agent-run: ${msg}\n`); + if (SUPERVISED && !fs.existsSync(path.join(runDir(SUPERVISED.id), "meta.json"))) { + const now = nowIso(); + const result = { status: "failed", summary: `agent-run refused to start the worker: ${msg}`, changes: [], checks_run: [], not_verified: [], findings: [], plan: [], questions: [], sources: [], next_steps: [] }; + fs.mkdirSync(runDir(SUPERVISED.id), { recursive: true }); + saveMeta({ ...SUPERVISED, dispatch: "external", status: "failed", pid: process.pid, started: now, finished: now, error: msg, result }); + writeJson(path.join(runDir(SUPERVISED.id), "result.json"), result); + } + process.exit(code); +}; const nowIso = () => new Date().toISOString(); const newId = () => `${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`; const readJson = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; } }; @@ -163,19 +203,71 @@ function activeRuns() { return fs.readdirSync(RUNS_DIR).map(metaOf).filter(Boolean).filter((m) => m.status === "running" && alive(m.pid)); } -function acquireWriteLock(cwd, id, role) { +// Why the worktree cannot take this writer, or null. Checked in the parent before the +// detach fork (so the refusal is visible) and again in the supervisor when it takes the lock. +function writeLockConflict(cwd, id) { const lp = lockPath(cwd); - if (!lp) die(`--cwd ${cwd} is not inside a git repository; writers must run in a git worktree`); + if (!lp) return `--cwd ${cwd} is not inside a git repository; writers must run in a git worktree`; const existing = readJson(lp); if (existing && existing.kind === "native" && existing.id !== id) { - die(`worktree is locked for a native subagent (${existing.label || existing.id}, since ${existing.since}). Release it with \`agent-wt release \` when that subagent is done.`); + return `worktree is locked for a native subagent (${existing.label || existing.id}, since ${existing.since}). Release it with \`agent-wt release \` when that subagent is done.`; } if (existing && existing.pid && alive(existing.pid) && existing.id !== id) { - die(`worktree is locked by another writer (run ${existing.id}, pid ${existing.pid}, role ${existing.role}). One writer per worktree.`); + return `worktree is locked by another writer (run ${existing.id}, pid ${existing.pid}, role ${existing.role}). One writer per worktree.`; } + return null; +} +function acquireWriteLock(cwd, id, role) { + const conflict = writeLockConflict(cwd, id); + if (conflict) die(conflict); + const lp = lockPath(cwd); writeJson(lp, { id, role, kind: "process", pid: process.pid, owner: os.userInfo().username, cwd, since: nowIso() }); return lp; } +// Native writers hold a lock with no process behind it (`agent-wt lock`); they live at +//
/.git/worktrees//delegate-kit.lock and count toward the writer cap of the repository. +function nativeWriterLocks(cwd) { + const r = sh("git", ["rev-parse", "--git-common-dir"], { cwd }); + if (r.status !== 0) return []; + const dir = path.join(path.resolve(cwd, r.stdout.trim()), "worktrees"); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).flatMap((name) => { + const l = readJson(path.join(dir, name, "delegate-kit.lock")); + return l && l.kind === "native" ? [{ name, label: l.label || null, since: l.since || null }] : []; + }); +} + +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; + let writers = WRITERS_DEFAULT; + if (raw) { + const n = raw.v === true ? NaN : Number(raw.v); + if (!Number.isInteger(n) || n < 1) die(`${raw.from} must be an integer from 1 to ${WRITERS_CEILING} (got ${raw.v === true ? "nothing" : raw.v})`); + if (n > WRITERS_CEILING) die(`${raw.from}=${n} is above the ceiling of ${WRITERS_CEILING} writers; the ceiling holds against every flag. Split the work into waves.`); + writers = n; + } + let workers = writers + WORKERS_HEADROOM; + if (process.env.DELEGATE_KIT_MAX_WORKERS) { + const n = Number(process.env.DELEGATE_KIT_MAX_WORKERS); + if (!Number.isInteger(n) || n < 1) die(`DELEGATE_KIT_MAX_WORKERS must be a positive integer (got ${process.env.DELEGATE_KIT_MAX_WORKERS})`); + workers = Math.max(n, writers + 1); + } + return { writers, workers, ceiling: WRITERS_CEILING, source: raw ? raw.from : "default" }; +} +// Refuse before anything is spawned. `write` runs also count the native locks of their repo. +function checkCaps(caps, write, cwd) { + const running = activeRuns(); + if (running.length >= caps.workers) die(`max ${caps.workers} active workers reached (${running.map((r) => r.id).join(", ")}). The worker cap is writers (${caps.writers}) + ${WORKERS_HEADROOM}; raise --max-writers or DELEGATE_KIT_MAX_WORKERS.`); + if (!write) return; + const external = running.filter((r) => r.write); + const native = nativeWriterLocks(cwd); + const total = external.length + native.length; + if (total >= caps.writers) { + const who = [external.length ? `${external.length} external: ${external.map((r) => r.id).join(", ")}` : null, native.length ? `${native.length} native: ${native.map((n) => n.name).join(", ")}` : null].filter(Boolean).join("; "); + die(`max ${caps.writers} concurrent writers reached (${who}). Raising the cap is a per-task decision: state the partition — one ticket per writer, disjoint write scopes — get the user's yes, then --max-writers N (ceiling ${WRITERS_CEILING}) or DELEGATE_KIT_MAX_WRITERS for the session.`); + } +} function releaseWriteLock(cwd, id) { const lp = lockPath(cwd); if (!lp) return; @@ -750,6 +842,11 @@ async function cmdRun(opts, resumeOf = null) { if (onFinishRaw === true) die(`--on-finish needs a shell command, e.g. --on-finish 'echo "$DK_ROLE $DK_STATUS" >> ~/dk-done.log'`); const onFinish = onFinishRaw ? String(onFinishRaw) : null; + const caps = resolveCaps(opts); + if (opts._supervise) SUPERVISED = { id: opts.id, role, backend, model, effort, cwd, write }; + checkCaps(caps, write, cwd); + if (write) { const conflict = writeLockConflict(cwd, null); if (conflict) die(conflict); } + if (opts.detach && !opts._supervise) { const id = newId(); const args = process.argv.slice(2).filter((a) => a !== "--detach"); @@ -760,14 +857,14 @@ async function cmdRun(opts, resumeOf = null) { const deadline = Date.now() + 5000; while (Date.now() < deadline && !fs.existsSync(path.join(runDir(id), "meta.json"))) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); } const m = metaOf(id); + if (m && m.status === "failed" && m.error) die(`detached run ${id} did not start: ${m.error}`); process.stdout.write(JSON.stringify({ id, role, backend, model, effort, cwd, status: m ? m.status : "starting", supervisor_pid: sup.pid }) + "\n"); return; } - const running = activeRuns(); - if (running.length >= MAX_WORKERS) die(`max ${MAX_WORKERS} active workers reached (${running.map((r) => r.id).join(", ")})`); - if (write && running.filter((r) => r.write).length >= MAX_WRITERS) die(`max ${MAX_WRITERS} concurrent writers reached`); const id = opts.id || newId(); + // Re-check inside the supervisor: another run may have taken a slot since the parent looked. + checkCaps(caps, write, cwd); fs.mkdirSync(runDir(id), { recursive: true }); const outFile = path.join(runDir(id), "last-message.txt"); const built = buildCommand({ backend, role, model, effort, cwd, prompt, write, resumeId: resumeOf?.sessionId }); @@ -896,6 +993,7 @@ switch (sub) { agent-run run --role <${Object.keys(ROLES).join("|")}> [--preset P] [--backend claude|codex] [--model M] [--effort E] [--cwd DIR] (--brief FILE | --prompt TEXT) [--detach] [--timeout MIN] [--write true|false] [--allow-main-checkout] [--fallback auto|none] [--on-finish CMD] shell command run when this run reaches a terminal state + [--max-writers N] writer cap for this run (default ${WRITERS_DEFAULT}, ceiling ${WRITERS_CEILING}) [--lens spec|correctness|standards] [--panel ID] (reviewer only: lens preamble; panel id groups runs in the ledger) agent-run resume (--brief FILE | --prompt TEXT) [--detach] agent-run list | status | wait [--timeout MIN] | kill | log [out|err|prompt] @@ -915,6 +1013,12 @@ whoever next runs list, status, wait or notify. Exactly once — a claim file ke both delivering. After ${DELIVERY_MAX_ATTEMPTS} attempts the delivery is marked failed and only \`notify --force\` will retry it. A run that failed on quota delivers nothing: its fallback rerun does. +Caps: writers ${WRITERS_DEFAULT} by default, ceiling ${WRITERS_CEILING}; workers = writers + ${WORKERS_HEADROOM}. External runs count machine-wide, native +locks (\`agent-wt lock\`) within the repository of --cwd. --max-writers N raises the cap for this run, +DELEGATE_KIT_MAX_WRITERS for the session (DELEGATE_KIT_MAX_WORKERS overrides the total, floored at +writers + 1); the ceiling holds against both. A run refused by a cap or a locked worktree fails before +anything is spawned, --detach included: the parent prints the reason and the run is recorded as failed. + lifecycle is orthogonal to status: running, or parked when the session can still be revived with \`resume\`, or done when it cannot. status says how it ended, lifecycle whether you can go back to it. diff --git a/skills/delegate-kit/scripts/agent-wt b/skills/delegate-kit/scripts/agent-wt index 43b1ef9..3ba7834 100755 --- a/skills/delegate-kit/scripts/agent-wt +++ b/skills/delegate-kit/scripts/agent-wt @@ -9,7 +9,9 @@ agent-wt create [--base REF] create worktree + branch dk/ (defaul agent-wt list list worktrees with lock/owner/status agent-wt status path, branch, lock, dirty files, commits ahead of base agent-wt diff [--base REF] frozen diff: base..HEAD plus uncommitted changes -agent-wt lock [--label TXT] take the write-lock for a native subagent the parent spawns itself +agent-wt lock [--label TXT] [--max-writers N] + take the write-lock for a native subagent the parent spawns itself; + refuses past the writer cap (3 by default, ceiling 8; DELEGATE_KIT_MAX_WRITERS) agent-wt release [--force] remove the write-lock (only if its process is gone or --force) agent-wt remove [--force] remove worktree and branch (refuses if locked or dirty unless --force) agent-wt cleanup remove worktrees whose branch is merged into the base and are unlocked @@ -95,10 +97,31 @@ cmd_diff() { (cd "$p" && git add -N . >/dev/null 2>&1 || true; git diff "$base") } +# Writer cap, shared with agent-run: 3 by default, 8 at most. A native lock is a writer +# the parent runs itself, so it counts like an external one. The count here is per +# repository: every locked worktree of this checkout, native or live process. +WRITERS_DEFAULT=3; WRITERS_CEILING=8 +writer_cap() { + local v="$1" from="$2" + [ -n "$v" ] || { echo "$WRITERS_DEFAULT"; return; } + [[ "$v" =~ ^[0-9]+$ ]] && [ "$v" -ge 1 ] || die "$from must be an integer from 1 to $WRITERS_CEILING (got $v)" + [ "$v" -le "$WRITERS_CEILING" ] || die "$from=$v is above the ceiling of $WRITERS_CEILING writers; the ceiling holds against every flag. Split the work into waves." + echo "$v" +} +locked_worktrees() { + local base; base=$(wt_base_dir); [ -d "$base" ] || return 0 + for p in "$base"/*/; do + [ -d "$p" ] || continue + local n; n=$(basename "$p") + [[ "$(lock_info "$n")" == locked* ]] && echo "$n" + done + return 0 +} + cmd_lock() { local name=${1:-}; [ -n "$name" ] || die "name required"; shift || true - local label="native-subagent" - while [ $# -gt 0 ]; do case "$1" in --label) label=$2; shift 2;; *) die "unknown option $1";; esac; done + local label="native-subagent" cap_flag="" + while [ $# -gt 0 ]; do case "$1" in --label) label=$2; shift 2;; --max-writers) cap_flag=${2:-}; [ -n "$cap_flag" ] || die "--max-writers needs a number"; shift 2;; *) die "unknown option $1";; esac; done local p; p=$(wt_path "$name"); [ -d "$p" ] || die "no such worktree: $name (create it first)" local lf; lf=$(lock_file "$name") if [ -f "$lf" ]; then @@ -106,6 +129,13 @@ cmd_lock() { [[ "$info" == locked* ]] && die "worktree is already $info; one writer per worktree" rm -f "$lf" # stale process lock fi + local cap + if [ -n "$cap_flag" ]; then cap=$(writer_cap "$cap_flag" "--max-writers"); else cap=$(writer_cap "${DELEGATE_KIT_MAX_WRITERS:-}" "DELEGATE_KIT_MAX_WRITERS"); fi + local held; held=$(locked_worktrees | tr '\n' ' '); held=${held% } + local n=0; [ -n "$held" ] && n=$(wc -w <<<"$held" | tr -d ' ') + if [ "$n" -ge "$cap" ]; then + die "max $cap concurrent writers reached in this repository ($held). Raising the cap is a per-task decision: state the partition — one ticket per writer, disjoint write scopes — get the user's yes, then --max-writers N (ceiling $WRITERS_CEILING) or DELEGATE_KIT_MAX_WRITERS for the session." + fi jq -n --arg id "native-$(date +%Y%m%d%H%M%S)" --arg label "$label" --arg cwd "$p" --arg since "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{id:$id,kind:"native",label:$label,role:"implementer",pid:null,cwd:$cwd,since:$since}' > "$lf" jq -n --arg n "$name" --arg p "$p" --arg l "$label" '{name:$n,path:$p,locked_for:$l,release_with:("agent-wt release " + $n)}' diff --git a/skills/delegate-kit/tests/caps.sh b/skills/delegate-kit/tests/caps.sh new file mode 100755 index 0000000..f55dcb2 --- /dev/null +++ b/skills/delegate-kit/tests/caps.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Стенд потолков писателей (`--max-writers`, DELEGATE_KIT_MAX_WRITERS, нативные lock'и) +# и видимости отказа при `--detach`. +# +# Прогоны синтетические: meta.json «живых» внешних писателей пишется руками с pid +# этой оболочки, модели не вызываются. Ни один сценарий не доходит до spawn CLI: +# каждый заканчивается отказом — по потолку, по занятому worktree или по +# невалидному флагу, — и именно отказ здесь проверяется. +# +# ./caps.sh +set -u +SCRIPTS="$(cd "$(dirname "$0")/../scripts" && pwd)" +AR="$SCRIPTS/agent-run"; WT="$SCRIPTS/agent-wt" +BASE="${TMPDIR:-/tmp}/dk-caps-test.$$" +trap 'rm -rf "$BASE"' EXIT +export DELEGATE_KIT_HOME="$BASE/state" +export DELEGATE_KIT_PARENT=claude +unset DELEGATE_KIT_MAX_WRITERS DELEGATE_KIT_MAX_WORKERS +PASS=0; FAIL=0 +ok(){ if [ "$2" = "$3" ]; then echo " ✔ $1"; PASS=$((PASS+1)); else echo " ✘ $1: ожидалось [$3], получено [$2]"; FAIL=$((FAIL+1)); fi; } +has(){ grep -q -- "$2" <<<"$1" && echo yes || echo no; } +runs(){ ls "$DELEGATE_KIT_HOME/runs" 2>/dev/null | wc -l | tr -d ' '; } + +mkwriter(){ # id — живой внешний писатель + local d="$DELEGATE_KIT_HOME/runs/$1"; mkdir -p "$d" + node -e ' + const fs=require("fs");const[,d,id,pid]=process.argv; + fs.writeFileSync(d+"/meta.json",JSON.stringify({id,role:"implementer",backend:"codex",model:"gpt-5.6-sol",effort:"high", + cwd:"/tmp",write:true,status:"running",pid:Number(pid),started:new Date().toISOString(),finished:null,sessionId:null},null,2)); + ' "$d" "$1" "$$" +} + +# Репозиторий с worktree'ями w1..w5 (agent-wt кладёт их рядом: .worktrees/) +mkdir -p "$BASE/repo"; cd "$BASE/repo" || exit 1 +git init -q -b main . && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init +for w in w1 w2 w3 w4 w5; do "$WT" create "$w" >/dev/null 2>&1; done +WTS="$BASE/repo.worktrees" +mkdir -p "$DELEGATE_KIT_HOME/runs" + +run(){ # cwd extra-args… — печатает stderr; код возврата в $RC + local cwd=$1; shift + ERR=$(node "$AR" run --role implementer --backend codex --cwd "$cwd" --prompt x --no-route-hint "$@" 2>&1 >/dev/null); RC=$? +} + +echo "── потолок по умолчанию: 2 внешних + 1 нативный = 3" +mkwriter e1; mkwriter e2 +"$WT" lock w1 --label native-one >/dev/null +run "$WTS/w2" +ok "отказ" "$RC" "1" +ok "назван потолок 3" "$(has "$ERR" "max 3 concurrent writers")" "yes" +ok "внешние посчитаны" "$(has "$ERR" "2 external: e1, e2")" "yes" +ok "нативный lock посчитан" "$(has "$ERR" "1 native: w1")" "yes" +ok "подсказан --max-writers" "$(has "$ERR" "--max-writers N")" "yes" + +echo "── тот же отказ при --detach виден, прогон не заводится (регрессия тихого провала)" +run "$WTS/w2" --detach +ok "отказ" "$RC" "1" +ok "причина напечатана" "$(has "$ERR" "max 3 concurrent writers")" "yes" +ok "новых прогонов нет" "$(runs)" "2" + +echo "── занятый worktree при --detach тоже виден до fork" +run "$WTS/w1" --max-writers 4 --detach +ok "отказ" "$RC" "1" +ok "причина — нативный lock" "$(has "$ERR" "locked for a native subagent")" "yes" +ok "новых прогонов нет" "$(runs)" "2" + +echo "── --max-writers поднимает потолок (4 > 3 занятых: до lock'а доходит)" +run "$WTS/w1" --max-writers 4 +ok "потолок пройден, упёрлись в lock" "$(has "$ERR" "locked for a native subagent")" "yes" +ok "потолка в ошибке нет" "$(has "$ERR" "concurrent writers")" "no" + +echo "── DELEGATE_KIT_MAX_WRITERS делает то же на сессию" +DELEGATE_KIT_MAX_WRITERS=4 run "$WTS/w1" +ok "потолок пройден" "$(has "$ERR" "concurrent writers")" "no" + +echo "── жёсткий потолок 8" +run "$WTS/w2" --max-writers 9 +ok "отказ" "$RC" "1" +ok "назван потолок" "$(has "$ERR" "above the ceiling of 8")" "yes" +DELEGATE_KIT_MAX_WRITERS=9 run "$WTS/w2" +ok "env тоже не пересекает" "$(has "$ERR" "above the ceiling of 8")" "yes" +run "$WTS/w2" --max-writers +ok "флаг без числа" "$(has "$ERR" "must be an integer")" "yes" +run "$WTS/w2" --max-writers abc +ok "не число" "$(has "$ERR" "must be an integer")" "yes" + +echo "── воркеров всего = писатели + 3, DELEGATE_KIT_MAX_WORKERS не ниже писателей + 1" +for i in 3 4 5 6; do mkwriter "r$i"; done # 6 внешних прогонов +run "$WTS/w2" --max-writers 8 +ok "8 + 3 = 11: воркеров хватает, упёрлись не в них" "$(has "$ERR" "active workers")" "no" +DELEGATE_KIT_MAX_WORKERS=1 run "$WTS/w2" --max-writers 8 +ok "поднято до писателей + 1 = 9" "$(has "$ERR" "active workers")" "no" +run "$WTS/w2" --max-writers 3 +ok "3 + 3 = 6 занято: отказ по воркерам" "$(has "$ERR" "max 6 active workers")" "yes" +for i in 3 4 5 6; do rm -rf "$DELEGATE_KIT_HOME/runs/r$i"; done + +echo "── отказ внутри супервизора оставляет meta со статусом failed" +node "$AR" run --role implementer --backend codex --cwd "$WTS/w1" --prompt x --no-route-hint --max-writers 4 --_supervise --id sup-1 >/dev/null 2>&1 +ok "код возврата" "$?" "1" +ok "meta записана" "$([ -f "$DELEGATE_KIT_HOME/runs/sup-1/meta.json" ] && echo yes || echo no)" "yes" +ok "status=failed" "$(node "$AR" status sup-1 | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).status))')" "failed" +ok "lifecycle=done" "$(node "$AR" status sup-1 | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).lifecycle))')" "done" +ok "причина в result" "$(has "$(cat "$DELEGATE_KIT_HOME/runs/sup-1/result.json")" "locked for a native subagent")" "yes" +ok "list не падает" "$(node "$AR" list >/dev/null 2>&1 && echo ok)" "ok" + +echo "── agent-wt lock: тот же потолок по lock'ам репозитория" +"$WT" lock w2 >/dev/null; "$WT" lock w3 >/dev/null # w1..w3 заняты +ERR=$("$WT" lock w4 2>&1 >/dev/null); RC=$? +ok "отказ" "$RC" "1" +ok "назван потолок и занятые" "$(has "$ERR" "max 3 concurrent writers reached in this repository (w1 w2 w3)")" "yes" +ok "нет lock'а на w4" "$("$WT" status w4 | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).lock))')" "unlocked" +"$WT" lock w4 --max-writers 4 >/dev/null; ok "--max-writers 4 пускает" "$?" "0" +ERR=$("$WT" lock w5 --max-writers 9 2>&1 >/dev/null) +ok "жёсткий потолок" "$(has "$ERR" "above the ceiling of 8")" "yes" +DELEGATE_KIT_MAX_WRITERS=5 "$WT" lock w5 >/dev/null; ok "env пускает" "$?" "0" +ERR=$("$WT" lock w5 --max-writers 2>&1 >/dev/null) +ok "флаг без числа" "$(has "$ERR" "needs a number")" "yes" + +echo "── agent-run видит все четыре нативных lock'а" +rm -rf "$DELEGATE_KIT_HOME/runs/e1" "$DELEGATE_KIT_HOME/runs/e2" +run "$WTS/w5" --max-writers 5 +ok "5 нативных ≥ 5: отказ" "$(has "$ERR" "5 native: w1, w2, w3, w4, w5")" "yes" + +echo; echo "Пройдено: $PASS, провалено: $FAIL" +exit $((FAIL > 0)) From 658d3e6b8dc8815935562da841a4b6290f091a00 Mon Sep 17 00:00:00 2001 From: Kirill Kit Date: Wed, 2 Sep 2026 00:58:36 +0300 Subject: [PATCH 2/2] =?UTF-8?q?agent-run,=20agent-wt:=20=D0=BC=D1=8C=D1=8E?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=20=D0=B8=20=D0=B7=D0=B0=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20=D1=81=D0=BB=D0=BE=D1=82=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20lock'=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B1=D1=89=D0=B5=D0=BC=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По находкам ревью PR #4 (Codex P1/P2, Macroscope): - окно между чтением чужих meta/lock'ов и записью своего пропускало параллельные старты мимо потолка — воспроизведено: восемь `agent-wt lock --max-writers 1` давали восемь lock'ов. Теперь agent-run держит файл с флагом wx в state dir от проверки до появления meta.json, agent-wt — атомарный mkdir в общем .git; труп по мёртвому pid снимается; - нативные lock'и входят и в общий счёт воркеров для read-only прогонов; - документация: отказ при --detach родитель печатает, как failed записывается только отказ внутри супервизора. tests/caps.sh: гонка 8 lock'ов, трупы мьютексов, общий счёт — 43 проверки. Co-Authored-By: Claude Fable 5.1 --- skills/delegate-kit/references/external.md | 2 +- skills/delegate-kit/scripts/agent-run | 41 +++++++++++++++++++--- skills/delegate-kit/scripts/agent-wt | 18 ++++++++++ skills/delegate-kit/tests/caps.sh | 22 +++++++++++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/skills/delegate-kit/references/external.md b/skills/delegate-kit/references/external.md index d281749..c143d64 100644 --- a/skills/delegate-kit/references/external.md +++ b/skills/delegate-kit/references/external.md @@ -55,7 +55,7 @@ Per-role defaults for one user, in the same file — the shipped table in `scrip ## Limits and safety - **Delegation depth is 1.** `agent-run` disables subagents on both CLIs; the shipped `dk-*` definitions carry no `Agent` tool. -- **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 and the run is recorded as `failed`. N sessions on one subscription hit the rate limit together; `--fallback none` keeps a fleet from all retrying on the other family at once. +- **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. diff --git a/skills/delegate-kit/scripts/agent-run b/skills/delegate-kit/scripts/agent-run index df5e097..873bdc1 100755 --- a/skills/delegate-kit/scripts/agent-run +++ b/skills/delegate-kit/scripts/agent-run @@ -121,6 +121,7 @@ const RESULT_CONTRACT = `\n\nRETURN FORMAT: your final answer must be a single J let SUPERVISED = null; // { id, role, backend, model, effort, cwd, write } while running as --_supervise const die = (msg, code = 1) => { process.stderr.write(`agent-run: ${msg}\n`); + releaseCapsMutex(); if (SUPERVISED && !fs.existsSync(path.join(runDir(SUPERVISED.id), "meta.json"))) { const now = nowIso(); const result = { status: "failed", summary: `agent-run refused to start the worker: ${msg}`, changes: [], checks_run: [], not_verified: [], findings: [], plan: [], questions: [], sources: [], next_steps: [] }; @@ -255,13 +256,39 @@ function resolveCaps(opts = {}) { } return { writers, workers, ceiling: WRITERS_CEILING, source: raw ? raw.from : "default" }; } -// Refuse before anything is spawned. `write` runs also count the native locks of their repo. +/* + * Мьютекс на «посчитать и занять». + * + * Проверка потолка — чтение чужих meta.json и lock'ов, а занятие слота — + * запись своего meta.json спустя spawn. Восемь параллельных стартов из одного + * сообщения координатора все проходят чтение до первой записи и все стартуют + * (Codex-ревью PR #4 воспроизвёл это на agent-wt: восемь lock'ов при cap 1). + * Файл с флагом wx атомарен на любой POSIX-ФС; труп опознаётся по мёртвому + * pid. Держится от проверки до появления meta.json — для отсоединённого + * прогона его пишет супервизор, поэтому родитель ждёт файл, не отпуская. + */ +const CAPS_MUTEX = path.join(STATE_DIR, "caps.lock"); +let capsMutexHeld = false; +function acquireCapsMutex(timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { fs.writeFileSync(CAPS_MUTEX, String(process.pid), { flag: "wx" }); capsMutexHeld = true; return; } catch (e) { if (e.code !== "EEXIST") throw e; } + const holder = Number(fs.readFileSync(CAPS_MUTEX, "utf8").trim() || 0); + if (!holder || !alive(holder)) { try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} continue; } + if (Date.now() > deadline) die(`another agent-run (pid ${holder}) has held ${CAPS_MUTEX} for ${timeoutMs / 1000}s; if it is stuck, kill it or remove the file`); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); + } +} +function releaseCapsMutex() { if (!capsMutexHeld) return; capsMutexHeld = false; try { fs.rmSync(CAPS_MUTEX, { force: true }); } catch {} } + +// Refuse before anything is spawned. Native locks (`agent-wt lock`) of the repository of +// cwd count as workers and as writers; external runs count machine-wide. function checkCaps(caps, write, cwd) { const running = activeRuns(); - if (running.length >= caps.workers) die(`max ${caps.workers} active workers reached (${running.map((r) => r.id).join(", ")}). The worker cap is writers (${caps.writers}) + ${WORKERS_HEADROOM}; raise --max-writers or DELEGATE_KIT_MAX_WORKERS.`); + const native = nativeWriterLocks(cwd); + if (running.length + native.length >= caps.workers) die(`max ${caps.workers} active workers reached (${[running.length ? `${running.length} external: ${running.map((r) => r.id).join(", ")}` : null, native.length ? `${native.length} native: ${native.map((n) => n.name).join(", ")}` : null].filter(Boolean).join("; ")}). The worker cap is writers (${caps.writers}) + ${WORKERS_HEADROOM}; raise --max-writers or DELEGATE_KIT_MAX_WORKERS.`); if (!write) return; const external = running.filter((r) => r.write); - const native = nativeWriterLocks(cwd); const total = external.length + native.length; if (total >= caps.writers) { const who = [external.length ? `${external.length} external: ${external.map((r) => r.id).join(", ")}` : null, native.length ? `${native.length} native: ${native.map((n) => n.name).join(", ")}` : null].filter(Boolean).join("; "); @@ -844,6 +871,9 @@ async function cmdRun(opts, resumeOf = null) { const caps = resolveCaps(opts); if (opts._supervise) SUPERVISED = { id: opts.id, role, backend, model, effort, cwd, write }; + // The initial supervised start runs under the mutex its parent still holds; every other + // start (foreground, detach parent, quota fallback) takes it itself. + if (!opts._supervise || opts._fallbackFrom) acquireCapsMutex(); checkCaps(caps, write, cwd); if (write) { const conflict = writeLockConflict(cwd, null); if (conflict) die(conflict); } @@ -857,6 +887,7 @@ async function cmdRun(opts, resumeOf = null) { const deadline = Date.now() + 5000; while (Date.now() < deadline && !fs.existsSync(path.join(runDir(id), "meta.json"))) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); } const m = metaOf(id); + releaseCapsMutex(); if (m && m.status === "failed" && m.error) die(`detached run ${id} did not start: ${m.error}`); process.stdout.write(JSON.stringify({ id, role, backend, model, effort, cwd, status: m ? m.status : "starting", supervisor_pid: sup.pid }) + "\n"); return; @@ -879,6 +910,7 @@ async function cmdRun(opts, resumeOf = null) { const child = spawn(built.cmd, built.args, { cwd, detached: true, stdio: ["ignore", fs.openSync(stdoutPath, "w"), fs.openSync(stderrPath, "w")], env: { ...process.env, DELEGATE_KIT_DEPTH: "1", DELEGATE_KIT_RUN_ID: id } }); meta.pid = child.pid; saveMeta(meta); + releaseCapsMutex(); child.unref(); const finish = (code, signal) => { @@ -1017,7 +1049,8 @@ Caps: writers ${WRITERS_DEFAULT} by default, ceiling ${WRITERS_CEILING}; workers locks (\`agent-wt lock\`) within the repository of --cwd. --max-writers N raises the cap for this run, DELEGATE_KIT_MAX_WRITERS for the session (DELEGATE_KIT_MAX_WORKERS overrides the total, floored at writers + 1); the ceiling holds against both. A run refused by a cap or a locked worktree fails before -anything is spawned, --detach included: the parent prints the reason and the run is recorded as failed. +anything is spawned, --detach included: the parent prints the reason; a refusal inside the supervisor +(a race) is recorded as failed. Concurrent starts count and take their slot under one mutex. lifecycle is orthogonal to status: running, or parked when the session can still be revived with \`resume\`, or done when it cannot. status says how it ended, lifecycle whether you can go back to it. diff --git a/skills/delegate-kit/scripts/agent-wt b/skills/delegate-kit/scripts/agent-wt index 3ba7834..6d238cf 100755 --- a/skills/delegate-kit/scripts/agent-wt +++ b/skills/delegate-kit/scripts/agent-wt @@ -18,6 +18,9 @@ agent-wt cleanup remove worktrees whose branch is merged in EOF } +CAPS_MUTEX="" +release_caps_mutex() { [ -n "$CAPS_MUTEX" ] && rm -rf "$CAPS_MUTEX"; CAPS_MUTEX=""; } +trap release_caps_mutex EXIT die() { echo "agent-wt: $*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; } need git; need jq @@ -108,6 +111,19 @@ writer_cap() { [ "$v" -le "$WRITERS_CEILING" ] || die "$from=$v is above the ceiling of $WRITERS_CEILING writers; the ceiling holds against every flag. Split the work into waves." echo "$v" } +# The count-then-lock window: eight concurrent `lock --max-writers 1` all counted zero and +# all succeeded. `mkdir` is atomic, so the directory is the mutex; a dead pid inside is stale. +acquire_caps_mutex() { + local d; d="$(git rev-parse --git-common-dir)"; d="$(cd "$d" && pwd)/delegate-kit.caps.lock" + + for _ in $(seq 1 300); do + if mkdir "$d" 2>/dev/null; then echo "$$" > "$d/pid"; CAPS_MUTEX="$d"; return; fi + local pid; pid=$(cat "$d/pid" 2>/dev/null || true) + if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then rm -rf "$d"; continue; fi + sleep 0.05 + done + die "another agent-wt (pid $pid) has held $d for 15s; if it is stuck, kill it or remove the directory" +} locked_worktrees() { local base; base=$(wt_base_dir); [ -d "$base" ] || return 0 for p in "$base"/*/; do @@ -131,6 +147,7 @@ cmd_lock() { fi local cap if [ -n "$cap_flag" ]; then cap=$(writer_cap "$cap_flag" "--max-writers"); else cap=$(writer_cap "${DELEGATE_KIT_MAX_WRITERS:-}" "DELEGATE_KIT_MAX_WRITERS"); fi + acquire_caps_mutex local held; held=$(locked_worktrees | tr '\n' ' '); held=${held% } local n=0; [ -n "$held" ] && n=$(wc -w <<<"$held" | tr -d ' ') if [ "$n" -ge "$cap" ]; then @@ -138,6 +155,7 @@ cmd_lock() { fi jq -n --arg id "native-$(date +%Y%m%d%H%M%S)" --arg label "$label" --arg cwd "$p" --arg since "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{id:$id,kind:"native",label:$label,role:"implementer",pid:null,cwd:$cwd,since:$since}' > "$lf" + release_caps_mutex jq -n --arg n "$name" --arg p "$p" --arg l "$label" '{name:$n,path:$p,locked_for:$l,release_with:("agent-wt release " + $n)}' } diff --git a/skills/delegate-kit/tests/caps.sh b/skills/delegate-kit/tests/caps.sh index f55dcb2..036c514 100755 --- a/skills/delegate-kit/tests/caps.sh +++ b/skills/delegate-kit/tests/caps.sh @@ -116,10 +116,30 @@ DELEGATE_KIT_MAX_WRITERS=5 "$WT" lock w5 >/dev/null; ok "env пускает" "$? ERR=$("$WT" lock w5 --max-writers 2>&1 >/dev/null) ok "флаг без числа" "$(has "$ERR" "needs a number")" "yes" -echo "── agent-run видит все четыре нативных lock'а" +echo "── нативные lock'и входят и в общий счёт воркеров (read-only прогон)" +ERR=$(node "$AR" run --role researcher --backend codex --cwd "$BASE/repo" --prompt x --no-route-hint 2>&1 >/dev/null); RC=$? +ok "2 внешних + 5 нативных ≥ 6: отказ" "$RC" "1" +ok "названы оба вида" "$(has "$ERR" "max 6 active workers reached (2 external: e1, e2; 5 native: w1, w2, w3, w4, w5)")" "yes" + +echo "── agent-run видит все пять нативных lock'ов" rm -rf "$DELEGATE_KIT_HOME/runs/e1" "$DELEGATE_KIT_HOME/runs/e2" run "$WTS/w5" --max-writers 5 ok "5 нативных ≥ 5: отказ" "$(has "$ERR" "5 native: w1, w2, w3, w4, w5")" "yes" +echo "── гонка: 8 одновременных lock --max-writers 1 дают ровно один lock" +for w in w1 w2 w3 w4 w5; do "$WT" release "$w" >/dev/null; done +for w in w6 w7 w8; do "$WT" create "$w" >/dev/null 2>&1; done +for w in w1 w2 w3 w4 w5 w6 w7 w8; do "$WT" lock "$w" --max-writers 1 >/dev/null 2>&1 & done; wait +ok "занят один worktree" "$("$WT" list | jq '[.[] | select(.lock | startswith("locked"))] | length')" "1" +ok "мьютекс отпущен" "$([ -e "$BASE/repo/.git/delegate-kit.caps.lock" ] && echo held || echo free)" "free" + +echo "── брошенный мьютекс с мёртвым pid не блокирует" +mkdir -p "$BASE/repo/.git/delegate-kit.caps.lock"; echo 999999 > "$BASE/repo/.git/delegate-kit.caps.lock/pid" +"$WT" lock w8 --max-writers 8 >/dev/null; ok "lock прошёл" "$?" "0" +echo 999999 > "$DELEGATE_KIT_HOME/caps.lock" +run "$WTS/w8" --max-writers 1 +ok "agent-run снял труп мьютекса и дошёл до потолка" "$(has "$ERR" "concurrent writers reached")" "yes" +ok "мьютекс agent-run отпущен" "$([ -e "$DELEGATE_KIT_HOME/caps.lock" ] && echo held || echo free)" "free" + echo; echo "Пройдено: $PASS, провалено: $FAIL" exit $((FAIL > 0))