HOMELAB-1734: refactor(epic-session): generalize extension for all projects #44

Merged
aaron merged 5 commits from plane/HOMELAB-1734-epic-session-generic into main 2026-09-29 03:05:01 +00:00
Owner

What

Rewrites /epic-session so it works in any project instead of silently applying sendrast defaults everywhere. Observed live: running it in ~/repos/homelab claimed a sendrast slot via a hardcoded fallback path (/home/aaron/repos/sendrast/api-sendrast/scripts/dev-slot.sh) and used /tmp/sendrast-slots, sendrast-s<N>, and hardcoded sendrast locks.

Slot backends (auto-selected)

  • script — unchanged delegation to a project's scripts/dev-slot.sh (sendrast). The hardcoded absolute fallback path is removed; script candidates are --script/EPIC_SESSION_SLOT_SCRIPT → config slotScript → <cwd>/scripts → project root → each repo → git-object extraction with configurable slotScriptGitRefs.
  • builtin — new native registry (claim/release/list/ports) used whenever no script resolves: atomic claim JSON in the project's slotsDir (same schema as dev-slot.sh), env block generated from config port-block math.

Config

  • Name-derived defaults: ~/.cache/epic-session/slots/<project> registry, <project>-s compose prefix (was /tmp/sendrast-slots / sendrast-s).
  • EPIC_SESSION_SLOTS_DIR env; SENDRAST_SLOTS_DIR kept as legacy alias.
  • Config-driven ports map ({NAME: offset}) for the env block; generic default emits SLOT + COMPOSE_PROJECT_NAME only.
  • paraglide → generic postCreate hooks (legacy paraglideCommand/webRepo keys still accepted).
  • Kickoff locks (sharedLocks) and doc references are config-driven, not hardcoded.

Verification

  • Full start/attach/status/end cycle on a synthetic project with the builtin registry: port math (5500+10·N+offset), post-create hooks, duplicate-claim/slot-0/refusal gates, worktree create/remove on epic/<slug>.
  • Read-only status regression in ~/repos/sendrast: script backend resolved from api-sendrast/scripts, all six doc derivations intact (worktree layout, port block, slots dir, compose prefix, paraglide hook, teardown checklist).
  • ~/repos/homelab resolution with the new machine config: builtin backend, epic/<epic> from origin/live.

Machine config ~/.config/epic-session/projects.json now carries homelab (repos: infra-core, prod, baseRef: origin/live) and apexDirector entries; lab-director deferred (repos not cloned on this machine).

Notes

  • Live end-to-end (/epic-session start TEST in homelab with the merged extension) is the ticket's post-merge verify step.
  • A stray sendrast slot-4 claim (epic LD0) from the session that surfaced this bug will be released separately — tracked on the ticket.

Ticket: HOMELAB-1734

