Repository navigation
Description
State-backend v2: ref-level CAS, recursive deleteDir, stub-name collision, regression coverage
Background
This issue tracks the residual concerns from the PR #1200 review that were
intentionally deferred out of that PR to keep its scope reviewable. The
in-PR fixes resolved concerns B, G, H in full and concerns C, D in part. The
items below are the remainder.
Source review: PR #1200 review comment 4621356216
Decision file: .squad/workstreams/active/squad-agents-ai/decisions/inbox/picard-pr1200-followup-scope.md
Priority: Ship before promoting TwoLayerBackend to the default backend
for fleet / multi-session workloads. The single-user single-session case is
unaffected by concern A and can ship today.
Scope
A. Replace bare update-ref / git notes add -f with CAS-safe forms
Problem. The current write path uses:
git update-ref refs/heads/<X> <newSha>— last writer wins, silently
clobbers concurrent updates from another process.git notes add -f <ref> <sha>—-fis "force overwrite", same
last-writer-wins behaviour.
Under concurrent writers (squad watch + interactive session, two terminals,
fleet) this can drop state without any error.
Fix.
update-ref refs/heads/<X> <new> <old>— compare-and-swap form. Pass
the SHA read at the start of the write as<old>.- Replace
git notes add -fwith an optimistic loop: read current note
blob, build new blob,git notes add(no-f) and retry on the
"already exists" / mismatched-parent error. - Wrap both in a retry-on-mismatch loop with jittered exponential
back-off (50 ms → 800 ms, max 5 attempts). - On final give-up, throw a typed
StateBackendConcurrencyErrorso
callers can decide how to surface it (toast vs. re-queue vs. abort).
Acceptance criteria.
- All write paths in
WorktreeBackend,GitNotesBackend,
OrphanBranchBackend,TwoLayerBackenduse CAS / optimistic-loop form. - Grep for
update-refandnotes add -freturns zero hits in
packages/squad-sdk/src/. - Regression test from concern I below passes (see I).
- Typed
StateBackendConcurrencyErrorexported and documented.
I. Concurrent-writer regression test
Problem. There is no test that exercises the write path under contention.
Concern A would have been caught by such a test.
Fix. Add a Vitest case to test/state-backend.test.ts:
5 worker promises × 20 appends each → expect 100 distinct entries in final state
For each backend (WorktreeBackend, GitNotesBackend, OrphanBranchBackend,
TwoLayerBackend):
- Spin up a fresh repo fixture.
- Launch 5 concurrent
append(key, value-N)calls × 20 iterations. - After all settle, read the key and assert the resulting list has length
100 with no duplicates and no losses. - Test must be deterministic — use bounded jitter, not real wall clock.
Acceptance criteria.
- Test exists, passes locally, passes in CI.
- Test fails on
mainHEAD (proves it catches the bug concern A describes)
and passes after concern A's fix lands.
E. deleteDir must be recursive across nested subtrees
Problem. Current deleteDir(prefix) only removes direct children matching
the prefix. State keys with deeper nesting (e.g. agents/picard/history/2026/06/...)
leave orphan entries behind when their parent dir is deleted.
Fix.
- Update
deleteDirin every backend to walk the full subtree and remove
every entry whose key starts withprefix + '/'. - Add regression test:
- Write 5 keys:
a/b/c,a/b/d,a/b/e/f,a/b/e/g/h,a/other. - Call
deleteDir('a/b'). - Assert only
a/otherremains.
- Write 5 keys:
Acceptance criteria.
- All 4 backends pass the recursive-delete regression test.
- No regression in existing
deleteDirtests.
F. Rename stateBackend: 'external' stub to 'external-stub'
Problem. The state-backend config currently accepts 'external' as a
sentinel/stub value. PR #1194 plans to introduce a real
stateLocation: 'external' feature with the same string. If both ship the
same name, config parsing collides and silently misroutes state.
Fix.
- Rename the stub from
'external'to'external-stub'everywhere in
packages/squad-sdk/src/state-backend*.tsand types. - Add a config-read deprecation warning: when the config still says
stateBackend: 'external', log
[deprecation] stateBackend:'external' is renamed to 'external-stub'; please update your configand continue with the stub. - Update the upgrade migration in
squad-cli(the same machinery used by
squad upgrade) to rewrite'external'→'external-stub'in
user configs automatically.
Acceptance criteria.
- Land before PR State documentation out of sync with latest release #1194 merges.
grep -R "stateBackend: 'external'"returns only the deprecation-warning
test fixture.- Migration test confirms an old config is rewritten on
squad upgrade.
C-residual. Audit other backends for swallowed catches
Problem. The in-PR fix added console.warn('[two-layer] ...') to 3 hot
paths in TwoLayerBackend only. Other backends (WorktreeBackend,
GitNotesBackend, OrphanBranchBackend) may still have silent
catch (e) {} or catch { return undefined } blocks.
Fix.
- Grep
packages/squad-sdk/src/state-backend*.tsforcatchblocks that
do not re-throw, do not log, and do not return a typed error. - For each one, add either a structured warn (matching the
[<backend-name>]tag convention) or a typed thrown error, whichever
is appropriate for the call path. - Document the policy: "no silent catches in state-backend write/read
paths; every swallow must be deliberate and tagged."
Acceptance criteria.
- Audit-grep returns zero un-tagged silent catches in all backends.
- Policy doc in
packages/squad-sdk/src/state-backend.md(or equivalent).
D-residual. Extend per-layer verifyStateBackend contract
Problem. The in-PR fix added per-layer probing for TwoLayerBackend only.
Any future composite backend (e.g. a three-layer cache+notes+orphan stack, or
a remote+local mirror) would re-introduce the same blind-spot.
Fix.
- Promote per-layer health probing into the
StateBackendinterface
itself as an optionalverifyLayers?(): Promise<LayerHealth[]>method. verifyStateBackendcallsverifyLayers()if present and aggregates
the per-layer result into its top-level report.- Default implementation on the base class returns a single-layer
result so existing single-layer backends need no change.
Acceptance criteria.
StateBackendinterface gainsverifyLayers?(non-breaking).TwoLayerBackend.verifyLayersreturns 2 entries; single-layer backends
return 1 entry (or omit the method).- Startup probe report includes a per-layer breakdown when present.
Sequencing
- F must land before PR State documentation out of sync with latest release #1194 merges (calendar-driven).
- A + I are coupled (test in I proves A) and should ship together,
before promotingTwoLayerBackendto the default for fleet workloads. - C-residual, D-residual, E are independent and can ship in any order.
Priority
Ship before promoting TwoLayerBackend to default for fleet workloads.
The single-user single-session path on the current PR is safe. The follow-ups
above matter for: squad watch running concurrently with an interactive
session; two terminals on the same repo; multi-machine fleet usage.
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com
Activity
omercangumus commentedon Jul 15, 2026
taking the deleteDir + stub-rename slice (concerns E and F) — A and I already landed with the CAS work, so this is what's left. confirmed E is real: the storage adapter's deleteDir only walks direct children, so nested keys silently survive on git-notes. one reality note on F: the real external-state feature ended up shipping as stateLocation: 'external' (separate key), so the predicted config collision can't happen as written — renaming the stub is still worth doing so stateBackend: 'external' stops looking like the externalize feature. PR shortly
fix(sdk): recursive deleteDir + rename 'external' stub to 'external-s…
tamirdresher commentedon Jul 17, 2026
Status update on the multi-part concerns tracked here:
dev.StateBackendConcurrencyError, thetryUpdateRefCAS primitive, and a regression test that assertsStateBackendConcurrencyErrorafter exhausting all 5 attempts are present inpackages/squad-sdk/src/state-backend.tsandtest/state-backend.test.tstoday.devas part of the same CAS work referenced above.e185ae4c). CI is green; approving now, not merging while the residual items below remain open.Keeping this issue open. When #1487 merges, please leave a follow-up comment listing exactly which sub-items of C/D are still outstanding so we can spin them off as their own PRs.
fix(sdk): recursive deleteDir + rename 'external' stub to 'external-s…