@ran-sh/dsh-crew 2.1.8 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.1.8",
3
+ "version": "2.2.0",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -8,7 +8,7 @@ back to the caller.
8
8
 
9
9
  ```text
10
10
  caller objective
11
- -> Worker in isolated workspace
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
@@ -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 (recommended)",
616
- worktree: "Crew worktrees only",
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)("crew");
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
- scope === "before" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [
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 || scope === "before" && !Number.isFinite(new Date(before).getTime()),
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
- ...scope === "before" ? { before: new Date(before).toISOString() } : {}
848
+ ...timeScoped && before !== "" ? { before: new Date(before).toISOString() } : {}
848
849
  }),
849
850
  children: t.preview
850
851
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.1.8",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -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>`.** 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`.
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 `worktree` isolation fails with
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. Use `shared` deliberately if that is what you want; `allow_no_changes`
106
- works in either.
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
- `isolation: worktree` is the default: a coding worker runs in a per-job git
144
- worktree and the primary tree is untouched. Non-git workspaces fail with
145
- `NOT_GIT_REPOSITORY` rather than silently sharing the tree; use `shared`
146
- explicitly if that is what you want. Concurrent jobs are capped by
147
- `max_parallel`.
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
- Cleanup is scoped by provenance, and the default scope is **Crew-created only**,
157
- so the operator's own conversations survive. `all` removes theirs too; use it
158
- deliberately. Archived batches are restorable from the panel.
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 创建(推荐)', worktree: '仅 Crew 工作树', before: '指定时间之前',
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 (recommended)', worktree: 'Crew worktrees only', before: 'Before a date',
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('crew');
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
- {scope === 'before' && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
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 || (scope === 'before' && !Number.isFinite(new Date(before).getTime()))} onClick={() => void act('preview', { operation, scope, ...(scope === 'before' ? { before: new Date(before).toISOString() } : {}) })}>{t.preview}</button>
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 `crew`: a caller that forgets to narrow the range gets the
35
- * one range that cannot remove sessions the operator opened themselves. Widening
36
- * to `all` is always an explicit choice.
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 = 'crew', before } = {}) {
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
- const cutoff = scope === 'before' ? instant(before) : null;
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, createIsolatedWorkspace, isCrewWorktreeName } from '../workspace-isolation.mjs';
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
- const created = await createIsolatedWorkspace({ cwd, jobId: id, purpose: jobRole, baseRevision: workspace_branch });
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: that makes the
871
- // conversation, the worktree directory and the name an operator types the same
872
- // string. An allocated worktree's name wins, so a collision suffix stays
873
- // consistent between the two.
874
- const jobName = isCrewWorktreeName(basename(executionCwd))
875
- ? basename(executionCwd)
876
- : jobDisplayName({ purpose: jobRole });
877
- const workerPrompt = appendDeliveryInstructions(prependJobIdentity(task, { name: jobName, role: jobRole }), { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
878
-
879
- const sessionId = `session-${randomUUID()}`;
880
- // Record provenance while it is still knowable: a session header carries no
881
- // field naming who asked for the session, so a later Crew-scoped cleanup can
882
- // only tell Crew's own work apart from the operator's by this ledger.
883
- appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
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';
@@ -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 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.
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;
@@ -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 worktree name carries what the job was for, so an operator reading the
305
- // directory list can tell a worker tree from a reviewer tree without opening
306
- // anything. Role is the honest answer; it is what the job actually is.
307
- const created = await createIsolatedWorkspace({
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
- jobId: job.id,
310
- purpose: job.role ?? 'job',
312
+ role: job.role ?? 'worker',
311
313
  baseRevision: job.workspace_branch ?? repo.baseRevision,
312
314
  });
313
- if (!created.ok) {
314
- return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
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: created.worktreePath,
319
- base_revision: created.baseRevision,
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: created.worktreePath, repoRoot: created.repoRoot },
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 };
@@ -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.1.8';
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([
@@ -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
+ }