@oss-autopilot/core 3.13.1 → 3.13.2

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.
@@ -283,7 +283,10 @@ export async function fetchDashboardData(token) {
283
283
  // Partitioned over overriddenPRs (#1416): the shelve decision must use
284
284
  // the same post-override status the CLI partitions on.
285
285
  const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
286
- const freshShelved = overriddenPRs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
286
+ // Single copy of the shelve predicate (#1421): isShelvedForDisplay is
287
+ // the same rule reconcileShelvePartition applies on rebuilds, so its
288
+ // regression tests now guard this line too.
289
+ const freshShelved = overriddenPRs.filter((pr) => isShelvedForDisplay(pr, shelvedUrls));
287
290
  digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
288
291
  digest.autoUnshelvedPRs = [];
289
292
  digest.summary.totalActivePRs = overriddenPRs.length - freshShelved.length;
@@ -184,10 +184,20 @@ export async function runListMoveTier(options) {
184
184
  'Add the entry to the list first, then re-run list-move-tier.');
185
185
  }
186
186
  if (result.moved) {
187
+ // tmp+rename so a crash mid-write can't truncate the curated list —
188
+ // same atomic pattern as list-mark-done (#1421).
189
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
187
190
  try {
188
- fs.writeFileSync(filePath, result.content, 'utf8');
191
+ fs.writeFileSync(tmp, result.content, 'utf8');
192
+ fs.renameSync(tmp, filePath);
189
193
  }
190
194
  catch (error) {
195
+ try {
196
+ fs.unlinkSync(tmp);
197
+ }
198
+ catch {
199
+ // best-effort cleanup
200
+ }
191
201
  throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });
192
202
  }
193
203
  }
package/dist/core/auth.js CHANGED
@@ -13,6 +13,22 @@ const MODULE = 'auth';
13
13
  // Cached GitHub token (fetched once per session)
14
14
  let cachedGitHubToken = null;
15
15
  let tokenFetchAttempted = false;
16
+ /**
17
+ * A `gh auth token` spawn that timed out is transient (#1415): the CLI is
18
+ * installed and may answer on the next call (slow disk, machine waking,
19
+ * momentary load). Everything else is definitive for the process lifetime —
20
+ * ENOENT (gh not installed) and a non-zero exit (not authenticated) will not
21
+ * change without user action, so those still latch `tokenFetchAttempted`.
22
+ * The async execFile timeout surfaces as `killed: true` (signal SIGTERM);
23
+ * the sync execFileSync timeout surfaces as `code: 'ETIMEDOUT'` (no
24
+ * `killed` property) — both arms are load-bearing, one per path.
25
+ */
26
+ function isTransientTokenFetchError(err) {
27
+ if (!err || typeof err !== 'object')
28
+ return false;
29
+ const e = err;
30
+ return e.killed === true || e.code === 'ETIMEDOUT';
31
+ }
16
32
  /**
17
33
  * Retrieves a GitHub authentication token, checking sources in priority order.
18
34
  *
@@ -29,7 +45,6 @@ export function getGitHubToken() {
29
45
  if (tokenFetchAttempted) {
30
46
  return null;
31
47
  }
32
- tokenFetchAttempted = true;
33
48
  if (process.env.GITHUB_TOKEN) {
34
49
  cachedGitHubToken = process.env.GITHUB_TOKEN;
35
50
  return cachedGitHubToken;
@@ -40,6 +55,8 @@ export function getGitHubToken() {
40
55
  stdio: ['pipe', 'pipe', 'pipe'],
41
56
  timeout: 2000,
42
57
  }).trim();
58
+ // Definitive outcome (a token, or authoritatively none) — latch.
59
+ tokenFetchAttempted = true;
43
60
  if (token && token.length > 0) {
44
61
  cachedGitHubToken = token;
45
62
  debug(MODULE, 'Using GitHub token from gh CLI');
@@ -47,9 +64,14 @@ export function getGitHubToken() {
47
64
  }
48
65
  }
49
66
  catch (err) {
50
- // Promote to warn-once-per-session so a slow `gh` (2s timeout) or a
51
- // misconfigured CLI is visible without DEBUG=1 (#1209 L6). The
52
- // tokenFetchAttempted cache means subsequent calls don't re-warn.
67
+ // Latch only on definitive failures (#1415): a timeout must not
68
+ // permanently disable token fetch for a long-lived process (the MCP
69
+ // server), or gist-mode mutations silently degrade to local-only for
70
+ // the process lifetime. Promote to warn so a slow `gh` (2s timeout) or
71
+ // a misconfigured CLI is visible without DEBUG=1 (#1209 L6).
72
+ if (!isTransientTokenFetchError(err)) {
73
+ tokenFetchAttempted = true;
74
+ }
53
75
  warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
54
76
  }
55
77
  return null;
@@ -97,7 +119,6 @@ export async function getGitHubTokenAsync() {
97
119
  if (tokenFetchAttempted) {
98
120
  return null;
99
121
  }
100
- tokenFetchAttempted = true;
101
122
  if (process.env.GITHUB_TOKEN) {
102
123
  cachedGitHubToken = process.env.GITHUB_TOKEN;
103
124
  return cachedGitHubToken;
@@ -113,6 +134,8 @@ export async function getGitHubTokenAsync() {
113
134
  }
114
135
  });
115
136
  });
137
+ // Definitive outcome (a token, or authoritatively none) — latch.
138
+ tokenFetchAttempted = true;
116
139
  if (token && token.length > 0) {
117
140
  cachedGitHubToken = token;
118
141
  debug(MODULE, 'Using GitHub token from gh CLI (async)');
@@ -120,7 +143,11 @@ export async function getGitHubTokenAsync() {
120
143
  }
121
144
  }
122
145
  catch (err) {
123
- // Same warn-once promotion as the sync version (#1209 L6).
146
+ // Same transient/definitive split and warn promotion as the sync
147
+ // version (#1415, #1209 L6).
148
+ if (!isTransientTokenFetchError(err)) {
149
+ tokenFetchAttempted = true;
150
+ }
124
151
  warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
125
152
  }
126
153
  return null;
@@ -2,7 +2,7 @@
2
2
  * Core module exports
3
3
  * Re-exports all core functionality for convenient imports
4
4
  */
