@lifeaitools/rdc-skills 0.25.9 → 0.25.10

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/git-sha.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "sha": "63de440233deb666609d8b50d0cea47946e1cc2e"
2
+ "sha": "1a1e8ac827160ff90f4a5214a5266c81aef4f86d"
3
3
  }
@@ -137,6 +137,44 @@ function processesHoldingPackage() {
137
137
  }
138
138
  }
139
139
 
140
+ const stoppedStampPath = path.join(os.tmpdir(), 'rdc-skills-stopped-holders.json');
141
+
142
+ function writeStoppedStamp(names) {
143
+ try {
144
+ fs.writeFileSync(stoppedStampPath, JSON.stringify({ names, ts: new Date().toISOString() }), 'utf8');
145
+ } catch { /* best effort — the finally-restart still covers the normal path */ }
146
+ }
147
+
148
+ function clearStoppedStamp() {
149
+ try { fs.unlinkSync(stoppedStampPath); } catch { /* nothing to clear */ }
150
+ }
151
+
152
+ /**
153
+ * Restart anything a PREVIOUS run stopped but never restarted.
154
+ *
155
+ * The only way a holder stays stopped is if that run died between `pm2 stop` and
156
+ * `pm2 restart` — a harness timeout kill, which neither `finally` nor
157
+ * process.on('exit') catches. Leaving the MCP down is strictly worse than the
158
+ * unhealthy state being repaired, so every run repairs the previous run's crash
159
+ * before doing anything else.
160
+ */
161
+ function restartStrandedHolders() {
162
+ let names = [];
163
+ try {
164
+ names = JSON.parse(fs.readFileSync(stoppedStampPath, 'utf8')).names || [];
165
+ } catch {
166
+ return; // no stamp — nothing was left stopped
167
+ }
168
+ for (const name of names) {
169
+ if (!SAFE_PM2_NAME.test(name)) continue;
170
+ try {
171
+ shell(`pm2 restart ${q(name)}`, { timeout: 30000 });
172
+ hookLog('check-rdc-environment', 'SessionStart', 'restarted-stranded-holder', { name });
173
+ } catch { /* reported by the health check below */ }
174
+ }
175
+ clearStoppedStamp();
176
+ }
177
+
140
178
  function repair(reason) {
141
179
  hookLog('check-rdc-environment', 'SessionStart', 'repair', { reason });
142
180
 
@@ -154,6 +192,13 @@ function repair(reason) {
154
192
  hookLog('check-rdc-environment', 'SessionStart', 'skipped-unsafe-pm2-name', { name });
155
193
  return false;
156
194
  });
195
+ // Record what we are about to stop BEFORE stopping it. `finally` covers an
196
+ // exception but NOT the harness killing this hook at its timeout — and
197
+ // process.on('exit') does not fire for a SIGKILL either. Without this stamp a
198
+ // timeout kill between `pm2 stop` and `pm2 restart` leaves the MCP STOPPED with
199
+ // nothing that knows to bring it back. restartStrandedHolders() (called at hook
200
+ // entry) is the crash-safe half of this pair.
201
+ writeStoppedStamp(holders);
157
202
  for (const name of holders) {
158
203
  try {
159
204
  shell(`pm2 stop ${q(name)}`, { timeout: 30000 });
@@ -181,6 +226,9 @@ function repair(reason) {
181
226
  /* surfaced by the health re-check below */
182
227
  }
183
228
  }
229
+ // Restarted (or tried to) — the stamp has served its purpose. Clearing it here
230
+ // means a stamp that SURVIVES is unambiguous evidence of a killed run.
231
+ clearStoppedStamp();
184
232
  }
185
233
  }
186
234
 
@@ -244,6 +292,10 @@ function block(message, details = {}) {
244
292
  }
245
293
 
246
294
  async function main() {
295
+ // Repair a PREVIOUS run's crash first: if it was killed between `pm2 stop` and
296
+ // `pm2 restart`, the MCP is still down and no health check can fix that.
297
+ restartStrandedHolders();
298
+
247
299
  const initialPkg = globalPackageJson();
248
300
  const initialHealth = await health();
249
301
  const reasons = [];
@@ -315,4 +367,12 @@ async function main() {
315
367
  process.exit(0);
316
368
  }
317
369
 
318
- main().catch((err) => block(`RDC skills environment check crashed: ${err.message}`));
370
+ // Run ONLY when git/Claude invokes this file directly. Without the guard, merely
371
+ // require()-ing it executes the whole hook — which is what the installer's own
372
+ // load-check did, triggering a REAL `npm install -g` + pm2 stop/restart during the
373
+ // install, and on an unhealthy box a recursive install→repair→install loop.
374
+ if (require.main === module) {
375
+ main().catch((err) => block(`RDC skills environment check crashed: ${err.message}`));
376
+ }
377
+
378
+ module.exports = { main, repair, restartStrandedHolders };
@@ -96,8 +96,16 @@ function acquireBoxLock() {
96
96
  // 0/12, 1/12, 2/12 double-leader rounds across runs, which is the worst kind
97
97
  // of bug to ship: it passes CI and fails on a busy machine.
98
98
  settle(SETTLE_MS);
99
+ // Distinguish "someone else took it" from "the read failed". Standing down on
100
+ // a transient read error can elect ZERO leaders — nobody repairs the box while
101
+ // every session waits and then hard-blocks. Only a genuine byte MISMATCH means
102
+ // another session leads.
99
103
  let observed = null;
100
- try { observed = fs.readFileSync(LOCK_PATH, 'utf8'); } catch { observed = null; }
104
+ let readOk = false;
105
+ for (let r = 0; r < 2 && !readOk; r++) {
106
+ try { observed = fs.readFileSync(LOCK_PATH, 'utf8'); readOk = true; } catch { settle(20); }
107
+ }
108
+ if (!readOk) continue; // could not verify — retry the create
101
109
  if (observed !== body) return false; // someone reclaimed it; they lead
102
110
 
103
111
  lockHeld = true;
@@ -145,8 +153,21 @@ function acquireBoxLock() {
145
153
  let claimRaw = null;
146
154
  try { claimRaw = fs.readFileSync(claim, 'utf8'); } catch { claimRaw = null; }
147
155
  if (claimRaw !== heldRaw) {
148
- // Not ours to take — put it back and stand down.
149
- try { fs.renameSync(claim, LOCK_PATH); } catch { /* owner will re-create */ }
156
+ // Not ours to take — DISCARD the claim and stand down.
157
+ //
158
+ // Do NOT rename it back. renameSync silently OVERWRITES an existing
159
+ // destination, and the bytes we hold are the STALE ones (that is why we
160
+ // judged them reclaimable). Restoring them clobbers the live leader's
161
+ // fresh lock with a stale body, so the next session judges it
162
+ // reclaimable and becomes a SECOND LEADER while the true leader is
163
+ // mid `npm install -g` — the exact race this module exists to prevent,
164
+ // arriving through the restore path. Measured, and invisible to the
165
+ // concurrency probe because it seeds a dead-PID lock, so claimRaw
166
+ // always equals heldRaw and this branch never runs there.
167
+ //
168
+ // Whoever owns the path already owns the box. Losing the stale bytes
169
+ // costs nothing; putting them back can only mislead.
170
+ try { fs.unlinkSync(claim); } catch { /* already gone */ }
150
171
  return false;
151
172
  }
152
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.25.9",
3
+ "version": "0.25.10",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",