@ran-sh/dsh-crew 1.8.0 → 1.9.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": "1.8.0",
3
+ "version": "1.9.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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -285,7 +285,15 @@ export function buildMcpWorkflowRuntime(deps) {
285
285
  if (!repo.ok) {
286
286
  return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
287
287
  }
288
- const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: job.workspace_branch ?? repo.baseRevision });
288
+ // The worktree name carries what the job was for, so an operator reading the
289
+ // directory list can tell a worker tree from a reviewer tree without opening
290
+ // anything. Role is the honest answer; it is what the job actually is.
291
+ const created = await createIsolatedWorkspace({
292
+ cwd: job.requested_cwd,
293
+ jobId: job.id,
294
+ purpose: job.role ?? 'job',
295
+ baseRevision: job.workspace_branch ?? repo.baseRevision,
296
+ });
289
297
  if (!created.ok) {
290
298
  return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
291
299
  }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.8.0';
39
+ export const RUNTIME_VERSION = '1.9.0';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -11,7 +11,7 @@
11
11
  // lock blocks cleanup.
12
12
 
13
13
  import { execFile } from 'node:child_process';
14
- import { existsSync, rmSync } from 'node:fs';
14
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
15
15
  import { readFile, lstat } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { join, resolve, basename } from 'node:path';
@@ -26,10 +26,23 @@ export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
26
26
  export const GIT_TIMEOUT = 'GIT_TIMEOUT';
27
27
  export const GIT_ERROR = 'GIT_ERROR';
28
28
  export const WORKTREE_LOCKED = 'WORKTREE_LOCKED';
29
+ export const WORKTREE_RESERVE_FAILED = 'WORKTREE_RESERVE_FAILED';
29
30
  export const CANDIDATE_CAPTURE_FAILED = 'CANDIDATE_CAPTURE_FAILED';
30
31
  export const MAX_PARALLEL_CAP = 16;
31
32
  export const DEFAULT_MAX_PARALLEL = 3;
32
- const WORKTREE_PREFIX = 'dsh-crew-';
33
+ // Worktree names read `Crew_YYYYMMDD_HHMMSS_<purpose>` so an operator can tell
34
+ // from the directory alone when a job ran and what it was for. The legacy
35
+ // prefix stays recognised as ours, so worktrees created by an earlier release
36
+ // are still adopted and cleaned up rather than orphaned.
37
+ const WORKTREE_PREFIX = 'Crew_';
38
+ const LEGACY_WORKTREE_PREFIXES = Object.freeze(['dsh-crew-']);
39
+
40
+ /** Whether a directory name is a worktree Crew created. */
41
+ export function isCrewWorktreeName(name) {
42
+ const value = String(name ?? '');
43
+ return value.startsWith(WORKTREE_PREFIX)
44
+ || LEGACY_WORKTREE_PREFIXES.some((prefix) => value.startsWith(prefix));
45
+ }
33
46
 