## What Rewrites `/epic-session` so it works in any project instead of silently applying sendrast defaults everywhere. Observed live: running it in `~/repos/homelab` claimed a **sendrast** slot via a hardcoded fallback path (`/home/aaron/repos/sendrast/api-sendrast/scripts/dev-slot.sh`) and used `/tmp/sendrast-slots`, `sendrast-s<N>`, and hardcoded sendrast locks. ### Slot backends (auto-selected) - **script** — unchanged delegation to a project's `scripts/dev-slot.sh` (sendrast). The hardcoded absolute fallback path is removed; script candidates are `--script`/`EPIC_SESSION_SLOT_SCRIPT` → config `slotScript` → `<cwd>/scripts` → project root → each repo → git-object extraction with configurable `slotScriptGitRefs`. - **builtin** — new native registry (claim/release/list/ports) used whenever no script resolves: atomic claim JSON in the project's `slotsDir` (same schema as dev-slot.sh), env block generated from config port-block math. ### Config - Name-derived defaults: `~/.cache/epic-session/slots/<project>` registry, `<project>-s` compose prefix (was `/tmp/sendrast-slots` / `sendrast-s`). - `EPIC_SESSION_SLOTS_DIR` env; `SENDRAST_SLOTS_DIR` kept as legacy alias. - Config-driven `ports` map (`{NAME: offset}`) for the env block; generic default emits `SLOT` + `COMPOSE_PROJECT_NAME` only. - paraglide → generic `postCreate` hooks (legacy `paraglideCommand`/`webRepo` keys still accepted). - Kickoff locks (`sharedLocks`) and doc references are config-driven, not hardcoded. ## Verification - Full `start`/`attach`/`status`/`end` cycle on a synthetic project with the builtin registry: port math (5500+10·N+offset), post-create hooks, duplicate-claim/slot-0/refusal gates, worktree create/remove on `epic/<slug>`. - Read-only `status` regression in `~/repos/sendrast`: script backend resolved from `api-sendrast/scripts`, all six doc derivations intact (worktree layout, port block, slots dir, compose prefix, paraglide hook, teardown checklist). - `~/repos/homelab` resolution with the new machine config: builtin backend, `epic/<epic>` from `origin/live`. Machine config `~/.config/epic-session/projects.json` now carries homelab (`repos: infra-core, prod`, `baseRef: origin/live`) and apexDirector entries; lab-director deferred (repos not cloned on this machine). ## Notes - Live end-to-end (`/epic-session start TEST` in homelab with the merged extension) is the ticket's post-merge verify step. - A stray sendrast slot-4 claim (epic `LD0`) from the session that surfaced this bug will be released separately — tracked on the ticket. Ticket: HOMELAB-1734
HOMELAB-1734: refactor(epic-session): generalize extension for all projects
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / skill-args (pull_request) Successful in 36s
CI / ask-user-question (pull_request) Successful in 54s
CI / todo (pull_request) Successful in 1m0s
CI / pi-otel-telemetry (pull_request) Successful in 1m5s
CI / pi-swarm-suite (pull_request) Successful in 3m19s
e4c0ac3da3
Rewrite the /epic-session extension so it works in any project (homelab,
lab-director, apexdirector, sendrast) instead of silently applying sendrast
defaults everywhere — observed live when a homelab epic claimed a sendrast
slot via a hardcoded fallback path.

Slot backends:
- script: unchanged delegation to a project's scripts/dev-slot.sh
  (sendrast), with script resolution order explicit/config-driven; the
  hardcoded /home/aaron/repos/sendrast fallback path is gone and git-object
  extraction refs are config (slotScriptGitRefs, default origin/main)
- builtin: new native claim/release/list/ports registry used whenever no
  script resolves — atomic claim JSON in the project's slotsDir (same schema
  as dev-slot.sh), env block generated from config port-block math

Config (layered, doc < machine JSON < project JSON < env):
- name-derived defaults: slotsDir ~/.cache/epic-session/slots/<project>,
  compose prefix <project>-s (was /tmp/sendrast-slots / sendrast-s)
- EPIC_SESSION_SLOTS_DIR env (SENDRAST_SLOTS_DIR kept as legacy alias)
- config-driven ports map ({NAME: offset}) for the env block
- paraglide -> generic postCreate hooks (legacy paraglideCommand/webRepo
  keys still accepted)
- sharedLocks config feeds the agent kickoff (was hardcoded sendrast paths)
- kickoff references docs/parallel-sessions.md only when it exists

Verified: full start/attach/status/end cycle on a synthetic project with
the builtin registry (ports math, hooks, refusal gates, worktrees), and a
read-only sendrast status regression confirming the script backend and full
doc derivation are unchanged.
HOMELAB-1734: fix(epic-session): serialize builtin registry claims, clean up slot relocation
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / skill-args (pull_request) Successful in 46s
CI / pi-otel-telemetry (pull_request) Successful in 50s
CI / todo (pull_request) Successful in 38s
CI / ask-user-question (pull_request) Successful in 41s
CI / pi-swarm-suite (pull_request) Successful in 3m1s
ea0e6f504e
Review blockers on PR #44:
- builtin claim/release did read-select-write with no mutual exclusion, so
  concurrent claims double-assigned slots and silently overwrote the loser's
  claim file (reproduced: 8 parallel claims -> 7 files, 2 epics on one slot).
  Take an exclusive <slotsDir>/.lock (O_CREAT|O_EXCL, stale after 30s) around
  the whole critical section in both claim() and release(), mirroring
  dev-slot.sh's registry flock.
- relocating an epic to a different slot wrote the new claim but left the old
  one behind, so findClaimByEpic reported the stale slot and end() leaked it
  forever. Unlink the previous claim file inside the lock.

