@oss-autopilot/core 3.14.2 → 3.14.3

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.
@@ -14,35 +14,36 @@
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
16
  *
17
- * Concurrency model (#1115, #1235):
17
+ * Concurrency model (#1115, #1235, #1510):
18
18
  *
19
- * Each fetch captures the response's `ETag` and stores it as
20
- * `lastFetchedEtag`. The next `push()` sends that ETag back as
21
- * `If-Match` so the GitHub Gist API rejects the write with 412 Precondition
22
- * Failed if another machine pushed in the interval.
19
+ * Optimistic concurrency was originally enforced by replaying the fetched
20
+ * `ETag` as an `If-Match` header on `push()`, so the GitHub Gist API would
21
+ * reject a stale write with 412 Precondition Failed. That never worked
22
+ * against real GitHub: the Gist `PATCH` endpoint rejects conditional request
23
+ * headers on unsafe methods with HTTP 400 ("Conditional request headers are
24
+ * not allowed in unsafe requests unless supported by the endpoint"), which
25
+ * made every Gist write fail (#1510). The `If-Match` header is no longer
26
+ * sent.
23
27
  *
24
- * On 412, the store attempts a single merge: it re-fetches the Gist
25
- * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
- * the now-current remote state, and pushes once more. If that second push
27
- * also returns 412, `push()` throws {@link GistConcurrencyError}.
28
+ * Concurrency is now enforced client-side with a read-before-write check.
29
+ * Each fetch snapshots every file's content as a per-file baseline (the Gist
30
+ * API only exposes one ETag for the whole gist, so we cannot detect a
31
+ * per-file conflict from headers alone). On `push()`:
28
32
  *
29
- * Per-file conflict policy on the merge re-apply step:
30
- *
31
- * - `state.json` is fail-loud. The `StateManager` contract advertises
32
- * optimistic compare-and-swap (state.ts:285-296), so silently clobbering
33
- * a concurrent remote write would violate it. To detect concurrent
34
- * writes without per-file ETags (the Gist API only exposes one ETag for
35
- * the whole gist), we snapshot each file's content at fetch time. If
36
- * the post-refresh `state.json` differs from the snapshot we held
37
- * before the refresh, another machine wrote it: `push()` throws
38
- * `GistConcurrencyError` instead of overwriting.
33
+ * - `state.json` is fail-loud. When it is dirty, `push()` re-reads the Gist
34
+ * first and compares the remote `state.json` against the baseline from
35
+ * our last fetch. If it moved under us, another machine wrote it:
36
+ * `push()` throws {@link GistConcurrencyError} instead of overwriting,
37
+ * honoring the `StateManager` optimistic compare-and-swap contract
38
+ * (state.ts). The local mutation stays in memory so the caller can
39
+ * `refreshFromGist()` and reapply. A small TOCTOU window remains between
40
+ * the re-read and the write (the Gist API offers no atomic conditional
41
+ * write), but it is far narrower than the last-fetch-to-push window the
42
+ * dropped `If-Match` covered.
39
43
  * - Freeform documents (e.g. per-repo guidelines) keep last-write-wins.
40
44
  * The `setDocument` / `setGuidelines` callers' intent on a manual write
41
- * is "overwrite". Local dirty content is reapplied on top of the
42
- * refreshed remote.
43
- *
44
- * In both cases, when the second push also 412s, local mutations stay in
45
- * memory so the caller can decide whether to refresh and retry.
45
+ * is "overwrite", and the Gist `files` parameter is a partial update, so
46
+ * a freeform-only push needs no re-read and cannot clobber another file.
46
47
  */
47
48
  import { AgentState } from './types.js';
48
49
  /** Well-known Gist description used for search-based discovery. */
@@ -140,21 +141,26 @@ export declare class GistStateStore {
140
141
  readonly cachedFiles: Map<string, string>;
141
142
  readonly dirtyFiles: Set<string>;
142
143
  /**
143
- * Per-file content snapshot captured at fetch time (#1235). The Gist API
144
- * exposes a single ETag for the whole gist, so we cannot use `If-Match`
145
- * to detect a per-file conflict on the 412 merge re-apply. Holding the
146
- * baseline content lets us compare it against the freshly-fetched remote
147
- * and decide whether `state.json` changed under us. Updated only by
148
- * `fetchAndCache` and successful `push()`; not mutated by `setState` /
149
- * `setDocument` so the baseline reflects the remote, not local edits.
144
+ * Per-file content snapshot captured at fetch time (#1235, #1510). The Gist
145
+ * API exposes a single ETag for the whole gist and rejects `If-Match` on
146
+ * writes, so we detect a per-file conflict by content comparison instead:
147
+ * the read-before-write check in `push()` compares this baseline against
148
+ * the freshly re-read remote to decide whether `state.json` changed under
149
+ * us. Updated only by `fetchAndCache` and successful `push()`; not mutated
150
+ * by `setState` / `setDocument` so the baseline reflects the remote, not
151
+ * local edits.
150
152
  */
151
153
  private baselineFiles;
152
154
  /**
153
155
  * ETag captured from the most recent successful Gist fetch (#1115).
154
- * Sent back as `If-Match` on `update` so concurrent writes from another
155
- * machine surface as 412 Precondition Failed instead of silently winning
156
- * the last-write-wins race. `null` when the SDK didn't surface the header
157
- * (e.g. test mocks without headers) in that case we skip the check.
156
+ *
157
+ * Historically replayed as `If-Match` on `update` to detect concurrent
158
+ * writes, but the Gist `PATCH` endpoint rejects conditional headers with
159
+ * HTTP 400 (#1510), so it is no longer sent on writes concurrency is now
160
+ * detected by the read-before-write content comparison in `push()`. The
161
+ * ETag is still captured on reads (and on a successful write response) and
162
+ * carried on {@link GistConcurrencyError} for diagnostics / rollback.
163
+ * `null` when the SDK didn't surface the header (e.g. test mocks).
158
164
  */
159
165
  private lastFetchedEtag;
160
166
  private readonly octokit;
@@ -214,19 +220,32 @@ export declare class GistStateStore {
214
220
  * Push all dirty files to the backing Gist.
215
221
  *
216
222
  * Behavior:
217
- * - When an ETag is available from the previous fetch, sends `If-Match`
218
- * so a 412 surfaces if another machine pushed since we last fetched.
219
- * - On 412, attempts a single merge: re-fetches the Gist (refreshing the
220
- * ETag), re-applies the in-memory dirty content on top of the remote,
221
- * and pushes once more. If the second push also 412s, throws
222
- * {@link GistConcurrencyError} the caller can decide whether to
223
- * refreshFromGist + retry.
224
- * - On any other error, retries once (existing behavior). If the retry
225
- * also fails, returns `false`.
223
+ * - Writes are sent WITHOUT a conditional `If-Match` header (#1510). The
224
+ * Gist `PATCH` endpoint rejects conditional headers on unsafe methods
225
+ * with HTTP 400, so the server cannot enforce optimistic concurrency.
226
+ * - Optimistic concurrency is instead enforced client-side: when
227
+ * `state.json` is among the dirty files, `push()` re-reads the Gist
228
+ * first and compares the remote `state.json` against the baseline
229
+ * captured at our last fetch. If it moved under us, another machine
230
+ * wrote concurrently and `push()` throws {@link GistConcurrencyError}
231
+ * instead of clobbering — preserving the StateManager compare-and-swap
232
+ * contract (#1235). The local mutation stays in memory so the caller
233
+ * can `refreshFromGist()` and reapply. There is a small unavoidable
234
+ * TOCTOU window between the re-read and the write (the Gist API offers
235
+ * no atomic conditional write), but it is far narrower than the
236
+ * last-fetch-to-push window the dropped `If-Match` covered.
237
+ * - Freeform documents (guidelines, etc.) keep last-write-wins: the Gist
238
+ * `files` parameter is a partial update, so a freeform-only push needs
239
+ * no re-read and cannot clobber another file.
240
+ * - On any push error, retries once. If the retry also fails, returns
241
+ * `false`.
226
242
  *
227
243
  * Returns `true` on success (or when there is nothing to push).
228
- * Returns `false` if a non-conflict push fails on both attempts.
229
- * Throws {@link GistConcurrencyError} on unresolvable conflicts (#1115).
244
+ * Returns `false` if a push fails on both attempts.
245
+ * Throws {@link GistConcurrencyError} when `state.json` moved remotely
246
+ * since our last fetch (#1235, #1510).
247
+ * Throws {@link GistCorruptError} when the pre-write re-read finds a
248
+ * corrupt remote (so the caller does not retry against it).
230
249
  * Throws if the Gist ID has not been resolved yet (bootstrap not called).
231
250
  */
232
251
  push(): Promise<boolean>;
@@ -14,35 +14,36 @@
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
16
  *
17
- * Concurrency model (#1115, #1235):
17
+ * Concurrency model (#1115, #1235, #1510):
18
18
  *
19
- * Each fetch captures the response's `ETag` and stores it as
20
- * `lastFetchedEtag`. The next `push()` sends that ETag back as
21
- * `If-Match` so the GitHub Gist API rejects the write with 412 Precondition
22
- * Failed if another machine pushed in the interval.
19
+ * Optimistic concurrency was originally enforced by replaying the fetched
20
+ * `ETag` as an `If-Match` header on `push()`, so the GitHub Gist API would
21
+ * reject a stale write with 412 Precondition Failed. That never worked
22
+ * against real GitHub: the Gist `PATCH` endpoint rejects conditional request
23
+ * headers on unsafe methods with HTTP 400 ("Conditional request headers are
24
+ * not allowed in unsafe requests unless supported by the endpoint"), which
25
+ * made every Gist write fail (#1510). The `If-Match` header is no longer
26
+ * sent.
23
27
  *
24
- * On 412, the store attempts a single merge: it re-fetches the Gist
25
- * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
- * the now-current remote state, and pushes once more. If that second push
27
- * also returns 412, `push()` throws {@link GistConcurrencyError}.
28
+ * Concurrency is now enforced client-side with a read-before-write check.
29
+ * Each fetch snapshots every file's content as a per-file baseline (the Gist
30
+ * API only exposes one ETag for the whole gist, so we cannot detect a
31
+ * per-file conflict from headers alone). On `push()`:
28
32
  *
29
- * Per-file conflict policy on the merge re-apply step:
30
- *
31
- * - `state.json` is fail-loud. The `StateManager` contract advertises
32
- * optimistic compare-and-swap (state.ts:285-296), so silently clobbering
33
- * a concurrent remote write would violate it. To detect concurrent
34
- * writes without per-file ETags (the Gist API only exposes one ETag for
35
- * the whole gist), we snapshot each file's content at fetch time. If
36
- * the post-refresh `state.json` differs from the snapshot we held
37
- * before the refresh, another machine wrote it: `push()` throws
38
- * `GistConcurrencyError` instead of overwriting.
33
+ * - `state.json` is fail-loud. When it is dirty, `push()` re-reads the Gist
34
+ * first and compares the remote `state.json` against the baseline from
35
+ * our last fetch. If it moved under us, another machine wrote it:
36
+ * `push()` throws {@link GistConcurrencyError} instead of overwriting,
37
+ * honoring the `StateManager` optimistic compare-and-swap contract
38
+ * (state.ts). The local mutation stays in memory so the caller can
39
+ * `refreshFromGist()` and reapply. A small TOCTOU window remains between
40
+ * the re-read and the write (the Gist API offers no atomic conditional
41
+ * write), but it is far narrower than the last-fetch-to-push window the
42
+ * dropped `If-Match` covered.
39
43
  * - Freeform documents (e.g. per-repo guidelines) keep last-write-wins.
40
44
  * The `setDocument` / `setGuidelines` callers' intent on a manual write
41
- * is "overwrite". Local dirty content is reapplied on top of the
42
- * refreshed remote.
43
- *
44
- * In both cases, when the second push also 412s, local mutations stay in
45
- * memory so the caller can decide whether to refresh and retry.
45
+ * is "overwrite", and the Gist `files` parameter is a partial update, so
46
+ * a freeform-only push needs no re-read and cannot clobber another file.
46
47
  */
47
48
  import * as fs from 'node:fs';
48
49
  import { AgentStateSchema } from './state-schema.js';
@@ -91,21 +92,26 @@ export class GistStateStore {
91
92
  cachedFiles = new Map();
92
93
  dirtyFiles = new Set();
93
94
  /**
94
- * Per-file content snapshot captured at fetch time (#1235). The Gist API
95
- * exposes a single ETag for the whole gist, so we cannot use `If-Match`
96
- * to detect a per-file conflict on the 412 merge re-apply. Holding the
97
- * baseline content lets us compare it against the freshly-fetched remote
98
- * and decide whether `state.json` changed under us. Updated only by
99
- * `fetchAndCache` and successful `push()`; not mutated by `setState` /
100
- * `setDocument` so the baseline reflects the remote, not local edits.
95
+ * Per-file content snapshot captured at fetch time (#1235, #1510). The Gist
96
+ * API exposes a single ETag for the whole gist and rejects `If-Match` on
97
+ * writes, so we detect a per-file conflict by content comparison instead:
98
+ * the read-before-write check in `push()` compares this baseline against
99
+ * the freshly re-read remote to decide whether `state.json` changed under
100
+ * us. Updated only by `fetchAndCache` and successful `push()`; not mutated
101
+ * by `setState` / `setDocument` so the baseline reflects the remote, not
102
+ * local edits.
101
103
  */
102
104
  baselineFiles = new Map();
103
105
  /**
104
106
  * ETag captured from the most recent successful Gist fetch (#1115).
105
- * Sent back as `If-Match` on `update` so concurrent writes from another
106
- * machine surface as 412 Precondition Failed instead of silently winning
107
- * the last-write-wins race. `null` when the SDK didn't surface the header
108
- * (e.g. test mocks without headers) in that case we skip the check.
107
+ *
108
+ * Historically replayed as `If-Match` on `update` to detect concurrent
109
+ * writes, but the Gist `PATCH` endpoint rejects conditional headers with
110
+ * HTTP 400 (#1510), so it is no longer sent on writes concurrency is now
111
+ * detected by the read-before-write content comparison in `push()`. The
112
+ * ETag is still captured on reads (and on a successful write response) and
113
+ * carried on {@link GistConcurrencyError} for diagnostics / rollback.
114
+ * `null` when the SDK didn't surface the header (e.g. test mocks).
109
115
  */
110
116
  lastFetchedEtag = null;
111
117
  octokit;
@@ -366,19 +372,32 @@ export class GistStateStore {
366
372
  * Push all dirty files to the backing Gist.
367
373
  *
368
374
  * Behavior:
369
- * - When an ETag is available from the previous fetch, sends `If-Match`
370
- * so a 412 surfaces if another machine pushed since we last fetched.
371
- * - On 412, attempts a single merge: re-fetches the Gist (refreshing the
372
- * ETag), re-applies the in-memory dirty content on top of the remote,
373
- * and pushes once more. If the second push also 412s, throws
374
- * {@link GistConcurrencyError} the caller can decide whether to
375
- * refreshFromGist + retry.
376
- * - On any other error, retries once (existing behavior). If the retry
377
- * also fails, returns `false`.
375
+ * - Writes are sent WITHOUT a conditional `If-Match` header (#1510). The
376
+ * Gist `PATCH` endpoint rejects conditional headers on unsafe methods
377
+ * with HTTP 400, so the server cannot enforce optimistic concurrency.
378
+ * - Optimistic concurrency is instead enforced client-side: when
379
+ * `state.json` is among the dirty files, `push()` re-reads the Gist
380
+ * first and compares the remote `state.json` against the baseline
381
+ * captured at our last fetch. If it moved under us, another machine
382
+ * wrote concurrently and `push()` throws {@link GistConcurrencyError}
383
+ * instead of clobbering — preserving the StateManager compare-and-swap
384
+ * contract (#1235). The local mutation stays in memory so the caller
385
+ * can `refreshFromGist()` and reapply. There is a small unavoidable
386
+ * TOCTOU window between the re-read and the write (the Gist API offers
387
+ * no atomic conditional write), but it is far narrower than the
388
+ * last-fetch-to-push window the dropped `If-Match` covered.
389
+ * - Freeform documents (guidelines, etc.) keep last-write-wins: the Gist
390
+ * `files` parameter is a partial update, so a freeform-only push needs
391
+ * no re-read and cannot clobber another file.
392
+ * - On any push error, retries once. If the retry also fails, returns
393
+ * `false`.
378
394
  *
379
395
  * Returns `true` on success (or when there is nothing to push).
380
- * Returns `false` if a non-conflict push fails on both attempts.
381
- * Throws {@link GistConcurrencyError} on unresolvable conflicts (#1115).
396
+ * Returns `false` if a push fails on both attempts.
397
+ * Throws {@link GistConcurrencyError} when `state.json` moved remotely
398
+ * since our last fetch (#1235, #1510).
399
+ * Throws {@link GistCorruptError} when the pre-write re-read finds a
400
+ * corrupt remote (so the caller does not retry against it).
382
401
  * Throws if the Gist ID has not been resolved yet (bootstrap not called).
383
402
  */
384
403
  async push() {
@@ -398,16 +417,22 @@ export class GistStateStore {
398
417
  }
399
418
  return out;
400
419
  };
401
- /** Attempt a single push, capturing the response ETag for the next push. */
402
- const attempt = async (etag) => {
420
+ /**
421
+ * Attempt a single push, capturing the response ETag for the next push.
422
+ *
423
+ * Deliberately does NOT send an `If-Match` conditional header (#1510).
424
+ * GitHub's REST API rejects conditional request headers on unsafe methods
425
+ * (`PATCH /gists/{id}` among them) with HTTP 400 ("Conditional request
426
+ * headers are not allowed in unsafe requests unless supported by the
427
+ * endpoint"), which made every Gist write fail. Reads still capture the
428
+ * ETag, but it is no longer replayed on the write.
429
+ */
430
+ const attempt = async () => {
403
431
  try {
404
432
  const params = {
405
433
  gist_id: this.gistId,
406
434
  files: buildFiles(),
407
435
  };
408
- if (etag) {
409
- params.headers = { 'if-match': etag };
410
- }
411
436
  const response = await this.octokit.gists.update(params);
412
437
  // Refresh our cached ETag so the next push uses the post-update value.
413
438
  const newEtag = extractEtag(response.headers);
@@ -420,27 +445,30 @@ export class GistStateStore {
420
445
  return { ok: false, status, err };
421
446
  }
422
447
  };
423
- // ── Phase 1: best-shot push with current ETag ─────────────────────
424
- let result = await attempt(this.lastFetchedEtag);
425
- // ── Phase 2: ETag mismatch try to merge once ────────────────────
426
- if (!result.ok && result.status === 412) {
427
- const expectedEtag = this.lastFetchedEtag;
428
- debug(MODULE, `push hit 412 (ETag mismatch); attempting merge refreshing and retrying once`);
429
- // Snapshot the dirty contents before refresh (fetchAndCache wipes the cache).
448
+ // ── Optimistic-concurrency check (#1510) ──────────────────────────
449
+ // The Gist PATCH endpoint rejects conditional `If-Match` headers, so we
450
+ // enforce optimistic concurrency client-side: re-read the Gist and
451
+ // compare its `state.json` against the baseline captured at our last
452
+ // fetch. If it changed under us, another machine wrote concurrently —
453
+ // fail loud rather than clobber (#1235). We only pay the round-trip when
454
+ // `state.json` is dirty; freeform documents (guidelines, etc.) keep
455
+ // last-write-wins because the Gist `files` parameter is a partial update.
456
+ if (this.dirtyFiles.has(STATE_FILE_NAME)) {
457
+ // Snapshot the staged dirty contents before the re-read — fetchAndCache
458
+ // clears and repopulates the cache from the remote.
430
459
  const stagedContents = {};
431
460
  for (const filename of this.dirtyFiles) {
432
461
  const content = this.cachedFiles.get(filename);
433
462
  if (content !== undefined)
434
463
  stagedContents[filename] = content;
435
464
  }
436
- // Snapshot the full cache and baseline before refresh so a partial
437
- // failure inside fetchAndCache (e.g. parse throws after the cache
438
- // was already cleared and repopulated) can be rolled back without
439
- // leaving the store with a stale ETag pointing at corrupt or
440
- // half-populated state (#1235).
441
- const preRefreshCache = new Map(this.cachedFiles);
442
- const preRefreshBaseline = new Map(this.baselineFiles);
443
- const preRefreshEtag = this.lastFetchedEtag;
465
+ // Snapshot the full cache, baseline, and ETag so a failed re-read
466
+ // (e.g. a parse throwing after the cache was already cleared) can be
467
+ // rolled back without leaving the store half-populated (#1235).
468
+ const preReadCache = new Map(this.cachedFiles);
469
+ const preReadBaseline = new Map(this.baselineFiles);
470
+ const preReadEtag = this.lastFetchedEtag;
471
+ const baselineStateJson = preReadBaseline.get(STATE_FILE_NAME);
444
472
  const reapplyStaged = () => {
445
473
  for (const [filename, content] of Object.entries(stagedContents)) {
446
474
  this.cachedFiles.set(filename, content);
@@ -449,63 +477,49 @@ export class GistStateStore {
449
477
  try {
450
478
  await this.fetchAndCache(this.gistId);
451
479
  }
452
- catch (refreshErr) {
453
- warn(MODULE, `Merge refresh after 412 failed: ${refreshErr}`);
454
- // Roll back any partial mutation from fetchAndCache so the
455
- // caller's retry path sees the prior state (with local
456
- // mutations re-applied on top).
480
+ catch (readErr) {
481
+ warn(MODULE, `Pre-write re-read failed: ${readErr}`);
482
+ // Roll back any partial mutation so the caller's retry path sees the
483
+ // prior in-memory state with local mutations re-applied on top.
457
484
  this.cachedFiles.clear();
458
- for (const [filename, content] of preRefreshCache) {
485
+ for (const [filename, content] of preReadCache) {
459
486
  this.cachedFiles.set(filename, content);
460
487
  }
461
- this.baselineFiles = preRefreshBaseline;
462
- this.lastFetchedEtag = preRefreshEtag;
488
+ this.baselineFiles = preReadBaseline;
489
+ this.lastFetchedEtag = preReadEtag;
463
490
  reapplyStaged();
464
- // Preserve the GistCorruptError type so callers don't retry
465
- // against a corrupt remote (which a CONCURRENCY_ERROR would
466
- // invite). Other failure types continue to surface as a
467
- // concurrency error — the gist content is still likely fine.
468
- if (refreshErr instanceof GistCorruptError)
469
- throw refreshErr;
470
- throw new GistConcurrencyError(expectedEtag, null);
491
+ // Preserve GistCorruptError so callers don't retry against a corrupt
492
+ // remote. Any other failure surfaces as a concurrency error — the
493
+ // gist content is still likely fine, the caller can refresh + retry.
494
+ if (readErr instanceof GistCorruptError)
495
+ throw readErr;
496
+ throw new GistConcurrencyError(preReadEtag, null);
471
497
  }
472
- // Per-file conflict policy (#1235):
473
- // - state.json must fail loud when its remote copy moved under us;
474
- // silently overwriting would violate the StateManager
475
- // optimistic-concurrency contract (state.ts:285-296).
476
- // - Freeform documents (guidelines, etc.) keep last-write-wins
477
- // `setDocument` callers' intent on a manual write is "overwrite".
478
- if (stagedContents[STATE_FILE_NAME] !== undefined) {
479
- const baselineBeforeRefresh = preRefreshBaseline.get(STATE_FILE_NAME);
480
- const remoteAfterRefresh = this.cachedFiles.get(STATE_FILE_NAME);
481
- if (baselineBeforeRefresh !== remoteAfterRefresh) {
482
- warn(MODULE, 'state.json changed remotely while a push was in flight; surfacing GistConcurrencyError instead of clobbering (#1235)');
483
- // Preserve local state.json (and other staged dirty files) in
484
- // memory so the caller can refresh and reapply if appropriate.
485
- reapplyStaged();
486
- throw new GistConcurrencyError(expectedEtag, this.lastFetchedEtag);
487
- }
498
+ const remoteStateJson = this.cachedFiles.get(STATE_FILE_NAME);
499
+ if (baselineStateJson !== remoteStateJson) {
500
+ warn(MODULE, 'state.json changed remotely since our last fetch; surfacing GistConcurrencyError instead of clobbering (#1235, #1510)');
501
+ // Preserve local state.json (and other staged dirty files) in memory
502
+ // so the caller can refresh and reapply if appropriate.
503
+ reapplyStaged();
504
+ throw new GistConcurrencyError(preReadEtag, this.lastFetchedEtag);
488
505
  }
489
- // Re-apply our staged dirty contents on top of the refreshed remote.
506
+ // No conflict — re-apply our staged dirty contents on top of the
507
+ // refreshed remote so the write carries our edits.
490
508
  reapplyStaged();
491
- result = await attempt(this.lastFetchedEtag);
492
- if (!result.ok && result.status === 412) {
493
- warn(MODULE, 'Second push after merge also returned 412; surfacing GistConcurrencyError');
494
- throw new GistConcurrencyError(expectedEtag, this.lastFetchedEtag);
495
- }
496
509
  }
497
- // ── Phase 3: any other failure retry once (existing behavior) ───
510
+ // ── Write (unconditional) with a single retry on transient failure
511
+ let result = await attempt();
498
512
  if (!result.ok) {
499
513
  debug(MODULE, `push failed on first attempt, retrying: ${result.err}`);
500
- result = await attempt(this.lastFetchedEtag);
514
+ result = await attempt();
501
515
  if (!result.ok) {
502
516
  warn(MODULE, `push failed after retry, giving up: ${result.err}`);
503
517
  return false;
504
518
  }
505
519
  }
506
- // Success: flush dirty set and write local cache. Refresh the
507
- // per-file baseline to reflect what we just wrote — the next 412
508
- // merge re-apply check compares against this.
520
+ // Success: flush dirty set and write local cache. Refresh the per-file
521
+ // baseline to reflect what we just wrote — the next pre-write conflict
522
+ // check compares against this.
509
523
  this.dirtyFiles.clear();
510
524
  this.baselineFiles = new Map(this.cachedFiles);
511
525
  const raw = this.cachedFiles.get(STATE_FILE_NAME);
@@ -312,7 +312,15 @@ export class StateManager {
312
312
  if (!this.gistStore)
313
313
  return true; // not in Gist mode
314
314
  this.gistStore.setState(JSON.stringify(this.state, null, 2));
315
- return this.gistStore.push();
315
+ const pushed = await this.gistStore.push();
316
+ // A push that fails after its internal retry means the Gist is no longer
317
+ // the authoritative copy of our in-memory state — surface that as degraded
318
+ // so `state --show`, the daily check, and doctor stop reporting healthy
319
+ // (#1510). A later successful refresh/checkpoint can clear it.
320
+ if (!pushed) {
321
+ this.gistDegraded = true;
322
+ }
323
+ return pushed;
316
324
  }
317
325
  /** Whether this StateManager is backed by a Gist. */
318
326
  isGistMode() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "3.14.2",
3
+ "version": "3.14.3",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {