@ran-sh/dsh-crew 1.10.4 → 1.10.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "1.10.4",
3
+ "version": "1.10.6",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -56,6 +56,33 @@ reserves handoff ownership before releasing its update lock, resumes any
56
56
  unfinished handoff first, and succeeds only after the isolated 3210 runtime
57
57
  reports the expected Crew and DSH versions.
58
58
 
59
+ Both the update lock and the handoff lock record the owning process's start
60
+ time alongside its PID, and a lock is reclaimed only when the PID is gone or
61
+ when that PID is provably a different process than the one that took the lock.
62
+ Without it, a recycled PID — the operating system handing a dead owner's PID to
63
+ an unrelated process — made a stale lock look alive for as long as the stranger
64
+ ran, and no contender could ever reclaim it. Where the platform cannot report a
65
+ process start time the check falls back to the PID alone, so a lock is never
66
+ stolen on a guess.
67
+
68
+ The update transaction itself is an ordered state machine, recorded in the
69
+ journal's `runtime.state` and advanced one checkpoint at a time:
70
+
71
+ | State | Meaning |
72
+ | --- | --- |
73
+ | `before-stop` | Intent journaled; the owned runtime has not been touched |
74
+ | `stopped` | A stop completed, so the runtime tree may be mutated |
75
+ | `restarted` | A start was **initiated** — written before the start call |
76
+ | `verified` | The candidate is running and its dual identity checked out |
77
+ | `committed` | The release pointer moved; terminal |
78
+
79
+ `restarted` is written before the start happens, on purpose: a crash between
80
+ the start and the verification is then indistinguishable from a completed
81
+ start, which is the safe direction. Recovery uses this to decide whether the
82
+ runtime tree may be replaced at all — replacing it under a live process is the
83
+ damage the record exists to prevent. Journals written by earlier versions
84
+ (`staged`, `starting`) are still read under their new names.
85
+
59
86
  On Windows, installation registers login startup. To start immediately and
60
87
  open the Crew control:
61
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.10.4",
3
+ "version": "1.10.6",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -86,12 +86,27 @@ review asks for changes, that is a task result to act on — not approval.
86
86
 
87
87
  ## Common ways a dispatch surprises you
88
88
 
89
- - **A task that only asks a question fails.** The delivery contract wants a
90
- change; a reply-only task returns `DELIVERY_INCOMPLETE`. That is the gate
91
- working, not the worker failing.
89
+ - **A task that only asks a question fails.** The delivery contract wants
90
+ auditable evidence, and a reply with neither a change nor a verified check is
91
+ incomplete. That is the gate working, not the worker failing. A task that
92
+ changes nothing *on purpose* can pass — see the next entry.
93
+ - **A job is named `Crew_<date>_<time>_<purpose>`.** The worktree directory, the
94
+ session the Harness lists in its workspace panel, and the name in a status
95
+ payload all use that one string, so a conversation can be matched to a
96
+ directory by eye. The purpose is the role: `worker` or `reviewer`.
97
+ - **A verified zero-change task needs `constraints.allow_no_changes: true`.**
98
+ For a deliberately temporary job — create, verify, clean up, end with an empty
99
+ diff — that flag plus the reported checks is what certifies it. It requires a
100
+ clean, readable baseline, at least one `PASS` and no `FAIL`, and it relaxes
101
+ nothing else: no evidence, a failed check, a dirty baseline or an actual change
102
+ each still refuse.
92
103
  - **Isolated workspaces need git.** The default `worktree` isolation fails with
93
104
  `NOT_GIT_REPOSITORY` for a non-git workspace rather than silently sharing the
94
- tree. Use `shared` deliberately if that is what you want.
105
+ tree. Use `shared` deliberately if that is what you want; `allow_no_changes`
106
+ works in either.
107
+ - **A repository with no commits cannot be isolated.** `git init` with nothing
108
+ committed reports `REPOSITORY_HAS_NO_COMMITS` — there is no revision to start
109
+ from. Commit once, or run that job with `shared`.
95
110
  - **Long tasks need a longer timeout.** `timeout_seconds` is per attempt and
96
111
  caps at 7200; the default is far shorter than a real refactor.
97
112
  - **A worker cannot see your conversation.** Anything it needs must be in
package/src/delivery.mjs CHANGED
@@ -81,6 +81,27 @@ Then stop and return control to the Main Agent.
81
81
  Do not autonomously start a new task or delegate further work.` : ''}`;
82
82
  }
83
83
 
84
+ /** A prompt that already carries the job identity header (added once). */
85
+ export const JOB_HEADER_MARKER = 'Crew job ';
86
+
87
+ /**
88
+ * Open the prompt with the job's Crew name.
89
+ *
90
+ * The Harness lists a dispatched job in its workspace panel under a title it
91
+ * derives from the opening words of the first message the agent receives, so
92
+ * without this a Crew job appears as "In this isolated git repository," or
93
+ * whatever the task happened to begin with — while its worktree is named
94
+ * `Crew_<date>_<time>_<purpose>`. Prefixing the name makes all three agree, and
95
+ * makes a job findable in that list by the same string an operator already uses
96
+ * on disk. Idempotent, so a re-dispatch or a review prompt is not doubled.
97
+ */
98
+ export function prependJobIdentity(task, { name, role } = {}) {
99
+ if (typeof task !== 'string' || !name) return task;
100
+ if (task.startsWith(JOB_HEADER_MARKER)) return task;
101
+ const roleSuffix = role ? ` — role: ${role}` : '';
102
+ return `${JOB_HEADER_MARKER}${name}${roleSuffix}\n\n${task}`;
103
+ }
104
+
84
105
  /**
85
106
  * Append the delivery instructions to a worker task prompt. Idempotent: a task
86
107
  * that already carries the delivery report (e.g. a review prompt, or a
@@ -92,16 +113,55 @@ export function appendDeliveryInstructions(task, { tier, isReview } = {}) {
92
113
  return `${task}\n\n${buildDeliveryInstructions({ tier, isReview })}`;
93
114
  }
94
115
 
95
- function parseTestsSection(value) {
96
- if (typeof value !== 'string' || value.trim() === '') return { valid: false, status: undefined };
97
- const statuses = [];
98
- for (const line of value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
99
- const match = line.match(/^(?:[-*+]\s+)?(PASS|FAIL|NOT RUN)\s+—\s+\S.*?\s+—\s+\S.*$/);
100
- if (!match) return { valid: false, status: undefined };
101
- statuses.push(match[1]);
116
+ /**
117
+ * Parse one Tests entry, or null when the line is not one.
118
+ *
119
+ * The canonical form is `STATUS — <check> — <result>`; a model often writes it as
120
+ * a Markdown table row instead, and both are read here. This is deliberately
121
+ * forgiving about *noise* — a heading, a note, a wrapped continuation line — and
122
+ * strict about *evidence*: a line with no auditable state, or a bare `PASS` with
123
+ * nothing after it, is not an entry.
124
+ *
125
+ * The earlier revision marked the whole section invalid as soon as one line
126
+ * failed to match, so a single trailing note made a report with several PASS rows
127
+ * count as having no Tests section at all — delivery incomplete, task blocked —
128
+ * while a second, looser parser in `workflow.mjs` had already populated the
129
+ * visible evidence. Two parsers gave two answers about one section; there is now
130
+ * one, and it lives here.
131
+ */
132
+ export function parseTestRow(line) {
133
+ const normalized = String(line ?? '')
134
+ .replace(/^\s*[-*+]\s+/, '')
135
+ .replace(/^\|/, '')
136
+ .replace(/\|\s*$/, '')
137
+ .trim();
138
+ const match = /^(PASS|FAIL|NOT RUN)\b(.*)$/i.exec(normalized);
139
+ if (!match) return null;
140
+ const parts = match[2]
141
+ .split(/\s*(?:—|\||\t)\s*/)
142
+ .map((part) => part.trim())
143
+ .filter(Boolean);
144
+ // A state on its own is not evidence, and neither is a bare check: the contract
145
+ // asks for what was checked *and* what happened.
146
+ if (parts.length < 2) return null;
147
+ return { status: match[1].toUpperCase(), command: parts[0], summary: parts.slice(1).join(' — ') };
148
+ }
149
+
150
+ /**
151
+ * The auditable state of a Tests section plus the entries it contains. `valid`
152
+ * means at least one entry was readable; it does not mean every line was.
153
+ */
154
+ export function parseTestsSection(value) {
155
+ if (typeof value !== 'string' || value.trim() === '') return { valid: false, status: undefined, tests: [] };
156
+ const tests = [];
157
+ for (const line of value.split(/\r?\n/)) {
158
+ const row = parseTestRow(line);
159
+ if (row) tests.push(row);
102
160
  }
103
- const status = statuses.includes('FAIL') ? 'FAIL' : statuses.includes('NOT RUN') ? 'NOT RUN' : statuses.includes('PASS') ? 'PASS' : undefined;
104
- return { valid: status !== undefined, status };
161
+ if (tests.length === 0) return { valid: false, status: undefined, tests: [] };
162
+ const statuses = tests.map((test) => test.status);
163
+ const status = statuses.includes('FAIL') ? 'FAIL' : statuses.includes('NOT RUN') ? 'NOT RUN' : 'PASS';
164
+ return { valid: true, status, tests };
105
165
  }
