@reddb-io/red-skills-dev 2.75.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +30 -0
- package/package.json +32 -0
- package/skills/engineering/README.md +36 -0
- package/skills/engineering/_report-runtime/WRAPPER.md +47 -0
- package/skills/engineering/afk/AGENT-PROMPT.md +295 -0
- package/skills/engineering/afk/SAFETY.md +103 -0
- package/skills/engineering/afk/SKILL.md +160 -0
- package/skills/engineering/afk/TROUBLESHOOTING.md +260 -0
- package/skills/engineering/afk/actions-lane.md +213 -0
- package/skills/engineering/afk/bin/README.md +29 -0
- package/skills/engineering/afk/bin/afk.mjs +51 -0
- package/skills/engineering/afk/defaults/cargo-pre-worktree.sh +37 -0
- package/skills/engineering/afk/defaults/envelope-post-attempt.sh +47 -0
- package/skills/engineering/afk/defaults/gradle-pre-worktree.sh +42 -0
- package/skills/engineering/afk/defaults/heartbeat-post-attempt.sh +43 -0
- package/skills/engineering/afk/defaults/validation-post-merge.sh +77 -0
- package/skills/engineering/afk/detectors/README.md +72 -0
- package/skills/engineering/afk/detectors/cargo.sh +27 -0
- package/skills/engineering/afk/detectors/gradle.sh +35 -0
- package/skills/engineering/afk/docs/BOOT-SWEEPS.md +81 -0
- package/skills/engineering/afk/docs/CONFIG.md +238 -0
- package/skills/engineering/afk/docs/ENVELOPE.md +177 -0
- package/skills/engineering/afk/docs/HANDOFF.md +70 -0
- package/skills/engineering/afk/docs/LIVENESS.md +56 -0
- package/skills/engineering/afk/docs/OPERATIONS.md +409 -0
- package/skills/engineering/afk/examples/only-mine.sh +32 -0
- package/skills/engineering/afk/examples/red-afk-attempt-action.yml +53 -0
- package/skills/engineering/afk/examples/rs-afk-attempt.yml +76 -0
- package/skills/engineering/afk/fleet.md +127 -0
- package/skills/engineering/afk/hooks/red-cargo +44 -0
- package/skills/engineering/afk/hooks/red-envelope +50 -0
- package/skills/engineering/afk/hooks/red-gradle +50 -0
- package/skills/engineering/afk/hooks/red-heartbeat +46 -0
- package/skills/engineering/afk/hooks/red-validation +79 -0
- package/skills/engineering/afk/monitor.md +170 -0
- package/skills/engineering/afk/runner-claude-minimax.md +71 -0
- package/skills/engineering/afk/runner-claude.md +67 -0
- package/skills/engineering/afk/runner-codex.md +138 -0
- package/skills/engineering/afk/runner-hermes.md +40 -0
- package/skills/engineering/afk/runner-opencode.md +142 -0
- package/skills/engineering/ask-red/SKILL.md +137 -0
- package/skills/engineering/audit-skills/SKILL.md +46 -0
- package/skills/engineering/code-review/SKILL.md +106 -0
- package/skills/engineering/context/SKILL.md +142 -0
- package/skills/engineering/curate/SKILL.md +133 -0
- package/skills/engineering/daily-review/SKILL.md +68 -0
- package/skills/engineering/dashboard/SKILL.md +44 -0
- package/skills/engineering/diagnose/SKILL.md +155 -0
- package/skills/engineering/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/engineering/go/SKILL.md +144 -0
- package/skills/engineering/go/TROUBLESHOOTING.md +81 -0
- package/skills/engineering/ground-truth/SKILL.md +84 -0
- package/skills/engineering/hitl/SKILL.md +171 -0
- package/skills/engineering/hitl/TROUBLESHOOTING.md +76 -0
- package/skills/engineering/implement/SKILL.md +69 -0
- package/skills/engineering/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/engineering/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/engineering/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/engineering/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/engineering/model-tier-policy/SKILL.md +83 -0
- package/skills/engineering/prototype/LOGIC.md +79 -0
- package/skills/engineering/prototype/SKILL.md +30 -0
- package/skills/engineering/prototype/UI.md +112 -0
- package/skills/engineering/red-doctor/APPLY.md +35 -0
- package/skills/engineering/red-doctor/SKILL.md +172 -0
- package/skills/engineering/red-gains/SKILL.md +49 -0
- package/skills/engineering/red-setup/INTERVIEW.md +266 -0
- package/skills/engineering/red-setup/ISSUE-SWEEP.md +18 -0
- package/skills/engineering/red-setup/REFERENCE.md +35 -0
- package/skills/engineering/red-setup/SKILL.md +58 -0
- package/skills/engineering/red-setup/WORKFLOWS.md +74 -0
- package/skills/engineering/red-setup/WRITE-CONTRACT.md +151 -0
- package/skills/engineering/red-setup/config-template.yaml +174 -0
- package/skills/engineering/red-setup/domain.md +52 -0
- package/skills/engineering/red-setup/issue-tracker-github.md +88 -0
- package/skills/engineering/red-setup/rsp-retirement-runbook.md +35 -0
- package/skills/engineering/red-setup/scripts/install-runtime-shim.sh +189 -0
- package/skills/engineering/red-setup/triage-labels.md +176 -0
- package/skills/engineering/red-setup/workflows/red-issues-needs-triage.yml +36 -0
- package/skills/engineering/red-statusline/HOST-NOTES.md +180 -0
- package/skills/engineering/red-statusline/SKILL.md +58 -0
- package/skills/engineering/report-bug/SKILL.md +155 -0
- package/skills/engineering/resolving-merge-conflicts/SKILL.md +143 -0
- package/skills/engineering/retake/SKILL.md +184 -0
- package/skills/engineering/review-adrs/SKILL.md +125 -0
- package/skills/engineering/start/ADR-FORMAT.md +47 -0
- package/skills/engineering/start/CONTEXT-FORMAT.md +77 -0
- package/skills/engineering/start/DOC-LANDING-FINALIZER.md +9 -0
- package/skills/engineering/start/SKILL.md +179 -0
- package/skills/engineering/tdd/SKILL.md +96 -0
- package/skills/engineering/tdd/deep-modules.md +33 -0
- package/skills/engineering/tdd/interface-design.md +31 -0
- package/skills/engineering/tdd/mocking.md +59 -0
- package/skills/engineering/tdd/tests.md +85 -0
- package/skills/engineering/to-spec/SKILL.md +104 -0
- package/skills/engineering/to-tickets/SKILL.md +158 -0
- package/skills/engineering/triage/AGENT-BRIEF.md +207 -0
- package/skills/engineering/triage/OUT-OF-SCOPE.md +101 -0
- package/skills/engineering/triage/SKILL.md +176 -0
- package/skills/engineering/verify/SKILL.md +136 -0
- package/skills/engineering/wayfinder/SKILL.md +128 -0
- package/skills/engineering/zoom-out/SKILL.md +51 -0
- package/skills/engineering/zoom-out/scripts/graph-mode-plumbing.sh +36 -0
- package/skills/engineering/zoom-out/scripts/tests/contract.test.sh +168 -0
- package/skills/knowledge/README.md +5 -0
- package/skills/knowledge/research/SKILL.md +76 -0
- package/skills/misc/README.md +8 -0
- package/skills/misc/branch-lock/SKILL.md +187 -0
- package/skills/misc/branch-lock/scripts/branch-lock-hook.sh +113 -0
- package/skills/misc/branch-lock/scripts/branch-lock-session-start.sh +81 -0
- package/skills/misc/branch-lock/scripts/branch-lock.sh +68 -0
- package/skills/misc/branch-lock/scripts/lib/dev-config.sh +126 -0
- package/skills/misc/branch-lock/scripts/lib/git-command-classifier.sh +198 -0
- package/skills/misc/branch-lock/scripts/lib/lock-store.sh +67 -0
- package/skills/misc/branch-lock/scripts/lib/scope-resolver.sh +43 -0
- package/skills/misc/branch-lock/scripts/tests/branch-lock-cli.test.sh +114 -0
- package/skills/misc/branch-lock/scripts/tests/claude-plugin-hook.test.sh +117 -0
- package/skills/misc/branch-lock/scripts/tests/codex-hook.test.sh +229 -0
- package/skills/misc/branch-lock/scripts/tests/dev-config.test.sh +99 -0
- package/skills/misc/branch-lock/scripts/tests/git-command-classifier.test.sh +187 -0
- package/skills/misc/branch-lock/scripts/tests/lock-store.test.sh +90 -0
- package/skills/misc/branch-lock/scripts/tests/scope-resolver.test.sh +63 -0
- package/skills/misc/branch-lock/scripts/tests/session-start.test.sh +123 -0
- package/skills/misc/git-guardrails-claude-code/SKILL.md +105 -0
- package/skills/misc/git-guardrails-claude-code/scripts/block-dangerous-git.sh +231 -0
- package/skills/misc/git-guardrails-claude-code/scripts/tests/block-dangerous-git.test.sh +143 -0
- package/skills/misc/migrate-to-shoehorn/SKILL.md +96 -0
- package/skills/misc/setup-pre-commit/SKILL.md +94 -0
- package/skills/productivity/README.md +8 -0
- package/skills/productivity/ff/SKILL.md +116 -0
- package/skills/productivity/handoff/SKILL.md +37 -0
- package/skills/productivity/reflect/SKILL.md +18 -0
- package/skills/productivity/write-a-skill/SKILL.md +190 -0
- package/skills/productivity/write-a-skill/WRITING-STYLE.md +62 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# AFK configuration & lifecycle hooks (reference)
|
|
2
|
+
|
|
3
|
+
> Extracted from `afk/SKILL.md` for progressive disclosure. Consulted on demand — not the agent's step-by-step loop.
|
|
4
|
+
>
|
|
5
|
+
> Every `.red/config.yaml` knob and `RED_AFK_*` env var the loop reads, and the lifecycle-hook contract.
|
|
6
|
+
|
|
7
|
+
## Configuration
|
|
8
|
+
|
|
9
|
+
Scalar run settings live in `.red/config.yaml` under the `afk:` key (alongside the `afk.hooks` block documented below). Each one has a matching `RED_AFK_*` env override that wins over the config value, so an E2E/CI run can pick a setting without mutating the target repo's config.
|
|
10
|
+
|
|
11
|
+
| Config key | Env override | Default | Meaning |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `afk.default_runner` | `RED_AFK_RUNNER` | `claude` | Caller runner identity/default backend consumed before ambient sniffing. |
|
|
14
|
+
| `afk.model` | — | runner-specific | Legacy global model override. Prefer tiered `afk.models.<runner>.<tier>.model` so Codex never receives a Claude-only model. |
|
|
15
|
+
| `afk.models.<runner>` | — | runner-specific | Legacy per-runner scalar model override. Used only when no explicit tier model is set. |
|
|
16
|
+
| `afk.models.claude.<tier>.model` | — | tier-specific | Claude Code model id for `validate`, `simple`, `complex`, or `think`. |
|
|
17
|
+
| `afk.models.claude.<tier>.effort` | — | tier-specific | Claude Code effort for that tier. |
|
|
18
|
+
| `afk.models.codex.<tier>.model` | — | tier-specific | Codex model id for `validate`, `simple`, `complex`, or `think`. |
|
|
19
|
+
| `afk.models.codex.<tier>.effort` | — | tier-specific | Codex effort for that tier. |
|
|
20
|
+
| `afk.sandbox` | `RED_AFK_SANDBOX` | `none` | Isolation backend (`none` \| `docker` \| `podman`, ADR 0033). |
|
|
21
|
+
| `afk.max_iterations` | `RED_AFK_MAX_ITERATIONS` | `12` | Sandcastle re-invocation ceiling (issue #322) — the safety cap for "the agent never emits `<promise>DONE</promise>` or `<promise>BLOCKED</promise>`". The completion sentinel is the real terminator, so a normal issue finishes in 1–3 iterations; this leaves headroom without letting repeated no-sentinel failures run for too long. A non-numeric / zero / negative value in either the env or the config is ignored (falls through to the default) so a typo can never disable the cap or pin the agent to 1. |
|
|
22
|
+
| — | `RED_AFK_IDLE_TIMEOUT_S` | `600` | Sandcastle's per-iteration **silence** watchdog (seconds): an iteration that produces no stream output for this long is aborted. The actual termination bound on a quiet hang. Env-only; typo-safe (non-numeric / zero / negative is ignored → default). |
|
|
23
|
+
| `afk.claim_reaper.refresh_s` | `RED_AFK_CLAIM_REFRESH_S` | `300` | Cross-host stale-claim refresh cadence (seconds). The stale window is `refresh_s × (stale_tolerance + 1)`. |
|
|
24
|
+
| `afk.claim_reaper.stale_tolerance` | `RED_AFK_CLAIM_STALE_TOLERANCE` | `3` | Consecutive missed claim refreshes tolerated before the stale-claim sweep may recover the issue. `0` is allowed. |
|
|
25
|
+
| `afk.claim_reaper.grace_s` | `RED_AFK_CLAIM_REAPER_GRACE_S` | `300` | Minimum claim age before the stale-claim sweep may recover a `running` issue, even if the stale window is configured aggressively. |
|
|
26
|
+
| `afk.claim_reaper.recent_commit_s` | `RED_AFK_CLAIM_REAPER_RECENT_COMMIT_S` | `2700` | Sliding progress-protection window: a live `afk/*/<issue>-*` attempt branch with a commit this recent protects the claimed issue from stale-claim recovery. |
|
|
27
|
+
| `afk.statusline_cache_ttl` | `RED_AFK_STATUSLINE_CACHE_TTL_S` | `180` | TTL (seconds) of every EXPENSIVE FETCHED statusline number — the GitHub-derived queue/human + open-PR/open-issue counts AND the repo-global local diffstat, cached in `.red/state/statusline/statusline-cache.toon` / `.red/state/statusline/statusline-repo-cache.toon` (issue #1178, #1217). The statusline renders on every prompt, so a per-render gh/git round-trip would freeze the TUI; the network cost is paid at most once per TTL. Also drives the monitor's stale-cache marker. Use the **flat** key — do **not** nest it under `afk.statusline` (that key is the boolean statusline opt-out; YAML cannot make one key both a boolean and a map). Typo-safe (env > config > default): a non-numeric / zero / negative value in **either** source falls through to the next and ultimately the 180 default — never 0 (a 0 TTL would refresh on every render, defeating the cache). |
|
|
28
|
+
| `afk.backpressure` | — | _(empty)_ | Ordered list of shell commands run as an extra pre-merge gate on the DONE path (issue #430, PRD #429). |
|
|
29
|
+
| `afk.validation.node_max_old_space_mb` | — | `2048` | Node heap cap applied to feedback/backpressure validation subprocesses via `NODE_OPTIONS=--max-old-space-size=<mb>` (#1758). Keeps heavy Vitest/build validation bounded per worker. |
|
|
30
|
+
| `afk.validation.vitest_max_workers` | — | `1` | Vitest worker fan-out cap exposed as `VITEST_MAX_WORKERS` to validation subprocesses (#1758). Repos with larger machines may raise it; the conservative default prevents width-2 fleets from multiplying heavy suites. |
|
|
31
|
+
| `afk.validation.heavy_available_memory_mb` | — | `4096` | Minimum free-memory threshold for admitting known-heavy validation work (#1758). Heavy validation admission serializes when another heavy validation is active, and otherwise waits until this much memory is available. |
|
|
32
|
+
| `afk.output_shaping.terse_steering` | — | `false` | Opt-in AFK output-shaping experiment (#1638). When true, even-numbered issues receive a phrasing-only terse steering block; odd-numbered issues are the holdout. The assignment is persisted in worker state beside heartbeat output-token counters and reported by `afk-output-shaping`. |
|
|
33
|
+
| `afk.worktree_launches_pull_request` | — | `true` | Landing **mode**, decoupled from the branch-lock (ADR 0030 amended, #842). `true` (default) → the attempt lands via an **admin-merged PR** into the resolved base; `false` → a **direct merge** into that base (offline, no PR — only the post-commit push the worker already does). The branch-lock now only resolves the *target* base (lock > pin > main, ADR 0031); this flag decides PR-vs-direct **independently**. So: no lock + `true` → admin-PR to `main`; no lock + `false` → direct merge to `main`; lock=`X` + `true` → admin-PR to `X`; lock=`X` + `false` → direct merge to `X`. *How* a PR merges (admin vs `wait_for_review` vs `review_gate`) stays governed by `afk.merge.*`. **Migration:** the default `true` flips the old *locked* behaviour (which direct-merged) — a locked repo now gets an admin-PR to its lock branch; set `false` to keep the old offline/direct-promotion flow. |
|
|
34
|
+
| `afk.merge.wait_for_review` | — | `false` | Merge-gate policy (ADR 0048). When `false` (default), the unlocked admin-merge proceeds **ignoring advisory review checks** (e.g. CodeRabbit) — the binding gates are `drift-guard` (the `pre_merge` hook) + in-process backpressure/feedback. When `true`, the unlocked landing **waits** for the configured review check to conclude before merging, then merges regardless of its verdict (the review stays advisory). `drift-guard` is a hard gate either way. |
|
|
35
|
+
| `afk.merge.review_check` | — | `CodeRabbit` | Name (case-insensitive substring) of the advisory review check `wait_for_review` polls via `gh pr checks`. Only consulted when `afk.merge.wait_for_review` is `true`. |
|
|
36
|
+
| `afk.merge.ci_aware` | — | `false` | CI-aware merge (#812). When `false` (default), the unlocked admin-merge fires immediately — correct only on a base with **no** required status checks. When `true`, the unlocked landing first polls `gh pr view --json mergeStateStatus,statusCheckRollup` until the PR settles, then admin-merges **only** once it is genuinely ready (`CLEAN`, or `BLOCKED` solely by a required review `--admin` waives). Required for any `enforce_admins` base, where an admin-merge **cannot** bypass required checks: a real conflict / `DIRTY` / `BEHIND` → `blocked:merge-conflict`; a **failed** required check → `blocked:ci`; checks still **pending** at the timeout → `blocked:ci` with the open PR preserved (never re-runs the inner agent). |
|
|
37
|
+
| `RED_AFK_MERGE_CI_TIMEOUT_S` | env | `1800` | CI-aware merge wait budget, in seconds (#812). The poll runs at a fixed 10s cadence until `mergeStateStatus` settles; on timeout the open, MERGEABLE PR is handed off (`ci-pending` → `blocked:ci`) instead of re-running the agent. Non-positive / unparseable → the 1800s default. Only consulted when `afk.merge.ci_aware` is `true`. |
|
|
38
|
+
| `afk.review_gate.enabled` | — | `false` | PR review gate (ADR 0064 §10, #749). When `true`, a completed **non-mechanical** attempt (classified tier at/above `afk.review_gate.threshold`) gets `ready-for-review` on its PR — firing the advisory review — and **holds the merge** for a fresh-agent review by a different agent than the one that implemented it. Mechanical/trivial work keeps the fast-merge path. Only affects a PR landing (`worktree_launches_pull_request: true`); a direct merge never opens a PR, so the gate is moot there. Off by default so the "merge fast / no drift" loop is unchanged until a repo opts in. |
|
|
39
|
+
| `afk.review_gate.threshold` | — | `complex` | The cheapest issue-classifier tier (`validate` \| `simple` \| `complex` \| `think`) counted as non-mechanical. Tiers below it stay mechanical (fast-merge); this tier and above request review. |
|
|
40
|
+
| `afk.companion.iteration_churn` | — | `8` | Companion (active) monitor drift threshold (#921): a live worker at/above this iteration **and** below `min_progress_loc` added lines is judged `iteration-churn`. Only read when `monitor --companion` / `--active` is set (off → byte-for-byte read-only dashboard, no gh writes). A non-positive override falls back to the default. |
|
|
41
|
+
| `afk.companion.waiting_windows` | — | `20` | Companion drift threshold: a flat-diff worker with this many zero-progress waiting windows is judged `stuck-waiting`. |
|
|
42
|
+
| `afk.companion.diff_drift_loc` | — | `4000` | Companion drift threshold: total churn (added + removed) at/above this is judged `scope-creep` (sprawling past the issue), the highest-priority signal. |
|
|
43
|
+
| `afk.companion.min_progress_loc` | — | `5` | Companion progress floor: a worker that has added at least this many lines has produced real work and is never flagged for churn/stuck. |
|
|
44
|
+
| `afk.companion.*` (cap) | `RED_AFK_RETRY_DRIFT` | `2` | Companion bounded re-enqueue budget. Each detected drift on an attempt injects **one** correction (write-only, idempotent via a fingerprint, rewriting `## Agent brief`); once the attempt count reaches this cap the companion **escalates** to `ready-for-human` (a `## Current blocker` of kind `drift`) instead of correcting again. Shares the bounded-recovery policy (`core/recovery.ts`); never kills a process — termination/respawn is the reaper + fleet's job. |
|
|
45
|
+
| `afk.drain.max_cost_usd` | `RED_AFK_DRAIN_MAX_COST_USD` / `fleet --budget-usd` | _(unset)_ | Per-drain USD budget for the fleet supervisor. Spend is read from WorkerVitals (`current.cost_usd`) in worker state files, not a parallel ledger. Tiers are OK below 75%, WARNING at 75%, CRITICAL at 90%, and HARD_STOP at 100%. CRITICAL spawns new workers with one model-tier-policy downgrade; HARD_STOP stops all new spawns, lets in-flight workers finish, and records a TOON budget event in `.red/tmp/supervisors/default/supervisor.log.toonl`. |
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
afk:
|
|
49
|
+
worktree_launches_pull_request: true # true → admin-PR landing; false → direct merge (offline). Decoupled from the lock (#842)
|
|
50
|
+
models:
|
|
51
|
+
claude:
|
|
52
|
+
think:
|
|
53
|
+
model: claude-opus-4-8
|
|
54
|
+
effort: high
|
|
55
|
+
codex:
|
|
56
|
+
think:
|
|
57
|
+
model: gpt-5.5
|
|
58
|
+
effort: high
|
|
59
|
+
sandbox: none
|
|
60
|
+
max_iterations: 12 # override the default re-invocation ceiling here
|
|
61
|
+
statusline_cache_ttl: 180 # statusline gh/git cache TTL (seconds); flat key, NOT under afk.statusline
|
|
62
|
+
backpressure: # extra pre-merge gate, runs after the feedback gate
|
|
63
|
+
- npm run test
|
|
64
|
+
- npm run lint
|
|
65
|
+
validation:
|
|
66
|
+
node_max_old_space_mb: 2048
|
|
67
|
+
vitest_max_workers: 1
|
|
68
|
+
heavy_available_memory_mb: 4096
|
|
69
|
+
output_shaping:
|
|
70
|
+
terse_steering: false # true → even issues steered, odd issues holdout; report with afk-output-shaping
|
|
71
|
+
merge:
|
|
72
|
+
wait_for_review: false # true → hold the unlocked admin-merge until the review check concludes
|
|
73
|
+
review_check: CodeRabbit
|
|
74
|
+
ci_aware: false # true → poll mergeStateStatus and merge only once required checks settle (#812; needed for enforce_admins bases)
|
|
75
|
+
review_gate:
|
|
76
|
+
enabled: false # true → non-mechanical PRs get ready-for-review + hold the merge for a fresh-agent review
|
|
77
|
+
threshold: complex # cheapest tier counted as non-mechanical (validate|simple|complex|think)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`RED_AFK_IDLE_TIMEOUT_S` is env-only (no `afk.*` config key); `sandbox`, `max_iterations`, and `statusline_cache_ttl` resolve env > config > default. The three runtime bounds — silence (`idleTimeoutSeconds`), re-invocation count (`maxIterations`), and the fixed no-commit-progress attempt guard — are detailed under *Attempt Completion & Termination Bounds*.
|
|
81
|
+
|
|
82
|
+
### Backpressure gate
|
|
83
|
+
|
|
84
|
+
`afk.backpressure` is an operator-declared, ordered list of shell commands that **supplements** the auto-derived feedback gate (it does not replace it). On a successful DONE attempt — after the scope-derived `test`/`typecheck`/`lint`/`build` feedback gate passes, before landing — AFK runs each backpressure command in order (`sh -c <command>`) against a checkout of the worker branch. If **any** command exits non-zero the merge is blocked and the issue is parked to `ready-for-human` with `blocked:validation`, exactly like a feedback failure: the failing command and its output tail land in the terminal envelope and in the `red.afk.validation.v1` validation sidecar (records named `backpressure:<command>`). An absent or empty block is a no-op (today's behaviour). The namespaced `plugins.dev.afk.backpressure` location is honoured with the legacy bare `afk.backpressure` fallback (ADR 0042).
|
|
85
|
+
|
|
86
|
+
**This is how maintainers tell an inner agent the exact gate it must satisfy — without ad-hoc `-r` retry guidance.** When `afk.backpressure` is set, every inner-agent handoff carries a `<merge-gate>` section listing the configured commands verbatim, and the agent's exit-protocol completion contract instructs it to run and pass those commands *before* emitting `<promise>DONE</promise>` (issue #849). The contract distinguishes two kinds of check: the **touched-package confidence checks** the agent runs while developing (the package's own `test`/`typecheck`/`lint`/`build`) versus the **binding merge gate** the orchestrator enforces after DONE (these backpressure commands plus `drift-guard`). So for repos with a broader gate than any single touched package — `cargo fmt --all -- --check`, a workspace-wide `cargo clippy`, an integration smoke — declare it once under `afk.backpressure` and the agent sees and satisfies it on the first attempt instead of bouncing as `blocked:validation`. On a retry, the prior failure's command and output tail remain visible through `<prior-attempt-context>`, so the next agent can target the real blocker. The agent is told **not** to re-run an unbounded full repository suite after its final commit; the listed gate commands are the contract.
|
|
87
|
+
|
|
88
|
+
### Merge-gate policy
|
|
89
|
+
|
|
90
|
+
The unlocked admin-merge (`gh pr merge --admin --merge`, ADR 0030) **ignores advisory review checks by default** — this is intentional, not an oversight. The binding gates on a landing are:
|
|
91
|
+
|
|
92
|
+
1. **`drift-guard`** — the `pre_merge` hook, a hard gate that aborts the merge for this issue and routes through bounded `blocked:merge-conflict` recovery.
|
|
93
|
+
2. **In-process backpressure / feedback** — the pre-merge feedback-validation step (typecheck/tests, ADR 0008) that only mechanism can refuse a merge on.
|
|
94
|
+
|
|
95
|
+
External advisory reviewers (CodeRabbit and the like) are **not** binding: the worker is autonomous, and gating an autonomous loop on a human-paced external reviewer would stall the queue (ADR 0048). Opt into waiting with `afk.merge.wait_for_review: true` — the unlocked landing then polls `afk.merge.review_check` until it concludes and merges regardless of the verdict (so its comments are posted before the merge, but the review never blocks the land). The wait is **fail-open**: a reviewer that never registers or never concludes within the poll budget does not wedge the landing.
|
|
96
|
+
|
|
97
|
+
## Lifecycle Hooks
|
|
98
|
+
|
|
99
|
+
`/afk` exposes a fixed set of lifecycle points declared in `.red/config.yaml` under `afk.hooks` and resolved as ordered lists of shell commands. Every hook follows a single interceptor contract:
|
|
100
|
+
|
|
101
|
+
- Input: documented `RED_AFK_*` env vars (unset — *not* empty-string — when the field is irrelevant to the current point) plus the full mutable context as JSON on stdin.
|
|
102
|
+
- Output: empty stdout → context unchanged; JSON object on stdout → AFK replaces the documented mutable slice with the returned value. Non-JSON stdout is treated as a parse failure.
|
|
103
|
+
- Exit code: `0` continues the chain; non-zero is routed through a per-hook policy table — `pre_*` aborts the step, `post_*` / `on_idle` / `on_*_error` log and continue so a broken notifier never wedges AFK.
|
|
104
|
+
|
|
105
|
+
Within a single hook list, **built-in defaults run first, user-declared commands run after**, and declaration order is preserved inside each group. A bare string is shorthand for a one-element list. An unknown hook name in `.red/config.yaml` is a hard error at session boot. Disable a built-in default with `afk.hooks.defaults.<name>: false` — reordering is not supported.
|
|
106
|
+
|
|
107
|
+
Every hook command emits explicit dispatch breadcrumbs around the shell call:
|
|
108
|
+
`[afk:hooks] <point>: enter: <command>` and
|
|
109
|
+
`[afk:hooks] <point>: exit rc=<n>: <command>`. Session-scoped hooks write those
|
|
110
|
+
lines to the session output, per-issue hooks write them to the attempt's
|
|
111
|
+
`afk.log`, and fleet hooks use the analogous `[afk:fleet-hooks]` prefix in the
|
|
112
|
+
supervisor log. A quiet Worker can therefore still show policy/hook activity
|
|
113
|
+
without pretending the inner agent lane advanced.
|
|
114
|
+
|
|
115
|
+
### Hook Hardening Contract
|
|
116
|
+
|
|
117
|
+
Shipped RedSkills hook implementations and hook launchers must satisfy this
|
|
118
|
+
checklist even though operator-authored AFK hooks may still use the lifecycle
|
|
119
|
+
exit-code policy table above:
|
|
120
|
+
|
|
121
|
+
- Exit 0 unconditionally. Blocking host hooks return a structured denial
|
|
122
|
+
payload on stdout; crashes and missing dependencies fail open with `{}`.
|
|
123
|
+
- Drain stdin with a deadline. Shell hooks use
|
|
124
|
+
`timeout "${RED_SKILLS_HOOK_STDIN_TIMEOUT_S:-5s}" cat ... || true`; Node hooks
|
|
125
|
+
use an async bounded drain rather than `readFileSync(0)`.
|
|
126
|
+
- Arm a bounded, unref'd process deadline for Node hook processes that do work
|
|
127
|
+
after startup. A hook must not keep a host session alive because a timer or
|
|
128
|
+
child process is still referenced.
|
|
129
|
+
- Parse tool input as structured stdin JSON. Do not interpolate raw tool input
|
|
130
|
+
into a shell command string; if a hook must deny a command, emit JSON that asks
|
|
131
|
+
the host to deny it.
|
|
132
|
+
|
|
133
|
+
Run `scripts/audit-hook-hardening-contract.sh` before shipping changes to
|
|
134
|
+
`plugins/*/hooks/`, AFK library hooks, or hook launcher wrappers. The audit is
|
|
135
|
+
intentionally greppable: it catches pattern-matchable regressions and includes a
|
|
136
|
+
violating fixture that must fail.
|
|
137
|
+
|
|
138
|
+
The full lifecycle table is defined in PRD #207. The hooks shipped so far:
|
|
139
|
+
|
|
140
|
+
| Hook | When it fires | Env vars | Mutable slice | Exit-code policy |
|
|
141
|
+
|-----------------|--------------------------------------------|-----------------------|-----------------|-------------------------|
|
|
142
|
+
| `pre_session` | Boot, before any queue work | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` | session config (`runner`, `worker_id`, `filter`, `iter_cap`) | non-zero **aborts** the session loudly |
|
|
143
|
+
| `pre_pick` | Before listing the tracker queue | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` | query params (`label`, `state`, `limit`) — `filter.{kind,value}` is read-only context | non-zero **aborts** the pick; queue listing is **skipped this iteration** and AFK falls through to the empty-queue / `on_idle` path |
|
|
144
|
+
| `post_pick` | After listing, before claiming | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` | `issues[]` (filter / reorder; replace with `{issues:[…]}`) — extra keys are silently ignored | non-zero is **logged** and AFK continues with the **un-mutated** list (defensive default — a broken filter must not silently drop work) |
|
|
145
|
+
| `pre_worktree` | After claim, before `git worktree add` | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ISSUE`, `RED_AFK_SLOT` | `issue`, `target` (worktree path), `env` (k/v map merged into the parent shell so `CARGO_TARGET_DIR` etc. propagate to the runner) — `branch` is read-only context | non-zero **aborts**: the claim is restored to `ready-for-agent`, the iteration tear-down runs, and the worktree is **not** created |
|
|
146
|
+
| `pre_attempt` | After worktree exists, **before each runner invocation** (per attempt, not per issue — re-fires on a `--fallback-runner` swap with `attempt_n=2`) | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (now the worktree), `RED_AFK_ISSUE` | `issue`, `workspace` (worktree path), `attempt_n` — `runner` is read-only context | non-zero **skips runner invocation**: the worktree is preserved, the heartbeat stops, and the claim is restored to `ready-for-agent` so post-pick state is reconciled cleanly |
|
|
147
|
+
| `post_attempt` | After the runner returned **with an authored `<promise>` exit** — DONE or BLOCKED — for that attempt. Does **not** fire on runner crash or EOF-without-sentinel (see `on_attempt_error`). Under `--fallback-runner` it fires once per runner invocation (the swapped-away attempt closes with `result.status=exhausted`). The parsed sentinel outcome (`done` / `blocked` / `no_more_tasks`, or `""` for the exhausted firings) rides in `result.outcome` (ADR 0028). | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (the worktree), `RED_AFK_ISSUE`, `RED_AFK_RESULT_STATUS` (`success` \| `fail`), `RED_AFK_RESULT_OUTCOME` (`done` \| `blocked` \| `no_more_tasks` \| empty) | `issue`, `workspace`, `result` (`{status, outcome}`), `attempt_n` | non-zero is **logged** and the loop continues — a broken notifier/pager must never wedge AFK |
|
|
148
|
+
| `on_attempt_error` | When the attempt produced **no authored exit**: either an unhandled exception in the worker path (`run_inner` exited non-zero outside the quota branch — `runner-crash`), or the runner's pipe closed with **no `<promise>` sentinel** (EOF-without-sentinel — `no-sentinel`, ADR 0028; the issue routes through bounded `blocked:crashed` recovery). Distinct from `post_attempt` with `result.status=fail`, so hook authors do not have to demultiplex. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (the worktree), `RED_AFK_ISSUE`, `RED_AFK_ERROR_CLASS` (`runner-crash` \| `no-sentinel`) | `issue`, `workspace`, `error` (`{class, rc}`), `attempt_n` | non-zero is **logged** and the loop continues |
|
|
149
|
+
| `pre_feedback` | After a green attempt, before the scope-derived feedback gate (the merge gate, ADR 0008) runs (#832). The resolved scopes are on stdin so a guard can veto validation. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (the worktree), `RED_AFK_ISSUE` | `issue`, `workspace`, `scopes[]` | non-zero **aborts** the feedback gate; the attempt routes through the bounded `blocked:policy` (hook-aborted) recovery, branch/PR preserved |
|
|
150
|
+
| `on_baseline_probe` | After the feedback gate **failed** and the "already failing on the base branch?" probe ran (ADR 0071). Fires only on a gate failure (the probe never runs on green). `downgraded[]` lists the checks reclassified from `failed` to `skipped` because they also failed on the baseline. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ISSUE` | `issue`, `workspace`, `ok` (always `false` here), `downgraded[]` — read-only context | non-zero is **logged** and the loop continues |
|
|
151
|
+
| `on_feedback_classify` | After a feedback failure is classified **INFRA vs SEMANTIC** (ADR 0071), before the recovery routing reads the verdict. **Mutable**: a hook may override the classification. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ISSUE`, `RED_AFK_FEEDBACK_CLASS` (`infra` \| `semantic`) | `issue`, `workspace`, `class` (`infra` \| `semantic`) — return `{class:…}` to override; any other value keeps the computed class | non-zero is **logged** and the computed class stands |
|
|
152
|
+
| `post_feedback` | After the scope-derived feedback gate produced its verdict (#832), on both pass and fail, before the merge or the failure routing. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ISSUE` | `issue`, `workspace`, `result` (`{status: pass\|fail}`) | non-zero is **logged** and the loop continues |
|
|
153
|
+
| `pre_merge` | Before the merge mechanism (`git merge --no-ff` into the pinned base). The diff between the merge base and the worker branch is on stdin so a guard hook can reject changes by size, file pattern, etc. The merge itself plus conflict resolution remain **mechanism** (ADR 0008) and sit between `pre_merge` and `post_merge` — never dispatched as a hook. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (primary checkout), `RED_AFK_ISSUE`, `RED_AFK_MERGE_BASE` | `issue`, `workspace`, `diff` — `branch` is read-only context | non-zero **aborts the merge** for this issue; the failure surfaces as a worker-failure and routes through bounded `blocked:merge-conflict` recovery |
|
|
154
|
+
| `post_merge` | After a successful merge and push to origin/`{pinned}`. The merge commit already exists, so user notifiers can include the real merge commit URL. Does **not** fire when the merge was aborted (`pre_merge` rejection, conflict resolver exhausted, push rejected). | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (primary checkout), `RED_AFK_ISSUE`, `RED_AFK_MERGE_COMMIT` (full sha), `RED_AFK_MERGE_SHA` (short sha) | `issue`, `workspace`, `merge_commit` (`{sha, short}`) — extended by the built-in `validation` default with `result.{validation_status, validation_summary}` | non-zero is **logged** and the loop continues — the merge has already landed; a broken notifier or a flaky smoke test must never roll it back |
|
|
155
|
+
| `on_attempt_timeout` | When the commit-anchored progress guard fires (ADR 0044/0045, #832): the attempt was alive but produced no new commit within the cap. Fires before the no-agent reconcile / escalation routing. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` (the worktree), `RED_AFK_ISSUE`, `RED_AFK_RECOVERY_REASON` (`timeout`) | `issue`, `workspace`, `attempt_n`, `reason` (`timeout`) — read-only context | non-zero is **logged** and the loop continues |
|
|
156
|
+
| `on_recovery_decision` | After the disposition composer proposes **retry vs escalate** for a terminal failure (#832), before any label is applied. **Mutable**: a hook may override the decision. | `RED_AFK_ISSUE`, `RED_AFK_RECOVERY_DECISION` (`retry` \| `escalate`), `RED_AFK_RECOVERY_REASON` | `issue`, `decision` (`retry` \| `escalate`), `reason`, `attempt_n` — return `{decision:…}` to override | non-zero is **logged** and the composer's decision stands |
|
|
157
|
+
| `on_blocked` | After an issue is parked to a human gate (the escalate path, #832): the typed `blocked:*` label and `ready-for-human` are applied. | `RED_AFK_ISSUE`, `RED_AFK_BLOCKED_LABEL` (`blocked:<reason>`), `RED_AFK_RECOVERY_REASON` | `issue`, `blocked_label`, `reason`, `attempt_n` — read-only context | non-zero is **logged** and the loop continues |
|
|
158
|
+
| `on_reconcile` | After the no-agent reconcile (ADR 0055, #832) re-validated a parked mechanical branch and **landed / parked / skipped** it without re-running the agent. | `RED_AFK_ISSUE`, `RED_AFK_RECONCILE_OUTCOME` (`landed` \| `parked` \| `skipped`) | `issue`, `workspace`, `attempt_n`, `outcome` — read-only context | non-zero is **logged** and the loop continues |
|
|
159
|
+
| `on_heartbeat` | Once per attempt-guard poll (~60s) during an inner-agent run — a periodic proof-of-life point, not a once-per-lifecycle one. The context carries the **full worker vitals** (ADR 0065/#832: tools/text/reasoning/reasoning-tokens/loc/cost) so an operator can drive custom live alerting. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ISSUE`, `RED_AFK_VITAL_*` (one per numeric vital) | `issue`, `workspace`, `runner`, `attempt_n`, `vitals` (`{tools_called_count, text_chunk_count, reasoning_events, reasoning_tokens, waiting_count, input_tokens, output_tokens, cost_usd, loc_added, loc_removed}`) — read-only context | non-zero is **logged** and the loop continues |
|
|
160
|
+
| `on_idle` | Queue drained at top of loop iteration, before sleep/exit. Distinct from `post_session` — this is "between drains" maintenance (e.g. cache cleanup), not session termination. Does **not** fire on session exit. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` | none in this slice — `stats.{done,blocked,total}` are read-only context | non-zero is **logged** and the loop continues |
|
|
161
|
+
| `post_session` | Normal session termination | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE` | session stats (`runner`, `worker_id`, `stats.{done,blocked,total}`) | non-zero is **logged** and the session ends as `NO MORE TASKS` |
|
|
162
|
+
| `on_session_error` | Last gasp — the AFK loop itself crashed (unhandled `set -e` exit, supervisor died, unrecoverable orchestrator exception). Distinct from `on_attempt_error` (a single attempt blew up; the loop continued) and from `post_session` (clean shutdown). This is the only path that guarantees a notification when the autonomous worker stopped without the operator noticing. Does **not** fire on a user-requested abort (`pre_session` rejection, straggler decline, Ctrl+C / SIGTERM through the cleanup trap) — those set the clean-exit sentinel before exiting. | `RED_AFK_RUNNER`, `RED_AFK_WORKSPACE`, `RED_AFK_ERROR_CLASS` (`session-crash` by default), `RED_AFK_ERROR_MESSAGE` | `error` (`{class, rc, message}`) — none mutable (the loop is already collapsing) | non-zero is **logged** but the process still exits — this hook cannot rescue the session, only announce its death |
|
|
163
|
+
|
|
164
|
+
### Hook context schema (generated)
|
|
165
|
+
|
|
166
|
+
> Generated from the canonical hook registry (`apps/dev/src/core/hook-registry.ts`, #834). A drift test (`hook-registry.test.ts`) fails if this block and the registry disagree, so the contract below can never drift from the wired hooks. Edit the registry, not this table.
|
|
167
|
+
|
|
168
|
+
The stdin-JSON context each point receives, the **mutable slice** a hook may rewrite via stdout JSON (everything else is read-only context), and the exit-code policy class:
|
|
169
|
+
|
|
170
|
+
<!-- BEGIN GENERATED: hook-context-schema -->
|
|
171
|
+
| Hook | Stdin context (JSON) | Mutable slice | Exit policy |
|
|
172
|
+
|---|---|---|---|
|
|
173
|
+
| `pre_session` | `runner`, `worker_id`, `filter`, `iter_cap` | `runner`, `worker_id`, `filter`, `iter_cap` | non-zero **aborts** |
|
|
174
|
+
| `pre_pick` | `label`, `state`, `limit`, `filter` | `label`, `state`, `limit` | non-zero **aborts** |
|
|
175
|
+
| `post_pick` | `issues[]` | `issues[]` | non-zero **logged**, continues |
|
|
176
|
+
| `pre_worktree` | `issue`, `target`, `env`, `branch` | `issue`, `target`, `env` | non-zero **aborts** |
|
|
177
|
+
| `pre_attempt` | `issue`, `workspace`, `attempt_n`, `runner` | `issue`, `workspace`, `attempt_n` | non-zero **aborts** |
|
|
178
|
+
| `post_attempt` | `issue`, `workspace`, `result`, `attempt_n` | `issue`, `workspace`, `result`, `attempt_n` | non-zero **logged**, continues |
|
|
179
|
+
| `pre_feedback` | `issue`, `workspace`, `scopes[]` | `issue`, `workspace`, `scopes[]` | non-zero **aborts** |
|
|
180
|
+
| `on_baseline_probe` | `issue`, `workspace`, `ok`, `downgraded[]` | _none (read-only)_ | non-zero **logged**, continues |
|
|
181
|
+
| `on_feedback_classify` | `issue`, `workspace`, `class` | `class` | non-zero **logged**, continues |
|
|
182
|
+
| `post_feedback` | `issue`, `workspace`, `result` | `issue`, `workspace`, `result` | non-zero **logged**, continues |
|
|
183
|
+
| `pre_merge` | `issue`, `workspace`, `diff`, `branch` | `issue`, `workspace`, `diff` | non-zero **aborts** |
|
|
184
|
+
| `post_merge` | `issue`, `workspace`, `merge_commit` | `issue`, `workspace`, `merge_commit` | non-zero **logged**, continues |
|
|
185
|
+
| `on_attempt_error` | `issue`, `workspace`, `error`, `attempt_n` | `issue`, `workspace`, `error`, `attempt_n` | non-zero **logged**, continues |
|
|
186
|
+
| `on_attempt_timeout` | `issue`, `workspace`, `attempt_n`, `reason` | _none (read-only)_ | non-zero **logged**, continues |
|
|
187
|
+
| `on_recovery_decision` | `issue`, `decision`, `reason`, `attempt_n` | `decision` | non-zero **logged**, continues |
|
|
188
|
+
| `on_blocked` | `issue`, `blocked_label`, `reason`, `attempt_n` | _none (read-only)_ | non-zero **logged**, continues |
|
|
189
|
+
| `on_reconcile` | `issue`, `workspace`, `attempt_n`, `outcome` | _none (read-only)_ | non-zero **logged**, continues |
|
|
190
|
+
| `on_idle` | `stats` | _none (read-only)_ | non-zero **logged**, continues |
|
|
191
|
+
| `on_heartbeat` | `issue`, `workspace`, `runner`, `attempt_n`, `vitals` | _none (read-only)_ | non-zero **logged**, continues |
|
|
192
|
+
| `post_session` | `runner`, `worker_id`, `stats` | `runner`, `worker_id`, `stats` | non-zero **logged**, continues |
|
|
193
|
+
| `on_session_error` | `error` | _none (read-only)_ | non-zero **logged**, continues |
|
|
194
|
+
<!-- END GENERATED: hook-context-schema -->
|
|
195
|
+
|
|
196
|
+
**Attempt vocabulary & back-compat (issue #226, ADR 0026).** The attempt-level hooks were renamed from `pre_worker` / `post_worker` / `on_worker_error` to `pre_attempt` / `post_attempt` / `on_attempt_error` so "worker" unambiguously names the orchestrator process (`RED_AFK_WORKER_ID`) and the hooks align 1:1 with ADR 0017's `attempt` (one node = one runner invocation). They fire **per runner invocation**, so a `--fallback-runner` swap on one issue yields two `pre_attempt → post_attempt` cycles; `attempt_n` remains in the mutable hook context for the first runner versus fallback swap distinction, but it is no longer exported as a `RED_AFK_*` environment variable.
|
|
197
|
+
|
|
198
|
+
### Built-in defaults
|
|
199
|
+
|
|
200
|
+
Defaults are AFK-shipped commands registered before any user hook at the
|
|
201
|
+
same lifecycle point. They run **first**, in a fixed registration order
|
|
202
|
+
that users cannot change — only **disable** individual defaults via
|
|
203
|
+
`afk.hooks.defaults.<name>: false`. The disable-not-reorder rule keeps
|
|
204
|
+
later defaults (and user hooks) able to assume an earlier default has
|
|
205
|
+
already had its turn at the env.
|
|
206
|
+
|
|
207
|
+
Currently shipped:
|
|
208
|
+
|
|
209
|
+
| Default | Lifecycle point | Effect | Disable |
|
|
210
|
+
|----------|-----------------|-----------------------------------------------------------------------------------------|--------------------------------------|
|
|
211
|
+
| `cargo` | `pre_worktree` | When `Cargo.toml` exists at `$PROJECT_ROOT`, sets `CARGO_TARGET_DIR=${RED_AFK_CARGO_TARGET_BASE:-/opt/cargo-target}/slot-${RED_AFK_SLOT}` (mkdir-p'd) so each slot's cargo state is isolated. | `afk.hooks.defaults.cargo: false` |
|
|
212
|
+
| `gradle` | `pre_worktree` | When `build.gradle*` exists at `$PROJECT_ROOT` **and** `RED_AFK_GRADLE_USER_HOME_BASE` is set, sets `GRADLE_USER_HOME=${RED_AFK_GRADLE_USER_HOME_BASE}/slot-${RED_AFK_SLOT}` so each slot's Gradle daemons / caches are isolated. The env-var opt-in is deliberate — AFK will not claim a path on your filesystem without consent. | `afk.hooks.defaults.gradle: false` |
|
|
213
|
+
| `heartbeat` | `post_attempt` | Stops the orchestrator's per-minute heartbeat sub-shell (`RED_AFK_HEARTBEAT_PID`) and appends the `iteration stopped` boundary marker to `RED_AFK_ITER_LOG`. Migrated from an inline `heartbeat_stop` call so the heartbeat now terminates *before* any user `post_attempt` hook runs. | `afk.hooks.defaults.heartbeat: false` |
|
|
214
|
+
| `envelope` | `post_attempt` | Reconciles `result.status` onto the AFK state file (`current.result_status` in `RED_AFK_STATE_FILE`) so a user `post_attempt` notifier reading state sees the worker's terminal status without re-deriving it from the sentinel. | `afk.hooks.defaults.envelope: false` |
|
|
215
|
+
| `validation` | `post_merge` | Runs `pnpm test` / `typecheck` / `lint` / `build` against the merged primary checkout (when a `package.json` is present at the workspace root), then attaches `result.{validation_status, validation_summary}` to the post_merge context so user hooks see the CI/smoke outcome reconciled before they fire. Migrated from the inline post-merge CI/smoke call; the pre-merge feedback-validation step remains as the mechanism-owned safety gate (ADR 0008 — only mechanism can refuse a merge), so this default is observability + notification surface, not a gate. | `afk.hooks.defaults.validation: false` |
|
|
216
|
+
|
|
217
|
+
Example configuration:
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
afk:
|
|
221
|
+
hooks:
|
|
222
|
+
pre_session: "echo boot" # bare-string shorthand
|
|
223
|
+
post_pick:
|
|
224
|
+
# filter the queue to issues you opened — RED_AFK_GITHUB_LOGIN must be set
|
|
225
|
+
- "RED_AFK_GITHUB_LOGIN=$(gh api user --jq .login) \
|
|
226
|
+
plugins/dev/skills/engineering/afk/examples/only-mine.sh"
|
|
227
|
+
pre_worktree:
|
|
228
|
+
# user hooks see CARGO_TARGET_DIR / GRADLE_USER_HOME already exported
|
|
229
|
+
# by the built-in `cargo` / `gradle` defaults that ran before them
|
|
230
|
+
- "echo isolated cargo dir: $CARGO_TARGET_DIR"
|
|
231
|
+
on_idle:
|
|
232
|
+
- "cargo clean -p reddb-storage" # safe between drains, not on exit
|
|
233
|
+
post_session:
|
|
234
|
+
- "echo session done"
|
|
235
|
+
- "curl -s -X POST $SLACK_URL -d \"done=$(jq -r .stats.done)\""
|
|
236
|
+
defaults:
|
|
237
|
+
gradle: false # opt out of the gradle built-in
|
|
238
|
+
```
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# AFK terminal-event envelope, stage detection, live header, state file (reference)
|
|
2
|
+
|
|
3
|
+
> Extracted from `afk/SKILL.md` for progressive disclosure. Consulted on demand — not the agent's step-by-step loop.
|
|
4
|
+
>
|
|
5
|
+
> The wire formats: the per-terminal-event issue comment (schema Slice C depends on), how stages are read off the agent stream, the live terminal header, and the `afk.state.json` schema.
|
|
6
|
+
|
|
7
|
+
## Terminal-Event Envelope
|
|
8
|
+
|
|
9
|
+
Every terminal event of an iteration posts **exactly one** structured comment on the issue. The comment is the canonical record of what the worker saw and did, and a future Slice C parser will reconstruct iteration history by walking these envelopes in a thread.
|
|
10
|
+
|
|
11
|
+
Envelope statuses are the wire-level `data-attempt-status` facet. They are intentionally coarser than the full Attempt Outcome vocabulary below: several outcomes emit a `blocked` envelope, and some short-circuit outcomes record labels/history without a per-issue terminal envelope.
|
|
12
|
+
|
|
13
|
+
| `data-attempt-status` | trigger |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `blocked` | generic failure envelope: spec block, validation failure, or another failure folded into the blocked bucket |
|
|
16
|
+
| `no-sentinel` | inner agent exited without `DONE` or `BLOCKED` and the path emits the crash envelope |
|
|
17
|
+
| `merge-conflict` | orchestrator could not merge to `main` |
|
|
18
|
+
| `done` | success — merged, closing envelope |
|
|
19
|
+
| `discarded` | supervisor slot/circuit discard envelope |
|
|
20
|
+
|
|
21
|
+
Attempt Outcome is the runtime's terminal vocabulary. It owns the typed `blocked:<reason>` label, recovery policy key, and envelope-status mapping:
|
|
22
|
+
|
|
23
|
+
| Attempt Outcome | `data-attempt-status` | typed label | recovery |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `done` | `done` | none | none |
|
|
26
|
+
| `blocked` | `blocked` | `blocked:spec` | none |
|
|
27
|
+
| `no-sentinel` | `no-sentinel` | `blocked:crashed` | `crashed` |
|
|
28
|
+
| `merge-conflict` | `merge-conflict` | `blocked:merge-conflict` | `merge-conflict` |
|
|
29
|
+
| `feedback-failed` | `blocked` | `blocked:validation` | none |
|
|
30
|
+
| `hook-aborted` | `blocked` | `blocked:policy` | `policy` |
|
|
31
|
+
| `exhausted` | `blocked` | `blocked:quota` | `quota` |
|
|
32
|
+
| `runner-transient` | `blocked` | `blocked:runner-transient` | `runner-transient` |
|
|
33
|
+
| `stalled` | `blocked` | `blocked:stalled` | none |
|
|
34
|
+
| `infra` | `blocked` | `blocked:infra` | none |
|
|
35
|
+
| `claim-lost` | `blocked` | none | none |
|
|
36
|
+
|
|
37
|
+
Schema (deterministic — Slice C depends on this shape):
|
|
38
|
+
|
|
39
|
+
```html
|
|
40
|
+
<details data-attempt-status="blocked"><summary>worker `wZ2R4` · status: blocked · duration: 2m5s · diff: +42 -10 · attempt: 1</summary>
|
|
41
|
+
|
|
42
|
+
<details data-section="notes"><summary>notes</summary>
|
|
43
|
+
|
|
44
|
+
…handoff `<agent-notes>` body…
|
|
45
|
+
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+
</details>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Per-status body sections:
|
|
52
|
+
|
|
53
|
+
- `blocked` → one `data-section="notes"` block carrying the handoff's `<agent-notes>` body (the inner agent's appended progress/blockers).
|
|
54
|
+
- `no-sentinel` → both `data-section="notes"` (handoff `<agent-notes>`, may be empty placeholder) **and** `data-section="log"` (last 50 lines of the captured inner-agent stdout, fenced).
|
|
55
|
+
- `merge-conflict` → one `data-section="log"` block carrying the merge-conflict diff tail (last 50 lines of `git merge` output), fenced. Mirrors the no-sentinel log shape.
|
|
56
|
+
- `done` → one `data-section="validation"` block carrying the package-aware feedback report. Summary carries `diff: merged` and `merge: ` `<sha>` (GitHub auto-links bare SHAs to the commit on `main`). The merge commit on `main` *is* the diff — no need to duplicate it inline.
|
|
57
|
+
|
|
58
|
+
**User-hook executions section (issue #215).** Every non-`discarded` terminal Envelope also carries a trailing `data-section="hooks"` block when at least one **user-declared** lifecycle hook ran during the issue's lifecycle. Built-in defaults (`cargo`, `gradle`, `heartbeat`, `envelope`, `validation` — see the *Lifecycle Hooks* table) are deliberately excluded; the block exists to surface the policy the operator wrote in `.red/config.yaml`, not the skill's own machinery. Each line has the deterministic shape `<lifecycle_name> <command> exit=<rc>`, in execution order across the entire lifecycle (`pre_session` → `pre_pick` → `post_pick` → `pre_worktree` → `pre_attempt` → `post_attempt` → `pre_merge` → `post_merge` → `on_attempt_error` → `on_idle` → `post_session` / `on_session_error`). Non-zero exits are listed with their exit code — never omitted — so a reviewer can see which user-declared policy guarded the merge or mutated the queue, and whether it failed. When no user hook ran (the common case for projects without an `afk.hooks` block in `.red/config.yaml`), the section is omitted entirely rather than rendered empty. The `discarded` supervisor envelope never carries this section: discards record a slot-park decision made above the per-issue lifecycle, so no per-issue hook chain exists to enumerate.
|
|
59
|
+
|
|
60
|
+
**Branch namespace — `afk/*` (issue #191, ADR 0103).** AFK uses one remote worker namespace:
|
|
61
|
+
|
|
62
|
+
- `afk/{id}/{N}-{slug}` is the **live-iteration** branch. It's pushed at worktree-create (`push_initial`), kept in sync after every inner-agent commit by a per-worktree `post-commit` hook (`install_post_commit_hook`), and deleted on DONE after `gh issue close` succeeds (`delete_remote`). On any terminal failure the live ref is **not** deleted — it survives on origin so a human can `git fetch && git checkout afk/{id}/{N}-{slug}` to inspect mid-iteration state.
|
|
63
|
+
|
|
64
|
+
### Validation Sidecar
|
|
65
|
+
|
|
66
|
+
During feedback validation, AFK also writes a structured JSONL sidecar at
|
|
67
|
+
`$ITER_DIR/validation.jsonl`. It is not rendered into the issue comment; it is
|
|
68
|
+
the machine-readable source used by the optional Memory bridge.
|
|
69
|
+
|
|
70
|
+
Each line is one command/check execution:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{"schema":"red.afk.validation.v1","name":"test:plugins/memory","command":"pnpm -C /repo/plugins/memory test","status":"passed","durationMs":1234,"summary":"command exited 0"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Fields:
|
|
77
|
+
|
|
78
|
+
- `schema`: literal `red.afk.validation.v1`.
|
|
79
|
+
- `name`: stable check name, usually `{script}:{scope}` such as `typecheck:root` or `lint:plugins/memory`.
|
|
80
|
+
- `command`: command string when a command ran; omitted for skipped checks.
|
|
81
|
+
- `status`: `passed`, `failed`, or `skipped`.
|
|
82
|
+
- `durationMs`: command duration when a command ran.
|
|
83
|
+
- `summary`: short relevant output/error summary, or a skip reason.
|
|
84
|
+
|
|
85
|
+
The Memory attempt writer only consumes this structured sidecar after parsing it
|
|
86
|
+
as JSON. It must not derive validation graph nodes by parsing free-form stdout,
|
|
87
|
+
Envelope notes, validation-summary prose, or `<agent-notes>`.
|
|
88
|
+
|
|
89
|
+
Summary line is always `worker `{id}` · status: {status} · duration: NmSs · diff: {diff} · attempt: K [· merge: {sha}]`, where `{diff}` is `+N -M` against `origin/main` for non-DONE statuses and the literal `merged` for DONE.
|
|
90
|
+
|
|
91
|
+
After a successful POST (any 2xx), the orchestrator sets `envelope.posted: true` in the iteration state file. The boot-time *Orphan Cleanup* reads that field to pick a TTL for preserved `ready-for-human` dirs: 1 day when the envelope made it to the issue (the thread carries the canonical record), 7 days when the POST failed (the local dir is the only copy of the notes/log). The field is initialised `false` at iteration start.
|
|
92
|
+
|
|
93
|
+
On any terminal **failure** (BLOCKED, no-sentinel, merge-conflict), the envelope's `data-section="diff"` block carries a clickable **live-branch** `tree/afk/{id}/{N}-{slug}` link (the live ref survives on origin after a terminal failure, so a human can `git checkout` it to inspect or continue, #443), the local worktree path, and a `+N -M files=K` diffstat. DONE iterations delete the live remote branch after the issue closes because the merge commit on `main` is the diff. Terminal failures do not create a separate snapshot branch; the envelope plus pushed worker-branch commits are the forensic record.
|
|
94
|
+
|
|
95
|
+
The Slice D heartbeat-glyph cleanup has landed — there is no periodic `:one: :two: …` traffic on the issue thread to defer or replace.
|
|
96
|
+
|
|
97
|
+
## Stage Detection
|
|
98
|
+
|
|
99
|
+
Inner agent stages, derived from the sandcastle agent stream (the `onAgentStreamEvent` callback AFK fans into `agent.log.jsonl` + the firehose), not from a raw runner stdout pipe:
|
|
100
|
+
|
|
101
|
+
| stage | signal |
|
|
102
|
+
|-------|--------|
|
|
103
|
+
| setup | first output line |
|
|
104
|
+
| explore | `git ls-files`, `find`, repeated `Read` |
|
|
105
|
+
| impl | first `Edit`/`Write` call |
|
|
106
|
+
| tests | `pnpm test` invocation |
|
|
107
|
+
| commit | `git commit` invocation |
|
|
108
|
+
| merge | orchestrator stage, post-inner |
|
|
109
|
+
| push | orchestrator stage |
|
|
110
|
+
| close | orchestrator stage |
|
|
111
|
+
|
|
112
|
+
Each transition writes to state file. The monitor renders the current stage.
|
|
113
|
+
|
|
114
|
+
## Live Header
|
|
115
|
+
|
|
116
|
+
Redraw every 3 s on the controlling TTY, top of the scroll buffer. Use `tput sc; tput cup 0 0; …; tput rc` so the inner agent's stream below stays intact.
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
┌─ /afk ────────────────────────────────────────────────────┐
|
|
120
|
+
│ runner: codex elapsed: 00:14:23 eta: ~01:20:00 │
|
|
121
|
+
│ done: 3 / 12 (25%) blocked: 0 merged: 3 │
|
|
122
|
+
│ │
|
|
123
|
+
│ ▶ #142 wire OAuth callback │
|
|
124
|
+
│ worktree: .red/tmp/workers/wZ2R4/142-a1/worktree │
|
|
125
|
+
│ stage: impl │
|
|
126
|
+
│ last: writing tests for callback handler │
|
|
127
|
+
│ │
|
|
128
|
+
│ queue: #143 #144 #145 #146 ... │
|
|
129
|
+
└────────────────────────────────────────────────────────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If stdout is not a TTY (CI, piped log), skip header rendering and print one JSON line per state transition to stderr.
|
|
133
|
+
|
|
134
|
+
## State File
|
|
135
|
+
|
|
136
|
+
Path: `.red/tmp/workers/{id}/{N}-a{n}/afk.state.json` — one snapshot per (worker, issue, attempt). Schema:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"version": 1,
|
|
141
|
+
"worker_id": "wZ2R4",
|
|
142
|
+
"pid": 12340,
|
|
143
|
+
"pid_start_time": "123456789",
|
|
144
|
+
"log": ".red/tmp/workers/wZ2R4/142-a1/afk.log",
|
|
145
|
+
"started_at": "2026-05-16T12:00:00-03:00",
|
|
146
|
+
"runner": "codex",
|
|
147
|
+
"filter": { "kind": "prd|issues|all", "value": "42" },
|
|
148
|
+
"total": 12,
|
|
149
|
+
"done": 3,
|
|
150
|
+
"failed": 0,
|
|
151
|
+
"blocked": 0,
|
|
152
|
+
"completed": [139, 140, 141],
|
|
153
|
+
"queue": [143, 144, 145, 146],
|
|
154
|
+
"current": {
|
|
155
|
+
"number": 142,
|
|
156
|
+
"title": "wire OAuth callback",
|
|
157
|
+
"slug": "wire-oauth-callback",
|
|
158
|
+
"worktree": ".red/tmp/workers/wZ2R4/142-a1/worktree",
|
|
159
|
+
"handoff": ".red/tmp/workers/wZ2R4/142-a1/handoff.md",
|
|
160
|
+
"started_at": "2026-05-16T12:14:00-03:00",
|
|
161
|
+
"stage": "impl",
|
|
162
|
+
"heartbeat_glyph": null,
|
|
163
|
+
"heartbeat_pid": null,
|
|
164
|
+
"runner": "codex",
|
|
165
|
+
"retries": 0,
|
|
166
|
+
"last_stream_line": "writing tests for callback handler"
|
|
167
|
+
},
|
|
168
|
+
"durations_seconds": [820, 940, 760],
|
|
169
|
+
"envelope": { "posted": false }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`pid_start_time` is a best-effort process identity token paired with `pid`
|
|
174
|
+
(Linux `/proc/<pid>/stat` field 22). It is empty on legacy or unsupported
|
|
175
|
+
platforms; readers then fall back to pid-only liveness.
|
|
176
|
+
|
|
177
|
+
Atomic write: write to `afk.state.json.tmp` inside the attempt directory, `mv` over the original. `/afk monitor` and any other reader open it read-only. Between issues the worker has no live state file — monitor renders that as "idle".
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# AFK handoff file template (reference)
|
|
2
|
+
|
|
3
|
+
> Extracted from `afk/SKILL.md` for progressive disclosure. Consulted on demand — not the agent's step-by-step loop.
|
|
4
|
+
>
|
|
5
|
+
> The exact `handoff.md` the inner agent reads — the XML-wrapped issue body, prior attempts, human guidance, and agent notes.
|
|
6
|
+
|
|
7
|
+
## Handoff File Template
|
|
8
|
+
|
|
9
|
+
`.red/tmp/workers/{id}/{N}-a{n}/handoff.md`:
|
|
10
|
+
|
|
11
|
+
Top-level content is XML elements (not markdown headers) so the inner agent
|
|
12
|
+
cannot confuse the issue body with comments, or human direction with
|
|
13
|
+
orchestrator audits. Markdown sections like `## Agent brief`, `## Acceptance`,
|
|
14
|
+
`## Refs`, and `## Suggested Skills` live *inside* the `<issue-body>` element
|
|
15
|
+
(they are part of the issue body verbatim).
|
|
16
|
+
|
|
17
|
+
```markdown
|
|
18
|
+
# Issue #{N} — {title} [AFK]
|
|
19
|
+
|
|
20
|
+
source: {gh-url}
|
|
21
|
+
prd: {prd-url-or-issue-ref} # omit if none
|
|
22
|
+
runner: {claude|codex}
|
|
23
|
+
started: {iso8601}
|
|
24
|
+
attempt: {1..}
|
|
25
|
+
|
|
26
|
+
<issue-body>
|
|
27
|
+
{issue body verbatim — includes the `## Agent brief`, `## Acceptance`, `## Refs`,
|
|
28
|
+
and `## Suggested Skills` markdown sections written by /triage}
|
|
29
|
+
</issue-body>
|
|
30
|
+
|
|
31
|
+
<previous-attempts> <!-- omitted when empty -->
|
|
32
|
+
<previous-attempt n="1" status="blocked" worker="wXXXX" duration="0m50s" branch="afk/wXXXX/N-slug">
|
|
33
|
+
<notes>
|
|
34
|
+
{inner agent's appended notes from prior attempt}
|
|
35
|
+
</notes>
|
|
36
|
+
<log>
|
|
37
|
+
{tail of prior attempt's stdout, if captured}
|
|
38
|
+
</log>
|
|
39
|
+
</previous-attempt>
|
|
40
|
+
</previous-attempts>
|
|
41
|
+
|
|
42
|
+
<prior-attempt-context> <!-- omitted on a first attempt -->
|
|
43
|
+
prev-attempt: 1
|
|
44
|
+
prev-snapshot-branch: (none)
|
|
45
|
+
prev-failure-reason:
|
|
46
|
+
{verbatim failure.reason from the previous attempt — the envelope summary}
|
|
47
|
+
{branch fresh off the base — do NOT fix-forward on prior history}
|
|
48
|
+
</prior-attempt-context>
|
|
49
|
+
|
|
50
|
+
<human-guidance-thread> <!-- omitted when empty -->
|
|
51
|
+
<human-guidance author="@alice" at="{iso8601}">
|
|
52
|
+
{verbatim content of one extracted <details data-kind="directive"> marker — one
|
|
53
|
+
<human-guidance> element per directive, so a single comment carrying two markers
|
|
54
|
+
emits two siblings with identical author/at}
|
|
55
|
+
</human-guidance>
|
|
56
|
+
</human-guidance-thread>
|
|
57
|
+
|
|
58
|
+
<thread-discussion> <!-- omitted when empty -->
|
|
59
|
+
<thread-discussion-entry author="@alice" at="{iso8601}">
|
|
60
|
+
{human comment body verbatim that carried no directive marker — advisory only,
|
|
61
|
+
lowest authority; orchestrator audits already filtered out by body shape}
|
|
62
|
+
</thread-discussion-entry>
|
|
63
|
+
</thread-discussion>
|
|
64
|
+
|
|
65
|
+
<agent-notes>
|
|
66
|
+
<!-- inner agent appends progress/blockers here across attempts -->
|
|
67
|
+
</agent-notes>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The handoff file follows the same minimalism as the `/handoff` skill — reference artifacts by path, do not duplicate their content.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# AFK liveness: heartbeat + solo-run stall protection (reference)
|
|
2
|
+
|
|
3
|
+
> Extracted from `afk/SKILL.md` for progressive disclosure. Consulted on demand — not the agent's step-by-step loop.
|
|
4
|
+
>
|
|
5
|
+
> How a running worker signals it is alive and how a hung inner agent is caught. Bundle-internal; the agent does not drive these.
|
|
6
|
+
|
|
7
|
+
## Heartbeat (local-only, post-Slice-D)
|
|
8
|
+
|
|
9
|
+
The issue-thread heartbeat (`:one:` / `:two:` / `:three:` / `:four:` cycling every 10 minutes via `gh issue comment`) was removed in Slice D. The issue thread is now timeline-only: boot stamp, attempt envelopes, human guidance, closing envelope. No periodic noise.
|
|
10
|
+
|
|
11
|
+
Local liveness is signalled by:
|
|
12
|
+
|
|
13
|
+
- **Inner-agent stream**, captured by sandcastle whose `logging.path` lane is pointed at the attempt dir's **`afk.log`** (the one unified human log — no separate `sandcastle.log`), and surfaced through the `onAgentStreamEvent` callback AFK forwards into the JSONL lanes — forensic inspection of a running worker tails this file. Because red-castle's file-log IS `afk.log`, the setup phase it narrates (worktree / sandbox / deps) lands there too, so the log is never empty before the agent streams; the plaintext `[agent]` mirror is dropped so turns are not doubled.
|
|
14
|
+
- **Clean agent lane + firehose** (issue #250) — alongside `afk.log`, the runtime fans each assistant turn out to a clean single-writer `agent.log.jsonl` TOONL lane (one `type=agent` record per turn, nothing synthetic — the true liveness signal, readable as a live transcript with `tail -f … | tq -p toonl -o json -r .msg`) and to a `log.jsonl` firehose that also carries the heartbeat vitals, hook dispatches, runner timings, and errors in the uniform envelope. `tq` is a pinned required host binary, so there is no jq fallback for reading RedSkills-owned TOONL lanes. The heartbeat writes its vitals to the firehose as a `type=heartbeat` record but never to the agent lane, so the agent lane's silence is real silence (the masking that defeated stall/reaper detection in #243). `afk.log` now carries red-castle's unified stream (setup narration + agent text/tools) plus the `[heartbeat]` lines.
|
|
15
|
+
- **Fleet supervisor firehose** — the supervisor's shared live lane at `.red/tmp/supervisors/default/supervisor.log.toonl` is written as append-only TOONL records with an honest extension. A one-time boot migration moves the legacy structured firehose copies out of `.red/tmp/` / `.red/state/afk/` / `.red/state/castle/`. Tail heartbeat messages with `tail -f .red/tmp/supervisors/default/supervisor.log.toonl | tq -p toonl -o json -r .msg`. Readers sniff each row, so older JSONL rows and mixed legacy+TOONL files remain readable; history is never rewritten.
|
|
16
|
+
- **Monitor activity counters** — compact `/afk monitor --once` renders the canonical WorkerVitals activity group (`tools:<n> reason:<n> text:<n> wait:<n>`) and a cursor-backed `afk.log` line count (`log:<total>(+<new>)`). These are tie-breakers for the common "quiet but pid-live" case: agent-lane silence alone must not mark a Worker done while the orchestrator pid is still alive.
|
|
17
|
+
- **State-file liveness verdict**, derived once by the Worker state reader. The monitor pairs the orchestrator `pid` with `pid_start_time` when the platform exposes a stable process-start token, so a recycled PID does not resurrect a finished worker. It then combines pid identity with activity freshness to render `[live]` (pid identity matches + recent activity), `[quiet]` (pid identity matches, agent lane quiet), or `[stale]` (pid dead/identity mismatch).
|
|
18
|
+
- **Iteration boundary markers** — `heartbeat_start` / `heartbeat_stop` write a single `[heartbeat] iteration started/stopped` line each to `afk.log` so forensic readers can see when an iteration entered and left the inner-agent stage.
|
|
19
|
+
- **Periodic orchestrator heartbeat** (issue #194) — `heartbeat_start` also spawns a side-channel sub-shell that appends one line every `RED_AFK_HEARTBEAT_S` (default 60s) to `afk.log`:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
[heartbeat] stage:tests t+00:14:02 last_stream_line="..." cpu=12% rss=420M
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The loop re-reads `current.stage` and `current.last_stream_line` from `afk.state.json` on every tick (so a mid-iteration stage flip shows up in the next heartbeat) and reads cpu/rss from `ps` against the orchestrator pid. Because it lives in its own sub-shell — independent of the inner-agent stream tee that buffers inside the runner pipeline — a forcibly hung worker (`kill -STOP` on the inner agent, or a runner that never flushes) still produces one heartbeat line per minute with stage frozen and wall-clock advancing. The `no-sentinel` envelope's `data-section=log` carries these lines, so the issue thread alone is enough to diagnose where the hang occurred. Set `RED_AFK_HEARTBEAT_S=0` to disable the periodic loop (boundary markers still fire).
|
|
26
|
+
|
|
27
|
+
The terminal header has its own independent 3 s redraw tick — see *Live Header* below. It is unrelated to (and survives the removal of) the GitHub-thread heartbeat.
|
|
28
|
+
|
|
29
|
+
**Deprecated state fields.** `current.heartbeat_glyph` and `current.heartbeat_pid` are kept as `null` for one release window so older monitors don't error on read; they are no longer written meaningfully and may be removed in a future release.
|
|
30
|
+
|
|
31
|
+
## Solo-run stall protection (issues #400, #363)
|
|
32
|
+
|
|
33
|
+
A solo `/afk run` worker is protected against a hung inner agent by **two complementary in-process layers**, both armed only under no-sandbox isolation (under docker/podman the agent commits and builds in an isolated copy the host can't see, so both guards stand down and only the per-iteration idle timeout + max-iterations apply):
|
|
34
|
+
|
|
35
|
+
- **Attempt progress guard (#400, commit-anchored).** Polls the worker branch HEAD on the attempt-guard cadence and **aborts the run when no NEW commit lands within 2700 seconds**, resetting the deadline on every commit. This catches the *productive-looking infinite loop* — an agent that is busy and emitting output but never converging on work. The abort maps to the `timeout` outcome → `blocked:stalled`, `ready-for-human`, PR/worktree preserved. It is the backstop for the fatal "hang forever" case (applies solo and fleet) and is the **progress** signal — never duplicated by the layer below.
|
|
36
|
+
- **Lane-idle reaper (#363, idle-anchored).** The solo-path port of Fleet Mode's passive stall detector + hard stall reaper, reusing the SAME fleet detector (`computeStalled`) and reaper-signal busy-predicate (`deriveSnapshot` + `decideReaperSignal`) — not a second mechanism. It samples the active attempt's **agent lane** `agent.log.jsonl` mtime (the clean liveness signal — never `afk.log` / the firehose `log.jsonl`, which the per-minute heartbeat keeps fresh and would mask a real stall, the #243 masking) every `RED_AFK_STALL_POLL_S` (default 30s) on a side-channel poll independent of the inner-agent stream, so a fully-hung runner is still observed. A worker alive ≥ `RED_AFK_STALL_THRESHOLD_S` (default 600s) whose agent lane has been idle ≥ the same is a **candidate**; once the lane is idle past `RED_AFK_STALL_KILL_THRESHOLD_S` (default 1800s) the irreversible kill is **gated behind the busy-predicate** — a worker with an active `vitest`/`tsc`/`cargo`/build descendant under its process tree, or non-trivial aggregate cpu, is **busy** and left alone, while a genuinely stuck worker [idle past the threshold, no active descendant, flat cpu] is reaped tree-wide (SIGTERM then SIGKILL after the grace). The reap aborts the run → `no-sentinel`, which flows through the existing no-sentinel terminal policy (envelope with the attempt-dir `afk.log` tail, label rotated back to `ready-for-agent`/`ready-for-human`, worktree dropped). This is the **faster idle layer**: it cuts an idle hang at the stall threshold (minutes) rather than only at the progress cap. `RED_AFK_STALL_KILL_THRESHOLD_S` must be strictly greater than `RED_AFK_STALL_THRESHOLD_S`, validated at boot (the same invariant the supervisor enforces) — a `<=` config fails fast before the run claims an issue.
|
|
37
|
+
|
|
38
|
+
The two layers share the run's `AbortController`: progress watches commits, lane-idle watches the agent lane. The threshold env vars (`RED_AFK_STALL_THRESHOLD_S`, `RED_AFK_STALL_KILL_THRESHOLD_S`, `RED_AFK_STALL_POLL_S`) are consistent with Fleet Mode.
|
|
39
|
+
|
|
40
|
+
## Host-level OOM signature (#1758)
|
|
41
|
+
|
|
42
|
+
When the worker log, supervisor tick log, and an unrelated interactive operator
|
|
43
|
+
session all stop in the same short window, treat it as a host-level kill/OOM
|
|
44
|
+
signature, not a worker-code bug. The expected recovery path is:
|
|
45
|
+
|
|
46
|
+
- relaunch the fleet;
|
|
47
|
+
- let the boot sweep reconcile any dangling `running` claims and stale slots;
|
|
48
|
+
- inspect the preserved pushed `afk/*` branch before deciding whether work was
|
|
49
|
+
lost.
|
|
50
|
+
|
|
51
|
+
Heavy validation suites are bounded by `afk.validation.*` config: validation
|
|
52
|
+
subprocesses receive a Node heap cap and Vitest worker cap, and known-heavy
|
|
53
|
+
validation admission serializes when another heavy validation is already active
|
|
54
|
+
or when available memory is below the configured threshold. On small reference
|
|
55
|
+
machines, keep the default `vitest_max_workers: 1` and avoid raising fleet width
|
|
56
|
+
without also raising the per-host memory budget.
|