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