@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.
- package/dist/cli.bundle.cjs +60 -60
- package/dist/core/gist-state-store.d.ts +65 -46
- package/dist/core/gist-state-store.js +122 -108
- package/dist/core/state.js +9 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
38
|
-
* `
|
|
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"
|
|
42
|
-
*
|
|
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
|
|
144
|
-
* exposes a single ETag for the whole gist
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* `fetchAndCache` and successful `push()`; not mutated
|
|
149
|
-
* `setDocument` so the baseline reflects the remote, not
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* the
|
|
157
|
-
* (
|
|
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
|
-
* -
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
229
|
-
* Throws {@link GistConcurrencyError}
|
|
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
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* the
|
|
37
|
-
*
|
|
38
|
-
* `
|
|
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"
|
|
42
|
-
*
|
|
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
|
|
95
|
-
* exposes a single ETag for the whole gist
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `fetchAndCache` and successful `push()`; not mutated
|
|
100
|
-
* `setDocument` so the baseline reflects the remote, not
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* the
|
|
108
|
-
* (
|
|
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
|
-
* -
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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
|
|
381
|
-
* Throws {@link GistConcurrencyError}
|
|
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
|
-
/**
|
|
402
|
-
|
|
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
|
-
// ──
|
|
424
|
-
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
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 (
|
|
453
|
-
warn(MODULE, `
|
|
454
|
-
// Roll back any partial mutation
|
|
455
|
-
//
|
|
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
|
|
485
|
+
for (const [filename, content] of preReadCache) {
|
|
459
486
|
this.cachedFiles.set(filename, content);
|
|
460
487
|
}
|
|
461
|
-
this.baselineFiles =
|
|
462
|
-
this.lastFetchedEtag =
|
|
488
|
+
this.baselineFiles = preReadBaseline;
|
|
489
|
+
this.lastFetchedEtag = preReadEtag;
|
|
463
490
|
reapplyStaged();
|
|
464
|
-
// Preserve
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
//
|
|
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
|
-
// ──
|
|
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(
|
|
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
|
-
//
|
|
508
|
-
//
|
|
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);
|
package/dist/core/state.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|