34
47
  async function defaultRunner(args, { cwd }) {
35
48
  try {
@@ -57,11 +70,46 @@ async function runGit(runner, args, opts) {
57
70
  }
58
71
  }
59
72
 
60
- function worktreeName(jobId) {
61
- const safe = String(jobId ?? '').replace(/[^A-Za-z0-9._-]/g, '-') || 'job';
62
- return `${WORKTREE_PREFIX}${safe}-${randomBytes(4).toString('hex')}`;
73
+ const PURPOSE_MAX = 32;
74
+
75
+ /** Local wall-clock stamp: readable, and what an operator expects to see. */
76
+ function stamp(at) {
77
+ const d = at instanceof Date ? at : new Date(at ?? Date.now());
78
+ const p = (n) => String(n).padStart(2, '0');
79
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`
80
+ + `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
63
81
  }
64
82
 
83
+ /**
84
+ * `Crew_<date>_<time>_<purpose>`, with a numeric suffix only when that name is
85
+ * already taken. Parallel jobs can start inside the same second, so the suffix is
86
+ * what keeps the name unique without putting random noise in every name.
87
+ */
88
+ /**
89
+ * `Crew_<date>_<time>_<purpose>`, reserved by creating the directory.
90
+ *
91
+ * The reservation is a mkdir, not a lookup: two jobs starting in the same second
92
+ * both probe before either has created anything, so a check-then-act name would
93
+ * hand them the same path and one `git worktree add` would fail. mkdir
94
+ * fails on EEXIST, which makes the suffix loop race-free.
95
+ */
96
+ function reserveWorktreeDir({ root, purpose, at }) {
97
+ mkdirSync(root, { recursive: true });
98
+ const safe = String(purpose ?? 'job').replace(/[^A-Za-z0-9]+/g, '-')
99
+ .replace(/^-+|-+$/g, '').slice(0, PURPOSE_MAX) || 'job';
100
+ const base = `${WORKTREE_PREFIX}${stamp(at)}_${safe}`;
101
+ for (let n = 1; n <= 100; n += 1) {
102
+ const name = n === 1 ? base : `${base}-${n}`;
103
+ const dir = join(root, name);
104
+ try {
105
+ mkdirSync(dir);
106
+ return { ok: true, dir, name };
107
+ } catch (error) {
108
+ if (error?.code !== 'EEXIST') return { ok: false, error: String(error?.message ?? error) };
109
+ }
110
+ }
111
+ return { ok: false, error: 'no free worktree name' };
112
+ }
65
113
  export function defaultWorktreeRoot() {
66
114
  return join(tmpdir(), 'dsh-crew-worktrees');
67
115
  }
@@ -90,15 +138,21 @@ export async function inspectRepository({ cwd, git, runner } = {}) {
90
138
  };
91
139
  }
92
140
 
93
- export async function createIsolatedWorkspace({ cwd, jobId, baseRevision, root = defaultWorktreeRoot(), git } = {}) {
141
+ export async function createIsolatedWorkspace({ cwd, jobId, purpose, baseRevision, at, root = defaultWorktreeRoot(), git } = {}) {
94
142
  const run = git ?? defaultRunner;
95
143
  const repo = await inspectRepository({ cwd, git: run });
96
144
  if (!repo.ok) return { ok: false, reason: repo.reason, error: repo.error };
97
145
  const rev = baseRevision ?? repo.baseRevision;
98
- const dir = join(root, worktreeName(jobId ?? repo.baseRevision));
146
+ const reserved = reserveWorktreeDir({ root, purpose, at });
147
+ if (!reserved.ok) return { ok: false, reason: WORKTREE_RESERVE_FAILED, error: reserved.error };
148
+ const { dir, name } = reserved;
99
149
  const res = await runGit(run, ['worktree', 'add', '--detach', dir, rev], { cwd: repo.repoRoot });
100
- if (!res.ok) return { ok: false, reason: res.reason, error: res.error };
101
- return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name: basename(dir) };
150
+ if (!res.ok) {
151
+ // Release the name so a retry is not blocked by an empty directory.
152
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
153
+ return { ok: false, reason: res.reason, error: res.error };
154
+ }
155
+ return { ok: true, worktreePath: dir, baseRevision: rev, repoRoot: repo.repoRoot, name };
102
156
  }
103
157
 
104
158
  function splitFirstTab(line) {
@@ -322,7 +376,7 @@ export async function cleanupIsolatedWorkspace({
322
376
  const run = git ?? defaultRunner;
323
377
  if (!worktreePath) return { ok: false, reason: NOT_GIT_REPOSITORY, error: 'worktree path required' };
324
378
  const root = repoRoot ?? (await mainRepoRoot(run, worktreePath));
325
- const owned = basename(resolve(worktreePath)).startsWith(WORKTREE_PREFIX);
379
+ const owned = isCrewWorktreeName(basename(resolve(worktreePath)));
326
380
 
327
381
  if (root) {
328
382
  // Preferred path: `git worktree remove --force` (removes registration and
@@ -368,7 +422,7 @@ export async function staleWorktrees({ git, allowed = [] } = {}) {
368
422
  const path = block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length)?.trim();
369
423
  if (!path) continue;
370
424
  const abs = resolve(path);
371
- if (basename(abs).startsWith(WORKTREE_PREFIX) && !set.has(abs)) stale.push(abs);
425
+ if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
372
426
  }
373
427
  }
374
428
  }