@ran-sh/dsh-crew 1.10.3 → 1.10.5

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.3",
3
+ "version": "1.10.5",
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.3",
3
+ "version": "1.10.5",
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
  /**
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
@@ -43,6 +43,8 @@ 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 { checkRuntimeAdvance, normalizeRuntimeState, runtimeStateMayHaveStarted } from './runtime-lifecycle.mjs';
46
48
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
47
49
  import {
48
50
  ensureOfficialWebIntegration,
@@ -254,7 +256,13 @@ export function acquireUpdateLock({ home = homedir() } = {}) {
254
256
  const file = updateLockFile({ home });
255
257
  mkdirSync(dirname(file), { recursive: true });
256
258
  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 };
259
+ const record = {
260
+ pid: process.pid,
261
+ started_at: isoNow(),
262
+ nonce,
263
+ hostname: process.env.COMPUTERNAME ?? null,
264
+ process_start_token: processStartToken(process.pid),
265
+ };
258
266
  try {
259
267
  writeFileSync(file, JSON.stringify(record) + '\n', { flag: 'wx' });
260
268
  return { ok: true, owner: true, nonce };
@@ -271,12 +279,16 @@ function lockOwnerAlive(record) {
271
279
  if (!Number.isInteger(pid) || pid < 1) return false;
272
280
  try {
273
281
  process.kill(pid, 0);
274
- return true;
275
282
  } catch (error) {
276
283
  // ESRCH = no such process (dead owner, safe to reclaim).
277
284
  // EPERM = process exists but we cannot signal it (live owner, keep).
278
285
  return error?.code !== 'ESRCH';
279
286
  }
287
+ // The pid exists — but is it still the process that took the lock? A recycled
288
+ // pid answers "yes" to `kill(pid, 0)` and left a stale lock looking alive
289
+ // until the unrelated process exited, which is a lock nobody can reclaim.
290
+ // The recorded start token decides; without one we keep the PID-only verdict.
291
+ return compareProcessToken(record) !== 'different';
280
292
  }
281
293
 
282
294
  // Stale-lock reclaim with a single atomic claim: each contender writes its
@@ -292,7 +304,7 @@ function lockOwnerAlive(record) {
292
304
  function tryReclaimUpdateLock({ home, record }) {
293
305
  const file = updateLockFile({ home });
294
306
  const arbitrationPath = `${file}.arbitration`;
295
- const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow() };
307
+ const myClaim = { pid: process.pid, nonce: record.nonce, started_at: isoNow(), process_start_token: processStartToken(process.pid) };
296
308
  for (let round = 0; round < 3; round += 1) {
297
309
  let current = null;
298
310
  try { current = JSON.parse(readFileSync(file, 'utf8')); } catch { current = null; }
@@ -812,17 +824,17 @@ export async function reconcileUpdateJournal({ home = homedir(), log = () => {},
812
824
  const liveVersion = readRuntimeTreeVersionSync(rt.liveRoot);
813
825
  if (liveVersion !== rt.priorVersion) {
814
826
  // 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.
827
+ // the damage. The journal says whether a start can have happened: the
828
+ // lifecycle records `restarted` before the candidate is started and
829
+ // `verified` after it checks out, so anything before that means no runtime
830
+ // was ever started from the candidate cohort and the swap is safe.
819
831
  //
820
- // `starting` cannot be resolved from the journal alone — the candidate may
832
+ // `restarted` cannot be resolved from the journal alone — the candidate may
821
833
  // be live and unverified. It must not be a dead end either, so recovery
822
834
  // stops the runtime and rolls back on a stop that actually happened, or on
823
835
  // a durable STOPPED session that already proves one did. Anything less is
824
836
  // not proof: a runtime that cannot be shown to be stopped keeps its tree.
825
- if (rt.state === 'starting') {
837
+ if (runtimeStateMayHaveStarted(rt.state)) {
826
838
  const window = await openMaintenanceWindow({ home, journal });
827
839
  if (window?.malformed) {
828
840
  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` };
@@ -2332,7 +2344,7 @@ export async function performCoordinatedCohortUpdate({
2332
2344
  prior: { name: prior.name, version: prior.version, path: prior.path, dshVersion: priorDshVersion },
2333
2345
  candidate: { name: candidateManifest.name, version: candidateManifest.version, stageDir, dshVersion: candidateDshVersion },
2334
2346
  runtime: {
2335
- state: 'staged',
2347
+ state: 'before-stop',
2336
2348
  liveRoot,
2337
2349
  priorRoot: prevPath,
2338
2350
  priorVersion: priorDshVersion,
@@ -2402,9 +2414,20 @@ export async function performCoordinatedCohortUpdate({
2402
2414
  // session then refused the next ordinary stop, so the machine could not
2403
2415
  // recover on its own. `startOwnedBackend` accepts this exact lease and
2404
2416
  // 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 } } });
2417
+ // The `stopped` checkpoint is recorded whether or not the supervisor handed
2418
+ // back a maintenance lease: a stop that succeeded is a stopped runtime
2419
+ // either way, and the state machine needs that fact before the tree is
2420
+ // touched. The lease is an additional capability, not the checkpoint.
2421
+ const stopAdvance = checkRuntimeAdvance(journalBase.runtime.state, 'stopped');
2422
+ if (!stopAdvance.ok) {
2423
+ clearUpdateJournal({ home });
2424
+ return { ok: false, code: stopAdvance.code, error: stopAdvance.error };
2407
2425
  }
2426
+ const maintenance = stopped.lease && stopped.runtime_id ? { lease: stopped.lease, runtime_id: stopped.runtime_id } : null;
2427
+ writeUpdateJournal({
2428
+ ...journalBase,
2429
+ runtime: { ...journalBase.runtime, state: 'stopped', ...(maintenance ? { maintenance } : {}) },
2430
+ });
2408
2431
  let liveMoved = false;
2409
2432
  try {
2410
2433
  // Move live runtime aside, retaining its tree for offline rollback.
@@ -2441,12 +2464,18 @@ export async function performCoordinatedCohortUpdate({
2441
2464
  const comp = await compensate();
2442
2465
  return finalizeCompensationFailure({ home, code: null, error: 'candidate activation failed', comp });
2443
2466
  }
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' } });
2467
+ // Record that a start is about to happen BEFORE it happens. This is the
2468
+ // `restarted` checkpoint, and it is written pre-emptively on purpose:
2469
+ // recovery decides whether the runtime tree can be replaced by reading it,
2470
+ // and a crash between the start and the verification would otherwise look
2471
+ // exactly like "the candidate was never started" — which is how a rollback
2472
+ // ends up swapping the tree out from under a running process.
2473
+ const startAdvance = checkRuntimeAdvance('stopped', 'restarted');
2474
+ if (!startAdvance.ok) {
2475
+ const comp = await compensate().catch(() => ({ ok: false }));
2476
+ return finalizeCompensationFailure({ home, code: startAdvance.code, error: startAdvance.error, comp });
2477
+ }
2478
+ writeUpdateJournal({ ...journalBase, runtime: { ...journalBase.runtime, state: 'restarted' } });
2450
2479
  const started = await startFn();
2451
2480
  if (started?.ok !== true) {
2452
2481
  const comp = await compensate();
@@ -2500,6 +2529,10 @@ export async function performCoordinatedCohortUpdate({
2500
2529
  // COMMIT POINT / LAST: pointer write after the verified journal.
2501
2530
  switchPointer(candidateRelease);
2502
2531
 
2532
+ // The pointer write IS the commit, so this only records that it happened.
2533
+ // A failure here must never roll a committed update back — the pointer is
2534
+ // the ground truth and recovery re-derives the outcome from it either way.
2535
+ markJournalCommitted({ home });
2503
2536
  clearUpdateJournal({ home });
2504
2537
  log(`✓ coordinated update committed: Crew ${candidateManifest.version} + DSH ${candidateDshVersion}`);
2505
2538
  return { ok: true, version: candidateManifest.version, path: stageDir, dsh_version: candidateDshVersion, restarted: started };
@@ -2509,6 +2542,25 @@ export async function performCoordinatedCohortUpdate({
2509
2542
  }
2510
2543
  }
2511
2544
 
2545
+ // Records the terminal lifecycle state. Deliberately non-fatal and deliberately
2546
+ // after the pointer write: by the time this runs the update is committed, so a
2547
+ // failure to annotate must not become a failure to update.
2548
+ function markJournalCommitted({ home }) {
2549
+ try {
2550
+ const file = updateJournalFile({ home });
2551
+ if (!existsSync(file)) return { ok: false, code: 'JOURNAL_ABSENT' };
2552
+ const journal = JSON.parse(readFileSync(file, 'utf8'));
2553
+ const rt = journal?.runtime;
2554
+ if (!rt || typeof rt !== 'object') return { ok: false, code: 'JOURNAL_RUNTIME_ABSENT' };
2555
+ const advance = checkRuntimeAdvance(rt.state, 'committed');
2556
+ if (!advance.ok) return advance;
2557
+ writeFileAtomic(file, JSON.stringify({ ...journal, runtime: { ...rt, state: 'committed', committed_at: isoNow() } }, null, 2) + '\n');
2558
+ return { ok: true };
2559
+ } catch {
2560
+ return { ok: false, code: 'JOURNAL_COMMIT_MARK_FAILED' };
2561
+ }
2562
+ }
2563
+
2512
2564
  // Shared failure exit for a compensated coordinated transaction. The journal
2513
2565
  // is cleared ONLY when compensation fully succeeded (prior payload
2514
2566
  // 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
+ }
@@ -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.3';
39
+ export const RUNTIME_VERSION = '1.10.5';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
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',
@@ -121,14 +121,9 @@ function parseChanges(section) {
121
121
  });
122
122
  }
123
123
 
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
- }
124
+ // The Tests section is parsed by the delivery contract's own parser. This module
125
+ // used to carry a second, looser one, so one report could show PASS entries to the
126
+ // caller while the delivery gate saw no Tests section at all.
132
127
 
133
128
  /**
134
129
  * Classify a worker run into a canonical task status. Completion of the
@@ -151,8 +146,11 @@ export function classifyTaskStatus({ executionStatus = 'completed', testsStatus,
151
146
  */