5
- export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
5
+ export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, type GistPersistenceStatus, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
6
6
  export { GistStateStore } from './gist-state-store.js';
7
7
  export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
8
8
  export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
@@ -441,7 +441,21 @@ export declare function getStateManagerAsync(token?: string): Promise<StateManag
441
441
  * // CLI bootstrap
442
442
  * await ensureGistPersistence(token);
443
443
  */
444
- export declare function ensureGistPersistence(token: string | null): Promise<void>;
444
+ export type GistPersistenceStatus =
445
+ /** No state file yet, or config does not request gist mode. */
446
+ 'local-mode'
447
+ /** The state file exists but could not be read/parsed for this attempt
448
+ * (permissions, transient FS error, corrupt JSON). Callers must NOT
449
+ * memoize this as "local mode chosen" — a later attempt may succeed. */
450
+ | 'state-unreadable'
451
+ /** Gist mode is configured but no token was available for this attempt. */
452
+ | 'no-token'
453
+ /** Gist mode active: the singleton is gist-backed. */
454
+ | 'gist'
455
+ /** Gist mode is configured and a token was available, but init fell back
456
+ * to local-only (transient network failure). A later call may recover. */
457
+ | 'degraded';
458
+ export declare function ensureGistPersistence(token: string | null): Promise<GistPersistenceStatus>;
445
459
  /**
446
460
  * Reset the singleton StateManager instance to null. Intended for test isolation.
447
461
  */