Verified: 5x8 parallel claims -> 8 files / 8 distinct slots / no lock left;
relocation leaves a single claim; existing gates, env block and the sendrast
script backend unchanged.
HOMELAB-1734: fix(epic-session): replace registry lock with atomic claim creation
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / skill-args (pull_request) Successful in 47s
CI / pi-otel-telemetry (pull_request) Successful in 51s
CI / todo (pull_request) Successful in 1m0s
CI / ask-user-question (pull_request) Successful in 29s
CI / pi-swarm-suite (pull_request) Successful in 3m14s
ff46f3abcb
The delta review showed no userspace lock-breaking is sound on POSIX
(rename/unlink operate on paths, not inodes, so a stale-lock break can
still double-own a slot), and that the lock itself is what wedged or
slowed every failure mode.

Make the claim-file creation atomic instead, and drop the lock entirely:

- createClaimFile writes a private temp file, then hard-links it into
  place: linkSync fails with EEXIST instead of overwriting (unlike
  rename), so two concurrent claims can never own the same slot
- claim() re-reads and re-selects a slot on collision, bounded by a 5s
  contention deadline; a requested slot fails fast with the actual holder
- own-slot refresh clears our file then re-creates through the same
  atomic path, so a contender stealing the gap relocates instead of
  silently overwriting
- relocation unlinks the exact byEpic.file read (not a recomputed
  <slot>.json), after the new claim exists
- release re-verifies file content (epic + claimed_at) immediately
  before unlinking; a changed claim is reported, not deleted
- no .lock file at all: no stale-lock breaking, no cross-backend .lock
  conflict with dev-slot.sh, no hang path; crash debris is ignored by
  readClaims and swept after 1h

Re-verified with the reviewer's harness (/tmp/h1734-verify): 10/10
aligned concurrent rounds with a stale .lock pre-seeded — 8/8 claims,
8 distinct slots, 0 lost; plus items 3-7 (hostile fixtures, no hangs,
no CPU spin, relocation correctness).
Author
Owner

Landing review on ff46f3a — NOT merged: 1 blocking regression in the builtin registry refresh path

Three fresh-context review rounds ran on this PR (primary sources only: the diff, the ticket acceptance criteria, dev-slot.sh as the reference registry, the repo docs). The first three blockers found in the new builtin slot registry are genuinely fixed on ff46f3a, but that fix introduced a new claim-loss regression, reproduced independently by the reviewer and by the landing worker (3/3 trials). Merging is on hold until it is resolved.

Blocker — own-slot refresh deletes the claim before the replacement exists (epic-session/slot.ts:494-502)

if (refreshed && !relocated) {
  try { unlinkSync(byEpic.file); } catch { /* already gone */ }   // slot path is now FREE, epic has NO claim
}
outcome = createClaimFile(config.slotsDir, { ... });               // mkdir + writeFileSync(tmp) + linkSync