152
147
  export function buildOutcome({ result = '', deliveryMeta, executionStatus, stopReason, deliveryMissing } = {}) {
153
148
  const parsed = parseDeliveryReport(result);
154
- const testsStatus = parsed.tests_status ?? deliveryMeta?.tests_status;
155
- const tests = parseTests(parsed.sections.Tests);
149
+ // The aggregate status and the visible entries come from one parse, so they can
150
+ // no longer disagree about whether the Tests section is evidence.
151
+ const parsedTests = parseTestsSection(parsed.sections.Tests);
152
+ const testsStatus = parsedTests.status ?? parsed.tests_status ?? deliveryMeta?.tests_status;
153
+ const tests = parsedTests.tests;
156
154
  const execStatus = executionStatus ?? (stopReason === 'completed' ? 'completed' : 'failed');
157
155
  return {
158
156
  execution_status: execStatus,
@@ -25,6 +25,8 @@ export const NOT_GIT_REPOSITORY = 'NOT_GIT_REPOSITORY';
25
25
  export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
26
26
  export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
27
  export const GIT_ERROR = 'GIT_ERROR';
28
+ /** A valid repository whose HEAD does not resolve because nothing is committed. */
29
+ export const REPOSITORY_HAS_NO_COMMITS = 'REPOSITORY_HAS_NO_COMMITS';
28
30
  export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
29
31
  export const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
30
32
  export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
@@ -269,7 +271,22 @@ export async function inspectRepository({ cwd, git, runner } = {}) {
269
271
  runGit(run, ['status', '--porcelain', '-uall'], { cwd }),
270
272
  ]);
271
273
  if (!root.ok) return { ok: false, reason: root.reason, error: root.error };
272
- if (!head.ok) return { ok: false, reason: head.reason, error: head.error };
274
+ if (!head.ok) {
275
+ // A repository with no commits yet is a valid repository whose HEAD simply
276
+ // does not resolve, and it cannot be told apart from a broken one by that
277
+ // command alone. It is worth telling apart: `git init && <ask Crew to do
278
+ // something>` is how a new project starts, and reporting it as a generic git
279
+ // error leaves the operator with nothing to act on.
280
+ const verified = await runGit(run, ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd });
281
+ if (verified.ok === false && (verified.code === 1 || verified.code === 128) && !verified.stdout?.trim()) {
282
+ return {
283
+ ok: false,
284
+ reason: REPOSITORY_HAS_NO_COMMITS,
285
+ error: 'this repository has no commits yet, so there is no revision for an isolated job to start from; make an initial commit, or set execution.isolation to "shared" to run in the working tree',
286
+ };
287
+ }
288
+ return { ok: false, reason: head.reason, error: head.error };
289
+ }
273
290
  return {
274
291
  ok: true,
275
292
  repoRoot: resolve(root.stdout.trim()),