@sdsrs/code-graph 0.111.0 → 0.111.1

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/README.md CHANGED
@@ -160,9 +160,11 @@ Then reconnect the MCP server in Claude Code with `/mcp`.
160
160
  Set it in the `env` block of `~/.claude/settings.json` (the environment of the
161
161
  process hosting the MCP server), then reconnect with `/mcp`.
162
162
 
163
- An update that keeps failing also stops retrying on its own: after 5 failed
164
- attempts at the *same* release, the updater goes check-only until a newer
165
- release is published. Run `code-graph-mcp doctor` to see the state.
163
+ An update that keeps failing also stops hammering: after 5 failed attempts at
164
+ the *same* release the updater drops to one retry per day (and retries
165
+ immediately when a newer release is published), instead of re-downloading on
166
+ every session. While it is in that state the statusline shows `⚠ update stuck`
167
+ and `code-graph-mcp doctor` prints the manual update command.
166
168
 
167
169
  #### Invited-memory mode (quieter prompts)
168
170
 
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.111.0",
7
+ "version": "0.111.1",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -57,6 +57,20 @@ const FETCH_TIMEOUT_MS = 3000;
57
57
  // STUCK_UPDATE_ATTEMPTS (drift-guarded in statusline.test.js) so the moment the
58
58
  // updater gives up is the moment the statusline stops promising "↻ updating".
59
59
  const MAX_UPDATE_ATTEMPTS = 5;
60
+ // ...but suspension is not permanent. The cap alone assumed every repeated
61
+ // failure is permanent, and the causes are not distinguishable at the failure
62
+ // site: a briefly-missing `.sha256` sidecar, a captive portal, a temporarily
63
+ // full disk burn the budget just as fast as a broken tar — and SessionStart
64
+ // forces a check with only a 2-minute floor, so ~5 Claude Code restarts in ~10
65
+ // minutes exhaust it. Before this, recovery required a NEWER release (or
66
+ // hand-deleting update-state.json, which nothing tells the user to do), so a
67
+ // ten-minute outage could park the updater for days. One retry per day keeps
68
+ // the per-session treadmill dead while guaranteeing self-heal.
69
+ //
70
+ // Note the retry can NOT be keyed to `--force`: session-init passes --force on
71
+ // every session start, so re-arming there would restore the exact treadmill
72
+ // this cap exists to stop.
73
+ const SUSPENSION_RETRY_MS = 24 * 60 * 60 * 1000;
60
74
 
61
75
  function isSilentMode(argv = process.argv.slice(2), env = process.env) {
62
76
  return argv.includes('--silent') || env.CODE_GRAPH_AUTO_UPDATE_SILENT === '1';
@@ -880,8 +894,17 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
880
894
  // always starts with a full budget. The counter used to be unscoped, which
881
895
  // only mattered because nothing ever read it: the download chain re-ran on
882
896
  // every single session no matter how many times it had already failed.
883
- const attempts = state.latestVersion === latest.version ? (state.updateAttempts || 0) : 0;
884
- if (attempts >= MAX_UPDATE_ATTEMPTS) {
897
+ const sameTarget = state.latestVersion === latest.version;
898
+ const attempts = sameTarget ? (state.updateAttempts || 0) : 0;
899
+ // A suspended release gets one retry per day (SUSPENSION_RETRY_MS), so a
900
+ // transient cause — sidecar blip, captive portal, briefly-full disk —
901
+ // heals itself instead of parking the updater until the next release.
902
+ // Spend the retry by entering the attempt path with the budget one short:
903
+ // if it fails again it re-suspends immediately (and re-stamps the clock),
904
+ // costing at most one download per day rather than one per session.
905
+ const suspendedAt = sameTarget && state.suspendedAt ? Date.parse(state.suspendedAt) : NaN;
906
+ const retryDue = Number.isFinite(suspendedAt) && (Date.now() - suspendedAt) >= SUSPENSION_RETRY_MS;
907
+ if (attempts >= MAX_UPDATE_ATTEMPTS && !retryDue) {
885
908
  // Suspended — check-only from here until a newer release moves the
886
909
  // target. The one thing still worth attempting is a MISSING cached
887
910
  // binary: without it the MCP server has no engine at all, so that
@@ -895,18 +918,32 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
895
918
  latestVersion: latest.version,
896
919
  updateAvailable: true,
897
920
  updateAttempts: attempts,
921
+ // Stamp on ENTRY to suspension, then leave it alone: the retry clock
922
+ // must measure time since we gave up, not time since the last check
923
+ // (which every session would reset, making the retry unreachable).
924
+ suspendedAt: (sameTarget && state.suspendedAt) || new Date().toISOString(),
898
925
  rateLimited: false,
899
926
  binaryUpdated: healedMissing || state.binaryUpdated,
900
927
  });
901
928
  console.error(
902
929
  `[code-graph] Auto-update to v${latest.version} suspended after ${attempts} failed attempts on this machine. ` +
903
930
  'Update manually (`/plugin update code-graph-mcp`, or `npm install -g @sdsrs/code-graph`) or run `code-graph-mcp doctor`. ' +
904
- 'Retries resume automatically when a newer release is published.'
931
+ 'Retried automatically once a day, and immediately when a newer release is published.'
905
932
  );
906
933
  return { updateAvailable: true, suspended: true, from: installedVersion, to: latest.version };
907
934
  }