106
166
 
107
167
  /**
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { createRequire } from 'node:module';
10
+ import { renameTree } from './install/tree-move.mjs';
10
11
  import {
11
12
  existsSync,
12
13
  lstatSync,
@@ -460,7 +461,7 @@ export async function migrateCrewDshRuntime({
460
461
  let liveMoved = false;
461
462
  try {
462
463
  if (existsSync(liveRoot)) {
463
- rename(liveRoot, prevRoot);
464
+ renameTree(liveRoot, prevRoot, { rename });
464
465
  liveMoved = true;
465
466
  }
466
467
  mkdirSync(liveRoot, { recursive: true });
package/src/hub/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
- import { dirname, join, isAbsolute, resolve as resolvePath } from 'node:path';
8
+ import { dirname, join, isAbsolute, resolve as resolvePath, basename } from 'node:path';
9
9
  import { homedir } from 'node:os';
10
10
  import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
11
11
  import {
@@ -19,7 +19,7 @@ import {
19
19
  import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../model-routing.mjs';
20
20
  import { scheduleAdmission } from '../model-schedule.mjs';
21
21
  import { readHarnessModelCatalog } from '../model-catalog.mjs';
22
- import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
22
+ import { appendDeliveryInstructions, prependJobIdentity, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
23
23
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
24
24
  import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
25
25
  import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
@@ -28,7 +28,8 @@ import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
28
28
  import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
29
29
  import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
30
30
  import { buildExtensionContract } from '../extension-contract.mjs';
31
- import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
31
+ import { cleanupIsolatedWorkspace, createIsolatedWorkspace, isCrewWorktreeName } from '../workspace-isolation.mjs';
32
+ import { jobDisplayName } from '../job-identity.mjs';
32
33
  import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
33
34
  import { buildConfigReadinessMatrix } from '../config-readiness.mjs';
34
35
  import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
@@ -499,6 +500,7 @@ export function hubCanonicalEvents(job = {}) {
499
500
  // ---------- job registry ----------
500
501
 
501
502
  export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
503
+ void isolation;
502
504
  const changes = workspaceDiff?.changes ?? {};
503
505
  const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
504
506
  .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
@@ -506,7 +508,14 @@ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChang
506
508
  return applyWorkspaceEvidence(outcome, {
507
509
  evidenceAvailable,
508
510
  hasChanges,
509
- allowNoChanges: allowNoChanges === true && isolation === 'worktree',
511
+ // `allow_no_changes` is the caller's explicit authorization, and `shared` is an
512
+ // ordinary way to run: requiring a worktree here meant an authorized
513
+ // zero-change task in a shared workspace could never be verified as one, and
514
+ // was downgraded to partial. Reliability is guarded by `evidenceAvailable`,
515
+ // which `applyWorkspaceEvidence` already requires — a dirty or unreadable
516
+ // baseline grants no authorization and the task stays unverified. The
517
+ // standalone path never had this condition, so the two disagreed.
518
+ allowNoChanges: allowNoChanges === true,
510
519
  requireNoChangeAuthorization: role === 'worker',
511
520
  });
512
521
  }
@@ -823,7 +832,6 @@ export class WorkerRegistry { constructor(ctx) {
823
832
  // it already carries one), so its final message follows ## Diff / ## Tests
824
833
  // / ## Risks — or the review contract for reviewer-role jobs.
825
834
  const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
826
- const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
827
835
 
828
836
  const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
829
837
 
@@ -839,11 +847,21 @@ export class WorkerRegistry { constructor(ctx) {
839
847
  let executionCwd = cwd;
840
848
  let isolatedWorkspace = null;
841
849
  if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
842
- const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
850
+ const created = await createIsolatedWorkspace({ cwd, jobId: id, purpose: jobRole, baseRevision: workspace_branch });
843
851
  if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
844
852
  executionCwd = created.worktreePath;
845
853
  isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
846
854
  }
855
+ // The Harness titles the session from the opening words of the prompt the
856
+ // agent receives, so the prompt opens with the job's Crew name: that makes the
857
+ // conversation, the worktree directory and the name an operator types the same
858
+ // string. An allocated worktree's name wins, so a collision suffix stays
859
+ // consistent between the two.
860
+ const jobName = isCrewWorktreeName(basename(executionCwd))
861
+ ? basename(executionCwd)
862
+ : jobDisplayName({ purpose: jobRole });
863
+ const workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role: jobRole }), { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
864
+
847
865
  const sessionId = `session-${randomUUID()}`;
848
866
  // Record provenance while it is still knowable: a session header carries no
849
867
  // field naming who asked for the session, so a later Crew-scoped cleanup can
@@ -1312,6 +1330,19 @@ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependen
1312
1330
  }
1313
1331
  // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
1314
1332
  // and the tier slot is derived from the role (reviewer always → pro).
1333
+ // A plain payload names no workspace or constraints, but it does name a role,
1334
+ // and the role's profile states how that role runs. Only the advanced shape
1335
+ // resolved it, so the same task submitted through the simple shape ran shared
1336
+ // while the profile said worktree: the profile was silently ignored for the
1337
+ // caller who wrote the least. A payload that names no role keeps its
1338
+ // pass-through shape.
1339
+ if (normalized.requested_isolation === undefined && (normalized.role === 'worker' || normalized.role === 'reviewer')) {
1340
+ const registry = dependencies.profileRegistry ?? loadRoleProfiles();
1341
+ const roleProfile = registry.ok ? resolveRoleProfile(registry, normalized.profile, normalized.role) : { ok: false };
1342
+ if (roleProfile.ok && typeof roleProfile.profile?.isolation === 'string') {
1343
+ normalized = { ...normalized, requested_isolation: roleProfile.profile.isolation };
1344
+ }
1345
+ }
1315
1346
  if (normalized.role === 'worker' || normalized.role === 'reviewer') {
1316
1347
  const hint = resolveRoleTierHint(normalized.role, normalized.tier);
1317
1348
  if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
@@ -0,0 +1,52 @@
1
+ // Where the Crew-owned Harness profile lives, and where the registration link
2
+ // that the host integrations point at is rooted.
3
+ //
4
+ // This is its own module because two layers must agree about it and neither may
5
+ // import the other: the installers write host integrations against the loader
6
+ // link, and the readiness checks decide whether those integrations are correct.
7
+ // When readiness derived its expected paths from the release directory instead,
8
+ // every correctly installed machine read as needing repair — the installer and
9
+ // the check disagreed about what "the installed server" is, and only the
10
+ // installer was right.
11
+
12
+ import { existsSync, realpathSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ export const CREW_PROFILE_NAME = 'dsh-crew';
17
+ export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
18
+
19
+ export function crewDshHome({ home = homedir() } = {}) {
20
+ return join(home, CREW_HOME_REL);
21
+ }
22
+
23
+ export function crewProfileDir({ home = homedir() } = {}) {
24
+ return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
25
+ }
26
+
27
+ // The profile's `node_modules/<name>` entry for the installed package. The
28
+ // registration points this at the live release, so it is stable across
29
+ // upgrades while the release path underneath it is not.
30
+ export function loaderLinkPath({ home = homedir(), name } = {}) {
31
+ if (typeof name !== 'string' || name.trim() === '') return null;
32
+ return join(crewProfileDir({ home }), 'node_modules', ...name.split('/'));
33
+ }
34
+
35
+ function sameDirectory(left, right) {
36
+ try { return realpathSync(left) === realpathSync(right); } catch { return false; }
37
+ }
38
+
39
+ /**
40
+ * The root the host integrations were installed from.
41
+ *
42
+ * Readiness must judge the integrations against the path they were actually
43
+ * written with. That path is the loader link when it resolves to this release,
44
+ * and the release directory itself otherwise — a machine whose link is missing
45
+ * or points somewhere else is reported against the release, which is exactly
46
+ * the mismatch the operator needs to see.
47
+ */
48
+ export function integrationRoot({ home = homedir(), root, name } = {}) {
49
+ const link = loaderLinkPath({ home, name });
50
+ if (!link || !root || !existsSync(link)) return root;
51
+ return sameDirectory(link, root) ? link : root;
52
+ }
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
10
10
  import { normalizeModelPriority } from '../model-routing.mjs';
