@ran-sh/dsh-crew 2.1.7 → 2.2.0
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/job-contracts.md +14 -1
- package/docs/readiness-matrix.md +20 -1
- package/lib/client.js +9 -8
- package/package.json +1 -1
- package/skills/dsh-crew/SKILL.md +24 -15
- package/src/client/history-panel.tsx +6 -5
- package/src/crew-workspaces.mjs +136 -0
- package/src/history/cleanup-plan.mjs +15 -5
- package/src/hub/index.mjs +45 -18
- package/src/job-identity.mjs +11 -4
- package/src/mcp-runtime.mjs +17 -12
- package/src/runtime-identity.mjs +1 -1
- package/src/workflow-runtime.mjs +27 -6
- package/src/workspace-isolation.mjs +67 -0
- package/src/workspace-lock.mjs +135 -0
package/docs/job-contracts.md
CHANGED
|
@@ -8,7 +8,7 @@ back to the caller.
|
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
10
|
caller objective
|
|
11
|
-
-> Worker in
|
|
11
|
+
-> Worker in the project's stable worker workspace (or a caller-named one)
|
|
12
12
|
-> structured outcome + candidate reference
|
|
13
13
|
-> optional Reviewer inspects the workspace directly
|
|
14
14
|
-> compact Result Contract + canonical job events
|
|
@@ -22,6 +22,10 @@ changes/tests/risks, changed-file names, base revision, and candidate
|
|
|
22
22
|
fingerprint. It opens the relevant files and runs `git diff` in the isolated
|
|
23
23
|
workspace when deeper inspection is needed.
|
|
24
24
|
|
|
25
|
+
The workspace a job ran in is grouped by directory, not created per job: a
|
|
26
|
+
reviewer reviewing in place runs in the worker's workspace, and only an explicit
|
|
27
|
+
reviewer gets `dsh-crew-review`. See the isolation note above.
|
|
28
|
+
|
|
25
29
|
The capsule also carries a pointer to the reviewed attempt's persisted execution
|
|
26
30
|
record — the Hub session id plus the Crew harness session store — because a
|
|
27
31
|
transient change leaves nothing in the workspace to inspect once it has been
|
|
@@ -95,6 +99,15 @@ Per-job precedence is request `constraints` > Profile > session defaults.
|
|
|
95
99
|
`auto`, `existing`, or `none`. Workspace preflight reports `READY`, `CONFLICT`,
|
|
96
100
|
`READ_ONLY`, or `UNAVAILABLE` before dispatch.
|
|
97
101
|
|
|
102
|
+
With the default isolation a job runs in its project's stable workspace —
|
|
103
|
+
`dsh-crew-worker`, or `dsh-crew-review` for an explicit reviewer — rather than a
|
|
104
|
+
tree of its own, which is what keeps the Harness session panel at two entries per
|
|
105
|
+
project. The workspace is reset to the job's base revision before it runs, so the
|
|
106
|
+
candidate is still that job's work alone, and it is held for the job's duration: a
|
|
107
|
+
second job finds it held and is refused with `WORKSPACE_BUSY` instead of sharing a
|
|
108
|
+
tree with the first. `none` (shared) keeps the caller's directory and takes no
|
|
109
|
+
lock.
|
|
110
|
+
|
|
98
111
|
`constraints.allow_no_changes: true` is for tasks explicitly expected to leave
|
|
99
112
|
no net workspace changes, including read-only search/inspection and bounded
|
|
100
113
|
temporary smoke work. It does not blindly convert a partial Delivery Report
|
package/docs/readiness-matrix.md
CHANGED
|
@@ -39,13 +39,32 @@ rows remain `NOT_RUN` until a trusted higher layer supplies explicit evidence.
|
|
|
39
39
|
- `provider_health`
|
|
40
40
|
- `reviewer_health`
|
|
41
41
|
- `model_execution`
|
|
42
|
+
- `worker_primary_callable`
|
|
43
|
+
- `worker_escalation_callable`
|
|
44
|
+
- `reviewer_primary_callable`
|
|
45
|
+
- `provider_lifecycle_consistent`
|
|
42
46
|
- `deepseek_flash`
|
|
43
47
|
- `deepseek_pro`
|
|
44
|
-
- `opencode_go_mimo_qwen`
|
|
45
48
|
- `reviewer_pipeline`
|
|
46
49
|
- `cancellation_timeout_escalation`
|
|
47
50
|
- `standalone_official`
|
|
48
51
|
|
|
52
|
+
This list is the one the code builds; it had drifted, listing a row that was
|
|
53
|
+
removed and omitting four that exist.
|
|
54
|
+
|
|
55
|
+
Coverage of a provider is not a row per provider. `deepseek_flash` and
|
|
56
|
+
`deepseek_pro` are the exception: they name DeepSeek Official because that
|
|
57
|
+
provider has its own slots, and a machine running its workers elsewhere has no
|
|
58
|
+
such execution to show — the row says so rather than borrowing another
|
|
59
|
+
provider's. Everything else that answers "can this machine's configured route
|
|
60
|
+
actually run" — `provider_health`, `reviewer_health`, `model_execution`,
|
|
61
|
+
`worker_primary_callable`, `reviewer_primary_callable`, `reviewer_pipeline` — is
|
|
62
|
+
filtered to whatever route is currently selected, so an operator on other
|
|
63
|
+
providers is covered by those and loses nothing by the two DeepSeek rows
|
|
64
|
+
reading `NOT_RUN`. A static row per provider was tried and replaced by these
|
|
65
|
+
dynamic signals; `opencode_go_mimo_qwen` was that attempt's last trace in this
|
|
66
|
+
document.
|
|
67
|
+
|
|
49
68
|
`provider_health` and `reviewer_health` describe only the currently resolved
|
|
50
69
|
Worker and Reviewer routes. A fresh negative observation overrides historical
|
|
51
70
|
success; unrelated providers and stale observations do not. Historical jobs
|
package/lib/client.js
CHANGED
|
@@ -570,8 +570,8 @@ const copy = {
|
|
|
570
570
|
delete: "删除(不可恢复)",
|
|
571
571
|
scope: "范围",
|
|
572
572
|
all: "全部(含你自己的会话)",
|
|
573
|
-
crew: "仅 Crew
|
|
574
|
-
worktree: "仅 Crew
|
|
573
|
+
crew: "仅 Crew 创建",
|
|
574
|
+
worktree: "仅 Crew 工作区(推荐)",
|
|
575
575
|
before: "指定时间之前",
|
|
576
576
|
time: "创建时间早于",
|
|
577
577
|
timeHint: "按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。",
|
|
@@ -612,8 +612,8 @@ const copy = {
|
|
|
612
612
|
delete: "Delete (permanent)",
|
|
613
613
|
scope: "Scope",
|
|
614
614
|
all: "Everything (includes your own sessions)",
|
|
615
|
-
crew: "Crew-created only
|
|
616
|
-
worktree: "Crew
|
|
615
|
+
crew: "Crew-created only",
|
|
616
|
+
worktree: "Crew workspaces only (recommended)",
|
|
617
617
|
before: "Before a date",
|
|
618
618
|
time: "Created before",
|
|
619
619
|
timeHint: "Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.",
|
|
@@ -651,7 +651,8 @@ function HistoryPanel({ locale }) {
|
|
|
651
651
|
const t = copy[locale === "zh" ? "zh" : "en"];
|
|
652
652
|
const [open, setOpen] = (0, react.useState)(false);
|
|
653
653
|
const [operation, setOperation] = (0, react.useState)("archive");
|
|
654
|
-
const [scope, setScope] = (0, react.useState)("
|
|
654
|
+
const [scope, setScope] = (0, react.useState)("worktree");
|
|
655
|
+
const timeScoped = scope === "before" || scope === "worktree";
|
|
655
656
|
const [before, setBefore] = (0, react.useState)("");
|
|
656
657
|
const [preview, setPreview] = (0, react.useState)(null);
|
|
657
658
|
const [status, setStatus] = (0, react.useState)({ phase: "IDLE" });
|
|
@@ -816,7 +817,7 @@ function HistoryPanel({ locale }) {
|
|
|
816
817
|
})
|
|
817
818
|
] })]
|
|
818
819
|
}),
|
|
819
|
-
|
|
820
|
+
timeScoped && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
|
|
820
821
|
t.time,
|
|
821
822
|
" ",
|
|
822
823
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
@@ -840,11 +841,11 @@ function HistoryPanel({ locale }) {
|
|
|
840
841
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
841
842
|
type: "button",
|
|
842
843
|
style,
|
|
843
|
-
disabled: locked || !connected ||
|
|
844
|
+
disabled: locked || !connected || timeScoped && before !== "" && !Number.isFinite(new Date(before).getTime()) || scope === "before" && before === "",
|
|
844
845
|
onClick: () => void act("preview", {
|
|
845
846
|
operation,
|
|
846
847
|
scope,
|
|
847
|
-
...
|
|
848
|
+
...timeScoped && before !== "" ? { before: new Date(before).toISOString() } : {}
|
|
848
849
|
}),
|
|
849
850
|
children: t.preview
|
|
850
851
|
}),
|
package/package.json
CHANGED
package/skills/dsh-crew/SKILL.md
CHANGED
|
@@ -90,20 +90,23 @@ review asks for changes, that is a task result to act on — not approval.
|
|
|
90
90
|
auditable evidence, and a reply with neither a change nor a verified check is
|
|
91
91
|
incomplete. That is the gate working, not the worker failing. A task that
|
|
92
92
|
changes nothing *on purpose* can pass — see the next entry.
|
|
93
|
-
- **A job is named `Crew_<date>_<time>_<purpose>`.**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
directory
|
|
93
|
+
- **A project has two workspaces, and a job is named `Crew_<date>_<time>_<purpose>`.**
|
|
94
|
+
Worker jobs and the review that follows one run in `dsh-crew-worker`; an
|
|
95
|
+
explicit reviewer runs in `dsh-crew-review`. The Harness groups sessions by the
|
|
96
|
+
directory a job ran in, so those two are what an operator sees in the panel,
|
|
97
|
+
however many jobs have run. The job's own name still distinguishes it inside
|
|
98
|
+
the group.
|
|
97
99
|
- **A verified zero-change task needs `constraints.allow_no_changes: true`.**
|
|
98
100
|
For a deliberately temporary job — create, verify, clean up, end with an empty
|
|
99
101
|
diff — that flag plus the reported checks is what certifies it. It requires a
|
|
100
102
|
clean, readable baseline, at least one `PASS` and no `FAIL`, and it relaxes
|
|
101
103
|
nothing else: no evidence, a failed check, a dirty baseline or an actual change
|
|
102
104
|
each still refuse.
|
|
103
|
-
- **Isolated workspaces need git.** The default
|
|
105
|
+
- **Isolated workspaces need git.** The default isolation fails with
|
|
104
106
|
`NOT_GIT_REPOSITORY` for a non-git workspace rather than silently sharing the
|
|
105
|
-
tree
|
|
106
|
-
|
|
107
|
+
tree; a directory already sitting at the workspace path that Crew did not
|
|
108
|
+
create fails with `WORKSPACE_CONFLICT` instead of being deleted. Use `shared`
|
|
109
|
+
deliberately if that is what you want; `allow_no_changes` works in either.
|
|
107
110
|
- **A repository with no commits cannot be isolated.** `git init` with nothing
|
|
108
111
|
committed reports `REPOSITORY_HAS_NO_COMMITS` — there is no revision to start
|
|
109
112
|
from. Commit once, or run that job with `shared`.
|
|
@@ -140,11 +143,13 @@ unrestricted. Defaults mirror DeepSeek's published peak hours.
|
|
|
140
143
|
|
|
141
144
|
## Work
|
|
142
145
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`
|
|
146
|
+
The default isolation gives a coding worker a git worktree of its own project,
|
|
147
|
+
and the primary tree is untouched. That worktree is the project's stable worker
|
|
148
|
+
or reviewer workspace, reset to the job's base revision before it runs, so the
|
|
149
|
+
candidate diff is that job's work alone. One job at a time runs in each — a
|
|
150
|
+
second finds the workspace held and is refused with `WORKSPACE_BUSY` rather than
|
|
151
|
+
sharing it, because two jobs in one tree would each see the other's edits. So
|
|
152
|
+
concurrency is bounded by workspaces even where `max_parallel` is higher.
|
|
148
153
|
|
|
149
154
|
The reviewer gate is `required` by default: an unreviewed change fails. Review
|
|
150
155
|
findings and failing tests are results to address, never a reason to bypass the
|
|
@@ -153,9 +158,13 @@ gate.
|
|
|
153
158
|
## History
|
|
154
159
|
|
|
155
160
|
Session storage is shared between 3080 and 3210 — clearing one clears both.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
The default scope is **Crew's own workspaces**: sessions Crew recorded creating
|
|
162
|
+
that ran in `dsh-crew-worker`, `dsh-crew-review`, or a per-job worktree an
|
|
163
|
+
earlier release left behind. The operator's own conversations survive, and so
|
|
164
|
+
does anything Crew ran in a directory that is not one of its workspaces.
|
|
165
|
+
`all` removes theirs too; use it deliberately. The panel's date field narrows
|
|
166
|
+
any scope to sessions created before it, and workspaces with newer sessions are
|
|
167
|
+
kept. Archived batches are restorable from the panel.
|
|
159
168
|
|
|
160
169
|
## When Crew is unavailable
|
|
161
170
|
|
|
@@ -5,7 +5,7 @@ const pending = (phase?: string) => !!phase && !['IDLE', 'DONE', 'FAILED', 'ROLL
|
|
|
5
5
|
const copy = {
|
|
6
6
|
zh: {
|
|
7
7
|
title: '工作区与会话清理', intro: '仅影响 3210 的工作区记录与会话日志;不删除项目文件、附件或 3080 数据。',
|
|
8
|
-
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '范围', all: '全部(含你自己的会话)', crew: '仅 Crew
|
|
8
|
+
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '范围', all: '全部(含你自己的会话)', crew: '仅 Crew 创建', worktree: '仅 Crew 工作区(推荐)', before: '指定时间之前',
|
|
9
9
|
time: '创建时间早于', timeHint: '按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。',
|
|
10
10
|
preview: '预览清理范围', confirm: '确认执行', acknowledgement: '删除确认:请输入 DELETE', idle: '会短暂停止并重启 3210;请先结束或关闭所有正在使用的 3210 会话。',
|
|
11
11
|
consent: '我已确认范围,并同意短暂重启 3210', restore: '恢复', archives: '已归档批次', empty: '暂无可恢复的归档',
|
|
@@ -17,7 +17,7 @@ const copy = {
|
|
|
17
17
|
},
|
|
18
18
|
en: {
|
|
19
19
|
title: 'Workspace & session cleanup', intro: 'Only 3210 workspace records and session logs. Project files, attachments and 3080 data are untouched.',
|
|
20
|
-
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Scope', all: 'Everything (includes your own sessions)', crew: 'Crew-created only
|
|
20
|
+
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Scope', all: 'Everything (includes your own sessions)', crew: 'Crew-created only', worktree: 'Crew workspaces only (recommended)', before: 'Before a date',
|
|
21
21
|
time: 'Created before', timeHint: 'Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.',
|
|
22
22
|
preview: 'Preview cleanup', confirm: 'Confirm operation', acknowledgement: 'Type DELETE to confirm deletion', idle: '3210 will briefly stop and restart. End or close all active 3210 conversations first.',
|
|
23
23
|
consent: 'I reviewed the scope and agree to restart 3210', restore: 'Restore', archives: 'Archived batches', empty: 'No restorable archives',
|
|
@@ -33,7 +33,8 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
33
33
|
const t = copy[locale === 'zh' ? 'zh' : 'en'];
|
|
34
34
|
const [open, setOpen] = useState(false);
|
|
35
35
|
const [operation, setOperation] = useState('archive');
|
|
36
|
-
const [scope, setScope] = useState('
|
|
36
|
+
const [scope, setScope] = useState('worktree');
|
|
37
|
+
const timeScoped = scope === 'before' || scope === 'worktree';
|
|
37
38
|
const [before, setBefore] = useState('');
|
|
38
39
|
const [preview, setPreview] = useState<any>(null);
|
|
39
40
|
const [status, setStatus] = useState<any>({ phase: 'IDLE' });
|
|
@@ -83,9 +84,9 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
83
84
|
<label>{t.operation} <select style={style} value={operation} disabled={locked} onChange={e => { setOperation(e.target.value); invalidate(); }}><option value="archive">{t.archive}</option><option value="delete">{t.delete}</option></select></label>
|
|
84
85
|
<label>{t.scope} <select style={style} value={scope} disabled={locked} onChange={e => { setScope(e.target.value); invalidate(); }}><option value="crew">{t.crew}</option><option value="worktree">{t.worktree}</option><option value="all">{t.all}</option><option value="before">{t.before}</option></select></label>
|
|
85
86
|
</div>
|
|
86
|
-
{
|
|
87
|
+
{timeScoped && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
|
|
87
88
|
<div style={{ fontSize: 11.5, opacity: .7 }}>{t.timeHint}</div>
|
|
88
|
-
<button type="button" style={style} disabled={locked || !connected || (
|
|
89
|
+
<button type="button" style={style} disabled={locked || !connected || (timeScoped && before !== '' && !Number.isFinite(new Date(before).getTime())) || (scope === 'before' && before === '')} onClick={() => void act('preview', { operation, scope, ...(timeScoped && before !== '' ? { before: new Date(before).toISOString() } : {}) })}>{t.preview}</button>
|
|
89
90
|
{preview && <div style={{ ...style, display: 'flex', flexDirection: 'column', gap: 9 }}>
|
|
90
91
|
<strong>{t.count(preview.counts.workspaces, preview.counts.sessions)}</strong>
|
|
91
92
|
{preview.before && <span>{t.time}: {new Date(preview.before).toLocaleString()}</span>}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The two stable workspaces a Crew job runs in, one pair per project.
|
|
2
|
+
//
|
|
3
|
+
// Every job used to get its own git worktree named `Crew_<date>_<time>_<role>`,
|
|
4
|
+
// so the Harness — which groups sessions by the job's cwd, and matches the
|
|
5
|
+
// workspace exactly (`resolveByPath`, never a parent directory) — grew a new
|
|
6
|
+
// workspace entry for every job an operator ran. A pair that is created once and
|
|
7
|
+
// reused means the session list stops growing: a worker job and its automatic
|
|
8
|
+
// review both run in `dsh-crew-worker` (the reviewer has to see the tree the
|
|
9
|
+
// worker produced), and an explicit reviewer dispatch runs in `dsh-crew-review`.
|
|
10
|
+
//
|
|
11
|
+
// Two cwds per project, never shared between projects: a worktree belongs to one
|
|
12
|
+
// repository, so the pair is namespaced under a key derived from the repository
|
|
13
|
+
// path.
|
|
14
|
+
//
|
|
15
|
+
// Because the workspace outlives the job, two jobs sharing it would corrupt each
|
|
16
|
+
// other's evidence — see `workspace-lock.mjs`, which this takes for the whole
|
|
17
|
+
// job, and the reset below, which is what keeps a job from inheriting the last
|
|
18
|
+
// one's work.
|
|
19
|
+
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createWorktreeAt,
|
|
26
|
+
crewOwnsWorktree,
|
|
27
|
+
defaultWorktreeRoot,
|
|
28
|
+
inspectRepository,
|
|
29
|
+
resetWorktreeTo,
|
|
30
|
+
} from './workspace-isolation.mjs';
|
|
31
|
+
import { acquireWorkspaceLock, DEFAULT_LOCK_WAIT_MS } from './workspace-lock.mjs';
|
|
32
|
+
|
|
33
|
+
export const CREW_WORKSPACE_WORKER = 'dsh-crew-worker';
|
|
34
|
+
export const CREW_WORKSPACE_REVIEW = 'dsh-crew-review';
|
|
35
|
+
export const CREW_WORKSPACE_NAMES = Object.freeze([CREW_WORKSPACE_WORKER, CREW_WORKSPACE_REVIEW]);
|
|
36
|
+
export const WORKSPACE_CONFLICT = 'WORKSPACE_CONFLICT';
|
|
37
|
+
|
|
38
|
+
/** The workspace a role runs in. Only the reviewer is separated out. */
|
|
39
|
+
export function crewWorkspaceName(role) {
|
|
40
|
+
return role === 'reviewer' || role === 'review' ? CREW_WORKSPACE_REVIEW : CREW_WORKSPACE_WORKER;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is this directory name one of the two stable workspaces? */
|
|
44
|
+
export function isCrewWorkspaceName(name) {
|
|
45
|
+
return CREW_WORKSPACE_NAMES.includes(String(name ?? ''));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A stable, filesystem-safe key for a repository.
|
|
50
|
+
*
|
|
51
|
+
* Two projects' `dsh-crew-worker` directories must not collide, and the human
|
|
52
|
+
* part is kept because the key is visible in the session path: the directory
|
|
53
|
+
* name plus eight hex characters of the resolved path, the same shape the legacy
|
|
54
|
+
* worktree names used.
|
|
55
|
+
*/
|
|
56
|
+
export function crewProjectKey(repoRoot) {
|
|
57
|
+
const identity = resolve(String(repoRoot ?? '')).split(sep).join('/');
|
|
58
|
+
const normalized = process.platform === 'win32' ? identity.toLowerCase() : identity;
|
|
59
|
+
const safe = basename(identity).replace(/[^A-Za-z0-9]+/g, '-').slice(0, 32).replace(/^-+|-+$/g, '') || 'project';
|
|
60
|
+
const digest = createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
61
|
+
return `dsh-crew-${safe}-${digest}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `<worktreeRoot>/<projectKey>/<name>` — where this project's pair lives. */
|
|
65
|
+
export function crewWorkspacePath({ repoRoot, role, root = defaultWorktreeRoot() } = {}) {
|
|
66
|
+
return join(resolve(root), crewProjectKey(repoRoot), crewWorkspaceName(role));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The lock for a workspace lives beside the pair, never inside the tree. */
|
|
70
|
+
export function crewWorkspaceLockPath({ repoRoot, role, root = defaultWorktreeRoot() } = {}) {
|
|
71
|
+
return join(resolve(root), crewProjectKey(repoRoot), '.locks', `${crewWorkspaceName(role)}.lock`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The workspace this job should run in, held for the job's duration.
|
|
76
|
+
*
|
|
77
|
+
* Creates the pair member on first use and adopts it afterwards; the adopted
|
|
78
|
+
* tree is reset to the job's base revision so the candidate diff is this job's
|
|
79
|
+
* work alone, exactly as a fresh worktree gave it. Returns a `release` that
|
|
80
|
+
* drops the lock but leaves the directory — the next job reuses it.
|
|
81
|
+
*/
|
|
82
|
+
export async function ensureCrewWorkspace({
|
|
83
|
+
cwd,
|
|
84
|
+
role,
|
|
85
|
+
baseRevision,
|
|
86
|
+
at,
|
|
87
|
+
root = defaultWorktreeRoot(),
|
|
88
|
+
git,
|
|
89
|
+
lockWaitMs = DEFAULT_LOCK_WAIT_MS,
|
|
90
|
+
now,
|
|
91
|
+
sleep,
|
|
92
|
+
isAlive,
|
|
93
|
+
pid,
|
|
94
|
+
} = {}) {
|
|
95
|
+
const repo = await inspectRepository({ cwd, git });
|
|
96
|
+
if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
|
|
97
|
+
const repoRoot = repo.repoRoot;
|
|
98
|
+
const revision = baseRevision ?? repo.baseRevision;
|
|
99
|
+
const name = crewWorkspaceName(role);
|
|
100
|
+
const worktreePath = crewWorkspacePath({ repoRoot, role, root });
|
|
101
|
+
const lockPath = crewWorkspaceLockPath({ repoRoot, role, root });
|
|
102
|
+
|
|
103
|
+
const lock = await acquireWorkspaceLock({ lockPath, purpose: name, waitMs: lockWaitMs, now, sleep, isAlive, pid });
|
|
104
|
+
if (!lock.ok) return { ok: false, reason: lock.reason, error: lock.error, holder: lock.holder ?? null };
|
|
105
|
+
const release = lock.release;
|
|
106
|
+
|
|
107
|
+
const fail = (reason, error) => {
|
|
108
|
+
release();
|
|
109
|
+
return { ok: false, reason, error };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const reused = await crewOwnsWorktree({ worktreePath, repoRoot, git });
|
|
113
|
+
if (!reused) {
|
|
114
|
+
if (existsSync(worktreePath)) {
|
|
115
|
+
// Something is there that Crew did not create. Deleting it would be a
|
|
116
|
+
// destructive guess about a directory Crew cannot claim, so refuse and
|
|
117
|
+
// say which path is in the way.
|
|
118
|
+
return fail(WORKSPACE_CONFLICT, `workspace path exists but is not a Crew worktree of this repository: ${worktreePath}`);
|
|
119
|
+
}
|
|
120
|
+
const created = await createWorktreeAt({ dir: worktreePath, repoRoot, revision, purpose: name, at, git });
|
|
121
|
+
if (!created.ok) return fail(created.reason, created.error);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const reset = await resetWorktreeTo({ worktreePath, revision, git });
|
|
125
|
+
if (!reset.ok) return fail(reset.reason, reset.error);
|
|
126
|
+
|
|
127
|
+
return { ok: true, worktreePath, repoRoot, baseRevision: revision, name, reused, release };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Release the lock a job held. The directory stays: it is the workspace the
|
|
132
|
+
* next job — and every session it produced — belongs to.
|
|
133
|
+
*/
|
|
134
|
+
export function releaseCrewWorkspace(handle) {
|
|
135
|
+
try { return handle?.release?.() === true; } catch { return false; }
|
|
136
|
+
}
|
|
@@ -31,14 +31,24 @@ function records(values) {
|
|
|
31
31
|
/**
|
|
32
32
|
* Pure preview only. No filesystem or project-path operations are performed.
|
|
33
33
|
*
|
|
34
|
-
* `scope` defaults to `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* `scope` defaults to `worktree` — Crew's own workspaces — because that is the
|
|
35
|
+
* range an operator is actually asking for when they clear a Crew project out,
|
|
36
|
+
* and it still cannot remove a session the operator opened themselves or one
|
|
37
|
+
* Crew ran in someone else's directory. Widening to `all` is always explicit.
|
|
38
|
+
*
|
|
39
|
+
* The value is named `worktree` for compatibility and now means workspaces: the
|
|
40
|
+
* pair a project's jobs run in, plus the per-job trees earlier releases left
|
|
41
|
+
* behind, so those stay cleanable instead of being stranded by the change.
|
|
37
42
|
*/
|
|
38
|
-
export function planHistoryCleanup(snapshot, { operation = 'archive', scope = '
|
|
43
|
+
export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'worktree', before } = {}) {
|
|
39
44
|
if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
|
|
40
|
-
|
|
45
|
+
// `worktree` takes the same time window as `before`, but optionally: the panel
|
|
46
|
+
// supplies one when the operator picks a date, and its absence means "everything
|
|
47
|
+
// in these workspaces" rather than an error. A date that was supplied and is
|
|
48
|
+
// malformed still fails, so a typo cannot silently widen the range.
|
|
49
|
+
const cutoff = (scope === 'before' || scope === 'worktree') ? instant(before) : null;
|
|
41
50
|
if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
|
|
51
|
+
if (scope === 'worktree' && before !== undefined && cutoff === null) throw new Error('HISTORY_INVALID_CUTOFF');
|
|
42
52
|
const workspaces = records(snapshot.workspaces);
|
|
43
53
|
const sessions = records(snapshot.sessions);
|
|
44
54
|
const active = ids(snapshot.activeSessionIds);
|
package/src/hub/index.mjs
CHANGED
|
@@ -27,10 +27,11 @@ import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
|
|
|
27
27
|
import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
|
|
28
28
|
import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
|
|
29
29
|
import { loadCiEvidence } from '../ci-evidence.mjs';
|
|
30
|
+
import { ensureCrewWorkspace, releaseCrewWorkspace } from '../crew-workspaces.mjs';
|
|
30
31
|
import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
|
|
31
32
|
import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
|
|
32
33
|
import { buildExtensionContract } from '../extension-contract.mjs';
|
|
33
|
-
import { cleanupIsolatedWorkspace,
|
|
34
|
+
import { cleanupIsolatedWorkspace, isCrewWorktreeName } from '../workspace-isolation.mjs';
|
|
34
35
|
import { jobDisplayName } from '../job-identity.mjs';
|
|
35
36
|
import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
|
|
36
37
|
import { buildConfigReadinessMatrix } from '../config-readiness.mjs';
|
|
@@ -861,26 +862,45 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
861
862
|
let executionCwd = cwd;
|
|
862
863
|
let isolatedWorkspace = null;
|
|
863
864
|
if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
|
|
864
|
-
|
|
865
|
+
// This project's stable worker/reviewer workspace, reset to the job's base
|
|
866
|
+
// revision and held for the job's duration, so the Harness groups these
|
|
867
|
+
// sessions into two entries instead of one per job.
|
|
868
|
+
const created = await ensureCrewWorkspace({ cwd, role: jobRole, baseRevision: workspace_branch });
|
|
865
869
|
if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
|
|
866
870
|
executionCwd = created.worktreePath;
|
|
867
|
-
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
871
|
+
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot, stable: true, release: created.release };
|
|
868
872
|
}
|
|
869
873
|
// The Harness titles the session from the opening words of the prompt the
|
|
870
|
-
// agent receives, so the prompt opens with the job's Crew name
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
874
|
+
// agent receives, so the prompt opens with the job's Crew name. An allocated
|
|
875
|
+
// worktree's name wins, so a collision suffix stays consistent between the
|
|
876
|
+
// two. A stable workspace deliberately does *not* win: its directory name is
|
|
877
|
+
// shared by every job that uses it, so naming jobs after it would give them
|
|
878
|
+
// all one title and leave an operator unable to tell them apart. The
|
|
879
|
+
// workspace groups; the job name distinguishes.
|
|
880
|
+
let jobName;
|
|
881
|
+
let workerPrompt;
|
|
882
|
+
let sessionId;
|
|
883
|
+
try {
|
|
884
|
+
jobName = isCrewWorktreeName(basename(executionCwd))
|
|
885
|
+
? basename(executionCwd)
|
|
886
|
+
: jobDisplayName({ purpose: jobRole });
|
|
887
|
+
workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role: jobRole }), { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
|
|
888
|
+
|
|
889
|
+
sessionId = `session-${randomUUID()}`;
|
|
890
|
+
// Record provenance while it is still knowable: a session header carries no
|
|
891
|
+
// field naming who asked for the session, so a later Crew-scoped cleanup can
|
|
892
|
+
// only tell Crew's own work apart from the operator's by this ledger.
|
|
893
|
+
appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
|
|
894
|
+
} catch (error) {
|
|
895
|
+
// Everything below this point runs inside the job's own lifecycle, whose
|
|
896
|
+
// `finally` releases the workspace lock. A throw here happens before that
|
|
897
|
+
// job exists, so nothing would release a lock already taken — and it would
|
|
898
|
+
// then block the workspace until the age backstop, which is hours. The
|
|
899
|
+
// window is small (`appendSessionOrigin` writes a file) but the cost of
|
|
900
|
+
// missing it is a workspace no job can use.
|
|
901
|
+
releaseCrewWorkspace(isolatedWorkspace);
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
884
904
|
const job = {
|
|
885
905
|
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
886
906
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
|
@@ -1158,7 +1178,14 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
1158
1178
|
review: job.review,
|
|
1159
1179
|
workspaceDiff: job.workspaceDiff,
|
|
1160
1180
|
});
|
|
1161
|
-
if (job.isolatedWorkspace) {
|
|
1181
|
+
if (job.isolatedWorkspace?.stable === true) {
|
|
1182
|
+
// A stable workspace is not disposed of — it is the directory the next
|
|
1183
|
+
// job and every session it produced belong to. Only the lock is dropped.
|
|
1184
|
+
const released = releaseCrewWorkspace(job.isolatedWorkspace);
|
|
1185
|
+
job.workspace_retained = false;
|
|
1186
|
+
const workspaceCleanupWarning = released ? null : 'workspace lock was not held at release';
|
|
1187
|
+
job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
|
|
1188
|
+
} else if (job.isolatedWorkspace) {
|
|
1162
1189
|
const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
|
|
1163
1190
|
job.workspace_retained = cleanup.ok !== true;
|
|
1164
1191
|
const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
|
package/src/job-identity.mjs
CHANGED
|
@@ -7,10 +7,17 @@
|
|
|
7
7
|
// of the first message the agent receives — so they only agree if they are built
|
|
8
8
|
// from the same string. This module is that string.
|
|
9
9
|
//
|
|
10
|
-
// The shape is `Crew_<YYYYMMDD>_<HHMMSS>_<purpose>`, matching the
|
|
11
|
-
// rule; `src/workspace-isolation.mjs` allocates
|
|
12
|
-
// test asserts that every name produced here satisfies its ownership
|
|
13
|
-
// the two cannot drift apart silently.
|
|
10
|
+
// The shape is `Crew_<YYYYMMDD>_<HHMMSS>_<purpose>`, matching the disposable
|
|
11
|
+
// worktree naming rule; `src/workspace-isolation.mjs` allocates those in the same
|
|
12
|
+
// shape, and a test asserts that every name produced here satisfies its ownership
|
|
13
|
+
// grammar, so the two cannot drift apart silently. A job that runs in a
|
|
14
|
+
// caller-supplied Crew worktree is still named after that directory.
|
|
15
|
+
//
|
|
16
|
+
// A job running in one of the stable per-role workspaces is the exception, and
|
|
17
|
+
// the agreement above deliberately does not apply to it: `dsh-crew-worker` is
|
|
18
|
+
// shared by every job that uses it, so naming jobs after their workspace would
|
|
19
|
+
// give them all one title. There the workspace groups the sessions and this name
|
|
20
|
+
// tells them apart.
|
|
14
21
|
|
|
15
22
|
export const JOB_NAME_PREFIX = 'Crew_';
|
|
16
23
|
export const JOB_PURPOSE_MAX = 32;
|
package/src/mcp-runtime.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import { createWorkflowRuntime } from './workflow-runtime.mjs';
|
|
|
11
11
|
import { CONFIG_SCHEMA_VERSION, normalizeGlobalConfig } from './policy.mjs';
|
|
12
12
|
import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
|
|
13
13
|
import {
|
|
14
|
-
createIsolatedWorkspace,
|
|
15
14
|
cleanupIsolatedWorkspace,
|
|
16
15
|
inspectRepository,
|
|
17
16
|
captureCandidate as captureIsolationCandidate,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
|
|
20
19
|
import { hub } from './hub-client.mjs';
|
|
21
20
|
import { crewHarnessSessionsDir } from './install/crew-paths.mjs';
|
|
21
|
+
import { ensureCrewWorkspace, releaseCrewWorkspace } from './crew-workspaces.mjs';
|
|
22
22
|
|
|
23
23
|
const SESSION_CONFIG_KEYS = [
|
|
24
24
|
'default_tier', 'default_effort', 'mode', 'default_timeout_seconds',
|
|
@@ -301,25 +301,27 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
301
301
|
if (!repo.ok) {
|
|
302
302
|
return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
|
|
303
303
|
}
|
|
304
|
-
// The
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
304
|
+
// The job runs in this project's stable worker/reviewer workspace rather
|
|
305
|
+
// than a worktree of its own, so the sessions the Harness groups by cwd
|
|
306
|
+
// accumulate in two entries instead of one per job. The workspace is reset
|
|
307
|
+
// to the job's base revision on the way in, so the candidate diff is still
|
|
308
|
+
// this job's work alone, and it is held for the job's duration so two jobs
|
|
309
|
+
// cannot share it.
|
|
310
|
+
const workspace = await ensureCrewWorkspace({
|
|
308
311
|
cwd: job.requested_cwd,
|
|
309
|
-
|
|
310
|
-
purpose: job.role ?? 'job',
|
|
312
|
+
role: job.role ?? 'worker',
|
|
311
313
|
baseRevision: job.workspace_branch ?? repo.baseRevision,
|
|
312
314
|
});
|
|
313
|
-
if (!
|
|
314
|
-
return { ok: false, reason:
|
|
315
|
+
if (!workspace.ok) {
|
|
316
|
+
return { ok: false, reason: workspace.reason ?? 'WORKTREE_CREATE_FAILED', error: `workspace unavailable: ${workspace.error ?? ''}` };
|
|
315
317
|
}
|
|
316
318
|
return {
|
|
317
319
|
ok: true,
|
|
318
|
-
execution_cwd:
|
|
319
|
-
base_revision:
|
|
320
|
+
execution_cwd: workspace.worktreePath,
|
|
321
|
+
base_revision: workspace.baseRevision,
|
|
320
322
|
isolation: 'worktree',
|
|
321
323
|
primary_workspace_dirty: repo.dirty === true,
|
|
322
|
-
handle: { worktreePath:
|
|
324
|
+
handle: { worktreePath: workspace.worktreePath, repoRoot: workspace.repoRoot, stable: true, release: workspace.release },
|
|
323
325
|
};
|
|
324
326
|
};
|
|
325
327
|
|
|
@@ -327,6 +329,9 @@ export function buildMcpWorkflowRuntime(deps) {
|
|
|
327
329
|
|
|
328
330
|
const releaseWorkspace = async (handle) => {
|
|
329
331
|
if (!handle) return { ok: true };
|
|
332
|
+
// A stable workspace is not disposed of — it is the directory the next job
|
|
333
|
+
// and every session it produced belong to. Only the lock is dropped.
|
|
334
|
+
if (handle.stable === true) return { ok: releaseCrewWorkspace(handle) };
|
|
330
335
|
try {
|
|
331
336
|
const r = await cleanupIsolatedWorkspace({ worktreePath: handle.worktreePath, repoRoot: handle.repoRoot });
|
|
332
337
|
return { ok: r.ok, error: r.ok ? undefined : r.error };
|
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 = '2.
|
|
39
|
+
export const RUNTIME_VERSION = '2.2.0';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -408,9 +408,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
408
408
|
source: job.source,
|
|
409
409
|
model_class_hint: job.model_class_hint,
|
|
410
410
|
escalation_reason: escalationReason,
|
|
411
|
-
onAttemptStarted: (actualId) =>
|
|
412
|
-
if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
|
|
413
|
-
},
|
|
411
|
+
onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
|
|
414
412
|
});
|
|
415
413
|
job.current_attempt_id = null;
|
|
416
414
|
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
@@ -602,9 +600,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
602
600
|
source: job.source,
|
|
603
601
|
model_class_hint: 'pro',
|
|
604
602
|
escalation_reason: null,
|
|
605
|
-
onAttemptStarted: (actualId) =>
|
|
606
|
-
if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
|
|
607
|
-
},
|
|
603
|
+
onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
|
|
608
604
|
});
|
|
609
605
|
job.current_attempt_id = null;
|
|
610
606
|
const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
|
|
@@ -665,6 +661,31 @@ export function createWorkflowRuntime(adapters, {
|
|
|
665
661
|
: `${workflowId}-a${suffix || '0'}`;
|
|
666
662
|
}
|
|
667
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Record the id the executor actually started, and honour a cancel that
|
|
666
|
+
* arrived while it was still being dispatched.
|
|
667
|
+
*
|
|
668
|
+
* `cancelWorkflow` can only stop what `current_attempt_id` names. During
|
|
669
|
+
* dispatch that is the workflow-scoped placeholder, which the transport does
|
|
670
|
+
* not recognise as a runnable attempt, so a cancel landing in that window
|
|
671
|
+
* stops nothing — and the job the executor starts a moment later runs to
|
|
672
|
+
* completion while the workflow reports itself cancelled. Re-cancelling after
|
|
673
|
+
* the attempt returns does not help: `cancelWorkflow` returns the promise it
|
|
674
|
+
* already made. This is the one moment the real id becomes known, so the
|
|
675
|
+
* cancel that already happened is applied here.
|
|
676
|
+
*/
|
|
677
|
+
function adoptAttemptId(job, actualId) {
|
|
678
|
+
if (typeof actualId !== 'string' || actualId === '') return;
|
|
679
|
+
job.current_attempt_id = actualId;
|
|
680
|
+
if (!job.cancelling) return;
|
|
681
|
+
try {
|
|
682
|
+
Promise.resolve(adapters.cancelAttempt?.(actualId)).catch(() => {});
|
|
683
|
+
} catch {
|
|
684
|
+
// A transport that throws synchronously is still a cancel that did not
|
|
685
|
+
// land; the workflow's own terminal state is unchanged by it.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
668
689
|
function cancelWorkflow(job) {
|
|
669
690
|
if (job.cancelPromise) return job.cancelPromise;
|
|
670
691
|
if (job.status !== 'running') return;
|
|
@@ -645,6 +645,73 @@ async function ownershipDrift(run, worktreePath, expect) {
|
|
|
645
645
|
export const WORKTREE_CLEANUP_RETRIES = 3;
|
|
646
646
|
export const WORKTREE_CLEANUP_BACKOFF_MS = 150;
|
|
647
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Is `worktreePath` a worktree Crew created, for this repository?
|
|
650
|
+
*
|
|
651
|
+
* The same identity check the automatic prune uses, exposed for the stable
|
|
652
|
+
* per-role workspaces: they are created once and then adopted by every later
|
|
653
|
+
* job, and "adopt" has to mean the same thing it means to the pruner — shape,
|
|
654
|
+
* name, path, repository and incarnation all matching a record Crew wrote.
|
|
655
|
+
* Anything less would let a directory that merely has the right name be reused
|
|
656
|
+
* as if Crew had made it.
|
|
657
|
+
*/
|
|
658
|
+
export async function crewOwnsWorktree({ worktreePath, repoRoot, git } = {}) {
|
|
659
|
+
const run = git ?? defaultRunner;
|
|
660
|
+
const repo = await commonDirOf(run, repoRoot);
|
|
661
|
+
if (!repo) return false;
|
|
662
|
+
const incarnation = await incarnationOf(run, worktreePath);
|
|
663
|
+
return Boolean(readOwnership({ worktreePath, repo, incarnation }));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Create a worktree of `repoRoot` at an exact path, and record ownership.
|
|
668
|
+
*
|
|
669
|
+
* `createIsolatedWorkspace` reserves a name it invents; the stable per-role
|
|
670
|
+
* workspaces already have their path, so this is the same creation without the
|
|
671
|
+
* reservation step. Ownership is recorded only after git succeeded, so a failed
|
|
672
|
+
* creation never leaves a claim on a tree that does not exist.
|
|
673
|
+
*/
|
|
674
|
+
export async function createWorktreeAt({ dir, repoRoot, revision, purpose, at, git } = {}) {
|
|
675
|
+
const run = git ?? defaultRunner;
|
|
676
|
+
const res = await runGit(run, ['worktree', 'add', '--detach', dir, revision], { cwd: repoRoot });
|
|
677
|
+
if (!res.ok) {
|
|
678
|
+
const released = await cleanupIsolatedWorkspace({ worktreePath: dir, repoRoot, git: run, reservation: true });
|
|
679
|
+
return {
|
|
680
|
+
ok: false,
|
|
681
|
+
reason: res.reason,
|
|
682
|
+
error: res.error,
|
|
683
|
+
...(released.ok ? {} : { cleanupBlocked: true, cleanupError: released.error }),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
const headRes = await runGit(run, ['rev-parse', 'HEAD'], { cwd: dir });
|
|
687
|
+
const created = headRes.ok ? headRes.stdout.trim() : '';
|
|
688
|
+
const commonDir = await commonDirOf(run, repoRoot);
|
|
689
|
+
const incarnation = commonDir ? await claimIncarnation(run, dir) : null;
|
|
690
|
+
const owned = Boolean(commonDir && incarnation && created)
|
|
691
|
+
&& recordOwnership({ worktreePath: dir, repo: commonDir, incarnation, head: created, purpose, at });
|
|
692
|
+
return { ok: true, worktreePath: dir, repoRoot, owned };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Return a stable workspace to a known revision before a job starts.
|
|
697
|
+
*
|
|
698
|
+
* The workspace outlives the job, so without this a job would inherit whatever
|
|
699
|
+
* the previous one left — and the candidate diff, which is taken against a base
|
|
700
|
+
* revision, would then contain someone else's work. `reset --hard` discards
|
|
701
|
+
* tracked modifications; `clean -fd` removes untracked files but deliberately
|
|
702
|
+
* not ignored ones, so a workspace's `node_modules` survives. A reset that fails
|
|
703
|
+
* is reported rather than ignored: starting a job on an unknown tree is what the
|
|
704
|
+
* reset exists to prevent.
|
|
705
|
+
*/
|
|
706
|
+
export async function resetWorktreeTo({ worktreePath, revision, git } = {}) {
|
|
707
|
+
const run = git ?? defaultRunner;
|
|
708
|
+
const reset = await runGit(run, ['reset', '--hard', revision], { cwd: worktreePath });
|
|
709
|
+
if (!reset.ok) return { ok: false, reason: reset.reason, error: reset.error };
|
|
710
|
+
const clean = await runGit(run, ['clean', '-fd'], { cwd: worktreePath });
|
|
711
|
+
if (!clean.ok) return { ok: false, reason: clean.reason, error: clean.error };
|
|
712
|
+
return { ok: true };
|
|
713
|
+
}
|
|
714
|
+
|
|
648
715
|
export async function cleanupIsolatedWorkspace({
|
|
649
716
|
worktreePath,
|
|
650
717
|
repoRoot,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// One job at a time per Crew workspace.
|
|
2
|
+
//
|
|
3
|
+
// A stable workspace is shared by every job that uses it, which is the point —
|
|
4
|
+
// the sessions group under one directory instead of multiplying. The cost is
|
|
5
|
+
// that two jobs running in it at once would corrupt each other's evidence: the
|
|
6
|
+
// client only captures a candidate for `worktree` isolation, the Hub diffs the
|
|
7
|
+
// whole tree before and after and would attribute a concurrent job's edits to
|
|
8
|
+
// this one, and the reviewer's before/after fingerprint would see the other
|
|
9
|
+
// job's writes as a mutated candidate and force `request_changes`. Nothing in
|
|
10
|
+
// the runtime serialised by directory before, so this does.
|
|
11
|
+
//
|
|
12
|
+
// The lock lives beside the workspace, never inside it: a file in the tree would
|
|
13
|
+
// show up in the very diffs it is there to keep clean.
|
|
14
|
+
//
|
|
15
|
+
// It fails closed. A holder that died leaves a lock behind, so an obviously
|
|
16
|
+
// abandoned one (its process is gone, or it has held the lock past any plausible
|
|
17
|
+
// job) is reclaimed; but a lock whose owner still looks alive is waited for and
|
|
18
|
+
// then refused with `WORKSPACE_BUSY`, never taken by force. Waiting is bounded,
|
|
19
|
+
// and the bound is part of the caller's attempt budget.
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export const WORKSPACE_BUSY = 'WORKSPACE_BUSY';
|
|
26
|
+
export const WORKSPACE_LOCK_FILE = 'workspace.lock';
|
|
27
|
+
export const WORKSPACE_LOCK_SCHEMA = 1;
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_LOCK_WAIT_MS = 120_000;
|
|
30
|
+
export const DEFAULT_LOCK_POLL_MS = 500;
|
|
31
|
+
// The two reasons a lock outlives its job are not the same, and they are covered
|
|
32
|
+
// by different things. A holder whose process died is caught immediately by the
|
|
33
|
+
// pid check below. This bound covers the other case — the process is alive but
|
|
34
|
+
// the job is gone, which is what a job that ended without releasing looks like —
|
|
35
|
+
// and it is only a backstop, so it wants to be as short as it can safely be.
|
|
36
|
+
//
|
|
37
|
+
// It must still exceed the longest a job may legitimately run, or a live job
|
|
38
|
+
// would lose its own workspace: `timeout_seconds` is capped at 2h, so this is
|
|
39
|
+
// that plus half an hour of margin. Six hours, the first value here, would have
|
|
40
|
+
// stalled a workspace for four hours longer than any job can last.
|
|
41
|
+
export const DEFAULT_LOCK_MAX_HOLD_MS = (2 * 60 + 30) * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
const defaultSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
44
|
+
|
|
45
|
+
/** Is a process with this pid running? Unknown pids read as dead. */
|
|
46
|
+
function defaultIsAlive(pid) {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// EPERM means it exists and belongs to someone else; ESRCH means it is gone.
|
|
53
|
+
return error?.code === 'EPERM';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readRecord(path) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
60
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Acquire the workspace lock, waiting up to `waitMs` for a live holder.
|
|
68
|
+
*
|
|
69
|
+
* Returns `{ ok: true, release }` or `{ ok: false, reason, error, holder }`.
|
|
70
|
+
* `release` only removes the lock this call created: after a reclaim, a stale
|
|
71
|
+
* holder waking up must not delete its successor's lock.
|
|
72
|
+
*/
|
|
73
|
+
export async function acquireWorkspaceLock({
|
|
74
|
+
lockPath,
|
|
75
|
+
purpose = null,
|
|
76
|
+
waitMs = DEFAULT_LOCK_WAIT_MS,
|
|
77
|
+
pollMs = DEFAULT_LOCK_POLL_MS,
|
|
78
|
+
maxHoldMs = DEFAULT_LOCK_MAX_HOLD_MS,
|
|
79
|
+
pid = process.pid,
|
|
80
|
+
now = Date.now,
|
|
81
|
+
sleep = defaultSleep,
|
|
82
|
+
isAlive = defaultIsAlive,
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (typeof lockPath !== 'string' || lockPath === '') {
|
|
85
|
+
return { ok: false, reason: WORKSPACE_BUSY, error: 'workspace lock requires a path' };
|
|
86
|
+
}
|
|
87
|
+
const deadline = now() + Math.max(0, waitMs);
|
|
88
|
+
const nonce = randomBytes(16).toString('hex');
|
|
89
|
+
|
|
90
|
+
for (;;) {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
93
|
+
writeFileSync(lockPath, `${JSON.stringify({
|
|
94
|
+
schemaVersion: WORKSPACE_LOCK_SCHEMA,
|
|
95
|
+
pid,
|
|
96
|
+
purpose,
|
|
97
|
+
nonce,
|
|
98
|
+
startedAt: now(),
|
|
99
|
+
})}\n`, { flag: 'wx' });
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
release: () => {
|
|
103
|
+
const held = readRecord(lockPath);
|
|
104
|
+
if (held?.nonce !== nonce) return false;
|
|
105
|
+
try { rmSync(lockPath, { force: true }); return true; } catch { return false; }
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code !== 'EEXIST') {
|
|
110
|
+
return { ok: false, reason: WORKSPACE_BUSY, error: `cannot take workspace lock: ${error?.message ?? error}` };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const holder = readRecord(lockPath);
|
|
115
|
+
const startedAt = Number.isFinite(holder?.startedAt) ? holder.startedAt : null;
|
|
116
|
+
const abandoned = holder === null
|
|
117
|
+
|| !isAlive(holder.pid)
|
|
118
|
+
|| (startedAt !== null && now() - startedAt > maxHoldMs);
|
|
119
|
+
if (abandoned) {
|
|
120
|
+
// Reclaim and loop: the exclusive create on the next pass decides the
|
|
121
|
+
// race, so two waiters cannot both take it.
|
|
122
|
+
try { rmSync(lockPath, { force: true }); } catch { /* the retry decides */ }
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (now() >= deadline) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: WORKSPACE_BUSY,
|
|
129
|
+
error: `workspace is held by another job (pid ${holder.pid}${holder.purpose ? `, ${holder.purpose}` : ''})`,
|
|
130
|
+
holder,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
await sleep(Math.min(pollMs, Math.max(1, deadline - now())));
|
|
134
|
+
}
|
|
135
|
+
}
|