@@ -924,13 +924,27 @@ export function getStateManager() {
924
924
  * StateManager and Gist checkpoints will be no-ops.
925
925
  */
926
926
  export async function getStateManagerAsync(token) {
927
- if (stateManager)
927
+ // #1415: a LOCAL-mode singleton does not short-circuit when a token is
928
+ // provided. The only token-bearing caller is ensureGistPersistence, which
929
+ // already verified the config says `persistence: gist` — so a local
930
+ // singleton here means a previous transient init failure fell back (or a
931
+ // tool body lazily created the local manager before auth resolved), and
932
+ // this call is the retry that must be allowed to upgrade it. The old
933
+ // unconditional early return made every retry a dead no-op, permanently
934
+ // latching gist-configured processes into silent local-only writes.
935
+ if (stateManager && (!token || stateManager.isGistMode()))
928
936
  return stateManager;
929
937
  if (asyncManagerPromise)
930
938
  return asyncManagerPromise;
931
939
  if (token) {
932
940
  asyncManagerPromise = StateManager.createWithGist(token)
933
941
  .then((mgr) => {
942
+ // Upgrade note: replacing a transient-fallback local singleton fixes
943
+ // FUTURE operations. Mutations made during the degraded window stay
944
+ // in the local state.json only — when a Gist already exists they are
945
+ // NOT merged into it by this upgrade (bootstrapWithMigration seeds a
946
+ // Gist from local state only on first creation). The per-call MCP
947
+ // warning is the signal that those writes were at risk.
934
948
  stateManager = mgr;
935
949
  asyncManagerPromise = null;
936
950
  return mgr;
@@ -949,6 +963,9 @@ export async function getStateManagerAsync(token) {
949
963
  // marker stays in config, causing permanent cross-machine divergence.
950
964
  if (!isTransientNetworkError(err))
951
965
  throw err;
966
+ // Intentional CLI semantics: a one-shot process warns and proceeds
967
+ // local-only. Long-lived callers (MCP) detect this via the
968
+ // ensureGistPersistence return status and retry on later calls.
952
969
  warn(MODULE, `Gist initialization failed (transient network error), falling back to local-only mode: ${err}`);
953
970
  return getStateManager();
954
971
  });
@@ -956,39 +973,32 @@ export async function getStateManagerAsync(token) {
956
973
  }
957
974
  return getStateManager();
958
975
  }
959
- /**
960
- * Bootstrap helper for processes that may run in Gist persistence mode.
961
- *
962
- * Peeks at the state file to check if Gist mode is configured. If so and a
963
- * valid token is provided, pre-sets the singleton via {@link getStateManagerAsync}
964
- * so subsequent synchronous {@link getStateManager} calls return the Gist-backed
965
- * instance. No-op when the state file is absent, unparseable, or not in Gist mode.
966
- *
967
- * Consolidates identical filesystem-peek + getStateManagerAsync logic that
968
- * was duplicated between the CLI bootstrap (`cli.ts`) and MCP tool bootstrap
969
- * (`mcp-server/src/tools.ts`) — #1000.
970
- *
971
- * @param token - GitHub token with `gist` scope, or `null` to skip activation
972
- *
973
- * @example
974
- * // CLI bootstrap
975
- * await ensureGistPersistence(token);
976
- */
977
976
  export async function ensureGistPersistence(token) {
978
- if (!token)
979
- return;
980
977
  let persistence;
981
978
  try {
982
979
  const raw = fs.readFileSync(getStatePath(), 'utf8');
983
980
  persistence = JSON.parse(raw)?.config?.persistence;
984
981
  }
985
- catch {
986
- // No state file or unreadable stay in local mode
987
- return;
988
- }
989
- if (persistence === 'gist') {
990
- await getStateManagerAsync(token);
991
- }
982
+ catch (err) {
983
+ // A missing file is the genuine "fresh local-mode user" case. Anything
984
+ // else (EACCES, EMFILE, corrupt JSON) must not be silently classified
985
+ // as a local-mode CHOICE — that would re-create the #1415 latch through
986
+ // a third door when the caller memoizes the answer.
987
+ if (err.code === 'ENOENT')
988
+ return 'local-mode';
989
+ warn(MODULE, `State file unreadable during gist-persistence check (will retry): ${errorMessage(err)}`);
990
+ return 'state-unreadable';
991
+ }
992
+ if (persistence !== 'gist')
993
+ return 'local-mode';
994
+ // #1415: report the distinction the MCP layer needs — "gist configured but
995
+ // token missing" and "init fell back to local" both mean the process is
996
+ // NOT writing to the Gist despite the config saying it should, and a
997
+ // long-lived caller must keep retrying instead of memoizing the outcome.
998
+ if (!token)
999
+ return 'no-token';
1000
+ const mgr = await getStateManagerAsync(token);
1001
+ return mgr.isGistMode() ? 'gist' : 'degraded';
992
1002
  }
993
1003
  /**
994
1004
  * Reset the singleton StateManager instance to null. Intended for test isolation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "3.13.1",
3
+ "version": "3.13.2",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {