@bradygaster/squad-sdk 0.9.6-insider.3 → 0.10.0

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.
@@ -1,12 +1,277 @@
1
1
  /**
2
2
  * Git-native state backends for `.squad/` state storage.
3
3
  *
4
+ * Hardening: retry with exponential backoff for transient git errors,
5
+ * circuit-breaker to prevent cascading failures, startup verification,
6
+ * and observable error surfacing (no silent swallowing).
7
+ *
4
8
  * @module state-backend
5
9
  */
6
10
  import { execFileSync } from 'node:child_process';
7
11
  import path from 'node:path';
8
12
  import { FSStorageProvider } from './storage/fs-storage-provider.js';
9
13
  const storage = new FSStorageProvider();
14
+ // ── Retry configuration ─────────────────────────────────────────────
15
+ const RETRY_MAX = 3;
16
+ const RETRY_BASE_MS = 100;
17
+ const RETRY_MAX_DELAY_MS = 2000;
18
+ /**
19
+ * Buffer ceiling for git stdout/stderr. The Node default is 1 MiB, which is
20
+ * easily blown by `git ls-tree` against large trees or `git notes show` on
21
+ * sizeable JSON blobs — spawnSync then dies with ENOBUFS and the wrapper
22
+ * surfaces it as a generic "git command failed". 256 MiB keeps us safely
23
+ * above any realistic `.squad/` state payload while still capping memory.
24
+ */
25
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
26
+ // ── Circuit breaker configuration ───────────────────────────────────
27
+ const CIRCUIT_BREAKER_THRESHOLD = 5;
28
+ const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
29
+ /** Classify git stderr as a transient (retryable) failure. */
30
+ function isTransientGitError(stderr) {
31
+ return /unable to access|could not lock|timeout|connection refused|network|SSL|couldn't connect|Another git process|index\.lock/i.test(stderr);
32
+ }
33
+ /** Non-busy synchronous sleep using Atomics. Safe in Node.js 20+. */
34
+ function sleepSync(ms) {
35
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
36
+ }
37
+ /**
38
+ * Execute a git command with retry for transient errors.
39
+ * Throws on failure after exhausting retries.
40
+ */
41
+ function gitExecWithRetry(args, cwd, trimOutput = true) {
42
+ let lastError;
43
+ for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
44
+ try {
45
+ const raw = execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER });
46
+ return trimOutput ? raw.trim() : raw;
47
+ }
48
+ catch (err) {
49
+ lastError = err;
50
+ const stderr = err.stderr ?? '';
51
+ if (attempt < RETRY_MAX && isTransientGitError(stderr)) {
52
+ const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
53
+ sleepSync(delay);
54
+ continue;
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+ throw lastError;
60
+ }
61
+ /**
62
+ * Execute a git command with stdin input and retry for transient errors.
63
+ * Throws on failure after exhausting retries.
64
+ */
65
+ function gitExecWithInputAndRetry(args, cwd, input) {
66
+ let lastError;
67
+ for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
68
+ try {
69
+ return execFileSync('git', args, { cwd, input, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER }).trim();
70
+ }
71
+ catch (err) {
72
+ lastError = err;
73
+ const stderr = err.stderr ?? '';
74
+ if (attempt < RETRY_MAX && isTransientGitError(stderr)) {
75
+ const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_DELAY_MS);
76
+ sleepSync(delay);
77
+ continue;
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+ throw lastError;
83
+ }
84
+ // ── Typed git errors ────────────────────────────────────────────────
85
+ /** Typed error for git command failures with stderr and command context. */
86
+ export class GitExecError extends Error {
87
+ command;
88
+ reason;
89
+ stderr;
90
+ name = 'GitExecError';
91
+ constructor(command, reason, stderr) {
92
+ super(`git command failed: ${command} — ${reason}`);
93
+ this.command = command;
94
+ this.reason = reason;
95
+ this.stderr = stderr;
96
+ }
97
+ }
98
+ /**
99
+ * Patterns indicating an expected "not found" result from git,
100
+ * as opposed to a real failure (corruption, permission, broken repo).
101
+ */
102
+ const GIT_EXPECTED_MISSING_RE = /no note found|does not exist in|Not a valid object name|invalid object name|not a tree object|bad default revision|Needed a single revision|unknown revision or path|bad object/i;
103
+ function isExpectedMissing(err) {
104
+ const stderr = err.stderr ?? '';
105
+ const msg = err instanceof Error ? err.message : '';
106
+ return GIT_EXPECTED_MISSING_RE.test(stderr) || GIT_EXPECTED_MISSING_RE.test(msg);
107
+ }
108
+ export class CircuitBreaker {
109
+ threshold;
110
+ cooldownMs;
111
+ state = 'closed';
112
+ failures = 0;
113
+ lastFailureTime = 0;
114
+ constructor(threshold = CIRCUIT_BREAKER_THRESHOLD, cooldownMs = CIRCUIT_BREAKER_COOLDOWN_MS) {
115
+ this.threshold = threshold;
116
+ this.cooldownMs = cooldownMs;
117
+ }
118
+ /** Execute an operation through the circuit breaker. */
119
+ execute(fn, operation) {
120
+ if (this.state === 'open') {
121
+ if (Date.now() - this.lastFailureTime >= this.cooldownMs) {
122
+ this.state = 'half-open';
123
+ }
124
+ else {
125
+ throw new Error(`Circuit breaker OPEN after ${this.failures} consecutive git failures. ` +
126
+ `Operation '${operation}' rejected. Will retry after ${Math.ceil((this.cooldownMs - (Date.now() - this.lastFailureTime)) / 1000)}s cooldown.`);
127
+ }
128
+ }
129
+ try {
130
+ const result = fn();
131
+ this.onSuccess();
132
+ return result;
133
+ }
134
+ catch (err) {
135
+ this.onFailure();
136
+ throw err;
137
+ }
138
+ }
139
+ onSuccess() {
140
+ this.failures = 0;
141
+ this.state = 'closed';
142
+ }
143
+ onFailure() {
144
+ this.failures++;
145
+ this.lastFailureTime = Date.now();
146
+ if (this.failures >= this.threshold) {
147
+ this.state = 'open';
148
+ }
149
+ }
150
+ get consecutiveFailures() { return this.failures; }
151
+ get currentState() { return this.state; }
152
+ }
153
+ // ── Git exec helpers (with retry + error classification) ────────────
154
+ /**
155
+ * Execute a git command, returning null for expected absence (e.g., missing ref/path/note).
156
+ * Throws GitExecError for real failures (permission denied, corruption, broken repo).
157
+ * Retries transient errors before classifying.
158
+ *
159
+ * NOTE: `args` is an array, NOT a space-separated string. This was previously a
160
+ * string split on whitespace, which silently mangled any argument containing a
161
+ * space (commit messages, paths with spaces, etc.).
162
+ */
163
+ function gitExecMaybeMissing(args, cwd, trimOutput = true) {
164
+ try {
165
+ return gitExecWithRetry(args, cwd, trimOutput);
166
+ }
167
+ catch (err) {
168
+ if (isExpectedMissing(err))
169
+ return null;
170
+ const stderr = err.stderr ?? '';
171
+ const msg = err instanceof Error ? err.message : String(err);
172
+ throw new GitExecError(`git ${args.join(' ')}`, msg, stderr);
173
+ }
174
+ }
175
+ /**
176
+ * Execute a git command that MUST succeed. Throws GitExecError on any failure.
177
+ * Retries transient errors before throwing.
178
+ *
179
+ * NOTE: `args` is an array, NOT a space-separated string (see gitExecMaybeMissing).
180
+ */
181
+ function gitExecOrThrow(args, cwd) {
182
+ try {
183
+ return gitExecWithRetry(args, cwd);
184
+ }
185
+ catch (err) {
186
+ const stderr = err.stderr ?? '';
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ throw new GitExecError(`git ${args.join(' ')}`, msg, stderr);
189
+ }
190
+ }
191
+ // ── Optimistic concurrency (compare-and-swap) ───────────────────────
192
+ /** Maximum CAS retry attempts before surfacing as concurrency error. */
193
+ const CAS_MAX_ATTEMPTS = 5;
194
+ /** Base delay for jittered exponential backoff: 50, 100, 200, 400, 800 ms. */
195
+ const CAS_BASE_DELAY_MS = 50;
196
+ /** Git's canonical "ref must not exist" sentinel for update-ref CAS. */
197
+ const GIT_NULL_OID = '0000000000000000000000000000000000000000';
198
+ /**
199
+ * Thrown when an optimistic CAS write (update-ref expected-old) fails after
200
+ * exhausting all retry attempts. Callers may surface, requeue, or retry with
201
+ * application-level coordination. Distinct from GitExecError, which signals
202
+ * a real git failure (corruption, permission, broken repo).
203
+ */
204
+ export class StateBackendConcurrencyError extends Error {
205
+ operation;
206
+ attempts;
207
+ lastStderr;
208
+ name = 'StateBackendConcurrencyError';
209
+ constructor(operation, attempts, lastStderr) {
210
+ super(`State backend concurrency conflict on '${operation}' after ${attempts} attempts: ${lastStderr || 'ref moved between read and write'}`);
211
+ this.operation = operation;
212
+ this.attempts = attempts;
213
+ this.lastStderr = lastStderr;
214
+ }
215
+ }
216
+ /**
217
+ * Jittered exponential backoff in milliseconds for attempt N (0-indexed):
218
+ * 50, 100, 200, 400, 800 ms base, with ±25% jitter to avoid thundering herd.
219
+ */
220
+ function jitteredBackoffMs(attempt) {
221
+ const base = CAS_BASE_DELAY_MS * Math.pow(2, attempt);
222
+ const jitter = (Math.random() - 0.5) * 0.5 * base;
223
+ return Math.max(1, Math.round(base + jitter));
224
+ }
225
+ /**
226
+ * Patterns indicating an `update-ref` CAS conflict (retryable) rather than
227
+ * a hard failure. We classify any "ref ... is at ... but expected ..." or
228
+ * lock contention as retryable so the caller can re-read and re-attempt.
229
+ */
230
+ const GIT_UPDATE_REF_CAS_RE = /is at .* but expected|cannot lock ref|reference already exists|cas_error/i;
231
+ /**
232
+ * Attempt an atomic ref update with compare-and-swap semantics.
233
+ *
234
+ * `expectedOldSha` of `null` means "create only if does not exist"
235
+ * (passed as 40 zeros, git's canonical no-such-ref sentinel).
236
+ *
237
+ * Returns `{ ok: true }` on success, `{ ok: false, stderr }` on CAS conflict,
238
+ * and re-throws any non-CAS git failure (corruption, permission, etc.).
239
+ */
240
+ function tryUpdateRef(ref, newSha, expectedOldSha, cwd) {
241
+ if (_casInjector) {
242
+ const forced = _casInjector(ref);
243
+ if (forced)
244
+ return forced;
245
+ }
246
+ const expected = expectedOldSha ?? GIT_NULL_OID;
247
+ try {
248
+ execFileSync('git', ['update-ref', ref, newSha, expected], {
249
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: GIT_MAX_BUFFER,
250
+ });
251
+ return { ok: true, stderr: '' };
252
+ }
253
+ catch (err) {
254
+ const stderr = err.stderr ?? '';
255
+ if (GIT_UPDATE_REF_CAS_RE.test(stderr)) {
256
+ return { ok: false, stderr };
257
+ }
258
+ throw err;
259
+ }
260
+ }
261
+ /**
262
+ * Test-only injector for forcing CAS-conflict / success outcomes deterministically.
263
+ * Production callers never set this. @internal
264
+ */
265
+ let _casInjector = null;
266
+ export function _setCasInjectorForTesting(fn) {
267
+ _casInjector = fn;
268
+ }
269
+ /**
270
+ * Internal CAS primitive — exported for unit tests only.
271
+ * @internal
272
+ */
273
+ export const _tryUpdateRefForTesting = tryUpdateRef;
274
+ // ── Backends ────────────────────────────────────────────────────────
10
275
  export class WorktreeBackend {
11
276
  name = 'local';
12
277
  root;
@@ -47,23 +312,6 @@ export class WorktreeBackend {
47
312
  storage.appendSync(path.join(this.root, key), content);
48
313
  }
49
314
  }
50
- function gitExec(args, cwd) {
51
- try {
52
- return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
53
- }
54
- catch {
55
- return null;
56
- }
57
- }
58
- function gitExecWithInput(args, input, cwd) {
59
- return execFileSync('git', args, { cwd, input, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
60
- }
61
- function gitExecOrThrow(args, cwd) {
62
- const result = gitExec(args, cwd);
63
- if (result === null)
64
- throw new Error(`git command failed: git ${args.join(' ')}`);
65
- return result;
66
- }
67
315
  /**
68
316
  * Validate a state key against characters that could corrupt git plumbing
69
317
  * input (mktree stdin format, branch:path refs) or cause path confusion.
@@ -103,26 +351,36 @@ export class GitNotesBackend {
103
351
  name = 'git-notes';
104
352
  cwd;
105
353
  ref = 'squad';
106
- cachedAnchor;
354
+ breaker = new CircuitBreaker();
355
+ _rootCommit;
107
356
  constructor(repoRoot) { this.cwd = repoRoot; }
357
+ /** Returns the root commit SHA — a stable anchor that never moves. Cached after first call. */
358
+ rootCommit() {
359
+ if (!this._rootCommit) {
360
+ this._rootCommit = gitExecOrThrow(['rev-list', '--max-parents=0', 'HEAD'], this.cwd);
361
+ }
362
+ return this._rootCommit;
363
+ }
364
+ /** Resolve the current SHA of refs/notes/<ref>, or null if it doesn't exist. */
365
+ readNotesRef() {
366
+ return gitExecMaybeMissing(['rev-parse', '--verify', `refs/notes/${this.ref}`], this.cwd);
367
+ }
108
368
  /**
109
- * Return the repo's root commit the first commit with no parents.
110
- * This commit exists on every branch, so the note persists across
111
- * branch switches (unlike HEAD, which moves with the checked-out branch).
369
+ * Load the JSON blob attached to the root commit at a SPECIFIC notes ref SHA.
370
+ * Reading at a pinned SHA (not the live ref tip) is the foundation of the CAS
371
+ * loop without it, a writer could observe state at version N, build version
372
+ * N+1, but race against another writer who already advanced to N+1' (losing
373
+ * data). With a pinned read, the subsequent update-ref CAS catches the race.
374
+ *
375
+ * NOTE: this relies on the notes tree having no fanout. Git uses fanout
376
+ * (ab/cdef.../) only when many notes are present; we only ever store a single
377
+ * note (on the root commit), so the path is just `<refSha>:<anchor>`.
112
378
  */
113
- getAnchorCommit() {
114
- if (this.cachedAnchor)
115
- return this.cachedAnchor;
116
- const root = gitExec(['rev-list', '--max-parents=0', 'HEAD'], this.cwd);
117
- if (!root)
118
- throw new Error('git-notes backend: no root commit found');
119
- // If multiple roots (e.g. from unrelated-history merges), use the first.
120
- this.cachedAnchor = root.split('\n')[0].trim();
121
- return this.cachedAnchor;
122
- }
123
- loadBlob() {
124
- const anchor = this.getAnchorCommit();
125
- const raw = gitExec(['notes', `--ref=${this.ref}`, 'show', anchor], this.cwd);
379
+ loadBlobAt(refSha) {
380
+ if (!refSha)
381
+ return {};
382
+ const anchor = this.rootCommit();
383
+ const raw = gitExecMaybeMissing(['show', `${refSha}:${anchor}`], this.cwd, false);
126
384
  if (!raw)
127
385
  return {};
128
386
  try {
@@ -136,168 +394,254 @@ export class GitNotesBackend {
136
394
  return {};
137
395
  }
138
396
  }
139
- saveBlob(blob) {
140
- const anchor = this.getAnchorCommit();
397
+ /** Convenience reader at the live ref tip (used for read-only operations). */
398
+ loadBlob() {
399
+ return this.loadBlobAt(this.readNotesRef());
400
+ }
401
+ /**
402
+ * Build a new notes commit and attempt to atomically swing refs/notes/<ref>
403
+ * from `expectedOldRefSha` to it. Returns the same `{ ok, stderr }` shape as
404
+ * tryUpdateRef so the caller's retry loop can act.
405
+ */
406
+ atomicSaveBlob(blob, expectedOldRefSha) {
407
+ const anchor = this.rootCommit();
141
408
  const json = JSON.stringify(blob, null, 2);
409
+ let blobSha;
410
+ let treeSha;
411
+ let commitSha;
142
412
  try {
143
- gitExecWithInput(['notes', `--ref=${this.ref}`, 'add', '-f', '--file', '-', anchor], json, this.cwd);
413
+ blobSha = gitExecWithInputAndRetry(['hash-object', '-w', '--stdin'], this.cwd, json);
414
+ treeSha = gitExecWithInputAndRetry(['mktree'], this.cwd, `100644 blob ${blobSha}\t${anchor}\n`);
415
+ const parentArgs = expectedOldRefSha ? ['-p', expectedOldRefSha] : [];
416
+ commitSha = gitExecWithRetry(['commit-tree', treeSha, ...parentArgs, '-m', 'Update squad state'], this.cwd);
144
417
  }
145
- catch {
146
- throw new Error('git-notes backend: failed to write note on ' + anchor);
418
+ catch (err) {
419
+ const msg = err instanceof Error ? err.message : String(err);
420
+ throw new Error(`git-notes backend: failed to build notes commit — ${msg}`);
147
421
  }
422
+ return tryUpdateRef(`refs/notes/${this.ref}`, commitSha, expectedOldRefSha, this.cwd);
423
+ }
424
+ /**
425
+ * Run a mutator under optimistic CAS. The mutator receives the current blob
426
+ * (re-read on every attempt) and may mutate it; its return value is forwarded
427
+ * to the caller on success. On CAS conflict, the loop retries with jittered
428
+ * backoff up to CAS_MAX_ATTEMPTS times, then throws StateBackendConcurrencyError.
429
+ */
430
+ mutateBlob(operation, mutator) {
431
+ let lastStderr = '';
432
+ for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
433
+ const oldRefSha = this.readNotesRef();
434
+ const blob = this.loadBlobAt(oldRefSha);
435
+ const result = mutator(blob);
436
+ const writeResult = this.atomicSaveBlob(blob, oldRefSha);
437
+ if (writeResult.ok)
438
+ return result;
439
+ lastStderr = writeResult.stderr;
440
+ if (attempt < CAS_MAX_ATTEMPTS - 1) {
441
+ sleepSync(jitteredBackoffMs(attempt));
442
+ }
443
+ }
444
+ throw new StateBackendConcurrencyError(operation, CAS_MAX_ATTEMPTS, lastStderr);
148
445
  }
149
446
  read(relativePath) {
150
- const blob = this.loadBlob();
151
- return blob[normalizeKey(relativePath)];
447
+ return this.breaker.execute(() => {
448
+ const blob = this.loadBlob();
449
+ return blob[normalizeKey(relativePath)];
450
+ }, `git-notes:read(${relativePath})`);
152
451
  }
153
452
  write(relativePath, content) {
154
- const blob = this.loadBlob();
155
- blob[normalizeKey(relativePath)] = content;
156
- this.saveBlob(blob);
453
+ this.breaker.execute(() => {
454
+ this.mutateBlob(`git-notes:write(${relativePath})`, (blob) => {
455
+ blob[normalizeKey(relativePath)] = content;
456
+ });
457
+ }, `git-notes:write(${relativePath})`);
157
458
  }
158
459
  exists(relativePath) {
159
- return Object.hasOwn(this.loadBlob(), normalizeKey(relativePath));
460
+ return this.breaker.execute(() => Object.hasOwn(this.loadBlob(), normalizeKey(relativePath)), `git-notes:exists(${relativePath})`);
160
461
  }
161
462
  list(relativeDir) {
162
- const blob = this.loadBlob();
163
- const normalized = normalizeKey(relativeDir);
164
- const dirPrefix = normalized ? normalized + '/' : '';
165
- const entries = new Set();
166
- for (const key of Object.keys(blob)) {
167
- if (key.startsWith(dirPrefix)) {
168
- const rest = key.slice(dirPrefix.length);
169
- const slash = rest.indexOf('/');
170
- entries.add(slash === -1 ? rest : rest.slice(0, slash));
463
+ return this.breaker.execute(() => {
464
+ const blob = this.loadBlob();
465
+ const normalized = normalizeKey(relativeDir);
466
+ const dirPrefix = normalized ? normalized + '/' : '';
467
+ const entries = new Set();
468
+ for (const key of Object.keys(blob)) {
469
+ if (key.startsWith(dirPrefix)) {
470
+ const rest = key.slice(dirPrefix.length);
471
+ const slash = rest.indexOf('/');
472
+ entries.add(slash === -1 ? rest : rest.slice(0, slash));
473
+ }
171
474
  }
172
- }
173
- return [...entries].sort();
475
+ return [...entries].sort();
476
+ }, `git-notes:list(${relativeDir})`);
174
477
  }
175
478
  delete(relativePath) {
176
- const blob = this.loadBlob();
177
- const key = normalizeKey(relativePath);
178
- if (!Object.hasOwn(blob, key))
179
- return false;
180
- delete blob[key];
181
- this.saveBlob(blob);
182
- return true;
479
+ return this.breaker.execute(() => {
480
+ const key = normalizeKey(relativePath);
481
+ return this.mutateBlob(`git-notes:delete(${relativePath})`, (blob) => {
482
+ if (!Object.hasOwn(blob, key))
483
+ return false;
484
+ delete blob[key];
485
+ return true;
486
+ });
487
+ }, `git-notes:delete(${relativePath})`);
183
488
  }
184
489
  append(relativePath, content) {
185
- const blob = this.loadBlob();
186
- const key = normalizeKey(relativePath);
187
- blob[key] = (blob[key] ?? '') + content;
188
- this.saveBlob(blob);
490
+ this.breaker.execute(() => {
491
+ this.mutateBlob(`git-notes:append(${relativePath})`, (blob) => {
492
+ const key = normalizeKey(relativePath);
493
+ blob[key] = (blob[key] ?? '') + content;
494
+ });
495
+ }, `git-notes:append(${relativePath})`);
189
496
  }
190
497
  }
191
498
  export class OrphanBranchBackend {
192
499
  name = 'orphan';
193
500
  cwd;
194
501
  branch;
502
+ breaker = new CircuitBreaker();
195
503
  constructor(repoRoot, branch = 'squad-state') {
196
504
  this.cwd = repoRoot;
197
505
  this.branch = branch;
198
506
  }
199
507
  ensureBranch() {
200
- if (gitExec(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd))
508
+ if (gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd))
201
509
  return;
202
510
  let tree;
203
511
  try {
204
- tree = gitExecWithInput(['mktree'], '', this.cwd);
512
+ tree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
205
513
  }
206
- catch {
207
- throw new Error('orphan backend: failed to create empty tree');
514
+ catch (err) {
515
+ const msg = err instanceof Error ? err.message : String(err);
516
+ throw new Error(`orphan backend: failed to create empty tree — ${msg}`);
208
517
  }
209
518
  let commit;
210
519
  try {
211
- commit = execFileSync('git', ['commit-tree', tree, '-m', 'Initialize squad-state branch'], {
212
- cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
213
- }).trim();
520
+ commit = gitExecWithRetry(['commit-tree', tree, '-m', 'Initialize squad-state branch'], this.cwd);
214
521
  }
215
- catch {
216
- throw new Error('orphan backend: failed to create initial commit');
522
+ catch (err) {
523
+ const msg = err instanceof Error ? err.message : String(err);
524
+ throw new Error(`orphan backend: failed to create initial commit — ${msg}`);
525
+ }
526
+ // CAS create: succeeds only if the ref still doesn't exist. If a concurrent
527
+ // writer created it between our check and now, fall through silently — the
528
+ // caller's mutation loop will pick up the new head on its next iteration.
529
+ const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, commit, null, this.cwd);
530
+ if (!writeResult.ok) {
531
+ // Re-verify someone else created it; if so, we're done.
532
+ if (gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd))
533
+ return;
534
+ throw new Error(`orphan backend: failed to initialize branch — ${writeResult.stderr}`);
217
535
  }
218
- gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, commit], this.cwd);
219
536
  }
220
537
  read(relativePath) {
221
- const key = normalizeKey(relativePath);
222
- try {
223
- return execFileSync('git', ['show', `${this.branch}:${key}`], {
224
- cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
225
- });
226
- }
227
- catch {
228
- return undefined;
229
- }
538
+ return this.breaker.execute(() => {
539
+ const result = gitExecMaybeMissing(['show', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd, false);
540
+ return result ?? undefined;
541
+ }, `orphan:read(${relativePath})`);
230
542
  }
231
543
  write(relativePath, content) {
232
- this.ensureBranch();
233
- const key = normalizeKey(relativePath);
234
- let blobHash;
235
- try {
236
- blobHash = gitExecWithInput(['hash-object', '-w', '--stdin'], content, this.cwd);
237
- }
238
- catch {
239
- throw new Error(`orphan backend: failed to hash content for ${key}`);
240
- }
241
- let currentTree;
242
- const treeResult = gitExec(['log', '--format=%T', '-1', this.branch], this.cwd);
243
- if (!treeResult) {
544
+ this.breaker.execute(() => {
545
+ this.ensureBranch();
546
+ const key = normalizeKey(relativePath);
547
+ // Blob is content-addressed, so hash once outside the CAS loop.
548
+ let blobHash;
244
549
  try {
245
- currentTree = gitExecWithInput(['mktree'], '', this.cwd);
550
+ blobHash = gitExecWithInputAndRetry(['hash-object', '-w', '--stdin'], this.cwd, content);
246
551
  }
247
- catch {
248
- throw new Error('orphan backend: failed to create empty tree');
552
+ catch (err) {
553
+ const msg = err instanceof Error ? err.message : String(err);
554
+ throw new Error(`orphan backend: failed to hash content for ${key} — ${msg}`);
249
555
  }
250
- }
251
- else {
252
- currentTree = treeResult;
253
- }
254
- const newTree = this.updateTree(currentTree, key.split('/'), blobHash);
255
- const parentCommit = gitExec(['rev-parse', this.branch], this.cwd);
256
- let newCommit;
257
- try {
258
- const parentArgs = parentCommit ? ['-p', parentCommit] : [];
259
- newCommit = execFileSync('git', ['commit-tree', newTree, ...parentArgs, '-m', `Update ${key}`], {
260
- cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
261
- }).trim();
262
- }
263
- catch {
264
- throw new Error(`orphan backend: failed to commit update for ${key}`);
265
- }
266
- gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, newCommit], this.cwd);
556
+ let lastStderr = '';
557
+ for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
558
+ // Re-read the ref every iteration so we rebuild on top of the latest tree.
559
+ const parentCommit = gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd);
560
+ let currentTree;
561
+ if (parentCommit) {
562
+ const treeResult = gitExecMaybeMissing(['rev-parse', `${parentCommit}^{tree}`], this.cwd);
563
+ currentTree = treeResult ?? gitExecWithInputAndRetry(['mktree'], this.cwd, '');
564
+ }
565
+ else {
566
+ try {
567
+ currentTree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
568
+ }
569
+ catch (err) {
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ throw new Error(`orphan backend: failed to create empty tree — ${msg}`);
572
+ }
573
+ }
574
+ const newTree = this.updateTree(currentTree, key.split('/'), blobHash);
575
+ let newCommit;
576
+ try {
577
+ const parentArgs = parentCommit ? ['-p', parentCommit] : [];
578
+ newCommit = gitExecWithRetry(['commit-tree', newTree, ...parentArgs, '-m', `Update ${key}`], this.cwd);
579
+ }
580
+ catch (err) {
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ throw new Error(`orphan backend: failed to commit update for ${key} — ${msg}`);
583
+ }
584
+ const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, newCommit, parentCommit, this.cwd);
585
+ if (writeResult.ok)
586
+ return;
587
+ lastStderr = writeResult.stderr;
588
+ if (attempt < CAS_MAX_ATTEMPTS - 1) {
589
+ sleepSync(jitteredBackoffMs(attempt));
590
+ }
591
+ }
592
+ throw new StateBackendConcurrencyError(`orphan:write(${relativePath})`, CAS_MAX_ATTEMPTS, lastStderr);
593
+ }, `orphan:write(${relativePath})`);
267
594
  }
268
595
  exists(relativePath) {
269
- return gitExec(['cat-file', '-t', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd) !== null;
596
+ return this.breaker.execute(() => gitExecMaybeMissing(['cat-file', '-t', `${this.branch}:${normalizeKey(relativePath)}`], this.cwd) !== null, `orphan:exists(${relativePath})`);
270
597
  }
271
598
  list(relativeDir) {
272
- const key = normalizeKey(relativeDir);
273
- const target = key ? `${this.branch}:${key}` : `${this.branch}:`;
274
- const result = gitExec(['ls-tree', '--name-only', target], this.cwd);
275
- if (!result)
276
- return [];
277
- return result.split('\n').filter(Boolean);
599
+ return this.breaker.execute(() => {
600
+ const key = normalizeKey(relativeDir);
601
+ const target = key ? `${this.branch}:${key}` : `${this.branch}:`;
602
+ const result = gitExecMaybeMissing(['ls-tree', '--name-only', target], this.cwd);
603
+ if (!result)
604
+ return [];
605
+ return result.split('\n').filter(Boolean);
606
+ }, `orphan:list(${relativeDir})`);
278
607
  }
279
608
  delete(relativePath) {
280
- const key = normalizeKey(relativePath);
281
- if (!this.exists(relativePath))
282
- return false;
283
- this.ensureBranch();
284
- const treeResult = gitExec(['log', '--format=%T', '-1', this.branch], this.cwd);
285
- if (!treeResult)
286
- return false;
287
- const newTree = this.removeFromTree(treeResult, key.split('/'));
288
- const parentCommit = gitExec(['rev-parse', this.branch], this.cwd);
289
- let newCommit;
290
- try {
291
- const parentArgs = parentCommit ? ['-p', parentCommit] : [];
292
- newCommit = execFileSync('git', ['commit-tree', newTree, ...parentArgs, '-m', `Delete ${key}`], {
293
- cwd: this.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
294
- }).trim();
295
- }
296
- catch {
297
- throw new Error(`orphan backend: failed to commit delete for ${key}`);
298
- }
299
- gitExecOrThrow(['update-ref', `refs/heads/${this.branch}`, newCommit], this.cwd);
300
- return true;
609
+ return this.breaker.execute(() => {
610
+ const key = normalizeKey(relativePath);
611
+ if (gitExecMaybeMissing(['cat-file', '-t', `${this.branch}:${key}`], this.cwd) === null)
612
+ return false;
613
+ this.ensureBranch();
614
+ let lastStderr = '';
615
+ for (let attempt = 0; attempt < CAS_MAX_ATTEMPTS; attempt++) {
616
+ const parentCommit = gitExecMaybeMissing(['rev-parse', '--verify', `refs/heads/${this.branch}`], this.cwd);
617
+ if (!parentCommit)
618
+ return false;
619
+ const treeResult = gitExecMaybeMissing(['rev-parse', `${parentCommit}^{tree}`], this.cwd);
620
+ if (!treeResult)
621
+ return false;
622
+ // Re-check existence at the freshly-read tree a concurrent delete may
623
+ // have already removed our key, in which case there's nothing to do.
624
+ if (gitExecMaybeMissing(['cat-file', '-t', `${parentCommit}:${key}`], this.cwd) === null)
625
+ return false;
626
+ const newTree = this.removeFromTree(treeResult, key.split('/'));
627
+ let newCommit;
628
+ try {
629
+ newCommit = gitExecWithRetry(['commit-tree', newTree, '-p', parentCommit, '-m', `Delete ${key}`], this.cwd);
630
+ }
631
+ catch (err) {
632
+ const msg = err instanceof Error ? err.message : String(err);
633
+ throw new Error(`orphan backend: failed to commit delete for ${key} — ${msg}`);
634
+ }
635
+ const writeResult = tryUpdateRef(`refs/heads/${this.branch}`, newCommit, parentCommit, this.cwd);
636
+ if (writeResult.ok)
637
+ return true;
638
+ lastStderr = writeResult.stderr;
639
+ if (attempt < CAS_MAX_ATTEMPTS - 1) {
640
+ sleepSync(jitteredBackoffMs(attempt));
641
+ }
642
+ }
643
+ throw new StateBackendConcurrencyError(`orphan:delete(${relativePath})`, CAS_MAX_ATTEMPTS, lastStderr);
644
+ }, `orphan:delete(${relativePath})`);
301
645
  }
302
646
  append(relativePath, content) {
303
647
  const existing = this.read(relativePath) ?? '';
@@ -307,40 +651,39 @@ export class OrphanBranchBackend {
307
651
  if (pathSegments.length === 0)
308
652
  throw new Error('orphan backend: empty path segments');
309
653
  if (pathSegments.length === 1) {
310
- // Remove the entry from the tree
311
- const listing = gitExec(['ls-tree', baseTree], this.cwd) ?? '';
654
+ const listing = gitExecMaybeMissing(['ls-tree', baseTree], this.cwd) ?? '';
312
655
  const lines = listing.split('\n').filter(Boolean);
313
656
  const filtered = lines.filter((line) => {
314
657
  const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
315
658
  return !(match && match[4] === pathSegments[0]);
316
659
  });
317
660
  try {
318
- return gitExecWithInput(['mktree'], filtered.length > 0 ? filtered.join('\n') + '\n' : '', this.cwd);
661
+ return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.length > 0 ? filtered.join('\n') + '\n' : '');
319
662
  }
320
- catch {
321
- throw new Error(`orphan backend: failed to remove entry ${pathSegments[0]}`);
663
+ catch (err) {
664
+ const msg = err instanceof Error ? err.message : String(err);
665
+ throw new Error(`orphan backend: failed to remove entry ${pathSegments[0]} — ${msg}`);
322
666
  }
323
667
  }
324
668
  const [dir, ...rest] = pathSegments;
325
669
  const subTreeHash = this.getSubtreeHash(baseTree, dir);
326
670
  if (!subTreeHash)
327
- return baseTree; // subtree doesn't exist, nothing to remove
671
+ return baseTree;
328
672
  const childTree = this.removeFromTree(subTreeHash, rest);
329
- // If the child tree is now empty, remove the directory entry entirely
330
- const childListing = gitExec(['ls-tree', childTree], this.cwd);
673
+ const childListing = gitExecMaybeMissing(['ls-tree', childTree], this.cwd);
331
674
  if (!childListing || childListing.length === 0) {
332
- // Remove the empty directory from the parent tree
333
- const listing = gitExec(['ls-tree', baseTree], this.cwd) ?? '';
675
+ const listing = gitExecMaybeMissing(['ls-tree', baseTree], this.cwd) ?? '';
334
676
  const lines = listing.split('\n').filter(Boolean);
335
677
  const filtered = lines.filter((line) => {
336
678
  const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
337
679
  return !(match && match[4] === dir);
338
680
  });
339
681
  try {
340
- return gitExecWithInput(['mktree'], filtered.length > 0 ? filtered.join('\n') + '\n' : '', this.cwd);
682
+ return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.length > 0 ? filtered.join('\n') + '\n' : '');
341
683
  }
342
- catch {
343
- throw new Error(`orphan backend: failed to prune empty directory ${dir}`);
684
+ catch (err) {
685
+ const msg = err instanceof Error ? err.message : String(err);
686
+ throw new Error(`orphan backend: failed to prune empty directory ${dir} — ${msg}`);
344
687
  }
345
688
  }
346
689
  return this.replaceEntry(baseTree, dir, '040000', 'tree', childTree);
@@ -358,13 +701,13 @@ export class OrphanBranchBackend {
358
701
  childTree = this.updateTree(subTreeHash, rest, blobHash);
359
702
  }
360
703
  else {
361
- const emptyTree = gitExecWithInput(['mktree'], '', this.cwd);
704
+ const emptyTree = gitExecWithInputAndRetry(['mktree'], this.cwd, '');
362
705
  childTree = this.updateTree(emptyTree, rest, blobHash);
363
706
  }
364
707
  return this.replaceEntry(baseTree, dir, '040000', 'tree', childTree);
365
708
  }
366
709
  getSubtreeHash(treeHash, name) {
367
- const listing = gitExec(['ls-tree', treeHash], this.cwd);
710
+ const listing = gitExecMaybeMissing(['ls-tree', treeHash], this.cwd);
368
711
  if (!listing)
369
712
  return null;
370
713
  for (const line of listing.split('\n')) {
@@ -375,7 +718,7 @@ export class OrphanBranchBackend {
375
718
  return null;
376
719
  }
377
720
  replaceEntry(treeHash, name, mode, type, hash) {
378
- const listing = gitExec(['ls-tree', treeHash], this.cwd) ?? '';
721
+ const listing = gitExecMaybeMissing(['ls-tree', treeHash], this.cwd) ?? '';
379
722
  const lines = listing.split('\n').filter(Boolean);
380
723
  const filtered = lines.filter((line) => {
381
724
  const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\t(.+)$/);
@@ -383,10 +726,11 @@ export class OrphanBranchBackend {
383
726
  });
384
727
  filtered.push(`${mode} ${type} ${hash}\t${name}`);
385
728
  try {
386
- return gitExecWithInput(['mktree'], filtered.join('\n') + '\n', this.cwd);
729
+ return gitExecWithInputAndRetry(['mktree'], this.cwd, filtered.join('\n') + '\n');
387
730
  }
388
- catch {
389
- throw new Error(`orphan backend: failed to create tree with entry ${name}`);
731
+ catch (err) {
732
+ const msg = err instanceof Error ? err.message : String(err);
733
+ throw new Error(`orphan backend: failed to create tree with entry ${name} — ${msg}`);
390
734
  }
391
735
  }
392
736
  }
@@ -492,16 +836,29 @@ export class StateBackendStorageAdapter {
492
836
  }
493
837
  /** Convert absolute path to relative path for the backend. */
494
838
  toRelative(filePath) {
495
- const normalized = filePath.replace(/\\/g, '/');
496
- const squadNorm = this.squadDir.replace(/\\/g, '/');
497
- if (normalized.startsWith(squadNorm + '/')) {
498
- return normalized.slice(squadNorm.length + 1);
839
+ // Use path.resolve() so drive-letter casing differences on Windows are
840
+ // normalised before comparison, preventing corrupt git-notes keys.
841
+ const resolvedFile = path.resolve(filePath);
842
+ const resolvedSquad = path.resolve(this.squadDir);
843
+ const isWindows = process.platform === 'win32';
844
+ const fileCmp = isWindows ? resolvedFile.toLowerCase() : resolvedFile;
845
+ const squadCmp = isWindows ? resolvedSquad.toLowerCase() : resolvedSquad;
846
+ const prefix = squadCmp.endsWith(path.sep) ? squadCmp : squadCmp + path.sep;
847
+ if (fileCmp.startsWith(prefix)) {
848
+ return resolvedFile.slice(resolvedSquad.length + 1).replace(/\\/g, '/');
499
849
  }
500
- if (normalized.startsWith(squadNorm)) {
501
- return normalized.slice(squadNorm.length).replace(/^\//, '') || '.';
850
+ if (fileCmp === squadCmp) {
851
+ return '.';
502
852
  }
503
- // Already relative
504
- return normalized;
853
+ // If the path is already relative (no drive letter or leading sep), normalise and return.
854
+ if (!path.isAbsolute(filePath)) {
855
+ return filePath.replace(/\\/g, '/');
856
+ }
857
+ // Absolute path that doesn't live under squadDir — this would produce a
858
+ // corrupt git-notes key (absolute path leaking into the ref namespace).
859
+ throw new Error(`[squad] toRelative: path is outside squadDir and cannot be used as a state key.\n` +
860
+ ` path: ${resolvedFile}\n` +
861
+ ` squadDir: ${resolvedSquad}`);
505
862
  }
506
863
  }
507
864
  /**
@@ -510,13 +867,19 @@ export class StateBackendStorageAdapter {
510
867
  * - Git notes for commit-scoped "why" annotations (per-agent namespace)
511
868
  * - Orphan branch for permanent state (decisions, histories, logs)
512
869
  *
513
- * Ralph promotes notes with promote_to_permanent after PR merge.
870
+ * The notes layer is a real, callable consumer in this backend: call
871
+ * {@link TwoLayerBackend.promoteNotes} after a PR merges to move notes flagged
872
+ * with `promote_to_permanent` into the orphan store, and copy notes flagged
873
+ * with `archive_on_close` into `archive/`. {@link TwoLayerBackend.readNote}
874
+ * returns a single note's payload.
514
875
  */
515
876
  export class TwoLayerBackend {
516
877
  name = 'two-layer';
517
878
  notes;
518
879
  orphan;
880
+ repoRoot;
519
881
  constructor(repoRoot) {
882
+ this.repoRoot = repoRoot;
520
883
  this.notes = new GitNotesBackend(repoRoot);
521
884
  this.orphan = new OrphanBranchBackend(repoRoot);
522
885
  }
@@ -530,7 +893,10 @@ export class TwoLayerBackend {
530
893
  try {
531
894
  this.notes.write(key, value);
532
895
  }
533
- catch { /* notes are best-effort */ }
896
+ catch (err) {
897
+ const msg = err instanceof Error ? err.message : String(err);
898
+ console.warn(`[two-layer] notes write failed for ${key}: ${msg}`);
899
+ }
534
900
  }
535
901
  list(dir) {
536
902
  return this.orphan.list(dir);
@@ -543,7 +909,10 @@ export class TwoLayerBackend {
543
909
  try {
544
910
  this.notes.delete(key);
545
911
  }
546
- catch { /* best-effort */ }
912
+ catch (err) {
913
+ const msg = err instanceof Error ? err.message : String(err);
914
+ console.warn(`[two-layer] notes delete failed for ${key}: ${msg}`);
915
+ }
547
916
  return result;
548
917
  }
549
918
  append(key, value) {
@@ -551,7 +920,123 @@ export class TwoLayerBackend {
551
920
  try {
552
921
  this.notes.append(key, value);
553
922
  }
554
- catch { /* best-effort */ }
923
+ catch (err) {
924
+ const msg = err instanceof Error ? err.message : String(err);
925
+ console.warn(`[two-layer] notes append failed for ${key}: ${msg}`);
926
+ }
927
+ }
928
+ /**
929
+ * Read a single git-notes payload as parsed JSON.
930
+ *
931
+ * Returns `null` if no note exists on the given commit for the given ref,
932
+ * or if the note body is not valid JSON.
933
+ */
934
+ readNote(ref, commitSha) {
935
+ if (!this.isSafeRef(ref) || !this.isSafeCommitSha(commitSha))
936
+ return null;
937
+ const raw = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'show', commitSha], this.repoRoot, false);
938
+ if (raw === null)
939
+ return null;
940
+ try {
941
+ return JSON.parse(raw);
942
+ }
943
+ catch {
944
+ return null;
945
+ }
946
+ }
947
+ /**
948
+ * Walk all notes attached to commits reachable from HEAD on the given ref
949
+ * and act based on their flags:
950
+ *
951
+ * - `promote_to_permanent: true` — write payload to the orphan layer under
952
+ * `promoted/<ref>/<sha>.json` and REMOVE the source note (the note has
953
+ * been promoted to permanent state and is no longer needed).
954
+ * - `archive_on_close: true` — copy payload to the orphan layer under
955
+ * `archive/<ref>/<sha>.json` and KEEP the source note (archive = copy).
956
+ * - Otherwise — leave the note alone (ephemeral, not worth promoting).
957
+ *
958
+ * Notes that fail to parse as JSON are counted as skipped.
959
+ */
960
+ promoteNotes(ref) {
961
+ const result = { promoted: [], archived: [], skipped: 0 };
962
+ if (!this.isSafeRef(ref)) {
963
+ throw new Error(`[two-layer] promoteNotes: unsafe ref '${ref}'`);
964
+ }
965
+ const listing = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'list'], this.repoRoot);
966
+ if (!listing)
967
+ return result;
968
+ // git notes list output: "<noteSha> <commitSha>" per line.
969
+ const noteCommitPairs = [];
970
+ for (const line of listing.split('\n')) {
971
+ const parts = line.trim().split(/\s+/);
972
+ if (parts.length < 2)
973
+ continue;
974
+ const commitSha = parts[1];
975
+ if (this.isSafeCommitSha(commitSha))
976
+ noteCommitPairs.push({ commitSha });
977
+ }
978
+ if (noteCommitPairs.length === 0)
979
+ return result;
980
+ // Reachability filter: only commits reachable from HEAD.
981
+ const reachableRaw = gitExecMaybeMissing(['rev-list', 'HEAD'], this.repoRoot);
982
+ if (!reachableRaw)
983
+ return result;
984
+ const reachable = new Set(reachableRaw.split('\n').map((s) => s.trim()).filter(Boolean));
985
+ const refKeySegment = this.sanitizeRefForKey(ref);
986
+ for (const { commitSha } of noteCommitPairs) {
987
+ if (!reachable.has(commitSha))
988
+ continue;
989
+ const raw = gitExecMaybeMissing(['notes', `--ref=${ref}`, 'show', commitSha], this.repoRoot, false);
990
+ if (raw === null)
991
+ continue;
992
+ let payload;
993
+ try {
994
+ payload = JSON.parse(raw);
995
+ }
996
+ catch {
997
+ result.skipped++;
998
+ continue;
999
+ }
1000
+ const flags = payload;
1001
+ const shouldPromote = flags?.promote_to_permanent === true;
1002
+ const shouldArchive = flags?.archive_on_close === true;
1003
+ if (!shouldPromote && !shouldArchive) {
1004
+ result.skipped++;
1005
+ continue;
1006
+ }
1007
+ // Stringify payload deterministically (2-space indent matches existing pattern).
1008
+ const body = JSON.stringify(payload, null, 2);
1009
+ if (shouldPromote) {
1010
+ const key = `promoted/${refKeySegment}/${commitSha}.json`;
1011
+ this.orphan.write(key, body);
1012
+ try {
1013
+ gitExecOrThrow(['notes', `--ref=${ref}`, 'remove', commitSha], this.repoRoot);
1014
+ }
1015
+ catch (err) {
1016
+ const msg = err instanceof Error ? err.message : String(err);
1017
+ console.warn(`[two-layer] promoteNotes: removed-source failed for ${commitSha} on ${ref}: ${msg}`);
1018
+ }
1019
+ result.promoted.push(key);
1020
+ }
1021
+ if (shouldArchive) {
1022
+ const key = `archive/${refKeySegment}/${commitSha}.json`;
1023
+ this.orphan.write(key, body);
1024
+ result.archived.push(key);
1025
+ }
1026
+ }
1027
+ return result;
1028
+ }
1029
+ /** True for refs that look like `squad/<name>` — alphanumerics, dash, underscore, slash. */
1030
+ isSafeRef(ref) {
1031
+ return /^[A-Za-z0-9_\-./]+$/.test(ref) && !ref.includes('..');
1032
+ }
1033
+ /** True for SHA-1 hex (40 chars) or SHA-256 hex (64 chars). */
1034
+ isSafeCommitSha(sha) {
1035
+ return /^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(sha);
1036
+ }
1037
+ /** Pass the ref through as path segments; normalizeKey will validate each. */
1038
+ sanitizeRefForKey(ref) {
1039
+ return ref.split('/').filter(Boolean).join('/');
555
1040
  }
556
1041
  }
557
1042
  export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
@@ -566,7 +1051,10 @@ export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
566
1051
  }
567
1052
  }
568
1053
  }
569
- catch { /* fall through */ }
1054
+ catch (err) {
1055
+ const msg = err instanceof Error ? err.message : String(err);
1056
+ console.warn(`⚠️ Failed to read state backend config from ${path.join(squadDir, 'config.json')}: ${msg}`);
1057
+ }
570
1058
  const explicitBackend = cliOverride !== undefined || configBackend !== undefined;
571
1059
  const chosen = normalizeBackendType(cliOverride ?? configBackend ?? 'local');
572
1060
  try {
@@ -574,23 +1062,62 @@ export function resolveStateBackend(squadDir, repoRoot, cliOverride) {
574
1062
  }
575
1063
  catch (err) {
576
1064
  const msg = err instanceof Error ? err.message : String(err);
577
- if (explicitBackend && chosen !== 'local') {
578
- throw new Error(`State backend '${chosen}' failed: ${msg}`);
579
- }
580
- console.warn(`Warning: State backend '${chosen}' failed: ${msg}. Falling back to 'local'.`);
1065
+ // Always fall back to local with a warning — a broken backend should not
1066
+ // prevent Squad from starting. Operators can fix config without losing work.
1067
+ console.warn(`Warning: State backend '${chosen}' failed${explicitBackend ? ' (explicit)' : ''}: ${msg}. Falling back to 'local'.`);
581
1068
  return new WorktreeBackend(squadDir);
582
1069
  }
583
1070
  }
1071
+ /**
1072
+ * Read-only health check for a state backend.
1073
+ * Verifies the backend is accessible without mutating state.
1074
+ *
1075
+ * For {@link TwoLayerBackend}, both layers are probed independently — the
1076
+ * notes layer can fail (corrupt notes ref, missing commits) even when the
1077
+ * orphan layer is healthy, and we surface that explicitly.
1078
+ */
1079
+ export function verifyStateBackend(backend) {
1080
+ try {
1081
+ backend.list('');
1082
+ }
1083
+ catch (err) {
1084
+ const msg = err instanceof Error ? err.message : String(err);
1085
+ return { ok: false, error: `Backend '${backend.name}' verification failed: ${msg}` };
1086
+ }
1087
+ if (backend instanceof TwoLayerBackend) {
1088
+ try {
1089
+ backend.notes.list('');
1090
+ }
1091
+ catch (err) {
1092
+ const msg = err instanceof Error ? err.message : String(err);
1093
+ return { ok: false, error: `Backend '${backend.name}' notes layer unhealthy: ${msg}` };
1094
+ }
1095
+ }
1096
+ return { ok: true };
1097
+ }
584
1098
  function isValidBackendType(value) {
585
1099
  return ['local', 'worktree', 'external', 'git-notes', 'orphan', 'two-layer'].includes(value);
586
1100
  }
587
1101
  // Note: 'worktree' and 'git-notes' are accepted for backward compatibility but normalized away
1102
+ // One-shot flag: warn once per process so repeated resolveStateBackend() calls
1103
+ // (e.g. multiple agent startups in the same process) don't spam the console.
1104
+ let _warnedGitNotesMigration = false;
588
1105
  /** Normalize legacy aliases to canonical backend type names. */
589
1106
  function normalizeBackendType(type) {
590
1107
  if (type === 'worktree')
591
1108
  return 'local';
592
- if (type === 'git-notes')
593
- return 'two-layer'; // standalone git-notes removed; migrate to two-layer
1109
+ if (type === 'git-notes') {
1110
+ if (!_warnedGitNotesMigration) {
1111
+ _warnedGitNotesMigration = true;
1112
+ console.warn("[squad] State backend 'git-notes' is deprecated and has been removed. " +
1113
+ "Your config is being silently migrated to 'two-layer', which creates a " +
1114
+ "'squad-state' orphan branch in your repository. " +
1115
+ "To suppress this warning, update .squad/config.json: " +
1116
+ "set \"stateBackend\": \"two-layer\". " +
1117
+ "See https://github.com/bradygaster/squad/blob/dev/docs/state-backends.md for upgrade instructions.");
1118
+ }
1119
+ return 'two-layer';
1120
+ }
594
1121
  return type;
595
1122
  }
596
1123
  function createBackend(type, squadDir, repoRoot) {
@@ -602,11 +1129,18 @@ function createBackend(type, squadDir, repoRoot) {
602
1129
  case 'two-layer':
603
1130
  requireGitRepository(repoRoot);
604
1131
  return new TwoLayerBackend(repoRoot);
605
- case 'external': return new WorktreeBackend(squadDir); // Stub — PR #797
1132
+ case 'external': {
1133
+ console.warn(`⚠️ State backend 'external' is a stub (PR #797). Using 'local' backend.`);
1134
+ return new WorktreeBackend(squadDir);
1135
+ }
606
1136
  default: throw new Error(`Unknown state backend type: ${type}`);
607
1137
  }
608
1138
  }
609
1139
  function requireGitRepository(repoRoot) {
610
1140
  gitExecOrThrow(['rev-parse', '--git-dir'], repoRoot);
611
1141
  }
1142
+ /** @internal Reset the one-shot git-notes migration warn flag. Only for use in tests. */
1143
+ export function _resetGitNotesMigrationWarnForTesting() {
1144
+ _warnedGitNotesMigration = false;
1145
+ }
612
1146
  //# sourceMappingURL=state-backend.js.map