@ran-sh/dsh-crew 1.7.2 → 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.
package/package.json
CHANGED
package/skills/dsh-crew/SKILL.md
CHANGED
|
@@ -32,10 +32,46 @@ same `dsh-crew` MCP server, so the behaviour below is identical from any host.
|
|
|
32
32
|
| `dsh_worker_cancel` | cancel a workflow |
|
|
33
33
|
| `dsh_worker_config` | read/update session settings: enable dispatch, tier, effort, timeout, presets, policy |
|
|
34
34
|
|
|
35
|
+
Dispatch takes `task` (make it self-contained — the worker sees nothing else),
|
|
36
|
+
`role` (`worker` for implementation, `reviewer` for an independent review pass),
|
|
37
|
+
`cwd` (the workspace; defaults to the current project) and `timeout_seconds`.
|
|
38
|
+
Use `dsh_spawn_worker` when you have other work to do meanwhile, then
|
|
39
|
+
`dsh_worker_result` with `wait_seconds` to collect it.
|
|
40
|
+
|
|
35
41
|
`dsh_worker_config` with no arguments is the cheapest way to answer "what is
|
|
36
42
|
Crew set to right now". Its settings last for the session only; persisted
|
|
37
43
|
changes belong in the 3210 panel.
|
|
38
44
|
|
|
45
|
+
## Reading a result
|
|
46
|
+
|
|
47
|
+
A result carries a `phase` and, when it did not succeed, a failure code. The
|
|
48
|
+
phases are `created`, `queued`, `running`, `verifying`, `escalating`,
|
|
49
|
+
`reviewing`, `ready`, `completed`, `failed`, `cancelled`, `interrupted`.
|
|
50
|
+
|
|
51
|
+
**`phase: failed` does not mean the worker broke.** Most often it means the
|
|
52
|
+
workflow's delivery gate rejected the result, and the reason code says which
|
|
53
|
+
gate. Read the code before reacting:
|
|
54
|
+
|
|
55
|
+
| Code | Means |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `DELIVERY_INCOMPLETE` | the worker returned no change where the contract required one — a reply-only or question-only task lands here, and it is not a defect |
|
|
58
|
+
| `TESTS_FAILED` / `TESTS_NOT_RUN` | the worker's own test evidence is failing or absent |
|
|
59
|
+
| `REVIEW_CHANGES_REQUESTED` | the reviewer asked for changes; act on them |
|
|
60
|
+
| `REVIEW_INCONCLUSIVE` | the review could not reach a verdict |
|
|
61
|
+
| `WORKSPACE_MISMATCH` | the worker's changes are not in the workspace the job was meant to touch |
|
|
62
|
+
| `TASK_BLOCKED` / `TASK_PARTIAL` | the worker says it could not finish |
|
|
63
|
+
| `ATTEMPT_TIMEOUT` / `RUNTIME_FAILURE` / `EXECUTION_FAILED` | the run itself failed |
|
|
64
|
+
| `POLICY_REJECTED` | Crew's own policy refused the dispatch; change the request, not the worker |
|
|
65
|
+
| `PROVIDER_UNAVAILABLE` / `HUB_INCOMPATIBLE` | no model or no reachable hub |
|
|
66
|
+
|
|
67
|
+
`terminal_reason: escalation_disabled` is **not** a separate failure — it is the
|
|
68
|
+
escalation policy declining to retry after a failure. The failure code above it
|
|
69
|
+
is the real reason.
|
|
70
|
+
|
|
71
|
+
The selection trace names the model actually used and every candidate that was
|
|
72
|
+
skipped, with the reason. Reach for it whenever the chosen model is not the one
|
|
73
|
+
you expected.
|
|
74
|
+
|
|
39
75
|
## Choosing what to dispatch
|
|
40
76
|
|
|
41
77
|
Delegate a bounded, independently verifiable unit when isolation,
|
|
@@ -48,6 +84,19 @@ Continue authorized work after a successful subtask; a returned workflow is a
|
|
|
48
84
|
checkpoint, not the end of the task. If a worker's result is incomplete or its
|
|
49
85
|
review asks for changes, that is a task result to act on — not approval.
|
|
50
86
|
|
|
87
|
+
## Common ways a dispatch surprises you
|
|
88
|
+
|
|
89
|
+
- **A task that only asks a question fails.** The delivery contract wants a
|
|
90
|
+
change; a reply-only task returns `DELIVERY_INCOMPLETE`. That is the gate
|
|
91
|
+
working, not the worker failing.
|
|
92
|
+
- **Isolated workspaces need git.** The default `worktree` isolation fails with
|
|
93
|
+
`NOT_GIT_REPOSITORY` for a non-git workspace rather than silently sharing the
|
|
94
|
+
tree. Use `shared` deliberately if that is what you want.
|
|
95
|
+
- **Long tasks need a longer timeout.** `timeout_seconds` is per attempt and
|
|
96
|
+
caps at 7200; the default is far shorter than a real refactor.
|
|
97
|
+
- **A worker cannot see your conversation.** Anything it needs must be in
|
|
98
|
+
`task`, in the workspace, or in a file it can read.
|
|
99
|
+
|
|
51
100
|
## Configuration
|
|
52
101
|
|
|
53
102
|
Two surfaces, and they are not equivalent:
|
package/src/mcp-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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)
|
|
101
|
-
|
|
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))
|
|
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)
|
|
425
|
+
if (isCrewWorktreeName(basename(abs)) && !set.has(abs)) stale.push(abs);
|
|
372
426
|
}
|
|
373
427
|
}
|
|
374
428
|
}
|