@ran-sh/dsh-crew 1.0.3 → 1.1.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.
@@ -30,17 +30,18 @@ import {
30
30
  readdirSync,
31
31
  readFileSync,
32
32
  realpathSync,
33
+ renameSync,
33
34
  rmSync,
34
35
  copyFileSync,
35
36
  writeFileSync,
36
37
  } from 'node:fs';
37
38
  import { createRequire } from 'node:module';
38
- import { dirname, isAbsolute, join, resolve } from 'node:path';
39
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
39
40
  import { fileURLToPath } from 'node:url';
40
41
  import { homedir } from 'node:os';
41
42
  import * as realInstaller from './install.mjs';
42
- import { crewProfileDir } from './install.mjs';
43
- import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
43
+ import { crewDshHome, crewProfileDir } from './install.mjs';
44
+ import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
44
45
  import {
45
46
  ensureOfficialWebIntegration,
46
47
  officialWebIntegrationStatus,
@@ -53,6 +54,10 @@ export const CURRENT_POINTER_FILENAME = 'current.json';
53
54
  export const KEEP_RELEASES = 2;
54
55
  export const INCOMPLETE_MARKER = '.dsh-crew-incomplete';
55
56
  const CREW_ROUTE_BASE = '/_dsh/dsh-crew';
57
+ // Exact DSH cohort version: dotted numeric with optional -prerelease suffix.
58
+ // Anything else (ranges, "../..", paths) is NOT an authorized cohort value
59
+ // and must never reach rename/rmSync authority via retained-runtimes keys.
60
+ export const EXACT_DSH_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
56
61
 
57
62
  export function npmCliInvocation(args, {
58
63
  platform = process.platform,
@@ -126,23 +131,585 @@ function isoNow() {
126
131
  * manifest to count as installed.
127
132
  */
128
133
  export function readCurrentPointer({ home = homedir() } = {}) {
134
+ const state = readCurrentPointerState({ home });
135
+ return state.status === 'valid' ? state.pointer : null;
136
+ }
137
+
138
+ // Tri-state pointer read: ABSENT (no file) / VALID / MALFORMED (present
139
+ // but unparseable or schema-invalid). Recovery must never merge ABSENT
140
+ // and MALFORMED: a corrupt pointer fails closed with the journal retained.
141
+ export function readCurrentPointerState({ home = homedir() } = {}) {
129
142
  const file = currentPointerFile({ home });
130
- if (!existsSync(file)) return null;
143
+ if (!existsSync(file)) return { status: 'absent', file };
131
144
  let raw;
132
- try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
133
- if (!raw || typeof raw !== 'object') return null;
134
- if (typeof raw.name !== 'string' || typeof raw.version !== 'string' || typeof raw.path !== 'string') return null;
135
- if (!isAbsolute(raw.path)) return null;
136
- return raw;
145
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
146
+ return { status: 'malformed', file, code: 'POINTER_UNPARSEABLE' };
147
+ }
148
+ if (!raw || typeof raw !== 'object') return { status: 'malformed', file, code: 'POINTER_NOT_OBJECT' };
149
+ if (typeof raw.name !== 'string' || raw.name.length === 0
150
+ || typeof raw.version !== 'string' || raw.version.length === 0
151
+ || typeof raw.path !== 'string' || raw.path.length === 0) {
152
+ return { status: 'malformed', file, code: 'POINTER_FIELDS_INVALID' };
153
+ }
154
+ if (!isAbsolute(raw.path)) return { status: 'malformed', file, code: 'POINTER_PATH_NOT_ABSOLUTE' };
155
+ return { status: 'valid', file, pointer: raw };
156
+ }
157
+
158
+ function validJournalRelease(value) {
159
+ return !!value && typeof value === 'object'
160
+ && typeof value.name === 'string' && value.name.length > 0
161
+ && typeof value.version === 'string' && value.version.length > 0
162
+ && typeof value.path === 'string' && isAbsolute(value.path);
163
+ }
164
+
165
+ function validJournalCandidate(value) {
166
+ return !!value && typeof value === 'object'
167
+ && typeof value.name === 'string' && value.name.length > 0
168
+ && typeof value.version === 'string' && value.version.length > 0
169
+ && typeof value.stageDir === 'string' && isAbsolute(value.stageDir);
170
+ }
171
+
172
+ function writeFileAtomic(file, content) {
173
+ // Same-directory temp + rename: a crash can never leave a truncated
174
+ // pointer or journal behind to be misread as valid state.
175
+ mkdirSync(dirname(file), { recursive: true });
176
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
177
+ writeFileSync(temp, content);
178
+ try {
179
+ renameSync(temp, file);
180
+ } catch (error) {
181
+ try { rmSync(temp, { force: true }); } catch {}
182
+ throw error;
183
+ }
137
184
  }
138
185
 
139
186
  function writeCurrentPointer({ home, name, version, path }) {
140
- mkdirSync(dirname(currentPointerFile({ home })), { recursive: true });
141
187
  const pointer = { name, version, path, installed_at: isoNow(), managed_by: 'npx' };
142
- writeFileSync(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
188
+ writeFileAtomic(currentPointerFile({ home }), JSON.stringify(pointer, null, 2) + '\n');
143
189
  return pointer;
144
190
  }
145
191
 
192
+ export const UPDATE_JOURNAL_FILENAME = 'update-journal.json';
193
+ export const UPDATE_LOCK_FILENAME = 'update-in-progress.lock';
194
+
195
+ export function updateJournalFile({ home = homedir() } = {}) {
196
+ return join(crewAppRoot({ home }), UPDATE_JOURNAL_FILENAME);
197
+ }
198
+
199
+ export function updateLockFile({ home = homedir() } = {}) {
200
+ return join(crewAppRoot({ home }), UPDATE_LOCK_FILENAME);
201
+ }
202
+
203
+ export function acquireUpdateLock({ home = homedir() } = {}) {
204
+ const file = updateLockFile({ home });
205
+ mkdirSync(dirname(file), { recursive: true });
206
+ const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
207
+ const record = { pid: process.pid, started_at: isoNow(), nonce, hostname: process.env.COMPUTERNAME ?? null };
208
+ try {
209
+ writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
210
+ return { ok: true, owner: true, nonce };
211
+ } catch (error) {
212
+ if (error?.code !== 'EEXIST') {
213
+ return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
214
+ }
215
+ return tryReclaimUpdateLock({ home, record });
216
+ }
217
+ }
218
+
219
+ function lockOwnerAlive(record) {
220
+ const pid = Number(record?.pid);
221
+ if (!Number.isInteger(pid) || pid < 1) return false;
222
+ try {
223
+ process.kill(pid, 0);
224
+ return true;
225
+ } catch (error) {
226
+ // ESRCH = no such process (dead owner, safe to reclaim).
227
+ // EPERM = process exists but we cannot signal it (live owner, keep).
228
+ return error?.code !== 'ESRCH';
229
+ }
230
+ }
231
+
232
+ // Stale-lock reclaim with a single atomic claim: each contender writes its
233
+ // full claim into a UNIQUE temp dir, then renames that dir onto the fixed
234
+ // arbitration path. Rename-onto-existing is atomic on the same filesystem:
235
+ // exactly one contender wins; the loser gets EEXIST and backs off. No
236
+ // shared claim.json is ever overwritten, so two contenders can never both
237
+ // believe they own the reclaim. A crashed winner leaves a stale arbitration
238
+ // dir: a later contender quarantines the whole dir (atomic rename away)
239
+ // only after proving the recorded owner dead, then restarts contention
240
+ // from the top. The winner's finally removes the arbitration dir only when
241
+ // its claim nonce still matches (CAS), never unconditionally.
242
+ function tryReclaimUpdateLock({ home, record }) {
243
+ const file = updateLockFile({ home });
244
+ const arbitrationPath = `${file}.arbitration`;
245
+ const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow() };
246
+ for (let round = 0; round < 3; round += 1) {
247
+ let current = null;
248
+ try { current = JSON.parse(readFileSync(file, 'utf8')); } catch { current = null; }
249
+ if (!current || typeof current !== 'object') {
250
+ // Unparseable lock: fail closed, keep for operator inspection.
251
+ return { ok: false, code: 'UPDATE_LOCK_CORRUPT' };
252
+ }
253
+ if (lockOwnerAlive(current)) {
254
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
255
+ }
256
+ // Single atomic claim: unique temp dir -> rename onto fixed path.
257
+ const tmpDir = `${arbitrationPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
258
+ try {
259
+ mkdirSync(tmpDir, { recursive: true });
260
+ writeFileSync(join(tmpDir, 'claim.json'), JSON.stringify(myClaim) + '\n', { flag: 'wx' });
261
+ } catch (error) {
262
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
263
+ return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
264
+ }
265
+ let claimed = false;
266
+ try {
267
+ renameSync(tmpDir, arbitrationPath);
268
+ claimed = true;
269
+ } catch (error) {
270
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
271
+ if (error?.code !== 'EEXIST' && error?.code !== 'EPERM' && error?.code !== 'ENOTEMPTY') {
272
+ return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
273
+ }
274
+ // Arbitration path occupied: quarantine it only when its recorded
275
+ // owner is provably dead; a live contender's claim is never touched.
276
+ let guard = null;
277
+ try { guard = JSON.parse(readFileSync(join(arbitrationPath, 'claim.json'), 'utf8')); } catch { guard = null; }
278
+ if (!guard || typeof guard !== 'object' || lockOwnerAlive(guard)) {
279
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
280
+ }
281
+ const quarantine = `${arbitrationPath}.quarantine.${Date.now()}.${Math.random().toString(36).slice(2)}`;
282
+ try {
283
+ renameSync(arbitrationPath, quarantine);
284
+ try { rmSync(quarantine, { recursive: true, force: true }); } catch {}
285
+ } catch {
286
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
287
+ }
288
+ continue;
289
+ }
290
+ try {
291
+ // Winner: re-verify the main lock is still the same dead record.
292
+ let reread = null;
293
+ try { reread = JSON.parse(readFileSync(file, 'utf8')); } catch { reread = null; }
294
+ if (!reread || reread.nonce !== current.nonce || reread.pid !== current.pid) {
295
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
296
+ }
297
+ if (lockOwnerAlive(reread)) {
298
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
299
+ }
300
+ rmSync(file, { force: true });
301
+ try {
302
+ writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
303
+ return { ok: true, owner: true, nonce: record.nonce, reclaimed: true };
304
+ } catch (error) {
305
+ if (error?.code === 'EEXIST') return { ok: false, code: 'UPDATE_IN_PROGRESS' };
306
+ return { ok: false, code: 'UPDATE_LOCK_FAILED', error: String(error?.message ?? error) };
307
+ }
308
+ } finally {
309
+ // CAS cleanup: remove the arbitration dir only when its claim is
310
+ // still ours; never delete another contender's claim.
311
+ try {
312
+ const mine = JSON.parse(readFileSync(join(arbitrationPath, 'claim.json'), 'utf8'));
313
+ if (mine?.nonce === myClaim.nonce && mine?.pid === process.pid) {
314
+ rmSync(arbitrationPath, { recursive: true, force: true });
315
+ }
316
+ } catch {}
317
+ }
318
+ }
319
+ return { ok: false, code: 'UPDATE_IN_PROGRESS' };
320
+ }
321
+
322
+ export function releaseUpdateLock({ home = homedir(), nonce = null } = {}) {
323
+ // Nonce-checked release: a stale owner finalizer must never delete a
324
+ // replacement owner's lock. Without an expected nonce this is best-effort
325
+ // legacy behavior; with one it fails closed on mismatch.
326
+ if (nonce !== null && nonce !== undefined) {
327
+ let current = null;
328
+ try { current = JSON.parse(readFileSync(updateLockFile({ home }), 'utf8')); } catch { current = null; }
329
+ if (!current || current.nonce !== nonce) {
330
+ return { ok: false, code: 'NOT_OWNER' };
331
+ }
332
+ }
333
+ try { rmSync(updateLockFile({ home }), { force: true }); } catch {}
334
+ return { ok: true };
335
+ }
336
+
337
+ function readUpdateJournal({ home = homedir() } = {}) {
338
+ const file = updateJournalFile({ home });
339
+ if (!existsSync(file)) return null;
340
+ let raw;
341
+ try {
342
+ raw = JSON.parse(readFileSync(file, 'utf8'));
343
+ } catch {
344
+ // A truncated journal is a crash artifact, NOT absence: fail closed and
345
+ // keep the file so an operator can inspect it instead of silently
346
+ // treating an interrupted transaction as clean.
347
+ return { malformed: true, file };
348
+ }
349
+ if (!raw || typeof raw !== 'object' || typeof raw.stage !== 'string') {
350
+ return { malformed: true, file };
351
+ }
352
+ // Full schema check: a semantically broken journal (null candidate,
353
+ // incomplete prior) must fail closed, never enter recovery.
354
+ if (!validJournalCandidate(raw.candidate)) {
355
+ return { malformed: true, file, code: 'JOURNAL_CANDIDATE_SCHEMA_INVALID' };
356
+ }
357
+ if (raw.prior !== null && raw.prior !== undefined && !validJournalRelease(raw.prior)) {
358
+ return { malformed: true, file, code: 'JOURNAL_PRIOR_SCHEMA_INVALID' };
359
+ }
360
+ return raw;
361
+ }
362
+
363
+ // Synchronously read the @deepseek-ai/dsh version a runtime tree ships.
364
+ function readRuntimeTreeVersionSync(root) {
365
+ if (!root || typeof root !== 'string') return null;
366
+ try {
367
+ const parsed = JSON.parse(readFileSync(join(root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'), 'utf8'));
368
+ return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
369
+ } catch { return null; }
370
+ }
371
+
372
+ function writeUpdateJournal({ home, stage, prior = null, candidate = null, verified = false, runtime = null }) {
373
+ const record = { stage, prior, candidate, verified: verified === true, updated_at: isoNow() };
374
+ if (runtime && typeof runtime === 'object') record.runtime = runtime;
375
+ writeFileAtomic(updateJournalFile({ home }), JSON.stringify(record, null, 2) + '\n');
376
+ return record;
377
+ }
378
+
379
+ // Atomically mark the current journal verified AFTER re-checking its
380
+ // identity matches the expected stage/prior/candidate. A crash between
381
+ // verification success and this write leaves the journal unverified, so
382
+ // reconcile refuses to finalize it.
383
+ export function markJournalVerified({ home = homedir(), stage, prior = null, candidate = null, runtime = null } = {}) {
384
+ const current = readUpdateJournal({ home });
385
+ if (!current || current.malformed) return { ok: false, code: 'JOURNAL_NOT_FOUND' };
386
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
387
+ if (current.stage !== stage || !same(current.prior, prior) || !same(current.candidate, candidate)) {
388
+ return { ok: false, code: 'JOURNAL_IDENTITY_MISMATCH' };
389
+ }
390
+ writeUpdateJournal({ home, stage, prior, candidate, verified: true, runtime });
391
+ return { ok: true };
392
+ }
393
+
394
+ function clearUpdateJournal({ home = homedir() } = {}) {
395
+ try { rmSync(updateJournalFile({ home }), { force: true }); } catch {}
396
+ }
397
+
398
+ // Synchronous profile-registration compensation for crash recovery:
399
+ // re-point the dedicated Crew profile at the prior release. Host
400
+ // integrations (Codex/ZCode/startup/Claude) are repaired by the next
401
+ // successful install/update activation; the profile link is the surface
402
+ // that must never dangle at a deleted candidate.
403
+ function compensateActivationSync({ home, prior, manifest, log, installer }) {
404
+ if (!prior?.path || !existsSync(prior.path)) return { ok: false, code: 'PRIOR_RELEASE_MISSING' };
405
+ if (!manifest?.name) return { ok: false, code: 'PRIOR_MANIFEST_INVALID' };
406
+ const registration = ensureCrewPluginRegistration({ home, root: prior.path, name: manifest.name });
407
+ if (!registration.ok) return { ok: false, code: registration.code ?? 'PRIOR_REGISTRATION_FAILED' };
408
+ return { ok: true, version: manifest.version, path: prior.path };
409
+ }
410
+
411
+ // Undo a first-install candidate's activation surfaces: remove its profile
412
+ // registration (dependency + bundle + junction) ONLY when each surface
413
+ // still references THIS journal's candidate. The dependency must resolve
414
+ // to exactly link:<candidateRealPath> (normalized slashes); a later
415
+ // legitimate registration pointing elsewhere is never removed. Mixed or
416
+ // unjudgeable state fails closed with the journal retained. When the
417
+ // profile manifest is missing/unreadable the junction is still handled
418
+ // independently so a "junction created, manifest never written" crash
419
+ // cannot leave a dangling junction.
420
+ function undoCandidateActivationSync({ home, candidateDir, candidateName }) {
421
+ if (!candidateDir || !candidateName) return { ok: true, undone: false };
422
+ const profileRoot = crewProfileDir({ home });
423
+ const profileFile = join(profileRoot, 'package.json');
424
+ let manifest = null;
425
+ let manifestReadable = true;
426
+ try { manifest = JSON.parse(readFileSync(profileFile, 'utf8')); } catch { manifest = null; manifestReadable = false; }
427
+ const linkPath = join(profileRoot, 'node_modules', ...candidateName.split('/'));
428
+ let linked = null;
429
+ try {
430
+ if (lstatSync(linkPath).isSymbolicLink()) linked = realpathSync(linkPath);
431
+ } catch { linked = null; }
432
+ let candidateReal = null;
433
+ try { candidateReal = realpathSync(candidateDir); } catch { candidateReal = candidateDir; }
434
+ const expectedDep = `link:${String(candidateReal).replace(/\\/g, '/')}`;
435
+ const rawDep = manifest?.dependencies?.[candidateName];
436
+ const depPointsAtCandidate = typeof rawDep === 'string'
437
+ && rawDep.replace(/\\/g, '/') === expectedDep;
438
+ const bundleNamesCandidate = Array.isArray(manifest?.dsh?.profile?.bundles) && manifest.dsh.profile.bundles.includes(candidateName);
439
+ const linkPointsAtCandidate = linked !== null && linked === candidateReal;
440
+ // Bundle carries no path: it NEVER grants deletion authority by itself.
441
+ // It is removed only when dependency or junction proves THIS journal's
442
+ // candidate still owns the registration. A re-pointed dep/junction with
443
+ // a leftover same-name bundle fails closed (journal retained).
444
+ const identityEvidence = depPointsAtCandidate || linkPointsAtCandidate;
445
+ const bundlePointsAtCandidate = bundleNamesCandidate && identityEvidence;
446
+ // Manifest unreadable but junction dangles at candidate: remove junction.
447
+ if (!manifestReadable) {
448
+ if (!linkPointsAtCandidate) return { ok: true, undone: false };
449
+ try { rmSync(linkPath, { force: true }); } catch (error) {
450
+ return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
451
+ }
452
+ return { ok: true, undone: true };
453
+ }
454
+ if (!manifest) return { ok: true, undone: false };
455
+ // Same-name package re-pointed elsewhere (dep or junction references a
456
+ // different target) while the bundle still names it: mixed state that
457
+ // cannot prove THIS candidate owns the registration. Fail closed,
458
+ // retain the journal for operator inspection.
459
+ const depPointsElsewhere = typeof rawDep === 'string' && !depPointsAtCandidate;
460
+ const linkPointsElsewhere = linked !== null && !linkPointsAtCandidate;
461
+ if ((depPointsElsewhere || linkPointsElsewhere) && bundleNamesCandidate) {
462
+ return { ok: false, code: 'CANDIDATE_UNDO_AMBIGUOUS', error: 'same-name package re-pointed elsewhere; refusing bundle removal' };
463
+ }
464
+ if (!depPointsAtCandidate && !bundlePointsAtCandidate && !linkPointsAtCandidate) {
465
+ return { ok: true, undone: false };
466
+ }
467
+ // Partial registration: only undo the surfaces that reference THIS
468
+ // candidate; leave anything already re-pointed elsewhere untouched.
469
+ try {
470
+ const next = { ...manifest };
471
+ if (depPointsAtCandidate) {
472
+ next.dependencies = { ...manifest.dependencies };
473
+ delete next.dependencies[candidateName];
474
+ if (Object.keys(next.dependencies).length === 0) delete next.dependencies;
475
+ }
476
+ if (bundlePointsAtCandidate) {
477
+ next.dsh = { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: manifest.dsh.profile.bundles.filter((b) => b !== candidateName) } };
478
+ }
479
+ writeFileAtomic(profileFile, JSON.stringify(next, null, 2) + '\n');
480
+ if (linkPointsAtCandidate) rmSync(linkPath, { force: true });
481
+ } catch (error) {
482
+ return { ok: false, code: 'CANDIDATE_UNDO_FAILED', error: String(error?.message ?? error) };
483
+ }
484
+ return { ok: true, undone: true };
485
+ }
486
+
487
+ // Canonical containment validation for a journal's runtime segment. Recovery
488
+ // may rename/recursively-delete these roots, so liveRoot and retainedRoot
489
+ // must be EXACTLY the canonical Crew-owned paths (resolved, never a suffix
490
+ // match), and a parked prior root must be a DIRECT child of the harness home
491
+ // whose basename matches the lifecycle's own runtime-prev-* naming.
492
+ function validateJournalRuntimeSegment({ home, rt }) {
493
+ const harnessRoot = resolve(crewDshHome({ home }));
494
+ const canonicalLive = resolve(crewDshRuntimeRoot({ home }));
495
+ const canonicalRetained = resolve(join(harnessRoot, 'retained-runtimes'));
496
+ // A coordinated-update journal MUST carry a runtime segment: without it
497
+ // recovery could restore the payload but leave a candidate-cohort runtime
498
+ // behind (an unsupported pair) and clear the journal.
499
+ if (!rt || typeof rt !== 'object') return { ok: false, error: 'runtime segment missing from coordinated-update journal' };
500
+ if (typeof rt.liveRoot !== 'string' || resolve(rt.liveRoot) !== canonicalLive) {
501
+ return { ok: false, error: `liveRoot is not the canonical Crew runtime root (${rt.liveRoot})` };
502
+ }
503
+ if (typeof rt.candidateVersion !== 'string' || !EXACT_DSH_VERSION_RE.test(rt.candidateVersion)) {
504
+ return { ok: false, error: 'candidateVersion is not an exact version' };
505
+ }
506
+ if (rt.priorVersion !== undefined && rt.priorVersion !== null
507
+ && (typeof rt.priorVersion !== 'string' || !EXACT_DSH_VERSION_RE.test(rt.priorVersion))) {
508
+ return { ok: false, error: 'priorVersion is not an exact version' };
509
+ }
510
+ if (rt.priorRoot !== undefined && rt.priorRoot !== null) {
511
+ if (typeof rt.priorRoot !== 'string') return { ok: false, error: 'priorRoot is not a path' };
512
+ const priorResolved = resolve(rt.priorRoot);
513
+ // Direct child of the harness home only.
514
+ if (dirname(priorResolved) !== harnessRoot) {
515
+ return { ok: false, error: `priorRoot is not a direct child of the Crew harness home (${rt.priorRoot})` };
516
+ }
517
+ const base = priorResolved.split(/[\\/]/).filter(Boolean).at(-1) ?? '';
518
+ if (!/^runtime-prev-[0-9a-z-]+$/i.test(base)) {
519
+ return { ok: false, error: `priorRoot basename is not a lifecycle runtime-prev-* dir (${base})` };
520
+ }
521
+ }
522
+ if (rt.retainedRoot !== undefined && rt.retainedRoot !== null) {
523
+ if (typeof rt.retainedRoot !== 'string' || resolve(rt.retainedRoot) !== canonicalRetained) {
524
+ return { ok: false, error: `retainedRoot is not the canonical retained-runtimes dir (${rt.retainedRoot})` };
525
+ }
526
+ }
527
+ return { ok: true };
528
+ }
529
+
530
+ // Reconcile a leftover journal from a crashed update/install. The single
531
+ // commit point is the pointer write: pointer == candidate means committed
532
+ // (finalize, do NOT roll back); pointer == prior/absent means pre-commit
533
+ // (restore activation surfaces, drop candidate). A malformed journal fails
534
+ // closed and is retained for operator inspection.
535
+ export function reconcileUpdateJournal({ home = homedir(), log = () => {}, installer = realInstaller } = {}) {
536
+ const journal = readUpdateJournal({ home });
537
+ if (!journal) return { ok: true, reconciled: false };
538
+ if (journal.malformed) {
539
+ return { ok: false, code: journal.code ?? 'JOURNAL_MALFORMED', file: journal.file };
540
+ }
541
+ // Tri-state pointer: a corrupt pointer is NOT absence. Recovery with a
542
+ // malformed pointer fails closed with the journal retained.
543
+ const pointerState = readCurrentPointerState({ home });
544
+ if (pointerState.status === 'malformed') {
545
+ return { ok: false, code: 'POINTER_MALFORMED', file: pointerState.file, error: pointerState.code ?? 'pointer unreadable; refusing recovery' };
546
+ }
547
+ const pointer = pointerState.status === 'valid' ? pointerState.pointer : null;
548
+ const candidateDir = journal.candidate?.stageDir ?? null;
549
+ const candidateManifest = candidateDir && existsSync(candidateDir) ? readManifest(candidateDir) : null;
550
+
551
+ // A coordinated-update journal MUST carry a runtime segment; recovery may
552
+ // rename or recursively delete its roots, so validate them canonically
553
+ // BEFORE granting any destructive authority: liveRoot/retainedRoot must be
554
+ // the exact canonical Crew-owned paths (resolved, no suffix games) and a
555
+ // parked prior root must be a direct child of the harness home matching
556
+ // the lifecycle's own runtime-prev-* naming. A missing or invalid segment
557
+ // fails closed and touches nothing.
558
+ if (journal.stage === 'coordinated-update') {
559
+ const validation = validateJournalRuntimeSegment({ home, rt: journal.runtime });
560
+ if (!validation.ok) {
561
+ return { ok: false, code: 'JOURNAL_RUNTIME_INVALID', stage: journal.stage, error: validation.error, file: updateJournalFile({ home }) };
562
+ }
563
+ }
564
+
565
+ // Committed side: pointer must match the FULL journal candidate identity
566
+ // (name + version + path), and the candidate manifest is verified against
567
+ // the JOURNAL candidate name/version (not its own). A rollback journal
568
+ // additionally requires explicit verification: pointer==target alone
569
+ // never means a verified rollback commit (activation + restart +
570
+ // dual-domain verification may never have completed).
571
+ const candidateIdent = journal.candidate ?? null;
572
+ const pointerMatchesCandidate = candidateDir !== null
573
+ && pointer?.path === candidateDir
574
+ && typeof pointer?.name === 'string' && pointer.name.length > 0
575
+ && typeof pointer?.version === 'string' && pointer.version.length > 0
576
+ && pointer.name === candidateIdent?.name
577
+ && pointer.version === candidateIdent?.version;
578
+ if (candidateDir && pointer?.path === candidateDir && !pointerMatchesCandidate) {
579
+ return { ok: false, code: 'JOURNAL_POINTER_DIVERGED', stage: journal.stage, error: 'pointer path matches candidate but name/version do not; refusing recovery' };
580
+ }
581
+ if (journal.stage === 'rollback' && pointerMatchesCandidate && journal.verified !== true) {
582
+ return { ok: false, code: 'JOURNAL_ROLLBACK_UNVERIFIED', stage: journal.stage, error: 'rollback journal not marked verified; refusing finalize' };
583
+ }
584
+ if (journal.stage === 'coordinated-update' && pointerMatchesCandidate && journal.verified !== true) {
585
+ return { ok: false, code: 'JOURNAL_COORDINATED_UNVERIFIED', stage: journal.stage, error: 'coordinated-update journal not marked verified; refusing finalize' };
586
+ }
587
+ if (journal.stage === 'coordinated-update' && pointerMatchesCandidate) {
588
+ // Coordinated commit also requires the live runtime to report the
589
+ // candidate cohort. We cannot reach the network synchronously here, so
590
+ // verify against the journal's recorded runtime roots: the live tree must
591
+ // BE the candidate runtime (candidateRoot moved onto liveRoot).
592
+ const rt = journal.runtime ?? null;
593
+ if (rt?.candidateVersion && rt?.liveRoot) {
594
+ const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
595
+ if (liveVersion !== rt.candidateVersion) {
596
+ return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_MISMATCH', stage: journal.stage, error: `live runtime is ${liveVersion ?? 'unknown'} but coordinated journal expects ${rt.candidateVersion}; refusing finalize` };
597
+ }
598
+ }
599
+ }
600
+ if (pointerMatchesCandidate) {
601
+ if (!candidateManifest?.name || !candidateManifest?.version) {
602
+ return { ok: false, code: 'JOURNAL_CANDIDATE_INVALID', stage: journal.stage };
603
+ }
604
+ const validated = validateInstalledPayload(candidateDir, { expectedName: candidateIdent.name, expectedVersion: candidateIdent.version });
605
+ if (!validated.ok) {
606
+ return { ok: false, code: 'JOURNAL_CANDIDATE_INVALID', stage: journal.stage, error: (validated.errors ?? []).join('; ') };
607
+ }
608
+ clearUpdateJournal({ home });
609
+ gcOldReleases({ home, protect: journal.prior?.path ?? null });
610
+ log(`- recovered update journal at stage ${journal.stage}: candidate ${candidateIdent.version} already committed, finalized`);
611
+ return { ok: true, reconciled: true, stage: journal.stage, committed: true };
612
+ }
613
+
614
+ // Diverged side: pointer references neither candidate nor prior. No
615
+ // authority to decide which release should win: fail closed, touch
616
+ // nothing (no registration change, no release delete, no journal clear).
617
+ const priorPath = journal.prior?.path ?? null;
618
+ const priorMatches = priorPath !== null
619
+ && pointer?.path === priorPath
620
+ && pointer?.name === journal.prior?.name
621
+ && pointer?.version === journal.prior?.version;
622
+ const isAbsent = !pointer;
623
+ if (!priorMatches && !isAbsent) {
624
+ return { ok: false, code: 'JOURNAL_POINTER_DIVERGED', stage: journal.stage, error: `pointer references unexpected release ${pointer?.path ?? 'unknown'}; refusing recovery` };
625
+ }
626
+
627
+ // Pre-commit side: prior stays authoritative. Re-point live activation
628
+ // surfaces back at prior (a crash between activation and pointer write
629
+ // leaves them on the candidate), then drop the candidate. A rollback
630
+ // journal's candidate is a RETAINED release, never an orphan stage: it
631
+ // must be preserved even when the pointer still references prior. A
632
+ // coordinated-update journal additionally carries a runtime segment: a
633
+ // crash mid-swap may have left the live runtime on the CANDIDATE cohort
634
+ // while the pointer (and payload) still reference prior — that unsupported
635
+ // combination must be restored to the prior cohort synchronously here.
636
+ if (priorPath) {
637
+ if (!existsSync(journal.prior.path)) {
638
+ return { ok: false, code: 'JOURNAL_PRIOR_MISSING', stage: journal.stage, error: 'prior release disappeared; refusing silent recovery' };
639
+ }
640
+ const rt = journal.stage === 'coordinated-update' ? journal.runtime ?? null : null;
641
+ if (rt?.priorVersion && rt?.liveRoot) {
642
+ const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
643
+ if (liveVersion !== rt.priorVersion) {
644
+ // Move the parked prior tree back onto liveRoot. This is a pure
645
+ // directory swap: no network, no registry, safe under a stale lock.
646
+ const parked = rt.priorRoot && existsSync(rt.priorRoot) ? rt.priorRoot : null;
647
+ if (!parked && rt.candidateRoot && existsSync(rt.candidateRoot)) {
648
+ // Fall back: liveRoot may hold the candidate tree and the prior
649
+ // tree may have been consumed into retained-runtimes. Attempt the
650
+ // retained path next.
651
+ }
652
+ if (parked) {
653
+ try {
654
+ rmSync(rt.liveRoot, { recursive: true, force: true });
655
+ renameSync(parked, rt.liveRoot);
656
+ } catch (error) {
657
+ return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: `prior runtime restore failed: ${error?.message ?? error}` };
658
+ }
659
+ } else {
660
+ // No parked tree: try the retained-runtimes/<priorVersion> copy.
661
+ const retainedDir = rt.retainedRoot ? join(rt.retainedRoot, rt.priorVersion) : null;
662
+ let restored = false;
663
+ if (retainedDir && existsSync(retainedDir)) {
664
+ try {
665
+ rmSync(rt.liveRoot, { recursive: true, force: true });
666
+ renameSync(retainedDir, rt.liveRoot);
667
+ restored = true;
668
+ } catch { /* fall through to fail closed */ }
669
+ }
670
+ if (!restored) {
671
+ return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: 'prior runtime tree unavailable for synchronous recovery; run update --candidate to re-migrate' };
672
+ }
673
+ }
674
+ log(`- coordinated recovery: restored prior DSH runtime (@${rt.priorVersion})`);
675
+ }
676
+ }
677
+ const priorManifest = readManifest(journal.prior.path);
678
+ const compensated = compensateActivationSync({ home, prior: journal.prior, manifest: priorManifest, log, installer });
679
+ if (!compensated.ok) {
680
+ return { ok: false, code: 'JOURNAL_COMPENSATE_FAILED', stage: journal.stage, error: compensated.error ?? compensated.code };
681
+ }
682
+ if (journal.stage !== 'rollback' && journal.stage !== 'coordinated-update' && candidateDir && existsSync(candidateDir)) {
683
+ // A coordinated-update candidate stage is consumed into the runtime
684
+ // swap already; if it still exists it is an orphan of the failed swap
685
+ // and may be removed. (rollback candidates are retained releases.)
686
+ try { rmSync(candidateDir, { recursive: true, force: true }); } catch {}
687
+ }
688
+ clearUpdateJournal({ home });
689
+ log(`- recovered update journal at stage ${journal.stage}: restored prior release ${journal.prior.version}`);
690
+ return { ok: true, reconciled: true, stage: journal.stage, committed: false };
691
+ }
692
+
693
+ // First-install pre-commit: no prior exists. Undo the candidate's
694
+ // activation surfaces FIRST (a crash between activation and pointer
695
+ // write leaves the profile link on the candidate), then remove the
696
+ // orphan candidate pointer + dir. Journal clears only after successful
697
+ // compensation.
698
+ const undone = undoCandidateActivationSync({ home, candidateDir, candidateName: journal.candidate?.name ?? null });
699
+ if (!undone.ok) {
700
+ return { ok: false, code: 'JOURNAL_UNDO_FAILED', stage: journal.stage, error: undone.error ?? undone.code };
701
+ }
702
+ if (candidateDir && existsSync(candidateDir)) {
703
+ try { rmSync(candidateDir, { recursive: true, force: true }); } catch {}
704
+ }
705
+ if (pointer && candidateDir && pointer.path === candidateDir) {
706
+ try { rmSync(currentPointerFile({ home }), { force: true }); } catch {}
707
+ }
708
+ clearUpdateJournal({ home });
709
+ log(`- recovered first-install journal at stage ${journal.stage}: removed orphan candidate`);
710
+ return { ok: true, reconciled: true, stage: journal.stage, committed: false };
711
+ }
712
+
146
713
  // ---- dependency tree materialization ----------------------------------------
147
714
 
148
715
  function listPackageEdges(manifest) {
@@ -468,24 +1035,53 @@ export function validateInstalledPayload(dir, { expectedName, expectedVersion, a
468
1035
  }
469
1036
 
470
1037
  // ---- release commit / activation ---------------------------------------------
1038
+ // Transactional commit: stage -> journal(activating) -> activation ->
1039
+ // pointer write LAST -> clear journal -> GC (prior protected until commit).
1040
+ // A crash at any point before the pointer write leaves the prior release
1041
+ // authoritative and the journal behind for reconcileUpdateJournal.
1042
+ export function beginReleaseActivation({ stageDir, manifest, home, prior = null }) {
1043
+ // Journal FIRST, marker removal second: a crash between the two leaves a
1044
+ // journaled (recoverable) candidate, never a marker-free orphan that
1045
+ // looks complete but was never in a transaction. Marker removal failure
1046
+ // aborts the transaction instead of proceeding with a half-marked stage.
1047
+ writeUpdateJournal({
1048
+ home,
1049
+ stage: 'activating',
1050
+ prior: prior ? { name: prior.name, version: prior.version, path: prior.path } : null,
1051
+ candidate: { name: manifest.name, version: manifest.version, stageDir },
1052
+ });
1053
+ try {
1054
+ rmSync(join(stageDir, INCOMPLETE_MARKER), { force: true });
1055
+ } catch (error) {
1056
+ throw Object.assign(new Error(`cannot clear stage marker: ${error?.message ?? error}`), { code: 'STAGE_MARKER_REMOVE_FAILED' });
1057
+ }
1058
+ if (existsSync(join(stageDir, INCOMPLETE_MARKER))) {
1059
+ throw Object.assign(new Error('stage marker still present after removal'), { code: 'STAGE_MARKER_REMOVE_FAILED' });
1060
+ }
1061
+ return stageDir;
1062
+ }
471
1063
 
472
- function commitStagedRelease({ stageDir, manifest, home }) {
473
- rmSync(join(stageDir, INCOMPLETE_MARKER));
1064
+ export function commitActivatedRelease({ stageDir, manifest, home, prior = null }) {
474
1065
  writeCurrentPointer({ home, name: manifest.name, version: manifest.version, path: stageDir });
475
- gcOldReleases({ home });
1066
+ clearUpdateJournal({ home });
1067
+ gcOldReleases({ home, protect: prior?.path ?? null });
476
1068
  return stageDir;
477
1069
  }
478
1070
 
1071
+ function commitStagedRelease({ stageDir, manifest, home, prior = null }) {
1072
+ return commitActivatedRelease({ stageDir, manifest, home, prior });
1073
+ }
1074
+
479
1075
  const STALE_INCOMPLETE_MS = 24 * 60 * 60 * 1000;
480
1076
 
481
- function gcOldReleases({ home, keep = KEEP_RELEASES }) {
1077
+ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
482
1078
  const pointer = readCurrentPointer({ home });
483
1079
  const releasesDir = crewReleasesDir({ home });
484
1080
  if (!existsSync(releasesDir)) return;
485
1081
  const removed = [];
486
1082
  const dirs = readdirSync(releasesDir)
487
1083
  .map((name) => join(releasesDir, name))
488
- .filter((dir) => !pointer || dir !== pointer.path);
1084
+ .filter((dir) => (!pointer || dir !== pointer.path) && (!protect || dir !== protect));
489
1085
  const incomplete = [];
490
1086
  const complete = [];
491
1087
  for (const dir of dirs) {
@@ -532,14 +1128,65 @@ export function listManagedReleases({ home = homedir() } = {}) {
532
1128
  .sort((a, b) => compareVersions(b.version, a.version) || b.path.localeCompare(a.path));
533
1129
  }
534
1130
 
1131
+ // Unified restart client: request a Crew 3210 restart through the durable
1132
+ // supervisor control channel. The hub (3210) writes a restart request; the
1133
+ // Windows supervisor executes it and writes a VERIFIED result. This polls
1134
+ // until the result arrives (or the timeout elapses). NEVER talks to the
1135
+ // legacy 3080 supervisor endpoint.
1136
+ export async function requestCrewRuntimeRestart({
1137
+ reason = null,
1138
+ fetchImpl = globalThis.fetch,
1139
+ pollIntervalMs = 1_000,
1140
+ timeoutMs = 90_000,
1141
+ log = () => {},
1142
+ } = {}) {
1143
+ const base = 'http://127.0.0.1:3210/_dsh/dsh-crew/runtime';
1144
+ let created;
1145
+ try {
1146
+ const response = await fetchImpl(`${base}/restart-request`, {
1147
+ method: 'POST',
1148
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
1149
+ body: JSON.stringify({ confirm: true, reason }),
1150
+ });
1151
+ created = await response.json();
1152
+ if (!response.ok || created?.ok !== true) {
1153
+ return { ok: false, code: created?.code ?? 'CREW_3210_RESTART_REQUEST_FAILED', error: created?.error ?? `restart request failed (HTTP ${response.status})` };
1154
+ }
1155
+ } catch (error) {
1156
+ return { ok: false, code: 'CREW_3210_RESTART_REQUEST_UNREACHABLE', error: String(error?.message ?? error) };
1157
+ }
1158
+ const requestId = created.request_id;
1159
+ const deadline = Date.now() + timeoutMs;
1160
+ for (;;) {
1161
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1162
+ if (Date.now() > deadline) {
1163
+ return { ok: false, code: 'CREW_3210_RESTART_TIMEOUT', error: `restart request ${requestId} not verified within ${timeoutMs}ms`, request_id: requestId };
1164
+ }
1165
+ try {
1166
+ const statusResponse = await fetchImpl(`${base}/restart-status?id=${encodeURIComponent(requestId)}`, { headers: { accept: 'application/json' } });
1167
+ const status = await statusResponse.json();
1168
+ if (!statusResponse.ok || status?.ok !== true) {
1169
+ // 404 while the watcher has not yet picked the request up is normal.
1170
+ if (statusResponse.status === 404) continue;
1171
+ return { ok: false, code: status?.code ?? 'CREW_3210_RESTART_STATUS_FAILED', error: status?.error ?? 'restart status query failed', request_id: requestId };
1172
+ }
1173
+ if (status.state === 'VERIFIED') {
1174
+ log(`- Crew 3210 restart verified (request ${requestId})`);
1175
+ return { ok: true, state: 'VERIFIED', request_id: requestId, detail: status.detail ?? null };
1176
+ }
1177
+ if (status.state === 'RESTART_REQUEST_EXPIRED' || status.state === 'SUPERVISOR_OWNERSHIP_CONFLICT' || status.state === 'SUPERVISOR_STOP_FAILED' || status.state === 'VERIFY_FAILED') {
1178
+ return { ok: false, code: `CREW_3210_RESTART_${status.state}`, error: `restart ended in state ${status.state}`, request_id: requestId, detail: status.detail ?? null };
1179
+ }
1180
+ // RESTART_REQUESTED: still pending; keep polling.
1181
+ } catch (error) {
1182
+ // Hub may be briefly down mid-restart; keep polling until the deadline.
1183
+ log(`- restart poll transient error: ${String(error?.message ?? error)}`);
1184
+ }
1185
+ }
1186
+ }
1187
+
535
1188
  async function restartOwnedRuntime(fetchImpl = globalThis.fetch) {
536
- const response = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
537
- method: 'POST',
538
- headers: { accept: 'application/json', 'content-type': 'application/json' },
539
- body: JSON.stringify({ confirm: true }),
540
- });
541
- const body = await response.json();
542
- return response.ok && body?.ok === true ? body : { ok: false, code: body?.code ?? 'CREW_3210_RESTART_FAILED' };
1189
+ return requestCrewRuntimeRestart({ fetchImpl, reason: 'lifecycle restart' });
543
1190
  }
544
1191
 
545
1192
  async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
@@ -550,6 +1197,262 @@ async function verifyRuntimeVersion(version, fetchImpl = globalThis.fetch) {
550
1197
  : { ok: false, code: 'RUNTIME_VERSION_MISMATCH' };
551
1198
  }
552
1199
 
1200
+ // Cohort verifier: compares the TARGET DSH cohort against the Hub's
1201
+ // dsh_version domain (the installed @deepseek-ai/dsh package), NEVER
1202
+ // against Crew's own runtime_version (the dsh-crew release). A null
1203
+ // dsh_version means unknown cohort and fails closed.
1204
+ export async function verifyCrewDshCohort(version, fetchImpl = globalThis.fetch) {
1205
+ let body = null;
1206
+ try {
1207
+ const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
1208
+ body = await response.json();
1209
+ if (!response.ok) return { ok: false, code: 'DSH_COHORT_UNREACHABLE' };
1210
+ } catch (error) {
1211
+ return { ok: false, code: 'DSH_COHORT_UNREACHABLE', error: String(error?.message ?? error) };
1212
+ }
1213
+ const dshVersion = body?.extension?.runtime?.dsh_version ?? body?.runtime?.dsh_version ?? null;
1214
+ if (typeof dshVersion !== 'string' || dshVersion.length === 0) {
1215
+ return { ok: false, code: 'DSH_COHORT_UNKNOWN' };
1216
+ }
1217
+ return dshVersion === version
1218
+ ? { ok: true, dsh_version: dshVersion }
1219
+ : { ok: false, code: 'DSH_COHORT_MISMATCH', installed: dshVersion, target: version };
1220
+ }
1221
+
1222
+ // Full Crew-owned 3210 identity check: service + execution plane + profile
1223
+ // + port + non-empty runtime_id, plus the exact target COHORT version read
1224
+ // from the dsh_version domain (the installed @deepseek-ai/dsh package).
1225
+ // Never compares the cohort against Crew's own runtime_version.
1226
+ export async function verifyCrewRuntimeIdentity(version, fetchImpl = globalThis.fetch) {
1227
+ let body = null;
1228
+ try {
1229
+ const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
1230
+ body = await response.json();
1231
+ if (!response.ok) return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE' };
1232
+ } catch (error) {
1233
+ return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE', error: String(error?.message ?? error) };
1234
+ }
1235
+ const runtime = body?.extension?.runtime ?? null;
1236
+ const checks = [
1237
+ body?.ok === true,
1238
+ runtime?.service === 'dsh-crew-hub',
1239
+ runtime?.execution_plane === 'hub-3210',
1240
+ runtime?.profile === 'dsh-crew',
1241
+ Number(runtime?.listen_port) === 3210,
1242
+ typeof runtime?.runtime_id === 'string' && runtime.runtime_id.trim().length > 0,
1243
+ ];
1244
+ if (!checks.every(Boolean)) return { ok: false, code: 'RUNTIME_IDENTITY_MISMATCH' };
1245
+ const cohort = await verifyCrewDshCohort(version, fetchImpl);
1246
+ if (!cohort.ok) return cohort;
1247
+ return { ok: true, runtime_id: runtime.runtime_id, dsh_version: cohort.dsh_version };
1248
+ }
1249
+
1250
+ // Dual-domain post-restart verifier for rollback: the restarted 3210 must
1251
+ // report BOTH the expected Crew release version (runtime_version domain)
1252
+ // AND the expected DSH cohort (dsh_version domain), alongside full 3210
1253
+ // identity. A stale old-Crew process on the right cohort (or vice versa)
1254
+ // fails closed instead of reporting rollback success.
1255
+ export async function verifyRollbackTarget({ crewVersion, dshVersion, fetchImpl = globalThis.fetch } = {}) {
1256
+ let body = null;
1257
+ try {
1258
+ const response = await fetchImpl('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
1259
+ body = await response.json();
1260
+ if (!response.ok) return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE' };
1261
+ } catch (error) {
1262
+ return { ok: false, code: 'RUNTIME_IDENTITY_UNREACHABLE', error: String(error?.message ?? error) };
1263
+ }
1264
+ const runtime = body?.extension?.runtime ?? null;
1265
+ const checks = [
1266
+ body?.ok === true,
1267
+ runtime?.service === 'dsh-crew-hub',
1268
+ runtime?.execution_plane === 'hub-3210',
1269
+ runtime?.profile === 'dsh-crew',
1270
+ Number(runtime?.listen_port) === 3210,
1271
+ typeof runtime?.runtime_id === 'string' && runtime.runtime_id.trim().length > 0,
1272
+ ];
1273
+ if (!checks.every(Boolean)) return { ok: false, code: 'RUNTIME_IDENTITY_MISMATCH' };
1274
+ if (typeof crewVersion === 'string' && runtime?.runtime_version !== crewVersion) {
1275
+ return { ok: false, code: 'CREW_VERSION_MISMATCH', installed: runtime?.runtime_version ?? null, target: crewVersion };
1276
+ }
1277
+ if (typeof dshVersion === 'string') {
1278
+ const cohort = await verifyCrewDshCohort(dshVersion, fetchImpl);
1279
+ if (!cohort.ok) return cohort;
1280
+ return { ok: true, runtime_id: runtime.runtime_id, runtime_version: runtime?.runtime_version ?? null, dsh_version: cohort.dsh_version };
1281
+ }
1282
+ return { ok: true, runtime_id: runtime.runtime_id, runtime_version: runtime?.runtime_version ?? null };
1283
+ }
1284
+
1285
+ // Crew-owned stop/start for cohort migration go through the Windows
1286
+ // launcher supervisor (the ONLY process authority) via durable maintenance
1287
+ // requests. The npx process owns the runtime TREE swap; the supervisor owns
1288
+ // the PROCESS stop/start. The Node sidecar supervisor is deliberately not
1289
+ // used here: two authorities must never share the same kill rights.
1290
+ function crewSupervisor({ home = homedir() } = {}) {
1291
+ // The ONLY Crew app root both the hub and the Windows launcher agree on.
1292
+ // Requests written anywhere else are invisible to the supervisor.
1293
+ const appRoot = join(home, '.config', 'dsh-crew');
1294
+ const pollMs = 1_000;
1295
+ const timeoutMs = 90_000;
1296
+ // One maintenance transaction = one lease + the pre-stop identity proven
1297
+ // live. stop() fetches the CURRENT live runtime_id (the process exists);
1298
+ // start() reuses that proven identity + lease (the process is stopped by
1299
+ // design and cannot re-prove itself). This pairs stop and start into a
1300
+ // single one-shot transaction instead of two independent requests.
1301
+ let transaction = null;
1302
+ async function fetchCrewIdentity() {
1303
+ try {
1304
+ const response = await fetch('http://127.0.0.1:3210/_dsh/dsh-crew/extension', { headers: { accept: 'application/json' } });
1305
+ if (!response.ok) return null;
1306
+ const body = await response.json();
1307
+ const runtime = body?.extension?.runtime ?? null;
1308
+ return runtime?.runtime_id ? { execution_plane: 'hub-3210', profile: 'dsh-crew', listen_port: 3210, runtime_id: runtime.runtime_id } : null;
1309
+ } catch { return null; }
1310
+ }
1311
+ async function writeMaintenance({ operation, lease, identity, extra = null }) {
1312
+ const { createSupervisorRequest, maintenanceResultsDir } = await import('../supervisor/restart-request.mjs');
1313
+ const created = createSupervisorRequest({
1314
+ appRoot,
1315
+ operation,
1316
+ runtimeIdentity: identity,
1317
+ lease,
1318
+ extra,
1319
+ });
1320
+ if (!created.ok) return created;
1321
+ const deadline = Date.now() + timeoutMs;
1322
+ for (;;) {
1323
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
1324
+ if (Date.now() > deadline) {
1325
+ return { ok: false, code: 'MAINTENANCE_TIMEOUT', error: `${operation} ${created.request.request_id} not completed within ${timeoutMs}ms` };
1326
+ }
1327
+ const resultFile = join(maintenanceResultsDir(appRoot), `${created.request.request_id}.json`);
1328
+ let result = null;
1329
+ try {
1330
+ if (existsSync(resultFile)) {
1331
+ result = JSON.parse(readFileSync(resultFile, 'utf8'));
1332
+ }
1333
+ } catch { /* not yet */ }
1334
+ if (!result || result.request_id !== created.request.request_id) continue;
1335
+ if (result.state === 'STOPPED' || result.state === 'VERIFIED') {
1336
+ return { ok: true, state: result.state, request_id: result.request_id, detail: result.detail ?? null };
1337
+ }
1338
+ return { ok: false, code: `MAINTENANCE_${result.state ?? 'FAILED'}`, error: `maintenance ended in state ${result.state}`, detail: result.detail ?? null };
1339
+ }
1340
+ }
1341
+ return {
1342
+ stopOwnedBackend: async () => {
1343
+ const identity = await fetchCrewIdentity();
1344
+ if (!identity?.runtime_id) {
1345
+ transaction = null;
1346
+ return { ok: false, code: 'MAINTENANCE_IDENTITY_UNAVAILABLE', error: 'live 3210 runtime_id unavailable' };
1347
+ }
1348
+ transaction = { lease: `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, runtime_id: identity.runtime_id };
1349
+ const result = await writeMaintenance({ operation: 'maintenance-stop', lease: transaction.lease, identity });
1350
+ if (!result.ok) {
1351
+ // A failed stop must not leave a usable lease behind: clear it so a
1352
+ // later startOwnedBackend() fails closed instead of presenting a
1353
+ // stale transaction.
1354
+ transaction = null;
1355
+ }
1356
+ return result;
1357
+ },
1358
+ startOwnedBackend: async () => {
1359
+ // Reuse the proven stop-phase identity + lease: the process is stopped
1360
+ // by design and fetching identity now would always fail.
1361
+ if (!transaction?.lease || !transaction?.runtime_id) {
1362
+ return { ok: false, code: 'MAINTENANCE_NO_TRANSACTION', error: 'maintenance-start requires a completed maintenance-stop in the same supervisor' };
1363
+ }
1364
+ const identity = { execution_plane: 'hub-3210', profile: 'dsh-crew', listen_port: 3210, runtime_id: transaction.runtime_id };
1365
+ const result = await writeMaintenance({ operation: 'maintenance-start', lease: transaction.lease, identity });
1366
+ transaction = null;
1367
+ return result;
1368
+ },
1369
+ };
1370
+ }
1371
+
1372
+ // ---- release cohort resolution -------------------------------------------------
1373
+
1374
+ // Lifecycle-owned cohort sidecar for a managed release directory. Historical
1375
+ // releases (pre-1.0.4) do not pin @deepseek-ai/dsh in their immutable
1376
+ // manifest; this records the cohort fact the lifecycle observed for them.
1377
+ const cohortFile = (releaseDir) => join(releaseDir, 'release-cohort.json');
1378
+
1379
+ // Exact DSH cohort version helper: delegates to the module-level regex.
1380
+ export function isExactDshVersion(value) {
1381
+ return typeof value === 'string' && EXACT_DSH_VERSION_RE.test(value);
1382
+ }
1383
+
1384
+ export function writeReleaseCohort({ releaseDir, dshVersion, source }) {
1385
+ if (!releaseDir || !existsSync(releaseDir) || !isExactDshVersion(dshVersion)) return null;
1386
+ try {
1387
+ const record = {
1388
+ schema_version: 1,
1389
+ release: readManifest(releaseDir)?.version ?? null,
1390
+ dsh_version: dshVersion,
1391
+ source: source ?? 'observed',
1392
+ recorded_at: isoNow(),
1393
+ };
1394
+ writeFileAtomic(cohortFile(releaseDir), JSON.stringify(record, null, 2) + '\n');
1395
+ return record;
1396
+ } catch { return null; }
1397
+ }
1398
+
1399
+ // Read + validate the cohort sidecar. Returns:
1400
+ // { ok: true, dshVersion } valid sidecar
1401
+ // { ok: false, present: false } no sidecar file
1402
+ // { ok: false, present: true, reason } sidecar EXISTS but is invalid —
1403
+ // callers MUST fail closed on this (never silently fall back to live
1404
+ // discovery, because the recorded value may be corrupt and would feed
1405
+ // rmSync/rename authority).
1406
+ function readReleaseCohortSidecar(releaseDir) {
1407
+ const file = cohortFile(releaseDir);
1408
+ if (!existsSync(file)) return { ok: false, present: false };
1409
+ let parsed;
1410
+ try {
1411
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
1412
+ } catch {
1413
+ return { ok: false, present: true, reason: 'sidecar unparseable' };
1414
+ }
1415
+ if (parsed?.schema_version !== 1) {
1416
+ return { ok: false, present: true, reason: 'sidecar schema_version invalid' };
1417
+ }
1418
+ if (!isExactDshVersion(parsed.dsh_version)) {
1419
+ return { ok: false, present: true, reason: `sidecar dsh_version is not an exact version (${String(parsed.dsh_version)})` };
1420
+ }
1421
+ const manifest = readManifest(releaseDir);
1422
+ if (typeof parsed.release !== 'string' || parsed.release.length === 0 || parsed.release !== manifest?.version) {
1423
+ return { ok: false, present: true, reason: 'sidecar release identity does not match the release manifest' };
1424
+ }
1425
+ return { ok: true, dshVersion: parsed.dsh_version };
1426
+ }
1427
+
1428
+ // Resolve the DSH cohort a managed release corresponds to. Priority:
1429
+ // 1. exact @deepseek-ai/dsh pin in the release manifest (dependencies,
1430
+ // then peerDependencies)
1431
+ // 2. lifecycle-owned sidecar (release-cohort.json) — if the sidecar EXISTS
1432
+ // but is invalid, FAIL CLOSED (return {invalid:true}) instead of falling
1433
+ // back, because the corrupt value must never be silently ignored.
1434
+ // 3. legacy discovery: the release predates cohort awareness; fall back to
1435
+ // the current live runtime tree's cohort ONLY when explicitly allowed
1436
+ // (the caller is the forward-upgrade path that will immediately migrate
1437
+ // to the target). Never used for rollback targeting.
1438
+ // Returns { ok:true, dshVersion } | { ok:false, code } | { ok:false, code,
1439
+ // invalid:true } when a present-but-invalid sidecar blocks resolution.
1440
+ export function resolveReleaseCohort({ releaseDir, allowLegacyLiveFallback = false, readRuntimeVersion = () => null } = {}) {
1441
+ const manifest = readManifest(releaseDir);
1442
+ const pinned = payloadDshVersion(manifest);
1443
+ if (pinned) return { ok: true, dshVersion: pinned };
1444
+ const sidecar = readReleaseCohortSidecar(releaseDir);
1445
+ if (sidecar.ok) return { ok: true, dshVersion: sidecar.dshVersion };
1446
+ if (sidecar.present) {
1447
+ return { ok: false, code: 'RELEASE_COHORT_SIDECAR_INVALID', invalid: true, error: sidecar.reason ?? 'sidecar invalid' };
1448
+ }
1449
+ if (allowLegacyLiveFallback) {
1450
+ const live = readRuntimeVersion();
1451
+ if (isExactDshVersion(live)) return { ok: true, dshVersion: live };
1452
+ }
1453
+ return { ok: false, code: 'RELEASE_COHORT_UNKNOWN' };
1454
+ }
1455
+
553
1456
  export async function npxReleases({ home = homedir(), log = console.log } = {}) {
554
1457
  const releases = listManagedReleases({ home });
555
1458
  log(JSON.stringify(releases, null, 2));
@@ -564,11 +1467,30 @@ export async function npxRollback({
564
1467
  ensureRuntime,
565
1468
  validatePayload = validateInstalledPayload,
566
1469
  activate,
567
- restart = () => restartOwnedRuntime(),
568
- verifyRuntime = (targetVersion) => verifyRuntimeVersion(targetVersion),
1470
+ restart,
1471
+ verifyRuntime,
1472
+ supervisorFactory = crewSupervisor,
569
1473
  } = {}) {
570
1474
  const targetVersion = typeof version === 'string' ? version.trim() : '';
571
1475
  if (!targetVersion) return { ok: false, error: 'rollback requires a target version' };
1476
+ // Rollback shares the update mutual-exclusion lock: it mutates the same
1477
+ // pointer/registration/runtime surfaces as install/update, and the watch
1478
+ // supervisor must observe-only while it runs.
1479
+ const updateLock = acquireUpdateLock({ home });
1480
+ if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
1481
+ try {
1482
+ return await npxRollbackInner({ home, version: targetVersion, log, installer, ensureRuntime, validatePayload, activate, restart, verifyRuntime, supervisorFactory });
1483
+ } finally {
1484
+ releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
1485
+ }
1486
+ }
1487
+
1488
+ async function npxRollbackInner({ home, version: targetVersion, log, installer, ensureRuntime, validatePayload, activate, restart, verifyRuntime, supervisorFactory }) {
1489
+ // Journal-aware entry: refuse to overwrite a journal left by a crashed
1490
+ // transaction. Reconcile it first (or fail closed) instead of silently
1491
+ // replacing it with the rollback intent.
1492
+ const pending = reconcileUpdateJournal({ home, log });
1493
+ if (!pending.ok) return { ok: false, error: `refusing rollback with unreconciled journal (${pending.code ?? 'unknown'})` };
572
1494
  const current = readCurrentPointer({ home });
573
1495
  if (!current?.path || !existsSync(current.path)) return { ok: false, error: 'no active Crew payload to roll back' };
574
1496
  const target = listManagedReleases({ home }).find((release) => release.version === targetVersion);
@@ -578,39 +1500,175 @@ export async function npxRollback({
578
1500
  const validation = validatePayload(target.path, { expectedName: targetManifest?.name, expectedVersion: targetManifest?.version });
579
1501
  if (!validation.ok) return { ok: false, error: 'target release failed payload validation' };
580
1502
  const previousManifest = readManifest(current.path);
1503
+ // Cross-cohort rollback: derive the DSH cohort each payload corresponds to
1504
+ // through the FULL resolution chain (manifest exact pin -> lifecycle
1505
+ // release-cohort.json sidecar). Never assume the running code's TARGET:
1506
+ // 1.0.4/rc.1 rolling back to 1.0.3 must restore alpha.5, not verify
1507
+ // against rc.1. A present-but-invalid sidecar fails closed (its recorded
1508
+ // value would otherwise feed rmSync/rename authority). The production path
1509
+ // (no injected verifyRuntime) fails closed when the cohort is unknown;
1510
+ // callers that inject their own verifier/restart mocks (tests, custom
1511
+ // drivers) own the cohort contract and may leave it unset, in which case
1512
+ // no runtime swap is attempted.
1513
+ const productionVerify = typeof verifyRuntime !== 'function';
1514
+ const targetCohort = resolveReleaseCohort({ releaseDir: target.path, allowLegacyLiveFallback: false });
1515
+ const priorCohort = resolveReleaseCohort({ releaseDir: current.path, allowLegacyLiveFallback: false });
1516
+ if (targetCohort.invalid || priorCohort.invalid) {
1517
+ return { ok: false, code: 'RELEASE_COHORT_SIDECAR_INVALID', error: targetCohort.invalid ? targetCohort.error : priorCohort.error };
1518
+ }
1519
+ const targetDshVersion = targetCohort.ok ? targetCohort.dshVersion : null;
1520
+ const priorDshVersion = priorCohort.ok ? priorCohort.dshVersion : null;
1521
+ const cohortKnown = targetDshVersion !== null && priorDshVersion !== null;
1522
+ if (productionVerify && !cohortKnown) {
1523
+ return { ok: false, code: 'RELEASE_DSH_COHORT_UNKNOWN', error: 'release cohort cannot be resolved (no manifest pin and no valid sidecar)' };
1524
+ }
581
1525
  const activateReleaseFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer, ensureRuntime }));
1526
+ // Direct-owned restart/verify through ONE supervisor instance. No legacy
1527
+ // 3080 bridge: rollback must work with the bridge fully absent.
1528
+ const supervisor = supervisorFactory({ home });
1529
+ const stopFn = () => supervisor.stopOwnedBackend();
1530
+ const restartFn = restart ?? (async () => {
1531
+ const stopped = await supervisor.stopOwnedBackend();
1532
+ if (!stopped.ok) return stopped;
1533
+ return supervisor.startOwnedBackend();
1534
+ });
1535
+ // Dual-domain post-restart verification: the restarted 3210 must report
1536
+ // BOTH the target Crew release (runtime_version) AND the cohort that
1537
+ // release pins (dsh_version). A stale old-Crew process on the wrong
1538
+ // cohort (or vice versa) fails closed. The verifier is version-parametric
1539
+ // in BOTH domains: target verification checks target crew+cohort,
1540
+ // compensation verification checks prior crew+cohort.
1541
+ const verifyFn = verifyRuntime ?? ((crewVersion, dshVersion) => verifyRollbackTarget({ crewVersion, dshVersion }));
1542
+ // Journal the rollback intent BEFORE switching anything. The rollback
1543
+ // journal carries an explicit verified flag: pointer==target alone does
1544
+ // NOT mean committed until activation + restart + dual verification all
1545
+ // succeed and the journal is marked verified.
1546
+ writeUpdateJournal({
1547
+ home,
1548
+ stage: 'rollback',
1549
+ prior: { name: current.name, version: current.version, path: current.path },
1550
+ candidate: { name: target.name, version: target.version, stageDir: target.path },
1551
+ });
582
1552
  const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
1553
+ // Cohort switch for cross-cohort rollback: when the payload we are rolling
1554
+ // back to pins a different DSH cohort than the one currently live, prepare
1555
+ // the target runtime tree (disk-only, process NOT started) BEFORE
1556
+ // activating the older payload. The 3210 is started exactly ONCE after
1557
+ // both the tree and the payload are in place, so an unsupported
1558
+ // payload/cohort pair is never booted. Prefer the retained offline tree;
1559
+ // a registry re-stage is the last resort (migrateCrewDshRuntime prepares
1560
+ // the live tree in place and restarts it — used only when no retained copy
1561
+ // exists and the caller accepts the network dependency).
1562
+ async function ensureTargetCohort() {
1563
+ if (!cohortKnown) return { ok: true, changed: false, unknown: true, prepared: false };
1564
+ // Decide from the DISK runtime tree, never a live 3210 fetch: the hub
1565
+ // may be down, mid-restart, or a stale process (the very condition the
1566
+ // supervisor cohort check exists to catch). The tree that the next boot
1567
+ // will load is the source of truth for whether a swap is needed.
1568
+ const liveDsh = readRuntimeTreeVersionSync(crewDshRuntimeRoot({ home }));
1569
+ if (liveDsh === targetDshVersion) return { ok: true, changed: false, prepared: false };
1570
+ // Prepare the retained tree WITHOUT starting the 3210 (pair ordering).
1571
+ const prepared = await restoreRetainedRuntime({
1572
+ home,
1573
+ version: targetDshVersion,
1574
+ stopOwned: stopFn,
1575
+ log,
1576
+ prepareOnly: true,
1577
+ });
1578
+ if (prepared.ok) return { ok: true, changed: true, prepared: true, version: targetDshVersion };
1579
+ return { ok: false, code: 'ROLLBACK_RUNTIME_RESTORE_FAILED', error: `cohort ${targetDshVersion} is not retained and cannot be restored offline`, detail: prepared };
1580
+ }
583
1581
  try {
1582
+ const cohort = await ensureTargetCohort();
1583
+ if (!cohort.ok) throw Object.assign(new Error(cohort.error), { code: cohort.code });
1584
+ // Activate the target payload BEFORE any start: with the target tree
1585
+ // already on disk (or the live tree already matching), the 3210 must
1586
+ // never boot while the profile still points at the prior payload.
584
1587
  switchPointer(target);
585
1588
  if (!await activateReleaseFn({ releaseDir: target.path, manifest: targetManifest })) throw new Error('target release activation failed');
586
- const restarted = await restart(target.version);
587
- if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
588
- const runtime = await verifyRuntime(target.version);
589
- if (runtime?.ok !== true) throw Object.assign(new Error('target runtime verification failed'), { code: runtime?.code ?? 'RUNTIME_VERSION_MISMATCH' });
1589
+ // Start ONCE after tree + payload both target the rollback release.
1590
+ let restarted = null;
1591
+ if (cohort.changed) {
1592
+ // Tree was swapped by the prepare step; the process is stopped.
1593
+ restarted = await supervisor.startOwnedBackend();
1594
+ if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
1595
+ } else {
1596
+ restarted = await restartFn(target.version);
1597
+ if (restarted?.ok === false) throw Object.assign(new Error('target runtime restart failed'), { code: restarted.code });
1598
+ }
1599
+ const runtime = await verifyFn(target.version, cohortKnown ? targetDshVersion : undefined);
1600
+ if (runtime?.ok !== true) throw Object.assign(new Error('target runtime verification failed'), { code: runtime?.code ?? 'RUNTIME_IDENTITY_MISMATCH' });
590
1601
  log(`✓ rolled back Crew payload to ${target.version}`);
1602
+ // Persist verification BEFORE clearing: a crash between verify
1603
+ // success and journal clear must still finalize on recovery.
1604
+ const marked = markJournalVerified({
1605
+ home,
1606
+ stage: 'rollback',
1607
+ prior: { name: current.name, version: current.version, path: current.path },
1608
+ candidate: { name: target.name, version: target.version, stageDir: target.path },
1609
+ });
1610
+ if (!marked.ok) throw Object.assign(new Error('rollback journal mark-verified failed'), { code: marked.code ?? 'JOURNAL_MARK_FAILED' });
1611
+ clearUpdateJournal({ home });
591
1612
  return { ok: true, rolled_back: true, version: target.version, path: target.path, restart: restarted, runtime };
592
1613
  } catch (error) {
593
1614
  const prior = { name: current.name, version: current.version, path: current.path };
594
1615
  let recovery;
595
1616
  try {
1617
+ // Pair-ordered compensation: prepare the PRIOR runtime tree (disk only,
1618
+ // process stopped), activate the prior payload, THEN start once and
1619
+ // dual-verify — the 3210 never boots a mismatched pair. When the
1620
+ // cohort contract is unknown (injected mocks own it), skip the runtime
1621
+ // swap and restore the payload only.
1622
+ let processStopped = false;
1623
+ if (cohortKnown) {
1624
+ const priorCohortOk = await restoreRetainedRuntime({
1625
+ home,
1626
+ version: priorDshVersion,
1627
+ stopOwned: stopFn,
1628
+ log,
1629
+ prepareOnly: true,
1630
+ });
1631
+ if (priorCohortOk.ok) {
1632
+ // Retained tree swapped in; the 3210 is stopped and must be
1633
+ // started after the payload activation below.
1634
+ processStopped = true;
1635
+ } else {
1636
+ // Fall back to a registry install at the live root — PREPARE ONLY:
1637
+ // the process stays stopped until the prior payload is activated,
1638
+ // so an unsupported payload/cohort pair is never booted.
1639
+ const m = await migrateCrewDshRuntime({
1640
+ home,
1641
+ version: priorDshVersion,
1642
+ stopOwned: stopFn,
1643
+ log,
1644
+ prepareOnly: true,
1645
+ });
1646
+ if (!m.ok) throw Object.assign(new Error('prior cohort could not be restored'), { stage: 'cohort' });
1647
+ processStopped = true;
1648
+ }
1649
+ }
596
1650
  switchPointer(prior);
597
1651
  if (!previousManifest) throw Object.assign(new Error('previous release manifest unavailable'), { stage: 'activation' });
598
1652
  const activated = await activateReleaseFn({ releaseDir: prior.path, manifest: previousManifest });
599
1653
  if (activated !== true) throw Object.assign(new Error('previous release activation failed'), { stage: 'activation' });
600
- const restarted = await restart(prior.version);
1654
+ // Start ONCE: tree + payload both point at the prior release now.
1655
+ const restarted = processStopped
1656
+ ? await supervisor.startOwnedBackend()
1657
+ : await restartFn(prior.version);
601
1658
  if (restarted?.ok !== true) throw Object.assign(new Error('previous runtime restart failed'), { stage: 'restart' });
602
- const runtime = await verifyRuntime(prior.version);
1659
+ const runtime = await verifyFn(prior.version, cohortKnown ? priorDshVersion : undefined);
603
1660
  if (runtime?.ok !== true) throw Object.assign(new Error('previous runtime verification failed'), { stage: 'verification' });
604
1661
  const restoredPointer = readCurrentPointer({ home });
605
1662
  if (restoredPointer?.path !== prior.path || restoredPointer.version !== prior.version) {
606
1663
  throw Object.assign(new Error('previous release pointer was not restored'), { stage: 'pointer' });
607
1664
  }
608
1665
  recovery = { ok: true, version: prior.version, path: prior.path };
1666
+ clearUpdateJournal({ home });
609
1667
  } catch (recoveryError) {
610
1668
  recovery = {
611
1669
  ok: false,
612
1670
  code: 'RELEASE_ROLLBACK_RECOVERY_FAILED',
613
- stage: ['activation', 'restart', 'verification', 'pointer'].includes(recoveryError?.stage) ? recoveryError.stage : 'unknown',
1671
+ stage: ['cohort', 'activation', 'restart', 'verification', 'pointer'].includes(recoveryError?.stage) ? recoveryError.stage : 'unknown',
614
1672
  };
615
1673
  }
616
1674
  return {
@@ -649,21 +1707,330 @@ export function sanitizedPackageManagerEnv(baseEnv = process.env) {
649
1707
  return env;
650
1708
  }
651
1709
 
652
- async function ensureRuntimeStep({ home, log, ensureRuntime }) {
653
- const ensure = ensureRuntime ?? ((opts) => {
1710
+ // Production migration adapter: builds the migrate callback used by the
1711
+ // default ensureRuntimeStep path. Exactly ONE supervisor instance is
1712
+ // created per migration and reused by stop/start/verify/rollback so the
1713
+ // child identity stays coherent. Exported so tests can prove the
1714
+ // production default wiring (single instance, full identity verify)
1715
+ // instead of hand-injecting three mock callbacks.
1716
+ export function buildProductionMigration({ home, log = () => {}, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor } = {}) {
1717
+ const supervisor = supervisorFactory({ home });
1718
+ return (opts) => migrateCrewDshRuntime({
1719
+ home,
1720
+ version: TARGET_DSH_VERSION,
1721
+ stopOwned: stopOwned ?? (() => supervisor.stopOwnedBackend()),
1722
+ startOwned: startOwned ?? (() => supervisor.startOwnedBackend()),
1723
+ verifyOwned: verifyOwned ?? (() => verifyCrewRuntimeIdentity(TARGET_DSH_VERSION)),
1724
+ ...opts,
1725
+ });
1726
+ }
1727
+
1728
+ async function ensureRuntimeStep({ home, log, ensureRuntime, migrateRuntime, stopOwned, startOwned, verifyOwned, supervisorFactory = crewSupervisor }) { const ensure = ensureRuntime ?? ((opts) => {
654
1729
  const r = ensureCrewDshRuntime({ ...opts, env: sanitizedPackageManagerEnv() });
655
1730
  if (!r.ok && r.stderrTail) {
656
1731
  log(` (runtime installer said: ${r.stderrTail})`);
657
1732
  }
1733
+ if (!r.ok && r.code === 'DSH_RUNTIME_COHORT_MISMATCH') {
1734
+ return { ok: false, code: r.code, error: r.error, installed: r.installed, target: r.target, needsMigration: true };
1735
+ }
658
1736
  return r.ok ? { ok: true, version: r.cli?.version ?? null } : { ok: false, error: r.error ?? r.code ?? 'runtime bootstrap failed' };
659
1737
  });
1738
+ // Production migration wiring: ONE supervisor instance per migration,
1739
+ // reused by stop/start/verify/rollback so child identity stays coherent.
1740
+ // No legacy bridge involved. Missing callbacks fail closed inside
1741
+ // migrateCrewDshRuntime.
1742
+ const migrate = migrateRuntime ?? buildProductionMigration({ home, log, stopOwned, startOwned, verifyOwned, supervisorFactory });
660
1743
  const r = await ensure({ home });
661
- if (!r?.ok) {
662
- log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
663
- return false;
1744
+ if (r?.ok) {
1745
+ log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
1746
+ return true;
1747
+ }
1748
+ if (r?.needsMigration === true) {
1749
+ log(`- runtime cohort stale (${r.installed} -> ${r.target}); migrating via staged transaction`);
1750
+ const m = await migrate({ log });
1751
+ if (!m?.ok) {
1752
+ log(`✗ runtime cohort migration failed: ${m?.error ?? m?.code ?? 'unknown error'}`);
1753
+ return false;
1754
+ }
1755
+ log(`✓ runtime cohort migrated (@${m.version})`);
1756
+ return true;
1757
+ }
1758
+ log(`✗ reusable Crew DSH runtime unavailable: ${r?.error ?? 'unknown error'}`);
1759
+ return false;
1760
+ }
1761
+
1762
+ // ---- coordinated payload + cohort transaction ---------------------------------
1763
+
1764
+ // Perform an update whose candidate payload pins a DIFFERENT DSH cohort than
1765
+ // the currently live one (e.g. 1.0.3/alpha.5 -> 1.0.4/rc.1). The payload
1766
+ // activation and the runtime swap are one transaction so the 3210 never runs
1767
+ // an unsupported payload/cohort combination:
1768
+ //
1769
+ // 1. stage candidate payload (caller did)
1770
+ // 2. stage target DSH cohort (no live touch)
1771
+ // 3. write durable journal (runtime segment included)
1772
+ // 4. stop owned 3210 ONCE
1773
+ // 5. swap runtime live -> retained-prior, staged -> live
1774
+ // 6. activate candidate Crew payload
1775
+ // 7. start owned 3210 ONCE
1776
+ // 8. dual verify: runtime_version == candidate version AND
1777
+ // dsh_version == candidate cohort
1778
+ // 9. mark journal verified
1779
+ // 10. write current.json LAST (commit point)
1780
+ // 11. clear journal
1781
+ //
1782
+ // Any failure before step 10 compensates: restore prior payload + prior
1783
+ // cohort (retained offline tree first, registry re-stage as fallback) and
1784
+ // restart, verifying the prior dual identity before clearing the journal.
1785
+ export async function performCoordinatedCohortUpdate({
1786
+ home = homedir(),
1787
+ log = console.log,
1788
+ prior, // current pointer record
1789
+ priorDshVersion, // cohort pinned by the prior payload
1790
+ candidateManifest, // candidate payload manifest
1791
+ candidateDshVersion, // cohort pinned by the candidate payload
1792
+ stageDir, // validated candidate payload stage dir
1793
+ installer = realInstaller,
1794
+ activate = null, // injectable activation (tests)
1795
+ stopOwned,
1796
+ startOwned,
1797
+ verifyOwned,
1798
+ supervisorFactory = crewSupervisor,
1799
+ stageOptions = {},
1800
+ } = {}) {
1801
+ if (!candidateManifest?.name || !candidateManifest?.version) return { ok: false, error: 'candidate manifest invalid' };
1802
+ if (!prior?.path || !existsSync(prior.path)) return { ok: false, error: 'prior payload missing for coordinated update' };
1803
+ const supervisor = supervisorFactory({ home });
1804
+ const stopFn = stopOwned ?? (() => supervisor.stopOwnedBackend());
1805
+ const startFn = startOwned ?? (() => supervisor.startOwnedBackend());
1806
+ const verifyFn = verifyOwned ?? ((crewVersion, dshVersion) => verifyRollbackTarget({ crewVersion, dshVersion }));
1807
+ const activateFn = activate ?? (({ releaseDir, manifest }) => activateRelease({ home, releaseDir, manifest, log, installer }));
1808
+
1809
+ const liveRoot = crewDshRuntimeRoot({ home });
1810
+ const retainedRoot = join(crewDshHome({ home }), 'retained-runtimes');
1811
+ // Plan the prior-tree parking path BEFORE any destructive step and persist
1812
+ // it in the journal so a crash mid-swap is synchronously recoverable by
1813
+ // reconcileUpdateJournal (it can move priorRoot back onto liveRoot without
1814
+ // needing to know any in-memory nonce).
1815
+ const prevPath = join(crewDshHome({ home }), `runtime-prev-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`);
1816
+
1817
+ // Journal the full coordinated intent BEFORE any destructive step. The
1818
+ // journal carries prior+candidate dshVersion plus runtime roots so a crash
1819
+ // at any point is recoverable by reconcileUpdateJournal.
1820
+ writeUpdateJournal({
1821
+ home,
1822
+ stage: 'coordinated-update',
1823
+ prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
1824
+ candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
1825
+ runtime: {
1826
+ state: 'staged',
1827
+ liveRoot,
1828
+ priorRoot: prevPath,
1829
+ priorVersion: priorDshVersion,
1830
+ candidateVersion: candidateDshVersion,
1831
+ retainedRoot,
1832
+ },
1833
+ });
1834
+
1835
+ const switchPointer = (release) => writeCurrentPointer({ home, name: release.name, version: release.version, path: release.path });
1836
+ const priorRelease = { name: prior.name, version: prior.version, path: prior.path };
1837
+ const candidateRelease = { name: candidateManifest.name, version: candidateManifest.version, path: stageDir };
1838
+
1839
+ async function compensate() {
1840
+ // Pair-ordered compensation: prepare the PRIOR runtime tree (disk only,
1841
+ // process left stopped — whether from the retained tree or a registry
1842
+ // install), activate the prior payload, then start ONCE and dual-verify
1843
+ // prior identity. The 3210 is never started while tree and payload
1844
+ // disagree, and never started twice.
1845
+ // Retained offline tree first (prepare-only)...
1846
+ const priorCohortOk = await restoreRetainedRuntime({
1847
+ home,
1848
+ version: priorDshVersion,
1849
+ stopOwned: stopFn,
1850
+ log,
1851
+ prepareOnly: true,
1852
+ });
1853
+ if (!priorCohortOk.ok) {
1854
+ // ...then registry install at the live root, prepare-only: the process
1855
+ // stays stopped until the matching payload is activated below.
1856
+ const migrated = await migrateCrewDshRuntime({
1857
+ home,
1858
+ version: priorDshVersion,
1859
+ stopOwned: stopFn,
1860
+ log,
1861
+ prepareOnly: true,
1862
+ });
1863
+ if (!migrated.ok) {
1864
+ return { ok: false, code: 'COORDINATED_COMPENSATE_COHORT_FAILED', error: migrated.error ?? 'prior cohort restore failed' };
1865
+ }
1866
+ }
1867
+ const activated = await activateFn({ releaseDir: prior.path, manifest: readManifest(prior.path) });
1868
+ if (activated !== true) return { ok: false, code: 'COORDINATED_COMPENSATE_ACTIVATION_FAILED' };
1869
+ // Start ONCE: tree + payload both point at the prior release now.
1870
+ const restarted = await startFn();
1871
+ if (restarted?.ok !== true) return { ok: false, code: restarted?.code ?? 'COORDINATED_COMPENSATE_RESTART_FAILED' };
1872
+ const verified = await verifyFn(prior.version, priorDshVersion);
1873
+ if (verified?.ok !== true) return { ok: false, code: verified?.code ?? 'COORDINATED_COMPENSATE_VERIFY_FAILED' };
1874
+ switchPointer(priorRelease);
1875
+ return { ok: true };
1876
+ }
1877
+
1878
+ try {
1879
+ // Stop ONCE, install the target cohort AT the live root (pnpm shims are
1880
+ // absolute-path; the tree must be born at its final resting path),
1881
+ // activate payload, start ONCE.
1882
+ const stopped = await stopFn();
1883
+ if (!stopped.ok) {
1884
+ // Nothing was swapped yet; just clear the journal (no activation ran).
1885
+ clearUpdateJournal({ home });
1886
+ return { ok: false, code: stopped.code ?? 'COORDINATED_STOP_FAILED', error: stopped.error ?? 'could not stop owned 3210' };
1887
+ }
1888
+ let liveMoved = false;
1889
+ try {
1890
+ // Move live runtime aside, retaining its tree for offline rollback.
1891
+ if (existsSync(liveRoot)) {
1892
+ // prevPath is unique per attempt and already recorded in the journal.
1893
+ try { rmSync(prevPath, { recursive: true, force: true }); } catch {}
1894
+ renameSync(liveRoot, prevPath);
1895
+ liveMoved = true;
1896
+ }
1897
+ mkdirSync(liveRoot, { recursive: true });
1898
+ } catch (error) {
1899
+ // Park failed: restore live tree before anything else.
1900
+ try {
1901
+ if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameSync(prevPath, liveRoot);
1902
+ } catch { /* best effort */ }
1903
+ const comp = await compensate();
1904
+ return finalizeCompensationFailure({ home, code: 'COORDINATED_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), comp });
1905
+ }
1906
+ // Install the target cohort at the live root. On failure, roll back the
1907
+ // parked prior tree and compensate the payload.
1908
+ const installed = installDshInto({ root: liveRoot, version: candidateDshVersion, ...stageOptions });
1909
+ if (!installed.ok) {
1910
+ let comp = { ok: false };
1911
+ try {
1912
+ rmSync(liveRoot, { recursive: true, force: true });
1913
+ if (liveMoved && existsSync(prevPath)) renameSync(prevPath, liveRoot);
1914
+ comp = await compensate();
1915
+ } catch { /* compensate below already reports */ }
1916
+ return finalizeCompensationFailure({ home, code: installed.code ?? 'COORDINATED_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', comp });
1917
+ }
1918
+ // Runtime now on candidate cohort; live tree preserved at prevPath.
1919
+ const activated = await activateFn({ releaseDir: stageDir, manifest: candidateManifest });
1920
+ if (activated !== true) {
1921
+ const comp = await compensate();
1922
+ return finalizeCompensationFailure({ home, code: null, error: 'candidate activation failed', comp });
1923
+ }
1924
+ const started = await startFn();
1925
+ if (started?.ok !== true) {
1926
+ const comp = await compensate();
1927
+ return finalizeCompensationFailure({ home, code: started?.code ?? 'COORDINATED_START_FAILED', error: started?.error ?? 'restart after coordinated update failed', comp });
1928
+ }
1929
+ const verified = await verifyFn(candidateManifest.version, candidateDshVersion);
1930
+ if (verified?.ok !== true) {
1931
+ const comp = await compensate();
1932
+ return finalizeCompensationFailure({ home, code: verified?.code ?? 'COORDINATED_VERIFY_FAILED', error: verified?.error ?? 'dual identity verification failed', comp });
1933
+ }
1934
+
1935
+ // Durable retention of the parked prior tree is a COMMIT PREREQUISITE:
1936
+ // "update complete" must imply the offline rollback source for the
1937
+ // previous cohort is durable. A failed retain never grants delete
1938
+ // authority over the parked tree; it aborts the commit and compensates.
1939
+ if (prevPath && existsSync(prevPath)) {
1940
+ const retained = retainParkedRuntimeTree({ home, prevPath, priorDshVersion });
1941
+ if (!retained.ok) {
1942
+ // Leave the parked tree in place (it is the only prior copy); do NOT
1943
+ // commit. Compensate so the live pair returns to prior.
1944
+ const comp = await compensate().catch(() => ({ ok: false }));
1945
+ return finalizeCompensationFailure({ home, code: 'COORDINATED_RETAIN_FAILED', error: retained.error ?? 'could not durably retain prior runtime', comp, parked: prevPath });
1946
+ }
1947
+ }
1948
+
1949
+ // Mark the journal verified BEFORE the pointer write. Crash recovery
1950
+ // then has a clean WAL relation: pointer==prior means not committed,
1951
+ // pointer==candidate with verified journal means committed.
1952
+ const marked = markJournalVerified({
1953
+ home,
1954
+ stage: 'coordinated-update',
1955
+ prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
1956
+ candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
1957
+ runtime: {
1958
+ state: 'verified',
1959
+ liveRoot,
1960
+ candidateRoot: liveRoot,
1961
+ priorVersion: priorDshVersion,
1962
+ candidateVersion: candidateDshVersion,
1963
+ retainedRoot,
1964
+ },
1965
+ });
1966
+ if (!marked.ok) {
1967
+ const comp = await compensate().catch(() => ({ ok: false }));
1968
+ return finalizeCompensationFailure({ home, code: marked.code ?? 'JOURNAL_MARK_FAILED', error: 'coordinated journal mark-verified failed', comp });
1969
+ }
1970
+
1971
+ // COMMIT POINT / LAST: pointer write after the verified journal.
1972
+ switchPointer(candidateRelease);
1973
+
1974
+ clearUpdateJournal({ home });
1975
+ log(`✓ coordinated update committed: Crew ${candidateManifest.version} + DSH ${candidateDshVersion}`);
1976
+ return { ok: true, version: candidateManifest.version, path: stageDir, dsh_version: candidateDshVersion, restarted: started };
1977
+ } catch (error) {
1978
+ const comp = await compensate().catch(() => ({ ok: false }));
1979
+ return finalizeCompensationFailure({ home, code: null, error: error?.message ?? 'coordinated update failed', comp });
1980
+ }
1981
+ }
1982
+
1983
+ // Shared failure exit for a compensated coordinated transaction. The journal
1984
+ // is cleared ONLY when compensation fully succeeded (prior payload
1985
+ // activated, prior runtime restored, restart + dual verify passed). When
1986
+ // compensation itself failed, the journal is the ONLY durable recovery
1987
+ // witness describing priorRoot/liveRoot/versions — it must survive so the
1988
+ // next reconcile can finish the recovery. A compensation-failed marker is
1989
+ // appended so operators can see why the journal is still present.
1990
+ function finalizeCompensationFailure({ home, code, error, comp, parked = null }) {
1991
+ if (comp.ok === true) {
1992
+ clearUpdateJournal({ home });
1993
+ } else {
1994
+ // Compensation failed: RETAIN the journal (never delete the recovery
1995
+ // witness) and annotate it with the failure for the next reconcile pass.
1996
+ try {
1997
+ const file = updateJournalFile({ home });
1998
+ if (existsSync(file)) {
1999
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
2000
+ raw.compensation_failed = true;
2001
+ raw.compensation_error = String(error ?? comp.error ?? 'compensation failed');
2002
+ writeFileAtomic(file, JSON.stringify(raw, null, 2) + '\n');
2003
+ }
2004
+ } catch { /* best effort annotation; journal itself stays untouched */ }
2005
+ }
2006
+ return { ok: false, code: code ?? undefined, error: error ?? 'coordinated update failed', compensated: comp.ok === true, compensation_failed: comp.ok !== true, parked };
2007
+ }
2008
+
2009
+ // Unified durable retention of a parked prior runtime tree. NEVER deletes
2010
+ // the parked tree on failure: the caller decides whether to compensate
2011
+ // (abort the commit) or surface the parked path as a durable rollback
2012
+ // source. Bounded retry absorbs transient Windows file-handle contention
2013
+ // right after the new 3210 starts.
2014
+ function retainParkedRuntimeTree({ home, prevPath, priorDshVersion, rename = renameSync }) {
2015
+ try {
2016
+ if (!existsSync(prevPath)) return { ok: true, retained: false };
2017
+ const retainedRoot = join(crewDshHome({ home }), 'retained-runtimes');
2018
+ const target = join(retainedRoot, priorDshVersion);
2019
+ mkdirSync(retainedRoot, { recursive: true });
2020
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true });
2021
+ const delays = [0, 50, 150, 400];
2022
+ let lastError = null;
2023
+ for (const delay of delays) {
2024
+ if (delay > 0) { const until = Date.now() + delay; while (Date.now() < until) { /* busy-wait bounded */ } }
2025
+ try {
2026
+ rename(prevPath, target);
2027
+ return { ok: true, retained: true, version: priorDshVersion, path: target };
2028
+ } catch (error) { lastError = error; }
2029
+ }
2030
+ return { ok: false, retained: false, prevPath, error: String(lastError?.message ?? lastError) };
2031
+ } catch (error) {
2032
+ return { ok: false, retained: false, prevPath, error: String(error?.message ?? error) };
664
2033
  }
665
- log(`✓ reusable Crew DSH runtime${r.version ? ` (@${r.version})` : ''}`);
666
- return true;
667
2034
  }
668
2035
 
669
2036
  // ---- commands -----------------------------------------------------------------
@@ -715,49 +2082,36 @@ async function activateRelease({ home, releaseDir, manifest, log, installer }) {
715
2082
  }
716
2083
  log('✓ Claude Code integration');
717
2084
 
2085
+ // The official ~/.dsh/profiles/web tree is read-only: activation never
2086
+ // repairs or mutates the official bridge. The isolated 3210 Crew backend
2087
+ // is the only runtime Crew owns.
718
2088
  const official = officialWebIntegrationStatus({ home });
719
- if (official.enabled) {
720
- const repaired = ensureOfficialWebIntegration({ home, releaseDir });
721
- if (!repaired.ok) {
722
- log(`✗ official 3080 bridge repair failed (${repaired.code ?? 'unknown'})`);
723
- return false;
724
- }
725
- log('✓ official 3080 UI bridge → isolated Crew backend on 3210');
2089
+ if (official.legacy_present) {
2090
+ log('- legacy official 3080 bridge record present but deprecated; official web profile is read-only and untouched');
726
2091
  }
727
2092
  return true;
728
2093
  }
729
2094
 
2095
+ // Compensate a failed candidate activation by re-pointing live activation
2096
+ // surfaces (profile registration + host integrations) back at the prior
2097
+ // release. Rewriting current.json alone is NOT enough: a crash between
2098
+ // activation and pointer commit leaves live surfaces on the candidate.
2099
+ async function compensateActivation({ home, prior, log, installer }) {
2100
+ if (!prior?.path || !existsSync(prior.path)) return { ok: false, code: 'PRIOR_RELEASE_MISSING' };
2101
+ const manifest = readManifest(prior.path);
2102
+ if (!manifest?.name || !manifest?.version) return { ok: false, code: 'PRIOR_MANIFEST_INVALID' };
2103
+ const ok = await activateRelease({ home, releaseDir: prior.path, manifest, log, installer });
2104
+ return ok ? { ok: true, version: manifest.version, path: prior.path } : { ok: false, code: 'PRIOR_ACTIVATION_FAILED' };
2105
+ }
2106
+
730
2107
  export async function npxIntegrate({ home = homedir(), log = console.log } = {}) {
731
- const pointer = readCurrentPointer({ home });
732
- if (!pointer || !existsSync(pointer.path)) {
733
- log('✗ install DSH Crew before enabling the official 3080 integration');
734
- return { ok: false, error: 'DSH Crew is not installed' };
735
- }
736
- const validated = validateInstalledPayload(pointer.path, { expectedName: pointer.name, expectedVersion: pointer.version });
737
- if (!validated.ok) {
738
- log('✗ installed DSH Crew payload is damaged; run dsh-crew update first');
739
- return { ok: false, error: 'installed payload invalid' };
740
- }
741
- const result = ensureOfficialWebIntegration({ home, releaseDir: pointer.path });
742
- if (!result.ok) {
743
- log(`✗ official 3080 integration failed (${result.code ?? 'unknown'})`);
744
- return { ok: false, error: result.code ?? 'integration failed' };
745
- }
746
- log(result.changed
747
- ? '✓ official 3080 UI connected to the isolated Crew backend on 3210'
748
- : '- official 3080 UI integration already healthy');
749
- log(` backup: ${result.backupFile}`);
750
- return { ok: true, changed: result.changed, backupFile: result.backupFile };
2108
+ log('✗ official 3080 integration is disabled: the official web profile is read-only');
2109
+ return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
751
2110
  }
752
2111
 
753
2112
  export async function npxDetach({ home = homedir(), log = console.log } = {}) {
754
- const result = removeOfficialWebIntegration({ home });
755
- if (!result.ok) {
756
- log(`✗ official 3080 integration removal failed (${result.code ?? 'unknown'})`);
757
- return { ok: false, error: result.code ?? 'detach failed' };
758
- }
759
- log(result.removed ? '✓ official 3080 bridge removed; isolated 3210 mode remains available' : '- official 3080 bridge already absent');
760
- return { ok: true, removed: result.removed };
2113
+ log('✗ official 3080 detach is disabled: the official web profile is read-only');
2114
+ return { ok: false, error: 'OFFICIAL_WEB_PROFILE_READ_ONLY' };
761
2115
  }
762
2116
 
763
2117
  export async function npxInstall({
@@ -769,6 +2123,18 @@ export async function npxInstall({
769
2123
  npmInstaller,
770
2124
  } = {}) {
771
2125
  log('DSH Crew installer (npx-managed)');
2126
+ const updateLock = acquireUpdateLock({ home });
2127
+ if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
2128
+ try {
2129
+ return await npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime, npmInstaller });
2130
+ } finally {
2131
+ releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
2132
+ }
2133
+ }
2134
+
2135
+ async function npxInstallInner({ home, log, sourceRoot, installer, ensureRuntime, npmInstaller }) {
2136
+ const reconciled = reconcileUpdateJournal({ home, log });
2137
+ if (!reconciled.ok) return { ok: false, error: `update journal recovery failed (${reconciled.code ?? 'unknown'})` };
772
2138
  const candidateRoot = sourceRoot ?? runningPackageRoot();
773
2139
  const manifest = readManifest(candidateRoot);
774
2140
  if (!manifest?.name || !manifest?.version) return { ok: false, error: 'candidate package manifest invalid' };
@@ -792,13 +2158,24 @@ export async function npxInstall({
792
2158
  }
793
2159
  log(`✓ candidate payload staged (${manifest.version})`);
794
2160
 
795
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
796
- log(`✓ durable release committed under Crew-owned state`);
2161
+ // Transaction order: stage -> runtime gate -> journal(begin) ->
2162
+ // activation -> pointer commit LAST -> clear journal -> GC. A crash
2163
+ // anywhere before the pointer write leaves the prior release
2164
+ // authoritative for reconcileUpdateJournal.
2165
+ const prior = readCurrentPointer({ home });
2166
+ if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
797
2167
 
798
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
799
- if (!activated) return { ok: false, error: 'activation failed' };
2168
+ beginReleaseActivation({ stageDir: staged.stageDir, manifest, home, prior });
2169
+ log(`✓ candidate payload staged (${manifest.version})`);
800
2170
 
801
- if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
2171
+ const activated = await activateRelease({ home, releaseDir: staged.stageDir, manifest, log, installer });
2172
+ if (!activated) {
2173
+ const compensated = prior?.path ? await compensateActivation({ home, prior, log, installer }) : null;
2174
+ return { ok: false, error: 'activation failed', compensated: compensated?.ok === true };
2175
+ }
2176
+
2177
+ const releaseDir = commitActivatedRelease({ stageDir: staged.stageDir, manifest, home, prior });
2178
+ log(`✓ durable release committed under Crew-owned state`);
802
2179
 
803
2180
  log('');
804
2181
  log('Done.');
@@ -954,6 +2331,18 @@ export async function npxUpdate({
954
2331
  runner = spawnSync,
955
2332
  } = {}) {
956
2333
  log('DSH Crew updater');
2334
+ const updateLock = acquireUpdateLock({ home });
2335
+ if (!updateLock.ok) return { ok: false, error: `another update is in progress (${updateLock.code})` };
2336
+ try {
2337
+ return await npxUpdateInner({ home, log, sourceRoot, candidate, spec, installer, ensureRuntime, npmInstaller, runner });
2338
+ } finally {
2339
+ releaseUpdateLock({ home, nonce: updateLock.nonce ?? null });
2340
+ }
2341
+ }
2342
+
2343
+ async function npxUpdateInner({ home, log, sourceRoot, candidate, spec, installer, ensureRuntime, npmInstaller, runner = spawnSync }) {
2344
+ const journalRecovery = reconcileUpdateJournal({ home, log });
2345
+ if (!journalRecovery.ok) return { ok: false, error: `update journal recovery failed (${journalRecovery.code ?? 'unknown'})` };
957
2346
  // Candidate resolution: explicit path/dir override > a newer validated
958
2347
  // running launcher > configured npm registry (@latest). This makes the
959
2348
  // supported legacy bootstrap (`npm install -g ...@latest`, then `update`)
@@ -1018,14 +2407,97 @@ export async function npxUpdate({
1018
2407
  }
1019
2408
  log(`✓ candidate payload staged and validated (${manifest.version})`);
1020
2409
 
1021
- const releaseDir = commitStagedRelease({ stageDir: staged.stageDir, manifest, home });
1022
- log('✓ durable release committed under Crew-owned state');
1023
-
1024
- const activated = await activateRelease({ home, releaseDir, manifest, log, installer });
1025
- if (!activated) return { ok: false, error: 'activation failed' };
2410
+ // Transaction order: stage -> runtime gate -> journal(begin) ->
2411
+ // activation -> pointer commit LAST -> clear journal -> GC. A crash
2412
+ // anywhere before the pointer write leaves the prior release
2413
+ // authoritative for reconcileUpdateJournal.
2414
+ const prior = readCurrentPointer({ home });
2415
+ const priorManifest = prior?.path ? readManifest(prior.path) : null;
2416
+ const candidateDsh = payloadDshVersion(manifest);
2417
+ // Resolve the PRIOR cohort through the full resolution chain. Historical
2418
+ // releases (pre-1.0.4) carry no manifest pin, so for the FORWARD upgrade
2419
+ // path the live runtime tree's current cohort is the authoritative fact
2420
+ // (it is exactly the cohort this upgrade will migrate away from). This
2421
+ // guarantees even a legacy unpinned 1.0.3 prior routes through the
2422
+ // coordinated payload+runtime transaction instead of the standalone
2423
+ // migrate path. A present-but-invalid sidecar fails closed.
2424
+ let priorDsh = null;
2425
+ if (prior?.path) {
2426
+ const priorCohort = resolveReleaseCohort({
2427
+ releaseDir: prior.path,
2428
+ allowLegacyLiveFallback: true,
2429
+ readRuntimeVersion: () => readRuntimeTreeVersionSync(crewDshRuntimeRoot({ home })),
2430
+ });
2431
+ if (priorCohort.invalid) {
2432
+ return { ok: false, error: `prior release cohort sidecar invalid (${priorCohort.error ?? 'unknown'})` };
2433
+ }
2434
+ priorDsh = priorCohort.ok ? priorCohort.dshVersion : null;
2435
+ }
2436
+ const needsCohortSwap = candidateDsh !== null && priorDsh !== null && candidateDsh !== priorDsh;
2437
+
2438
+ if (needsCohortSwap) {
2439
+ // Cross-cohort update: payload activation and runtime swap are ONE
2440
+ // coordinated transaction so the 3210 never runs an unsupported
2441
+ // payload/cohort combination.
2442
+ log(`- cross-cohort update: DSH ${priorDsh} -> ${candidateDsh}; running coordinated payload+runtime transaction`);
2443
+ // The discovered prior cohort must be DURABLY recorded as a sidecar
2444
+ // BEFORE the transaction: "update complete" must imply the legacy
2445
+ // release can later be rolled back offline. Write then READ BACK
2446
+ // (without live fallback) — a writer that returned null OR a sidecar
2447
+ // that does not round-trip to the discovered cohort aborts before any
2448
+ // runtime park / payload activation / pointer mutation. A pre-existing
2449
+ // valid sidecar for the same cohort also satisfies the read-back.
2450
+ const persistResult = writeReleaseCohort({ releaseDir: prior.path, dshVersion: priorDsh, source: 'discovered-live-runtime' });
2451
+ const readBack = resolveReleaseCohort({ releaseDir: prior.path, allowLegacyLiveFallback: false });
2452
+ const sidecarDurable = persistResult !== null
2453
+ && readBack.ok === true
2454
+ && readBack.dshVersion === priorDsh;
2455
+ if (!sidecarDurable) {
2456
+ return {
2457
+ ok: false,
2458
+ code: 'RELEASE_COHORT_SIDECAR_PERSIST_FAILED',
2459
+ error: `prior release cohort sidecar could not be durably persisted (${readBack.ok ? 'read-back mismatch' : readBack.error ?? 'write failed'}); refusing cross-cohort update that could not be rolled back offline`,
2460
+ };
2461
+ }
2462
+ const coordinated = await performCoordinatedCohortUpdate({
2463
+ home,
2464
+ log,
2465
+ prior,
2466
+ priorDshVersion: priorDsh,
2467
+ candidateManifest: manifest,
2468
+ candidateDshVersion: candidateDsh,
2469
+ stageDir: staged.stageDir,
2470
+ installer,
2471
+ });
2472
+ if (!coordinated.ok) {
2473
+ return { ok: false, error: `coordinated cohort update failed${coordinated.code ? ` (${coordinated.code})` : ''}`, compensated: coordinated.compensated === true };
2474
+ }
2475
+ // Record the candidate cohort sidecar for the newly committed release.
2476
+ const committedRelease = readCurrentPointer({ home });
2477
+ if (committedRelease?.path) writeReleaseCohort({ releaseDir: committedRelease.path, dshVersion: candidateDsh, source: 'manifest-pin' });
2478
+ noteLauncherDivergence({ log, home });
2479
+ log('');
2480
+ log('Done.');
2481
+ log('Restart DeepSeek Harness and Codex Desktop.');
2482
+ return { ok: true, updated: true, version: manifest.version, path: staged.stageDir, coordinated: true, dsh_version: candidateDsh };
2483
+ }
1026
2484
 
1027
2485
  if (!await ensureRuntimeStep({ home, log, ensureRuntime })) return { ok: false, error: 'Crew DSH runtime bootstrap failed' };
1028
2486
 
2487
+ beginReleaseActivation({ stageDir: staged.stageDir, manifest, home, prior });
2488
+
2489
+ const activated = await activateRelease({ home, releaseDir: staged.stageDir, manifest, log, installer });
2490
+ if (!activated) {
2491
+ const compensated = prior?.path ? await compensateActivation({ home, prior, log, installer }) : null;
2492
+ return { ok: false, error: 'activation failed', compensated: compensated?.ok === true };
2493
+ }
2494
+
2495
+ const releaseDir = commitActivatedRelease({ stageDir: staged.stageDir, manifest, home, prior });
2496
+ log('✓ durable release committed under Crew-owned state');
2497
+ // Record the cohort sidecar for the committed release (exact pin when
2498
+ // available, else the live cohort the activation just ran on).
2499
+ if (candidateDsh) writeReleaseCohort({ releaseDir, dshVersion: candidateDsh, source: 'manifest-pin' });
2500
+
1029
2501
  noteLauncherDivergence({ log, home });
1030
2502
  log('');
1031
2503
  log('Done.');
@@ -1111,7 +2583,7 @@ export function npxStatus({
1111
2583
  const zcode = st?.zcode?.installed ? 'installed' : 'not installed';
1112
2584
  const claude = st?.claude?.installed ? 'installed' : 'not installed';
1113
2585
  const official = officialWebIntegrationStatus({ home, releaseDir: pointer?.path });
1114
- const officialWeb = !official.enabled ? 'disabled' : official.healthy ? 'installed' : 'needs repair';
2586
+ const officialWeb = !official.legacy_present ? 'not present (native 3210 control plane)' : official.healthy ? 'legacy full bridge present (deprecated; manual cleanup available)' : 'legacy full bridge record present but unhealthy (deprecated)';
1115
2587
  const startupState = installer.windowsStartupStatus?.({ home });
1116
2588
  const windowsStartup = !startupState?.supported ? 'not supported'
1117
2589
  : startupState.ready ? 'installed' : startupState.installed ? 'needs repair' : 'not installed';
@@ -1181,7 +2653,8 @@ export async function npxUninstall({
1181
2653
  else if (startup?.supported) log('✓ Windows login startup removed');
1182
2654
 
1183
2655
  const official = removeOfficialWebIntegration({ home, preserveIntent: !purge, remember: !purge });
1184
- if (!official.ok) fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
2656
+ if (!official.ok && official.code !== 'OFFICIAL_WEB_PROFILE_READ_ONLY') fail(`official 3080 bridge removal failed (${official.code ?? 'unknown'})`);
2657
+ else if (official.code === 'OFFICIAL_WEB_PROFILE_READ_ONLY') log('- official 3080 bridge left untouched (official web profile is read-only)');
1185
2658
  else log(official.removed ? '✓ official 3080 bridge removed' : '- official 3080 bridge already absent');
1186
2659
 
1187
2660
  if (name) {
@@ -1222,8 +2695,8 @@ export const USAGE = `usage: dsh-crew <command> [--purge] [--candidate <path>]
1222
2695
 
1223
2696
  Commands:
1224
2697
  install persist the candidate package into Crew-owned state and register it
1225
- integrate show Crew inside the official 3080 UI; backend stays isolated on 3210
1226
- detach remove only the official 3080 bridge; isolated 3210 mode remains available
2698
+ integrate disabled: the official web profile is read-only (legacy bridge retired)
2699
+ detach disabled: the official web profile is read-only (legacy bridge retired)
1227
2700
  status read-only report of launcher/installed versions and integrations
1228
2701
  inspect print the machine-readable extension capability/readiness contract
1229
2702
  jobs machine-first job API: list|get|watch|cancel|submit
@@ -1460,13 +2933,8 @@ export async function npxProviders({
1460
2933
  let body = await response.json();
1461
2934
  if (!response.ok || body?.ok === false) throw new Error(body?.code ?? body?.error ?? 'Crew provider API unavailable');
1462
2935
  if (action === 'delete' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
1463
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1464
- method: 'POST',
1465
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1466
- body: JSON.stringify({ confirm: true }),
1467
- });
1468
- const restartBody = await restartResponse.json();
1469
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
2936
+ const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
2937
+ if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
1470
2938
  const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-delete`;
1471
2939
  const verifyResponse = await fetchImpl(verifyUrl, {
1472
2940
  method: 'POST',
@@ -1478,11 +2946,8 @@ export async function npxProviders({
1478
2946
  body = { ...body, restart: restartBody, verification: verifyBody };
1479
2947
  }
1480
2948
  if (action === 'migrate' && body?.restart_required === true && body?.result?.state === 'RESTART_PENDING') {
1481
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1482
- method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
1483
- });
1484
- const restartBody = await restartResponse.json();
1485
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
2949
+ const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
2950
+ if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
1486
2951
  const verifyResponse = await fetchImpl(`${base}/${encodeURIComponent(id)}/verify-migration`, {
1487
2952
  method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1488
2953
  });
@@ -1491,11 +2956,8 @@ export async function npxProviders({
1491
2956
  body = { ...body, restart: restartBody, verification: verifyBody };
1492
2957
  }
1493
2958
  if (action === 'rollback-migration' && body?.restart_required === true && body?.state === 'ROLLBACK_RESTART_PENDING') {
1494
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1495
- method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
1496
- });
1497
- const restartBody = await restartResponse.json();
1498
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
2959
+ const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
2960
+ if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
1499
2961
  const verifyResponse = await fetchImpl(`${base}/${encodeURIComponent(id)}/verify-rollback-migration`, {
1500
2962
  method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ transaction_id: resolvedPlan, confirm: true }),
1501
2963
  });
@@ -1504,13 +2966,8 @@ export async function npxProviders({
1504
2966
  body = { ...body, restart: restartBody, verification: verifyBody };
1505
2967
  }
1506
2968
  if (action === 'rollback' && body?.restart_required === true && body?.state === 'ROLLBACK_PENDING') {
1507
- const restartResponse = await fetchImpl('http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart', {
1508
- method: 'POST',
1509
- headers: { accept: 'application/json', 'content-type': 'application/json' },
1510
- body: JSON.stringify({ confirm: true }),
1511
- });
1512
- const restartBody = await restartResponse.json();
1513
- if (!restartResponse.ok || restartBody?.ok !== true) throw new Error(restartBody?.code ?? restartBody?.error ?? 'Crew 3210 restart failed');
2969
+ const restartBody = await requestCrewRuntimeRestart({ fetchImpl, reason: `providers ${action}` });
2970
+ if (restartBody?.ok !== true) throw new Error(restartBody?.error ?? restartBody?.code ?? 'Crew 3210 restart failed');
1514
2971
  const verifyUrl = `${base}/${encodeURIComponent(id)}/verify-rollback`;
1515
2972
  const verifyResponse = await fetchImpl(verifyUrl, {
1516
2973
  method: 'POST',