11
11
  import { crewSkillFiles, installCrewSkill, readCrewSkill, removeCrewSkill } from './crew-skill.mjs';
12
12
  import { zcodeStatus } from './zcode.mjs';
13
+ import { integrationRoot } from './crew-paths.mjs';
13
14
 
14
15
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
15
16
  const MARKETPLACE_NAME = 'dsh-crew';
@@ -372,6 +373,13 @@ export function writeGlobalConfig(patch) {
372
373
 
373
374
  /** What is currently installed where — drives the settings-page buttons. */
374
375
  export function installStatus({ home = homedir(), root = ROOT, env = process.env } = {}) {
376
+ // Host integrations are installed from the profile's loader link, not from the
377
+ // release directory, so an upgrade re-points one link instead of rewriting four
378
+ // host configurations. Readiness has to judge them against the path they were
379
+ // actually written with: deriving it from the release directory made every
380
+ // correctly installed machine read as needing repair.
381
+ const manifestName = readJson(join(root, 'package.json'), {})?.name ?? null;
382
+ const effectiveRoot = integrationRoot({ home, root, name: manifestName });
375
383
  const settings = readJson(join(home, '.claude', 'settings.json'), {});
376
384
  const enabled = settings.enabledPlugins;
377
385
  const claudeInstalled = !!(enabled && !Array.isArray(enabled) && enabled[PLUGIN_KEY]);
@@ -382,8 +390,8 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
382
390
  const claudeFootprint = claudeInstalled || typeof marketplaceRoot === 'string' || installedPluginRecord !== undefined;
383
391
  const claudeComponents = {
384
392
  enabled: claudeInstalled,
385
- marketplace: normalizedPath(marketplaceRoot) === normalizedPath(root) && claudePluginRootReady(root),
386
- snapshot: claudeSnapshotReady(home, root),
393
+ marketplace: normalizedPath(marketplaceRoot) === normalizedPath(effectiveRoot) && claudePluginRootReady(effectiveRoot),
394
+ snapshot: claudeSnapshotReady(home, effectiveRoot),
387
395
  permissions: claudePermissionsReady(settings),
388
396
  };
389
397
  const claudeMissing = Object.entries(claudeComponents).filter(([, present]) => !present).map(([key]) => key);
@@ -395,11 +403,11 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
395
403
  const workerTarget = codexRoleTarget(workerFile, 'ds-worker');
396
404
  const reviewerTarget = codexRoleTarget(reviewerFile, 'ds-reviewer');
397
405
  const mcpTarget = codexMcpTarget(configText);
398
- const expectedTarget = normalizedPath(join(root, 'src', 'server.mjs'));
399
- const expectedWorkerRole = renderedCodexRole(root, 'ds-worker.toml');
400
- const expectedReviewerRole = renderedCodexRole(root, 'ds-reviewer.toml');
401
- const expectedConfigPrompt = readText(join(root, 'codex', 'prompts', 'dsh-config.md'));
402
- const expectedStatusPrompt = readText(join(root, 'codex', 'prompts', 'dsh-status.md'));
406
+ const expectedTarget = normalizedPath(join(effectiveRoot, 'src', 'server.mjs'));
407
+ const expectedWorkerRole = renderedCodexRole(effectiveRoot, 'ds-worker.toml');
408
+ const expectedReviewerRole = renderedCodexRole(effectiveRoot, 'ds-reviewer.toml');
409
+ const expectedConfigPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-config.md'));
410
+ const expectedStatusPrompt = readText(join(effectiveRoot, 'codex', 'prompts', 'dsh-status.md'));
403
411
  const components = {
404
412
  worker_role: expectedWorkerRole !== null && readText(workerFile) === expectedWorkerRole,
405
413
  reviewer_role: expectedReviewerRole !== null && readText(reviewerFile) === expectedReviewerRole,
@@ -422,10 +430,34 @@ export function installStatus({ home = homedir(), root = ROOT, env = process.env
422
430
  missing: claudeMissing,
423
431
  },
424
432
  codex: { installed: codexInstalled, ready: missing.length === 0, components, missing },
425
- zcode: zcodeStatus({ home, root }),
433
+ zcode: zcodeStatus({ home, root: effectiveRoot }),
426
434
  };
427
435
  }
428
436
 
437
+ function removeLegacyCodexRoles({ agentsDir }) {
438
+ const actions = [];
439
+ for (const f of ['worker.toml', 'reviewer.toml']) {
440
+ const p = join(agentsDir, f);
441
+ if (!existsSync(p)) continue;
442
+ // Only Crew's own abandoned stub is removed. An earlier release wrote these
443
+ // before the roles were renamed to ds-worker/ds-reviewer, and every Codex
444
+ // start since has logged "Ignoring malformed agent role definition" about a
445
+ // file of ours that Codex cannot use: a role without `developer_instructions`
446
+ // is not a role, so anything that *is* a real user role cannot match here.
447
+ const text = readText(p);
448
+ if (typeof text !== 'string') continue;
449
+ const name = f.replace(/\.toml$/, '');
450
+ const stripped = text.replace(/#[^\n]*/g, '').trim();
451
+ if (/developer_instructions/.test(stripped)) continue;
452
+ if (!new RegExp(`^name\\s*=\\s*["']${name}["']$`).test(stripped)) continue;
453
+ const bak = backup(p);
454
+ if (bak) actions.push(`backup: ${bak}`);
455
+ rmSync(p);
456
+ actions.push(`removed obsolete role: ${p}`);
457
+ }
458
+ return actions;
459
+ }
460
+
429
461
  export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
430
462
  const actions = [];
431
463
  // Both the v0.2 roles (ds-worker / ds-reviewer) and the deprecated v0.1
@@ -435,6 +467,7 @@ export function uninstallCodex({ home = homedir(), env = process.env } = {}) {
435
467
  const p = join(codexHomeDir(home, env), 'agents', f);
436
468
  if (existsSync(p)) { backup(p); rmSync(p); actions.push(`removed: ${p} (backup kept)`); }
437
469
  }
470
+ actions.push(...removeLegacyCodexRoles({ agentsDir: join(codexHomeDir(home, env), 'agents') }));
438
471
  for (const f of ['dsh-config.md', 'dsh-status.md']) {
439
472
  const p = join(codexHomeDir(home, env), 'prompts', f);
440
473
  if (existsSync(p)) { rmSync(p); actions.push(`removed: ${p}`); }
@@ -596,6 +629,7 @@ export function installCodex({ home = homedir(), scope, root = ROOT, env = proce
596
629
  writeFileSync(join(promptsDir, f), readFileSync(join(promptsSrc, f), 'utf8'));
597
630
  actions.push(`prompt: ${join(promptsDir, f)}`);
598
631
  }
632
+ actions.push(...removeLegacyCodexRoles({ agentsDir }));
599
633
  if (scope !== 'project') {
600
634
  const act = writeGlobalCodexMcpServer(home, renderedPath, env);
601
635
  actions.push(...act);
@@ -32,16 +32,9 @@ const TIER_STATES = ['disabled', 'manual', 'auto'];
32
32
  // paths must never default to the user's official ~/.dsh or its ``web`` profile.
33
33
  // A fresh Crew Hub config points at the Crew-owned port only; the former
34
34
  // shared-profile default 3080 is treated as a legacy value to migrate away from.
35
- export const CREW_PROFILE_NAME = 'dsh-crew';
36
- export const CREW_HOME_REL = join('.config', 'dsh-crew', 'harness');
37
35
  export const CREW_DEFAULT_HUB_URL = 'http://127.0.0.1:3210';
38
36
  export const CREW_LEGACY_HUB_URL = 'http://127.0.0.1:3080';
39
- export function crewDshHome({ home = homedir() } = {}) {
40
- return join(home, CREW_HOME_REL);
41
- }
42
- export function crewProfileDir({ home = homedir() } = {}) {
43
- return join(crewDshHome({ home }), 'profiles', CREW_PROFILE_NAME);
44
- }
37
+ export { CREW_HOME_REL, CREW_PROFILE_NAME, crewDshHome, crewProfileDir } from './crew-paths.mjs';
45
38
 
46
39
  // The isolated default set: identical to the legacy defaults except the Hub URL,
47
40
  // so fresh Crew installs never point at the official web profile.
@@ -43,6 +43,9 @@ import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
45
  import { releaseClaimsState } from '../release-in-use.mjs';
46
+ import { compareProcessToken, processStartToken } from '../process-identity.mjs';
47
+ import { renameTree } from './tree-move.mjs';
48
+ import { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
46
49
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
47
50
  import {
48
51
  ensureOfficialWebIntegration,
@@ -254,7 +257,13 @@ export function acquireUpdateLock({ home = homedir() } = {}) {
254
257
  const file = updateLockFile({ home });
255
258
  mkdirSync(dirname(file), { recursive: true });
256
259
  const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
257
- const record = { pid: process.pid, started_at: isoNow(), nonce, hostname: process.env.COMPUTERNAME ?? null };
260
+ const record = {
261
+ pid: process.pid,
262
+ started_at: isoNow(),
263
+ nonce,
264
+ hostname: process.env.COMPUTERNAME ?? null,
265
+ process_start_token: processStartToken(process.pid),
266
+ };
258
267
  try {
259
268
  writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
260
269
  return { ok: true, owner: true, nonce };
@@ -271,12 +280,16 @@ function lockOwnerAlive(record) {
271
280
  if (!Number.isInteger(pid) || pid < 1) return false;
272
281
  try {
273
282
  process.kill(pid, 0);
274
- return true;
275
283
  } catch (error) {
276
284
  // ESRCH = no such process (dead owner, safe to reclaim).
277
285
  // EPERM = process exists but we cannot signal it (live owner, keep).
278
286
  return error?.code !== 'ESRCH';
279
287
  }
288
+ // The pid exists — but is it still the process that took the lock? A recycled
289
+ // pid answers "yes" to `kill(pid, 0)` and left a stale lock looking alive
290
+ // until the unrelated process exited, which is a lock nobody can reclaim.
291
+ // The recorded start token decides; without one we keep the PID-only verdict.
292
+ return compareProcessToken(record) !== 'different';
280
293
  }
281
294
 
282
295
  // Stale-lock reclaim with a single atomic claim: each contender writes its
@@ -292,7 +305,7 @@ function lockOwnerAlive(record) {
292
305
  function tryReclaimUpdateLock({ home, record }) {
293
306
  const file = updateLockFile({ home });
294
307
  const arbitrationPath = `${file}.arbitration`;
295
- const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow() };
308
+ const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow(), process_start_token: processStartToken(process.pid) };
296
309
  for (let round = 0; round < 3; round += 1) {
297
310
  let current = null;
298
311
  try { current = JSON.parse(readFileSync(file, 'utf8')); } catch { current = null; }
@@ -812,17 +825,17 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
812
825
  const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
813
826
  if (liveVersion !== rt.priorVersion) {
814
827
  // The tree has to be replaced, and replacing it under a running process is
815
- // the damage. The journal says whether a start can have happened: it is
816
- // set to `starting` before the candidate is started and to `verified`
817
- // after it checks out, so anything else means no runtime was ever started
818
- // from the candidate cohort and the swap is safe.
828
+ // the damage. The journal says whether a start can have happened: the
829
+ // lifecycle records `restarted` before the candidate is started and
830
+ // `verified` after it checks out, so anything before that means no runtime
831
+ // was ever started from the candidate cohort and the swap is safe.
819
832
  //
820
- // `starting` cannot be resolved from the journal alone — the candidate may
833
+ // `restarted` cannot be resolved from the journal alone — the candidate may
821
834
  // be live and unverified. It must not be a dead end either, so recovery
822
835
  // stops the runtime and rolls back on a stop that actually happened, or on
823
836
  // a durable STOPPED session that already proves one did. Anything less is
824
837
  // not proof: a runtime that cannot be shown to be stopped keeps its tree.
825
- if (rt.state === 'starting') {
838
+ if (runtimeStateMayHaveStarted(rt.state)) {
826
839
  const window = await openMaintenanceWindow({ home, journal });
827
840
  if (window?.malformed) {
828
841
  return { ok: false, code: 'JOURNAL_MAINTENANCE_SESSION_MALFORMED', stage: journal.stage, error: `${window.error}; refusing to replace a runtime tree while a stopped runtime cannot be accounted for` };
@@ -845,7 +858,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
845
858
  if (parked) {
846
859
  try {
847
860
  rmSync(rt.liveRoot, { recursive: true, force: true });
848
- renameSync(parked, rt.liveRoot);
861
+ renameTree(parked, rt.liveRoot);
849
862
  } catch (error) {
850
863
  return { ok: false, code: 'JOURNAL_COORDINATED_RUNTIME_RESTORE_FAILED', stage: journal.stage, error: `prior runtime restore failed: ${error?.message ?? error}` };
851
864
  }
@@ -856,7 +869,7 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
856
869
  if (retainedDir && existsSync(retainedDir)) {
857
870
  try {
858
871
  rmSync(rt.liveRoot, { recursive: true, force: true });
859
- renameSync(retainedDir, rt.liveRoot);
872
+ renameTree(retainedDir, rt.liveRoot);
860
873
  restored = true;
861
874
  } catch { /* fall through to fail closed */ }
862
875
  }
@@ -2332,7 +2345,7 @@ export async function performCoordinatedCohortUpdate({
2332
2345
  prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
2333
2346
  candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
2334
2347
  runtime: {
2335
- state: 'staged',
2348
+ state: 'before-stop',
2336
2349
  liveRoot,
2337
2350
  priorRoot: prevPath,
2338
2351
  priorVersion: priorDshVersion,
@@ -2402,23 +2415,34 @@ export async function performCoordinatedCohortUpdate({
2402
2415
  // session then refused the next ordinary stop, so the machine could not
2403
2416
  // recover on its own. `startOwnedBackend` accepts this exact lease and
2404
2417
  // runtime id, which is what recovery needs to close the window.
2405
- if (stopped.lease && stopped.runtime_id) {
2406
- writeUpdateJournal({ ...journalBase, runtime: { ...journalBase.runtime, maintenance: { lease: stopped.lease, runtime_id: stopped.runtime_id } } });
2418
+ // The `stopped` checkpoint is recorded whether or not the supervisor handed
2419
+ // back a maintenance lease: a stop that succeeded is a stopped runtime
2420
+ // either way, and the state machine needs that fact before the tree is
2421
+ // touched. The lease is an additional capability, not the checkpoint.
2422
+ const stopAdvance = checkRuntimeAdvance(journalBase.runtime.state, 'stopped');
2423
+ if (!stopAdvance.ok) {
2424
+ clearUpdateJournal({ home });
2425
+ return { ok: false, code: stopAdvance.code, error: stopAdvance.error };
2407
2426
  }
2427
+ const maintenance = stopped.lease && stopped.runtime_id ? { lease: stopped.lease, runtime_id: stopped.runtime_id } : null;
2428
+ writeUpdateJournal({
2429
+ ...journalBase,
2430
+ runtime: { ...journalBase.runtime, state: 'stopped', ...(maintenance ? { maintenance } : {}) },
2431
+ });
2408
2432
  let liveMoved = false;
2409
2433
  try {
2410
2434
  // Move live runtime aside, retaining its tree for offline rollback.
2411
2435
  if (existsSync(liveRoot)) {
2412
2436
  // prevPath is unique per attempt and already recorded in the journal.
2413
2437
  try { rmSync(prevPath, { recursive: true, force: true }); } catch {}
2414
- renameSync(liveRoot, prevPath);
2438
+ renameTree(liveRoot, prevPath);
2415
2439
  liveMoved = true;
2416
2440
  }
2417
2441
  mkdirSync(liveRoot, { recursive: true });
2418
2442
  } catch (error) {
2419
2443
  // Park failed: restore live tree before anything else.
2420
2444
  try {
2421
- if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameSync(prevPath, liveRoot);
2445
+ if (liveMoved && !existsSync(liveRoot) && prevPath && existsSync(prevPath)) renameTree(prevPath, liveRoot);
2422
2446
  } catch { /* best effort */ }
2423
2447
  const comp = await compensate();
2424
2448
  return finalizeCompensationFailure({ home, code: 'COORDINATED_RUNTIME_PARK_FAILED', error: String(error?.message ?? error), comp });
@@ -2430,7 +2454,7 @@ export async function performCoordinatedCohortUpdate({
2430
2454
  let comp = { ok: false };
2431
2455
  try {
2432
2456
  rmSync(liveRoot, { recursive: true, force: true });
2433
- if (liveMoved && existsSync(prevPath)) renameSync(prevPath, liveRoot);
2457
+ if (liveMoved && existsSync(prevPath)) renameTree(prevPath, liveRoot);
2434
2458
  comp = await compensate();
2435
2459
  } catch { /* compensate below already reports */ }
2436
2460
  return finalizeCompensationFailure({ home, code: installed.code ?? 'COORDINATED_RUNTIME_INSTALL_FAILED', error: installed.error ?? 'runtime install at live root failed', comp });
@@ -2441,12 +2465,18 @@ export async function performCoordinatedCohortUpdate({
2441
2465
  const comp = await compensate();
2442
2466
  return finalizeCompensationFailure({ home, code: null, error: 'candidate activation failed', comp });
2443
2467
  }
2444
- // Record that a start may be about to happen BEFORE it happens. Recovery
2445
- // decides whether the runtime tree can be replaced by reading this, and a
2446
- // crash between the start and the verification would otherwise look exactly
2447
- // like "the candidate was never started" — which is how a rollback ends up
2448
- // swapping the tree out from under a running process.
2449
- writeUpdateJournal({ ...journalBase, runtime: { ...journalBase.runtime, state: 'starting' } });
2468
+ // Record that a start is about to happen BEFORE it happens. This is the
2469
+ // `restarted` checkpoint, and it is written pre-emptively on purpose:
2470
+ // recovery decides whether the runtime tree can be replaced by reading it,
2471
+ // and a crash between the start and the verification would otherwise look
2472
+ // exactly like "the candidate was never started" — which is how a rollback
2473
+ // ends up swapping the tree out from under a running process.
2474
+ const startAdvance = checkRuntimeAdvance('stopped', 'restarted');
2475
+ if (!startAdvance.ok) {
2476
+ const comp = await compensate().catch(() => ({ ok: false }));
2477
+ return finalizeCompensationFailure({ home, code: startAdvance.code, error: startAdvance.error, comp });
2478
+ }
2479
+ writeUpdateJournal({ ...journalBase, runtime: { ...journalBase.runtime, state: 'restarted' } });
2450
2480
  const started = await startFn();
2451
2481
  if (started?.ok !== true) {
2452
2482
  const comp = await compensate();
@@ -2500,6 +2530,10 @@ export async function performCoordinatedCohortUpdate({
2500
2530
  // COMMIT POINT / LAST: pointer write after the verified journal.
2501
2531
  switchPointer(candidateRelease);
2502
2532
 
2533
+ // The pointer write IS the commit, so this only records that it happened.
2534
+ // A failure here must never roll a committed update back — the pointer is
2535
+ // the ground truth and recovery re-derives the outcome from it either way.
2536
+ markJournalCommitted({ home });
2503
2537
  clearUpdateJournal({ home });
2504
2538
  log(`✓ coordinated update committed: Crew ${candidateManifest.version} + DSH ${candidateDshVersion}`);
2505
2539
  return { ok: true, version: candidateManifest.version, path: stageDir, dsh_version: candidateDshVersion, restarted: started };
@@ -2509,6 +2543,25 @@ export async function performCoordinatedCohortUpdate({
2509
2543
  }
2510
2544
  }
2511
2545
 
2546
+ // Records the terminal lifecycle state. Deliberately non-fatal and deliberately
2547
+ // after the pointer write: by the time this runs the update is committed, so a
2548
+ // failure to annotate must not become a failure to update.
2549
+ function markJournalCommitted({ home }) {
2550
+ try {
2551
+ const file = updateJournalFile({ home });
2552
+ if (!existsSync(file)) return { ok: false, code: 'JOURNAL_ABSENT' };
2553
+ const journal = JSON.parse(readFileSync(file, 'utf8'));
2554
+ const rt = journal?.runtime;
2555
+ if (!rt || typeof rt !== 'object') return { ok: false, code: 'JOURNAL_RUNTIME_ABSENT' };
2556
+ const advance = checkRuntimeAdvance(rt.state, 'committed');
2557
+ if (!advance.ok) return advance;
2558
+ writeFileAtomic(file, JSON.stringify({ ...journal, runtime: { ...rt, state: 'committed', committed_at: isoNow() } }, null, 2) + '\n');
2559
+ return { ok: true };
2560
+ } catch {
2561
+ return { ok: false, code: 'JOURNAL_COMMIT_MARK_FAILED' };
2562
+ }
2563
+ }
2564
+
2512
2565
  // Shared failure exit for a compensated coordinated transaction. The journal
2513
2566
  // is cleared ONLY when compensation fully succeeded (prior payload
2514
2567
  // activated, prior runtime restored, restart + dual verify passed). When
@@ -0,0 +1,87 @@
1
+ // The coordinated-update runtime lifecycle, as one ordered state machine.
2
+ //
3
+ // The update transaction stops the owned 3210, swaps its runtime tree, starts
4
+ // the candidate, verifies a dual identity and only then moves the release
5
+ // pointer. Every one of those steps can be interrupted by a crash or a power
6
+ // loss, and what recovery is allowed to do afterwards depends entirely on how
7
+ // far the transaction got — in particular on whether a start *may* already have
8
+ // happened, because replacing a runtime tree under a live process is the damage
9
+ // this whole path exists to avoid.
10
+ //
11
+ // That question used to be answered by a state written at the last moment
12
+ // before the start, which left the earlier part of the transaction implicit:
13
+ // "no state recorded" and "state recorded but nothing done yet" were the same
14
+ // thing, and there was no name at all for the committed end. This module names
15
+ // all five checkpoints and rejects any write that would skip or rewind one, so
16
+ // the journal on disk is always a record of a transaction that actually
17
+ // followed this order.
18
+
19
+ export const RUNTIME_LIFECYCLE_STATES = Object.freeze([
20
+ 'before-stop', // Intent is journaled; the owned runtime has not been touched.
21
+ 'stopped', // A durable maintenance window is open; mutating the tree is authorized.
22
+ 'restarted', // A start has been INITIATED — written before the start call, so a
23
+ // crash in flight is indistinguishable from a completed start.
24
+ // That ambiguity is deliberate: it is the safe direction, and it
25
+ // means "the live runtime may already be the candidate".
26
+ 'verified', // Candidate is running and its dual identity checked out.
27
+ 'committed', // Release pointer moved. Terminal.
28
+ ]);
29
+
30
+ // Exactly one forward edge is legal between neighbouring checkpoints. A
31
+ // multi-step jump would describe work that never recorded its intermediate
32
+ // proof, and a rewind would rewrite history recovery has already acted on.
33
+ export const RUNTIME_LIFECYCLE_TRANSITIONS = Object.freeze({
34
+ 'before-stop': Object.freeze(['stopped']),
35
+ stopped: Object.freeze(['restarted']),
36
+ restarted: Object.freeze(['verified']),
37
+ verified: Object.freeze(['committed']),
38
+ committed: Object.freeze([]),
39
+ });
40
+
41
+ // Journals written by earlier versions used different names for the first two
42
+ // checkpoints. They are read, never written.
43
+ const LEGACY_RUNTIME_STATES = Object.freeze({ staged: 'before-stop', starting: 'restarted' });
44
+
45
+ export function normalizeRuntimeState(state) {
46
+ if (typeof state !== 'string' || state === '') return null;
47
+ const mapped = LEGACY_RUNTIME_STATES[state] ?? state;
48
+ return RUNTIME_LIFECYCLE_STATES.includes(mapped) ? mapped : null;
49
+ }
50
+
51
+ export function runtimeLifecycleIndex(state) {
52
+ const normalized = normalizeRuntimeState(state);
53
+ return normalized === null ? -1 : RUNTIME_LIFECYCLE_STATES.indexOf(normalized);
54
+ }
55
+
56
+ // The only question recovery must never get wrong. Anything from `restarted`
57
+ // onward means a start was initiated; `before-stop` and `stopped` mean the
58
+ // candidate tree has never been handed to a process.
59
+ export function runtimeStateMayHaveStarted(state) {
60
+ return runtimeLifecycleIndex(state) >= RUNTIME_LIFECYCLE_STATES.indexOf('restarted');
61
+ }
62
+
63
+ export function canAdvanceRuntimeState(from, to) {
64
+ const current = normalizeRuntimeState(from);
65
+ const next = normalizeRuntimeState(to);
66
+ if (current === null || next === null) return false;
67
+ return RUNTIME_LIFECYCLE_TRANSITIONS[current].includes(next);
68
+ }
69
+
70
+ export function checkRuntimeAdvance(from, to) {
71
+ const current = normalizeRuntimeState(from);
72
+ const next = normalizeRuntimeState(to);
73
+ if (current === null) {
74
+ return { ok: false, code: 'RUNTIME_LIFECYCLE_UNKNOWN_STATE', error: `unrecognized runtime lifecycle state ${JSON.stringify(from)}` };
75
+ }
76
+ if (next === null) {
77
+ return { ok: false, code: 'RUNTIME_LIFECYCLE_UNKNOWN_STATE', error: `unrecognized runtime lifecycle state ${JSON.stringify(to)}` };
78
+ }
79
+ if (!RUNTIME_LIFECYCLE_TRANSITIONS[current].includes(next)) {
80
+ return {
81
+ ok: false,
82
+ code: 'RUNTIME_LIFECYCLE_INVALID_TRANSITION',
83
+ error: `runtime lifecycle cannot go ${current} -> ${next}`,
84
+ };
85
+ }
86
+ return { ok: true, from: current, to: next };
87
+ }
@@ -0,0 +1,37 @@
1
+ import { renameSync } from 'node:fs';
2
+
3
+ /**
4
+ * Rename a directory, absorbing the transient refusal Windows gives for a tree
5
+ * that was just in use.
6
+ *
7
+ * Every runtime-tree move in this codebase happens immediately after the process
8
+ * using that tree was stopped: parking the live cohort, restoring a parked one
9
+ * during recovery, rotating a displaced cohort. On Windows the rename can be
10
+ * refused with EPERM/EACCES for a moment afterwards — a handle another process
11
+ * still holds, an indexer, or the previous child's own teardown — and the same
12
+ * call succeeds a few milliseconds later. Treating the first refusal as fatal
13
+ * turns that timing artifact into a failed upgrade, a failed rollback, or worst
14
+ * of all a recovery that cannot finish.
15
+ *
16
+ * Only the transient codes are retried. A missing source or a cross-device move
17
+ * is not going to become possible on the next attempt, so it is raised at once.
18
+ */
19
+ export const TRANSIENT_RENAME_CODES = Object.freeze(['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY']);
20
+
21
+ export function renameTree(from, to, { rename = renameSync, delays = [0, 40, 120, 300, 700] } = {}) {
22
+ let lastError = null;
23
+ for (const delay of delays) {
24
+ if (delay > 0) {
25
+ const until = Date.now() + delay;
26
+ while (Date.now() < until) { /* bounded wait: this path is not hot */ }
27
+ }
28
+ try {
29
+ rename(from, to);
30
+ return { ok: true };
31
+ } catch (error) {
32
+ lastError = error;
33
+ if (!TRANSIENT_RENAME_CODES.includes(error?.code)) throw error;
34
+ }
35
+ }
36
+ throw lastError;
37
+ }
@@ -8,6 +8,7 @@ import {
8
8
  writeFileSync,
9
9
  } from 'node:fs';
10
10
  import { dirname, join } from 'node:path';
11
+ import { compareProcessToken, processStartToken } from '../process-identity.mjs';
11
12
 
12
13
  export const WINDOWS_SUPERVISOR_HANDOFF_SCHEMA = 1;
13
14
  export const WINDOWS_SUPERVISOR_HANDOFF_PHASES = Object.freeze([
@@ -193,7 +194,11 @@ export function clearWindowsSupervisorHandoffJournal({ appRoot, journalFile, han
193
194
  function defaultAcquireLock({ journalFile, now }) {
194
195
  const file = `${journalFile}.lock`;
195
196
  const token = randomUUID();
196
- const record = { schema_version: 1, token, pid: process.pid, acquired_at: now };
197
+ // Same PID-reuse hazard as the update lock: the lock is reclaimed by asking
198
+ // whether its pid exists, so a recycled pid keeps a dead owner's lock alive.
199
+ // The start token makes the pair an identity; the probe is Windows-only here
200
+ // because this lock only ever guards the Windows supervisor handoff.
201
+ const record = { schema_version: 1, token, pid: process.pid, acquired_at: now, process_start_token: processStartToken(process.pid) };
197
202
  const tryCreate = () => {
198
203
  const pending = `${file}.pending.${process.pid}.${randomUUID()}`;
199
204
  try {
@@ -234,10 +239,10 @@ function lockOwnerAlive(record) {
234
239
  if (!Number.isInteger(pid) || pid < 1) return false;
235
240
  try {
236
241
  process.kill(pid, 0);
237
- return true;
238
242
  } catch (error) {
239
243
  return error?.code !== 'ESRCH';
240
244
  }
245
+ return compareProcessToken(record) !== 'different';
241
246
  }
242
247
 
243
248
  function reclaimDefaultLock({ file }) {
@@ -0,0 +1,39 @@
1
+ // The one name a Crew job is known by.
2
+ //
3
+ // A job has three identities an operator sees: the worktree directory, the
4
+ // conversation the Harness lists in its workspace panel, and whatever the host
5
+ // integration shows. Each of them is derived from something different — the
6
+ // worktree from the name Crew allocates, the conversation from the opening words
7
+ // of the first message the agent receives — so they only agree if they are built
8
+ // from the same string. This module is that string.
9
+ //
10
+ // The shape is `Crew_<YYYYMMDD>_<HHMMSS>_<purpose>`, matching the worktree naming
11
+ // rule; `src/workspace-isolation.mjs` allocates worktrees in the same shape, and a
12
+ // test asserts that every name produced here satisfies its ownership grammar, so
13
+ // the two cannot drift apart silently.
14
+
15
+ export const JOB_NAME_PREFIX = 'Crew_';
16
+ export const JOB_PURPOSE_MAX = 32;
17
+
18
+ export const JOB_NAME_RE = /^Crew_\d{8}_\d{6}_[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:-\d+)?$/;
19
+
20
+ /** Local wall-clock stamp: readable, and what an operator expects to see. */
21
+ export function jobNameStamp(at) {
22
+ const d = at instanceof Date ? at : new Date(at ?? Date.now());
23
+ const p = (n) => String(n).padStart(2, '0');
24
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
25
+ + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
26
+ }
27
+
28
+ /** A purpose reduced to something safe to put in a path and a heading. */
29
+ export function jobNamePurpose(purpose, max = JOB_PURPOSE_MAX) {
30
+ // Truncate before trimming: trimming first lets the slice end on the separator
31
+ // it just created, and the name then fails the grammar above.
32
+ return String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
33
+ .slice(0, max).replace(/^-+|-+$/g, '') || 'job';
34
+ }
35
+
36
+ /** `Crew_<date>_<time>_<purpose>` for a job starting at `at`. */
37
+ export function jobDisplayName({ purpose, at } = {}) {
38
+ return `${JOB_NAME_PREFIX}${jobNameStamp(at)}_${jobNamePurpose(purpose)}`;
39
+ }
package/src/jobs.mjs CHANGED
@@ -12,9 +12,11 @@ import { createStandaloneHarness } from './standalone-sdk.mjs';
12
12
  import { readFileSync, mkdirSync, existsSync } from 'node:fs';
13
13
  import { createShardWriter } from './status-shard.mjs';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { dirname, join, resolve } from 'node:path';
15
+ import { dirname, join, resolve, basename } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
- import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
17
+ import { appendDeliveryInstructions, prependJobIdentity, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
18
+ import { jobDisplayName } from './job-identity.mjs';
19
+ import { isCrewWorktreeName } from './workspace-isolation.mjs';
18
20
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from './workspace-audit.mjs';
19
21
  import { buildOutcome, JOB_PHASES } from './workflow.mjs';
20
22
  import { raceWaiters } from './removable-waiter.mjs';
@@ -134,7 +136,14 @@ export function startJob({
134
136
  // The worker always gets the auditable Delivery Contract appended (unless it
135
137
  // already carries one), so its final message follows ## Diff / ## Tests /
136
138
  // ## Risks — or the review contract for reviewer-role jobs.
137
- const workerPrompt = appendDeliveryInstructions(task, { tier, role, isReview: delivery === 'review' });
139
+ //
140
+ // It also opens with the job's Crew name, for the same reason the Hub does it:
141
+ // the Harness titles the session from the opening words of this prompt, so a
142
+ // standalone job should be as findable in that list as an isolated one.
143
+ const jobName = isCrewWorktreeName(basename(workspace))
144
+ ? basename(workspace)
145
+ : jobDisplayName({ purpose: role });
146
+ const workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role }), { tier, role, isReview: delivery === 'review' });
138
147
  const id = `job-${nextId++}-${Date.now().toString(36)}`;
139
148
  const dotEnv = loadDotEnv();
140
149
  if (!process.env.DEEPSEEK_API_KEY && !dotEnv.DEEPSEEK_API_KEY) {
@@ -0,0 +1,74 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ // A PID is not an identity. Windows and Linux both recycle them, so a lock
5
+ // record that carries only `pid` can describe a dead owner and a live stranger
6
+ // at the same time — and `process.kill(pid, 0)` cannot tell those apart, because
7
+ // the only question it answers is "does some process have this pid". That is
8
+ // precisely the question that made a stale lock look alive, so liveness needs a
9
+ // second field: the start time of the process. The Windows supervisor already
10
+ // proves its own identity with `StartTime.ToUniversalTime().Ticks`, and this is
11
+ // the same value read from Node.
12
+ //
13
+ // The probe is best-effort by construction. When the platform cannot answer,
14
+ // every caller falls back to its PID-only verdict rather than inventing one.
15
+
16
+ const cache = new Map();
17
+
18
+ export function processStartToken(pid, { probe = defaultProbe } = {}) {
19
+ const n = Number(pid);
20
+ if (!Number.isInteger(n) || n < 1) return null;
21
+ if (cache.has(n)) return cache.get(n);
22
+ let token = null;
23
+ try { token = probe(n); } catch { token = null; }
24
+ if (typeof token !== 'string' || token === '') token = null;
25
+ cache.set(n, token);
26
+ return token;
27
+ }
28
+
29
+ export function resetProcessStartTokenCache() {
30
+ cache.clear();
31
+ }
32
+
33
+ // 'same' — the pid exists and it is provably still the recorded process.
34
+ // 'different' — the pid exists but belongs to a different process (recycled PID).
35
+ // 'unknown' — no token on record, or the platform could not answer. Callers
36
+ // must treat this as "cannot prove the owner is gone".
37
+ export function compareProcessToken(record, { probe = defaultProbe } = {}) {
38
+ const recorded = record?.process_start_token ?? record?.processStartToken ?? null;
39
+ if (typeof recorded !== 'string' || recorded === '') return 'unknown';
40
+ const current = processStartToken(record?.pid, { probe });
41
+ if (current === null) return 'unknown';
42
+ return current === recorded ? 'same' : 'different';
43
+ }
44
+
45
+ function defaultProbe(pid) {
46
+ if (process.platform === 'win32') return windowsStartToken(pid);
47
+ if (process.platform === 'linux') return linuxStartToken(pid);
48
+ return null;
49
+ }
50
+
51
+ function windowsStartToken(pid) {
52
+ const out = execFileSync('powershell.exe', [
53
+ '-NoProfile', '-NonInteractive', '-Command',
54
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
55
+ ], { timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
56
+ const text = String(out ?? '').trim();
57
+ return /^\d+$/.test(text) ? `win-ticks:${text}` : null;
58
+ }
59
+
60
+ // `/proc/<pid>/stat` field 22 (starttime) counts clock ticks since boot, so it
61
+ // is only unique *within* a boot. Pairing it with the boot id makes the token
62
+ // unique across reboots too, which matters because the lock file outlives them.
63
+ function linuxStartToken(pid) {
64
+ let stat;
65
+ try { stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); } catch { return null; }
66
+ const close = stat.lastIndexOf(')');
67
+ if (close < 0) return null;
68
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
69
+ const starttime = fields[19];
70
+ if (!/^\d+$/.test(String(starttime ?? ''))) return null;
71
+ let boot = '';
72
+ try { boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(); } catch { boot = ''; }
73
+ return `linux-starttime:${boot}:${starttime}`;
74
+ }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.10.4';
39
+ export const RUNTIME_VERSION = '1.10.6';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -181,8 +181,14 @@ export function createWorkflowRuntime(adapters, {
181
181
  phase: JOB_PHASES.CREATED,
182
182
  status: 'running',
183
183
  cancelling: false,
184
+ // Where it will run is not known until the workspace is allocated, and the
185
+ // first snapshot a caller sees is taken before that happens. Claiming
186
+ // `shared` there was a wrong answer for every job that ends up isolated:
187
+ // the caller was told the work happens in their own tree while it was
188
+ // about to happen in a worktree. `null` means not allocated yet;
189
+ // `requested_isolation` carries what was asked for.
184
190
  execution_cwd: spec.cwd,
185
- isolation: 'shared',
191
+ isolation: null,
186
192
  base_revision: null,
187
193
  primary_workspace_dirty: false,
188
194
  attempts: [],
package/src/workflow.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // transport adapter.
12
12
 
13
13
  import { evaluateAttempt } from './policy.mjs';
14
- import { parseDeliveryReport } from './delivery.mjs';
14
+ import { parseDeliveryReport, parseTestsSection } from './delivery.mjs';
15
15
 
16
16
  export const JOB_PHASES = Object.freeze({
17
17
  CREATED: 'created',
@@ -63,17 +63,41 @@ function splitSection(value) {
63
63
  return value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
64
64
  }
65
65
 
66
- const NO_CHANGE_SENTINELS = new Set([
67
- 'no files changed',
68
- 'no file changed',
69
- 'no changes',
70
- '无文件变更',
71
- '没有文件变更',
72
- '未更改任何文件',
73
- '无变更',
74
- ]);
66
+ // A line that asserts the workspace ends unchanged. The report sections are
67
+ // prose, so this has to recognize the assertion inside a sentence rather than
68
+ // only as a whole line: "No net file changes — git status is empty" is the same
69
+ // claim as "no changes", and it is the *better* report.
70
+ const NO_CHANGE_ASSERTION_RE = new RegExp(
71
+ '^(?:'
72
+ + 'no\\s+(?:net\\s+)?(?:files?|changes?)\\b'
73
+ + '|nothing\\s+(?:was\\s+)?(?:changed|modified|added|removed|deleted)\\b'
74
+ + '|none\\s+(?:of\\s+the\\s+)?(?:files?|changes?)\\b'
75
+ + '|workspace\\s+(?:is\\s+|remains\\s+)?(?:unchanged|clean)\\b'
76
+ + '|(?:the\\s+)?(?:final\\s+|net\\s+)?(?:file\\s+)?changes?\\s*[:—-]\\s*(?:none|no\\b|empty)'
77
+ + '|无(?:文件)?变更|没有(?:文件)?变更|未(?:更改|修改)任何文件|工作区(?:保持)?不变'
78
+ + ')',
79
+ );
80
+
81
+ function isNoChangeAssertion(line) {
82
+ const normalized = line
83
+ .replace(/^(?:[-*+]\s+)+/, '')
84
+ .replace(/[`"'“”‘’]/g, '')
85
+ .replace(/[.!。!]+$/, '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (normalized === '') return false;
89
+ return NO_CHANGE_ASSERTION_RE.test(normalized);
90
+ }
75
91
 
92
+ // Whether the report claims the workspace holds changes. A report that asserts
93
+ // the workspace ends unchanged is not claiming any, whatever else it describes:
94
+ // an authorized task that creates something, verifies it and removes it must
95
+ // describe that transient work, and describing it is not a claim that it is
96
+ // still there. This can only ever turn a mismatch into a match when the
97
+ // workspace really is unchanged — if it did change, `claimsChanges === false`
98
+ // against real changes is still a mismatch, so no actual change is hidden.
76
99
  function deliveryClaimsChanges(outcome) {
100
+ if (outcome?.no_change_declared === true) return false;
77
101
  return Array.isArray(outcome?.changes) && outcome.changes.length > 0;
78
102
  }
79
103
 
@@ -110,25 +134,14 @@ export function applyWorkspaceEvidence(outcome, {
110
134
  }
111
135
 
112
136
  function parseChanges(section) {
113
- return splitSection(section).filter((line) => {
114
- const normalized = line
115
- .replace(/^(?:[-*+]\s+)+/, '')
116
- .replace(/[`"'“”‘’]/g, '')
117
- .replace(/[.!。!]+$/g, '')
118
- .trim()
119
- .toLowerCase();
120
- return !NO_CHANGE_SENTINELS.has(normalized);
121
- });
137
+ // The declaration lines are dropped so the remaining list is what the report
138
+ // says about files; the no-change assertion itself is kept out of it.
139
+ return splitSection(section).filter((line) => !isNoChangeAssertion(line));
122
140
  }
123
141
 
124
- function parseTests(section) {
125
- return splitSection(section).map((line) => {
126
- const m = line.match(/^(?:[-*+]\s+)?(PASS|FAIL|NOT RUN)\s+—\s+(.+?)\s+—\s+(.+)$/);
127
- if (!m) return { line };
128
- const [, status, command, summary] = m;
129
- return { status, command: command.trim(), summary: summary.trim() };
130
- });
131
- }
142
+ // The Tests section is parsed by the delivery contract's own parser. This module
143
+ // used to carry a second, looser one, so one report could show PASS entries to the
144
+ // caller while the delivery gate saw no Tests section at all.
132
145
 
133
146
  /**
134
147
  * Classify a worker run into a canonical task status. Completion of the
@@ -151,8 +164,11 @@ export function classifyTaskStatus({ executionStatus = 'completed', testsStatus,
151
164
  */
152
165
  export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopReason, deliveryMissing } = {}) {
153
166
  const parsed = parseDeliveryReport(result);
154
- const testsStatus = parsed.tests_status ?? deliveryMeta?.tests_status;
155
- const tests = parseTests(parsed.sections.Tests);
167
+ // The aggregate status and the visible entries come from one parse, so they can
168
+ // no longer disagree about whether the Tests section is evidence.
169
+ const parsedTests = parseTestsSection(parsed.sections.Tests);
170
+ const testsStatus = parsedTests.status ?? parsed.tests_status ?? deliveryMeta?.tests_status;
171
+ const tests = parsedTests.tests;
156
172
  const execStatus = executionStatus ?? (stopReason === 'completed' ? 'completed' : 'failed');
157
173
  return {
158
174
  execution_status: execStatus,
@@ -165,6 +181,11 @@ export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopR
165
181
  confidence: null,
166
182
  needs_escalation: false,
167
183
  changes: parseChanges(parsed.sections.Diff),
184
+ // The report's net claim about the workspace, read from the section as
185
+ // written. It cannot be inferred from `changes`: that list is filtered, so
186
+ // the declaration line is gone from it by the time anyone looks, and the
187
+ // lines that remain describe work the report already said it undid.
188
+ no_change_declared: splitSection(parsed.sections.Diff).some(isNoChangeAssertion),
168
189
  tests,
169
190
  tests_status: testsStatus ?? null,
170
191
  risks: splitSection(parsed.sections.Risks),