908
935
  const result = await downloadAndInstall(latest);
909
936
  const success = result.pluginUpdated;
937
+ // Suspension clock. It restarts when the daily retry is spent and fails,
938
+ // which is what keeps `retryDue` from staying true and turning the retry
939
+ // back into a per-session treadmill; it clears on success and on a new
940
+ // target version, so a stale stamp from the previous release cannot make
941
+ // the next one look instantly retry-due.
942
+ let nextSuspendedAt;
943
+ if (success) nextSuspendedAt = null;
944
+ else if (retryDue) nextSuspendedAt = new Date().toISOString();
945
+ else if (!sameTarget) nextSuspendedAt = null;
946
+ else nextSuspendedAt = state.suspendedAt || null;
910
947
  const newState = {
911
948
  lastCheck: new Date().toISOString(),
912
949
  installedVersion: success ? latest.version : installedVersion,
@@ -918,6 +955,7 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
918
955
  // forever, asserting a self-heal that never happens. The statusline stops
919
956
  // trusting it past STUCK_UPDATE_ATTEMPTS; success resets to 0.
920
957
  updateAttempts: success ? 0 : attempts + 1,
958
+ suspendedAt: nextSuspendedAt,
921
959
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
922
960
  rateLimited: false,
923
961
  binaryUpdated: result.binaryUpdated,
@@ -214,7 +214,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
214
214
  results.push({
215
215
  name: 'Auto-update',
216
216
  status: 'warn',
217
- detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry suspended until a newer release. `
217
+ detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry throttled to once a day. `
218
218
  + 'Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)',
219
219
  });
220
220
  } else if (state && state.updateAvailable && state.binaryUpdated === false) {
@@ -129,7 +129,20 @@ function installBinaryInBackground({
129
129
  // `npm.cmd` (needs a shell) and passing `args` alongside `shell: true` is
130
130
  // DEP0190 — runtime-deprecated in Node 24, and unescaped. It pre-quotes the
131
131
  // whole command into `file` with empty `args`, and carries windowsHide.
132
- const npm = npmInvocation(['install', '-g', `@sdsrs/code-graph@${version}`]);
132
+ // try/catch because quoteCmdArg THROWS on an argument it cannot quote, and
133
+ // `version` is read from plugin.json — our own file, but a parse of somebody
134
+ // else's disk. Uncaught, the throw escapes mcp-launcher.js and kills the MCP
135
+ // server AFTER the 0-tool stub is already serving, turning a bad version
136
+ // string into a dead server instead of a failed install. Every sibling call
137
+ // in auto-update.js is already inside a try.
138
+ let npm;
139
+ try {
140
+ npm = npmInvocation(['install', '-g', `@sdsrs/code-graph@${version}`]);
141
+ } catch (e) {
142
+ process.stderr.write(`[code-graph] cannot build the npm install command: ${e.message}\n`);
143
+ finish(onFailed);
144
+ return;
145
+ }
133
146
  runStep(npm.file, npm.args, npmTimeoutMs, '[code-graph][npm]', spawnFn, (npmExit) => {
134
147
  if (resolved()) {
135
148
  if (npmExit === 0 && recordGlobalInstall) {
@@ -30,7 +30,13 @@ function quoteCmdArg(arg) {
30
30
  if (/["%!\r\n]/.test(s)) {
31
31
  throw new Error(`npm argument cannot be safely quoted for cmd.exe: ${JSON.stringify(s)}`);
32
32
  }
33
- return `"${s}"`;
33
+ // Double a trailing run of backslashes. cmd.exe itself does not treat `\` as
34
+ // an escape, but the RECEIVING program's MSVCRT argv parser does: `"C:\x\"`
35
+ // reads the final `\"` as an escaped quote and swallows the rest of the
36
+ // command line into that argument. Only reachable for path-shaped args, which
37
+ // no current call site passes — but this helper reads as general-purpose.
38
+ const trailing = /\\+$/.exec(s);
39
+ return `"${trailing ? s + trailing[0] : s}"`;
34
40
  }
35
41
 
36
42
  /**
@@ -19,16 +19,29 @@ const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() =>
19
19
  // real status instead. Without this, a persistently-failing update (missing
20
20
  // tar/curl, full disk, blocked network) pins "updating" forever.
21
21
  const STUCK_UPDATE_ATTEMPTS = 5;
22
- function updatePending() {
22
+ function readUpdateState() {
23
23
  try {
24
- const st = JSON.parse(fs.readFileSync(
24
+ return JSON.parse(fs.readFileSync(
25
25
  path.join(os.homedir(), '.cache', 'code-graph', 'update-state.json'), 'utf8'));
26
- if ((st.updateAttempts || 0) >= STUCK_UPDATE_ATTEMPTS) return false;
27
- if (st.updateAvailable) return true;
28
- if (st.latestVersion && st.installedVersion && st.latestVersion !== st.installedVersion) return true;
29
- } catch { /* no state file or unreadable — treat as no pending update */ }
26
+ } catch { return null; /* no state file or unreadable */ }
27
+ }
28
+ function updatePending(st = readUpdateState()) {
29
+ if (!st) return false;
30
+ if ((st.updateAttempts || 0) >= STUCK_UPDATE_ATTEMPTS) return false;
31
+ if (st.updateAvailable) return true;
32
+ if (st.latestVersion && st.installedVersion && st.latestVersion !== st.installedVersion) return true;
30
33
  return false;
31
34
  }
35
+ // The updater has given up on this release (auto-update.js MAX_UPDATE_ATTEMPTS).
36
+ // This has to be SHOWN, not merely not-lied-about: the updater's own stderr
37
+ // notice is written by a process session-init spawns `detached` with
38
+ // `stdio: 'ignore'`, so nobody ever sees it, and updatePending() going quiet
39
+ // above means the only remaining signal was running `doctor` by hand. A user
40
+ // who never runs doctor would sit on a permanently parked updater with no way
41
+ // to know (found by pre-release review of v0.111.0, fixed in v0.111.1).
42
+ function updateStuck(st = readUpdateState()) {
43
+ return !!(st && st.updateAvailable && (st.updateAttempts || 0) >= STUCK_UPDATE_ATTEMPTS);
44
+ }
32
45
 
33
46
  const disabledCleanup = cleanupDisabledStatusline();
34
47
  if (disabledCleanup.cleaned) process.exit(0);
@@ -94,7 +107,10 @@ const bin = findBinary();
94
107
  if (!bin) {
95
108
  // No usable binary yet. If an update is queued, the background downloader is
96
109
  // still fetching it \u2014 that is "updating", not a broken "offline" state.
97
- process.stdout.write(updatePending() ? 'code-graph: \u21bb updating' : 'code-graph: offline');
110
+ process.stdout.write(
111
+ updateStuck() ? 'code-graph: \u26a0 update stuck'
112
+ : updatePending() ? 'code-graph: \u21bb updating'
113
+ : 'code-graph: offline');
98
114
  process.exit(0);
99
115
  }
100
116
 
@@ -121,6 +137,8 @@ function renderHealth(s) {
121
137
  // index doesn't masquerade as fully current.
122
138
  if (s.index_version_stale) line += ' | \u21bb rebuilding';
123
139
  if (s.watching) line += ' | watching';
140
+ // A parked updater is otherwise invisible in normal use — see updateStuck().
141
+ if (updateStuck()) line += ' | \u26a0 update stuck';
124
142
  return line;
125
143
  }
126
144
 
@@ -144,7 +162,8 @@ function statusUnavailable(errText) {
144
162
  // phrase so a cached binary predating the marker still reads as "updating".
145
163
  const errStr = errText || '';
146
164
  const binaryOutdated = errStr.includes('code-graph:schema-too-new') || /schema version/i.test(errStr);
147
- return (binaryOutdated || updatePending()) ? 'code-graph: \u21bb updating' : 'code-graph: offline';
165
+ if (binaryOutdated || updatePending()) return 'code-graph: \u21bb updating';
166
+ return updateStuck() ? 'code-graph: \u26a0 update stuck' : 'code-graph: offline';
148
167
  }
149
168
 
150
169
  let report = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.111.0",
3
+ "version": "0.111.1",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.111.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.111.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.111.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.111.0",
42
- "@sdsrs/code-graph-win32-x64": "0.111.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.111.1",
39
+ "@sdsrs/code-graph-linux-arm64": "0.111.1",
40
+ "@sdsrs/code-graph-darwin-x64": "0.111.1",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.111.1",
42
+ "@sdsrs/code-graph-win32-x64": "0.111.1"
43
43
  }
44
44
  }