@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.
- package/.claude-plugin/plugin.json +1 -1
- package/docs/installation.md +27 -0
- package/package.json +1 -1
- package/skills/dsh-crew/SKILL.md +19 -4
- package/src/delivery.mjs +69 -9
- package/src/hub/index.mjs +24 -6
- package/src/install/npx-lifecycle.mjs +70 -18
- package/src/install/runtime-lifecycle.mjs +87 -0
- package/src/install/windows-supervisor-lifecycle.mjs +7 -2
- package/src/job-identity.mjs +39 -0
- package/src/jobs.mjs +12 -3
- package/src/process-identity.mjs +74 -0
- package/src/runtime-identity.mjs +1 -1
- package/src/workflow.mjs +9 -11
- package/src/workspace-isolation.mjs +18 -1
package/docs/installation.md
CHANGED
|
@@ -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
package/skills/dsh-crew/SKILL.md
CHANGED
|
@@ -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
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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 = {
|
|
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:
|
|
816
|
-
//
|
|
817
|
-
// after it checks out, so anything
|
|
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
|
-
// `
|
|
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
|
|
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: '
|
|
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
|
-
|
|
2406
|
-
|
|
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
|
|
2445
|
-
//
|
|
2446
|
-
//
|
|
2447
|
-
//
|
|
2448
|
-
//
|
|
2449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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)
|
|
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()),
|