Between the unlink and the successful linkSync the epic holds no claim and the slot looks free. Consequences:

  1. Claim loss under ordinary concurrency (reproduced 3/3). index.ts always passes an explicit slot for a live epic (start: slot: requestedSlot ?? existing?.slot), so when a concurrent auto claim takes the slot in that gap the explicit branch (slot.ts:549-551) returns fail("slot N is held by epic '<other>' — pick another slot") — after the epic's own file was deleted. Harness (8-slot registry, every other slot busy, epic-a refreshing slot 3 while another session runs a plain auto claim):
    seeded:   ["1:x1","2:x2","3:epic-a","4:x4","5:x5","6:x6","7:x7","8:x8"]
    contender took slot 3 at iter 2
    REFRESH FAILED after 284 successful refreshes: slot 3 is held by epic 'newepic-2' — pick another slot
      epic-a claim now: NO CLAIM FILE
      registry: ["1:x1","2:x2","3:newepic-2","4:x4",…]
    
    The epic's session keeps running on the old slot's port block while the registry has handed that slot to another epic — the exact collision the registry exists to prevent, and end can no longer release it.
  2. Any create failure destroys the claim, no race needed. writeFileSync(tmp, …) sits outside createClaimFile's try/finally, so a failed temp write (ENOSPC/EDQUOT/EIO/ENAMETOOLONG) or a non-EEXIST linkSync failure returns fail(…) with the epic's file already gone (deterministic ENAMETOOLONG repro in review round 3).
  3. Auto path silently re-slots. With no explicit slot the loop re-reads and returns ok:true on a different slot (e.g. reported slot 1 while the session's env block was for slot 3).

This is a regression vs e4c0ac3/ea0e6f5, where refresh was a writeClaim (tmp + renameSync) that could never leave the slot free and left the old claim intact on failure. Suggested minimal fix (keep linkSync+EEXIST as the collision detector for new claims): for an own-slot refresh, re-verify the file is still ours (epic + claimed_at) and then atomically renameSync a temp over byEpic.file instead of unlink-then-create, so the path is never absent; and move writeFileSync(tmp, …) inside the try/finally.

Prior findings — verified closed on ff46f3a

  • Concurrent-claim loss (create path): closed. 8 processes × 3 rounds → 8 files, 8 distinct slots, 0 failures; same with a stale .lock and with a fresh .lock (3/3 each); no .lock or .claim-* left behind. linkSync+EEXIST is a sound atomic-claim primitive.
  • Relocation leak / wrong-file unlink: closed. Unlinks the exact byEpic.file that was read; the mismatched-fixture case (1.json body slot 7/epic victim, 7.json/victim2) now leaves victim2 intact.
  • Stale-lock hang / racy break / unbounded spin: closed. No lock code remains; a pre-seeded stale and a fresh .lock are inert and untouched (/tmp/sendrast-slots/.lock included); contention failures return at ~5.03 s with no busy-spin; read-only slotsDir returns in 0.36 s.
  • Error swallowing/mislabelling: closed — real errors surface instead of a false "lock timeout".
  • Sendrast script backend: byte-identical (argv, env, --worktrees fallback, export SLOT= parse, gate message literals diff-ed against e4c0ac3).

Non-blocking findings (follow-up material)

Actionable

  1. Env block is unquoted and its only validation fails open. config.ts:512 merely warns about a ports name that is not a shell variable; slot.ts:329 then emits export ${name}=… verbatim into a block the docs tell users to source. A repo-committed .pi/epic-session.json with "ports": {"BAD NAME; touch /tmp/PWNED": 0} sources into command execution. composeProjectPrefix (slot.ts:326) is unvalidated too. Validation should gate emission, not warn.
  2. Two docs claims are false. README.md:96 / docs/epic-session.md:86 say EPIC_SESSION_SLOT_SCRIPT errors when the path does not exist; it is only candidate #1 (slot.ts:156) and resolution silently falls through. docs/epic-session.md:194 says the two backends' claims "never mix"; if a script-backed project's script is ever unresolvable, the builtin writes into that project's script registry (e.g. /tmp/sendrast-slots) with a reduced env block (2 exports vs dev-slot.sh's 8 ports).
  3. Numeric JSON is silently ignored. slotBase/slotStride/slotCount pass through numberOr(asString(...)) (config.ts:477-479), so "slotCount": 5 silently becomes 9; only string values work and the README does not say so.
  4. New strict-mode type errors in a package no CI job covers. tsc --strict vs main: config.ts:374 rec.command is unknown (should be command.join(" ")) and slot.ts:499 'byEpic' is possibly 'undefined'. epic-session/ has no package.json/tsconfig.json and is not referenced in .forgejo/workflows/ci.yaml, so the green CI runs on this PR gated nothing in the changed package.
  5. A phantom <slot>.json blocks slot selection with misleading messages. A file readClaims cannot parse (invalid JSON, non-object, a directory, a dangling symlink) is invisible to claims()/list() yet still makes linkSync fail EEXIST. Measured: an auto claim burned the whole 5 s budget and reported slot registry contention … while 6 slots were free; --slot 4 answered slot 4 was taken concurrently — retry for a directory. The old renameSync silently recovered.
  6. Relocation cleanup swallows every error while reporting success (slot.ts:526-529): a non-ENOENT unlink failure leaves two files naming the epic and still returns ok:true, so findClaimByEpic (sorted by slot) reports the old slot — prior finding #2's symptom returning silently.
  7. Refresh unlink has no content check, unlike release (slot.ts:581-585 verifies epic + claimed_at; slot.ts:499 deletes whatever is at the path). A release+re-claim of that slot inside the read→unlink window deletes a third party's fresh claim.
  8. No stale-claim reaping. The builtin registry now defaults to a persistent ~/.cache/epic-session/slots/<project> and writes no heartbeat, so dev-slot.sh's opt-in TTL has no counterpart: a session killed without end holds its slot until a manual end --force --slot N.
  9. Ticket divergence for homelab is unrecorded. HOMELAB-1734 Scope/Verify say homelab branchPrefix plane/ + worktreeRoot .worktrees; in effect are the generic defaults epic/<slug> and ~/repos/homelab-wt (config.ts:433-441), pinned nowhere in-repo. The existing epic/LD0 worktrees make it defensible, but the ticket's verify step cannot be checked as written.

Notes

  1. linkSync needs hard-link support on the slotsDir filesystem — fine for ~/.cache, but there is no rename fallback, so a network/FUSE slotsDir would make the builtin backend permanently unable to claim (failure is honest, not silent).
  2. slot: 1.5 is accepted (slot.ts:466 only range-checks via < 1 || > slotCount); dev-slot.sh's validate_slot rejects non-integers.
  3. index.ts:773-776 escalates a refused release to force:true (same as main), so the new "claim changed while releasing — not deleting" guard is immediately followed by a forced delete of whichever epic now holds the slot.
  4. Readers (list/status) are unsynchronised, so the relocation window (new claim written, old unlinked) can momentarily show two files for one epic — never zero, which is the safe ordering.
  5. With no working-tree script, resolveSlotScript shells git show per repo × per ref on every invocation (homelab: 5 repos × 3 refs) with no negative caching.
  6. Dead field: SessionState.backend (state.ts:26-27) is written and never read.
  7. apexDirector has no example config in epic-session/examples/; it exists only in the machine-level ~/.config/epic-session/projects.json.
## Landing review on `ff46f3a` — NOT merged: 1 blocking regression in the builtin registry refresh path Three fresh-context review rounds ran on this PR (primary sources only: the diff, the ticket acceptance criteria, `dev-slot.sh` as the reference registry, the repo docs). The first three blockers found in the **new builtin slot registry** are genuinely fixed on `ff46f3a`, but that fix introduced a **new claim-loss regression**, reproduced independently by the reviewer and by the landing worker (3/3 trials). Merging is on hold until it is resolved. ### Blocker — own-slot refresh deletes the claim before the replacement exists (`epic-session/slot.ts:494-502`) ```ts if (refreshed && !relocated) { try { unlinkSync(byEpic.file); } catch { /* already gone */ } // slot path is now FREE, epic has NO claim } outcome = createClaimFile(config.slotsDir, { ... }); // mkdir + writeFileSync(tmp) + linkSync ``` Between the unlink and the successful `linkSync` the epic holds no claim and the slot looks free. Consequences: 1. **Claim loss under ordinary concurrency (reproduced 3/3).** `index.ts` always passes an explicit slot for a live epic (`start`: `slot: requestedSlot ?? existing?.slot`), so when a concurrent auto claim takes the slot in that gap the explicit branch (`slot.ts:549-551`) returns `fail("slot N is held by epic '<other>' — pick another slot")` — *after* the epic's own file was deleted. Harness (8-slot registry, every other slot busy, epic-a refreshing slot 3 while another session runs a plain auto claim): ``` seeded: ["1:x1","2:x2","3:epic-a","4:x4","5:x5","6:x6","7:x7","8:x8"] contender took slot 3 at iter 2 REFRESH FAILED after 284 successful refreshes: slot 3 is held by epic 'newepic-2' — pick another slot epic-a claim now: NO CLAIM FILE registry: ["1:x1","2:x2","3:newepic-2","4:x4",…] ``` The epic's session keeps running on the old slot's port block while the registry has handed that slot to another epic — the exact collision the registry exists to prevent, and `end` can no longer release it. 2. **Any create failure destroys the claim, no race needed.** `writeFileSync(tmp, …)` sits outside `createClaimFile`'s `try`/`finally`, so a failed temp write (ENOSPC/EDQUOT/EIO/ENAMETOOLONG) or a non-EEXIST `linkSync` failure returns `fail(…)` with the epic's file already gone (deterministic ENAMETOOLONG repro in review round 3). 3. **Auto path silently re-slots.** With no explicit slot the loop re-reads and returns `ok:true` on a *different* slot (e.g. `reported slot 1` while the session's env block was for slot 3). This is a regression vs `e4c0ac3`/`ea0e6f5`, where refresh was a `writeClaim` (tmp + `renameSync`) that could never leave the slot free and left the old claim intact on failure. Suggested minimal fix (keep `linkSync`+EEXIST as the collision detector for *new* claims): for an own-slot refresh, re-verify the file is still ours (epic + `claimed_at`) and then atomically `renameSync` a temp over `byEpic.file` instead of unlink-then-create, so the path is never absent; and move `writeFileSync(tmp, …)` inside the try/finally. ### Prior findings — verified closed on `ff46f3a` - **Concurrent-claim loss (create path):** closed. 8 processes × 3 rounds → 8 files, 8 distinct slots, 0 failures; same with a stale `.lock` and with a fresh `.lock` (3/3 each); no `.lock` or `.claim-*` left behind. `linkSync`+EEXIST is a sound atomic-claim primitive. - **Relocation leak / wrong-file unlink:** closed. Unlinks the exact `byEpic.file` that was read; the mismatched-fixture case (`1.json` body slot 7/epic `victim`, `7.json`/`victim2`) now leaves `victim2` intact. - **Stale-lock hang / racy break / unbounded spin:** closed. No lock code remains; a pre-seeded stale and a fresh `.lock` are inert and untouched (`/tmp/sendrast-slots/.lock` included); contention failures return at ~5.03 s with no busy-spin; read-only `slotsDir` returns in 0.36 s. - **Error swallowing/mislabelling:** closed — real errors surface instead of a false "lock timeout". - **Sendrast script backend:** byte-identical (argv, env, `--worktrees` fallback, `export SLOT=` parse, gate message literals `diff`-ed against `e4c0ac3`). ### Non-blocking findings (follow-up material) **Actionable** 1. **Env block is unquoted and its only validation fails open.** `config.ts:512` merely warns about a `ports` name that is not a shell variable; `slot.ts:329` then emits `export ${name}=…` verbatim into a block the docs tell users to `source`. A repo-committed `.pi/epic-session.json` with `"ports": {"BAD NAME; touch /tmp/PWNED": 0}` sources into command execution. `composeProjectPrefix` (`slot.ts:326`) is unvalidated too. Validation should gate emission, not warn. 2. **Two docs claims are false.** `README.md:96` / `docs/epic-session.md:86` say `EPIC_SESSION_SLOT_SCRIPT` errors when the path does not exist; it is only candidate #1 (`slot.ts:156`) and resolution silently falls through. `docs/epic-session.md:194` says the two backends' claims "never mix"; if a script-backed project's script is ever unresolvable, the builtin writes into that project's script registry (e.g. `/tmp/sendrast-slots`) with a reduced env block (2 exports vs dev-slot.sh's 8 ports). 3. **Numeric JSON is silently ignored.** `slotBase`/`slotStride`/`slotCount` pass through `numberOr(asString(...))` (`config.ts:477-479`), so `"slotCount": 5` silently becomes 9; only string values work and the README does not say so. 4. **New strict-mode type errors in a package no CI job covers.** `tsc --strict` vs `main`: `config.ts:374` `rec.command` is `unknown` (should be `command.join(" ")`) and `slot.ts:499` `'byEpic' is possibly 'undefined'`. `epic-session/` has no `package.json`/`tsconfig.json` and is not referenced in `.forgejo/workflows/ci.yaml`, so the green CI runs on this PR gated nothing in the changed package. 5. **A phantom `<slot>.json` blocks slot selection with misleading messages.** A file `readClaims` cannot parse (invalid JSON, non-object, a *directory*, a dangling symlink) is invisible to `claims()`/`list()` yet still makes `linkSync` fail EEXIST. Measured: an auto claim burned the whole 5 s budget and reported `slot registry contention …` **while 6 slots were free**; `--slot 4` answered `slot 4 was taken concurrently — retry` for a directory. The old `renameSync` silently recovered. 6. **Relocation cleanup swallows every error while reporting success** (`slot.ts:526-529`): a non-ENOENT unlink failure leaves two files naming the epic and still returns `ok:true`, so `findClaimByEpic` (sorted by slot) reports the old slot — prior finding #2's symptom returning silently. 7. **Refresh unlink has no content check, unlike `release`** (`slot.ts:581-585` verifies epic + `claimed_at`; `slot.ts:499` deletes whatever is at the path). A release+re-claim of that slot inside the read→unlink window deletes a third party's fresh claim. 8. **No stale-claim reaping.** The builtin registry now defaults to a persistent `~/.cache/epic-session/slots/<project>` and writes no `heartbeat`, so dev-slot.sh's opt-in TTL has no counterpart: a session killed without `end` holds its slot until a manual `end --force --slot N`. 9. **Ticket divergence for homelab is unrecorded.** HOMELAB-1734 Scope/Verify say homelab `branchPrefix plane/` + `worktreeRoot .worktrees`; in effect are the generic defaults `epic/<slug>` and `~/repos/homelab-wt` (`config.ts:433-441`), pinned nowhere in-repo. The existing `epic/LD0` worktrees make it defensible, but the ticket's verify step cannot be checked as written. **Notes** 10. `linkSync` needs hard-link support on the `slotsDir` filesystem — fine for `~/.cache`, but there is no rename fallback, so a network/FUSE `slotsDir` would make the builtin backend permanently unable to claim (failure is honest, not silent). 11. `slot: 1.5` is accepted (`slot.ts:466` only range-checks via `< 1 || > slotCount`); dev-slot.sh's `validate_slot` rejects non-integers. 12. `index.ts:773-776` escalates a refused release to `force:true` (same as `main`), so the new "claim changed while releasing — not deleting" guard is immediately followed by a forced delete of whichever epic now holds the slot. 13. Readers (`list`/`status`) are unsynchronised, so the relocation window (new claim written, old unlinked) can momentarily show two files for one epic — never zero, which is the safe ordering. 14. With no working-tree script, `resolveSlotScript` shells `git show` per repo × per ref on every invocation (homelab: 5 repos × 3 refs) with no negative caching. 15. Dead field: `SessionState.backend` (`state.ts:26-27`) is written and never read. 16. `apexDirector` has no example config in `epic-session/examples/`; it exists only in the machine-level `~/.config/epic-session/projects.json`.
HOMELAB-1734: fix(epic-session): atomic own-slot refresh, harden env block
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / ask-user-question (pull_request) Successful in 30s
CI / skill-args (pull_request) Successful in 54s
CI / pi-otel-telemetry (pull_request) Successful in 58s
CI / todo (pull_request) Successful in 58s
CI / pi-swarm-suite (pull_request) Successful in 3m14s
869a0d8ff7
Final-review findings on ff46f3a:

- Own-slot refresh must never unlink-then-recreate: the gap leaves the
  epic claimless and the slot free, so a concurrent auto claim steals it
  and the refresh fails after deleting its own claim (reviewer repro:
  3/3 trials). Refresh now re-verifies the file is still ours (epic +
  claimed_at) and atomically replaces it via renameSync — the slot is
  never free, and a changed file re-reads instead of clobbering.
- Move the temp write inside createClaimFile/replaceClaimFile try/finally
  so a failed write cannot leak partial debris.
- Sanitize composeProjectPrefix to [A-Za-z0-9._-]+ (with warning) since
  it is emitted verbatim into shell env blocks; fall back to
  <projectName>-s.
- Correct the docs gotcha: both backends share one claim schema, so a
  project migrating backends keeps its claims (the old 'never mix' claim
  was false for a shared slotsDir).

Re-verified: reviewer refresh repro (4000 refreshes + concurrent
auto-claim contender — epic-a keeps its slot), item12 8-way concurrency
(3 rounds, 0 bad), full pi start/refresh/end smoke.
HOMELAB-1734: fix(epic-session): bound refresh retry, close env-block injection holes
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / skill-args (pull_request) Successful in 47s
CI / ask-user-question (pull_request) Successful in 50s
CI / pi-otel-telemetry (pull_request) Successful in 54s
CI / todo (pull_request) Successful in 1m1s
CI / pi-swarm-suite (pull_request) Successful in 3m16s
7862520ad8
Final-review concerns worth fixing in place:
- !stillOurs retry loop had no deadline check: a writer flipping the claim
  file continuously (e.g. the script backend rewriting in place) could spin
  the refresh forever — now bounded by CLAIM_RETRY_MS with a small sleep.
- composeProjectPrefix fallback interpolated the unsanitized projectName
  (basename of an untrusted path) — the fallback is sanitized too.
- Invalid ports[].name values (warned, not rejected) are no longer emitted
  verbatim into the env block.

Remaining review notes (verify->rename TOCTOU requiring an interleaved
release+reclaim, 1ms claimed_at token resolution, relocation unlink
re-verify, SIGKILL debris/fsync) are operator-paced or theoretical —
posted to the PR as follow-up concerns.
Author
Owner

Final fresh-context review of 869a0d8 (and follow-up 7862520): VERDICT: CONCERNS — no blockers; blocker fixed confirmed (refresh no longer leaves a claimless gap; linkSync EEXIST + re-select retry sound).

Non-blocking findings posted per landing contract (follow-up candidates):

  1. verify→rename TOCTOU (slot.ts refresh path): a change landing after the ownership verify is still clobbered. Concrete interleave: force-refresh → end releases → another epic re-claims the slot → rename deletes the foreign claim. Window is microseconds and requires operator-paced release churn; a flock-style OS lock (matching dev-slot.sh) is the durable fix.
  2. Ownership token is epic + claimed_at with 1 ms resolution — same-millisecond rewrite is undetectable (affects refresh and release re-verify equally).
  3. Relocation unlink re-verifies nowhere (release and refresh now do) — same TOCTOU class as #1, smaller exposure.
  4. !stillOurs retry loop previously had no deadline — fixed in 7862520 (bounded by CLAIM_RETRY_MS + sleep).
  5. Sanitizer bypass via fallback — composeProjectPrefix fallback interpolated unsanitized projectName; fixed in 7862520 (fallback sanitized too).
  6. Invalid ports[].name emitted verbatim into the env block — fixed in 7862520 (skipped, warning retained).
  7. SIGKILL between temp write and rename leaves .claim-* debris (swept after 1 h; readClaims ignores it) and no fsync before rename (host-crash could expose a partial claim that readClaims silently skips → epic appears claimless). Low impact for a local registry.
  8. Pre-existing: symlink planting in a world-writable slotsDir (.claim-* temp names), and replaceClaimFile's overwriting rename is the one place the one-claim-per-slot invariant relies on verification rather than EEXIST.

Merging as-is; #1–#3 + #7 are the worthwhile follow-ups (a shared flock-based serializer would close #1–#3 together).

Final fresh-context review of `869a0d8` (and follow-up `7862520`): **VERDICT: CONCERNS — no blockers; blocker fixed confirmed** (refresh no longer leaves a claimless gap; `linkSync` EEXIST + re-select retry sound). Non-blocking findings posted per landing contract (follow-up candidates): 1. **verify→rename TOCTOU** (`slot.ts` refresh path): a change landing *after* the ownership verify is still clobbered. Concrete interleave: force-refresh → `end` releases → another epic re-claims the slot → rename deletes the foreign claim. Window is microseconds and requires operator-paced release churn; a flock-style OS lock (matching dev-slot.sh) is the durable fix. 2. **Ownership token is epic + `claimed_at`** with 1 ms resolution — same-millisecond rewrite is undetectable (affects refresh and release re-verify equally). 3. **Relocation unlink** re-verifies nowhere (release and refresh now do) — same TOCTOU class as #1, smaller exposure. 4. **`!stillOurs` retry loop** previously had no deadline — fixed in `7862520` (bounded by CLAIM_RETRY_MS + sleep). 5. **Sanitizer bypass via fallback** — `composeProjectPrefix` fallback interpolated unsanitized `projectName`; **fixed in `7862520`** (fallback sanitized too). 6. **Invalid `ports[].name` emitted verbatim** into the env block — **fixed in `7862520`** (skipped, warning retained). 7. SIGKILL between temp write and rename leaves `.claim-*` debris (swept after 1 h; `readClaims` ignores it) and no `fsync` before rename (host-crash could expose a partial claim that `readClaims` silently skips → epic appears claimless). Low impact for a local registry. 8. Pre-existing: symlink planting in a world-writable `slotsDir` (`.claim-*` temp names), and `replaceClaimFile`'s overwriting rename is the one place the one-claim-per-slot invariant relies on verification rather than EEXIST. Merging as-is; #1–#3 + #7 are the worthwhile follow-ups (a shared flock-based serializer would close #1–#3 together).
aaron merged commit a0718757ca into main 2026-09-29 03:05:01 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!44
No description provided.