@ran-sh/dsh-crew 0.3.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/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- package/worker.cordis.yml +77 -0
package/src/jobs.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Job manager: each job runs one dsh-jsonrpc-agent runtime via the DSH SDK.
|
|
2
|
+
// Progress derived from session.event notifications; status mirrored to a
|
|
3
|
+
// JSON file so the Claude Code statusline (and anything else) can render it.
|
|
4
|
+
|
|
5
|
+
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client';
|
|
6
|
+
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
7
|
+
import { createShardWriter } from './status-shard.mjs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from './delivery.mjs';
|
|
13
|
+
import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from './workspace-audit.mjs';
|
|
14
|
+
import { buildOutcome, JOB_PHASES } from './workflow.mjs';
|
|
15
|
+
import { buildDirectSelectionTrace } from './model-routing.mjs';
|
|
16
|
+
|
|
17
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
19
|
+
const STATUS_FILE = join(CONFIG_DIR, 'status.json');
|
|
20
|
+
const CORDIS = join(ROOT, 'worker.cordis.yml');
|
|
21
|
+
|
|
22
|
+
// The worker agent entry. The pnpm .bin shim on Windows is a POSIX shell
|
|
23
|
+
// script that Node spawn cannot execute (ENOENT), so we always launch
|
|
24
|
+
// `node <lib/bin.js> <cordis>` via process.execPath — cross-platform.
|
|
25
|
+
const requireAgents = createRequire(import.meta.url);
|
|
26
|
+
let AGENT_JS = null;
|
|
27
|
+
for (const spec of ['@deepseek-ai/dsh-sdk-jsonrpc-demo/bin', '@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/bin.js']) {
|
|
28
|
+
try { AGENT_JS = requireAgents.resolve(spec); break; } catch {}
|
|
29
|
+
}
|
|
30
|
+
if (!AGENT_JS) AGENT_JS = join(ROOT, 'node_modules', '@deepseek-ai', 'dsh-sdk-jsonrpc-demo', 'lib', 'bin.js');
|
|
31
|
+
|
|
32
|
+
export const TIERS = {
|
|
33
|
+
flash: { model: 'deepseek-v4-flash', label: 'V4 Flash' },
|
|
34
|
+
pro: { model: 'deepseek-v4-pro', label: 'V4 Pro' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// v0.2: a job carries a dispatch role (worker | reviewer) plus its legacy
|
|
38
|
+
// tier slot. The role describes who did the work; the tier is the model-class
|
|
39
|
+
// slot that standalone mode falls back to (role = execution intent).
|
|
40
|
+
export const ROLES = {
|
|
41
|
+
worker: { canReview: false },
|
|
42
|
+
reviewer: { canReview: true },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function loadDotEnv() {
|
|
46
|
+
const out = {};
|
|
47
|
+
try {
|
|
48
|
+
for (const line of readFileSync(join(CONFIG_DIR, '.env'), 'utf8').split('\n')) {
|
|
49
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
50
|
+
if (m && m[2]) out[m[1]] = m[2];
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const jobs = new Map();
|
|
57
|
+
let nextId = 1;
|
|
58
|
+
const shard = createShardWriter('mcp');
|
|
59
|
+
|
|
60
|
+
function publishStatus() {
|
|
61
|
+
shard.publish([...jobs.values()].map((j) => ({
|
|
62
|
+
id: j.id, role: j.role ?? 'worker', attempt: j.attempt ?? 0, phase: j.phase ?? null, tier: j.tier, provider: j.provider, model: j.model, selection_source: j.selection_source,
|
|
63
|
+
selection_trace: j.selection_trace ?? null,
|
|
64
|
+
effort: j.effort, requested_effort: j.effort, reasoning_effort: j.reasoning_effort ?? j.effort,
|
|
65
|
+
status: j.status, source: j.source,
|
|
66
|
+
task: j.task.slice(0, 300), cwd: j.cwd, turn: j.turn, step: j.step, toolCalls: j.toolCalls,
|
|
67
|
+
currentTool: j.currentTool, tokens: j.tokens,
|
|
68
|
+
startedAt: j.startedAt, endedAt: j.endedAt,
|
|
69
|
+
delivery_complete: !!j.delivery_complete,
|
|
70
|
+
workspace_diff_available: !!j.workspaceDiff && j.workspaceDiff.kind === 'git',
|
|
71
|
+
})));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function jobView(j, { withResult = false } = {}) {
|
|
75
|
+
const v = {
|
|
76
|
+
id: j.id, role: j.role ?? 'worker', attempt: j.attempt ?? 0, phase: j.phase ?? null, tier: j.tier, provider: j.provider, model: j.model, selection_source: j.selection_source,
|
|
77
|
+
selection_trace: j.selection_trace ?? null,
|
|
78
|
+
effort: j.effort, requested_effort: j.effort, reasoning_effort: j.reasoning_effort ?? j.effort,
|
|
79
|
+
status: j.status, source: j.source,
|
|
80
|
+
task: j.task.slice(0, 300), turn: j.turn, step: j.step, currentTool: j.currentTool,
|
|
81
|
+
tokens: j.tokens, toolCalls: j.toolCalls, startedAt: j.startedAt, endedAt: j.endedAt,
|
|
82
|
+
delivery_complete: !!j.delivery_complete,
|
|
83
|
+
workspace_diff_available: !!j.workspaceDiff && j.workspaceDiff.kind === 'git',
|
|
84
|
+
};
|
|
85
|
+
if (withResult) {
|
|
86
|
+
v.result = j.result; v.error = j.error; v.stopReason = j.stopReason;
|
|
87
|
+
v.delivery = j.delivery_metadata ?? null;
|
|
88
|
+
v.delivery_missing = j.delivery_missing ?? [];
|
|
89
|
+
v.outcome = j.outcome ?? null;
|
|
90
|
+
v.workspace_diff = j.workspaceDiff ?? null;
|
|
91
|
+
v.workspace_baseline_dirty = !!j.workspaceDiff?.dirtyBaseline;
|
|
92
|
+
}
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function listJobs() { return [...jobs.values()]; }
|
|
97
|
+
export function getJob(id) { return jobs.get(id); }
|
|
98
|
+
|
|
99
|
+
export function startJob({
|
|
100
|
+
task,
|
|
101
|
+
tier = 'flash',
|
|
102
|
+
role = 'worker',
|
|
103
|
+
attempt = 0,
|
|
104
|
+
effort = 'max',
|
|
105
|
+
cwd,
|
|
106
|
+
maxTokens = 49_152,
|
|
107
|
+
timeoutMs = 1_800_000,
|
|
108
|
+
source = 'api',
|
|
109
|
+
delivery = 'coding',
|
|
110
|
+
modelClassHint = null,
|
|
111
|
+
escalationReason = null,
|
|
112
|
+
}) {
|
|
113
|
+
const tierInfo = TIERS[tier];
|
|
114
|
+
if (!tierInfo) throw new Error(`unknown tier "${tier}" (expected: ${Object.keys(TIERS).join(', ')})`);
|
|
115
|
+
if (!ROLES[role]) throw new Error(`unknown role "${role}" (expected: ${Object.keys(ROLES).join(', ')})`);
|
|
116
|
+
if (!['off', 'high', 'max'].includes(effort)) throw new Error(`unknown effort "${effort}" (expected: off, high, max)`);
|
|
117
|
+
if (!existsSync(AGENT_JS)) throw new Error(`dsh-jsonrpc-agent not installed at ${AGENT_JS}; run pnpm install in ${ROOT}`);
|
|
118
|
+
const workspace = resolve(cwd ?? process.cwd());
|
|
119
|
+
// The worker always gets the auditable Delivery Contract appended (unless it
|
|
120
|
+
// already carries one), so its final message follows ## Diff / ## Tests /
|
|
121
|
+
// ## Risks — or the review contract for reviewer-role jobs.
|
|
122
|
+
const workerPrompt = appendDeliveryInstructions(task, { tier, role, isReview: delivery === 'review' });
|
|
123
|
+
const id = `job-${nextId++}-${Date.now().toString(36)}`;
|
|
124
|
+
const dotEnv = loadDotEnv();
|
|
125
|
+
if (!process.env.DEEPSEEK_API_KEY && !dotEnv.DEEPSEEK_API_KEY) {
|
|
126
|
+
throw new Error(`DEEPSEEK_API_KEY not found in env or ${join(CONFIG_DIR, '.env')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const harness = new DeepSeekHarness({
|
|
130
|
+
launch: {
|
|
131
|
+
command: process.execPath,
|
|
132
|
+
args: [AGENT_JS, CORDIS],
|
|
133
|
+
cwd: workspace,
|
|
134
|
+
env: {
|
|
135
|
+
...process.env,
|
|
136
|
+
...dotEnv,
|
|
137
|
+
DSH_CWD: workspace,
|
|
138
|
+
DSH_SESSION_ROOT: join(CONFIG_DIR, 'sessions'),
|
|
139
|
+
DSH_REASONING_EFFORT: effort,
|
|
140
|
+
},
|
|
141
|
+
requestTimeoutMs: timeoutMs,
|
|
142
|
+
},
|
|
143
|
+
cwd: workspace,
|
|
144
|
+
provider: 'deepseek-official',
|
|
145
|
+
model: tierInfo.model,
|
|
146
|
+
maxTokens,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const selectionTrace = buildDirectSelectionTrace({
|
|
150
|
+
role,
|
|
151
|
+
logicalAttempt: attempt,
|
|
152
|
+
modelClassHint: modelClassHint ?? tier,
|
|
153
|
+
strategy: 'standalone-legacy',
|
|
154
|
+
candidateSet: attempt > 0 ? 'escalation' : 'primary',
|
|
155
|
+
provider: 'deepseek-official',
|
|
156
|
+
model: tierInfo.model,
|
|
157
|
+
source: 'standalone-legacy',
|
|
158
|
+
escalationReason,
|
|
159
|
+
});
|
|
160
|
+
const job = {
|
|
161
|
+
id, role, attempt, tier, provider: 'deepseek-official', model: tierInfo.model, selection_source: 'standalone-legacy',
|
|
162
|
+
selection_trace: selectionTrace,
|
|
163
|
+
effort, reasoning_effort: effort, task, source, cwd: workspace,
|
|
164
|
+
prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
|
|
165
|
+
phase: JOB_PHASES.RUNNING,
|
|
166
|
+
status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
|
|
167
|
+
tokens: { input: 0, output: 0, reasoning: 0 },
|
|
168
|
+
startedAt: new Date().toISOString(), endedAt: null,
|
|
169
|
+
result: null, error: null, stopReason: null, harness,
|
|
170
|
+
delivery_complete: false, delivery_missing: [], delivery_metadata: null,
|
|
171
|
+
outcome: null,
|
|
172
|
+
workspaceDiff: null, baselinePromise: null,
|
|
173
|
+
waiters: [],
|
|
174
|
+
};
|
|
175
|
+
jobs.set(id, job);
|
|
176
|
+
|
|
177
|
+
// Read-only pre-run snapshot (async, never blocks dispatch): the audit only
|
|
178
|
+
// needs the before-state by the time the worker finishes. Non-repos degrade
|
|
179
|
+
// to { kind:'no-git' } instead of failing the job.
|
|
180
|
+
job.baselinePromise = captureWorkspaceBaseline({ cwd: workspace })
|
|
181
|
+
.catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
182
|
+
|
|
183
|
+
const sessionId = id;
|
|
184
|
+
const onNotification = (n) => {
|
|
185
|
+
if (n.method === 'session.status') return;
|
|
186
|
+
if (n.method !== 'session.event') return;
|
|
187
|
+
if (n.params?.sessionId && n.params.sessionId !== sessionId) return;
|
|
188
|
+
const e = n.params.event;
|
|
189
|
+
if (!e?.type) return;
|
|
190
|
+
switch (e.type) {
|
|
191
|
+
case 'step/start': job.turn = e.data?.turn ?? job.turn; job.step = e.data?.step ?? job.step; break;
|
|
192
|
+
case 'tool/call': job.currentTool = e.data?.name ?? null; job.toolCalls += 1; break;
|
|
193
|
+
case 'tool/result': job.currentTool = null; break;
|
|
194
|
+
case 'assistant/message': {
|
|
195
|
+
const u = e.data?.usage;
|
|
196
|
+
if (u) {
|
|
197
|
+
job.tokens.input += u.inputTokens ?? 0;
|
|
198
|
+
job.tokens.output += u.outputTokens ?? 0;
|
|
199
|
+
job.tokens.reasoning += u.reasoningTokens ?? 0;
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'turn/end': job.stopReason = e.data?.reason?.kind ?? null; break;
|
|
204
|
+
}
|
|
205
|
+
publishStatus();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
job.promise = harness
|
|
209
|
+
.run(workerPrompt, { sessionId, onNotification })
|
|
210
|
+
.then((result) => {
|
|
211
|
+
job.result = result.finalResponse ?? '';
|
|
212
|
+
const lastEnd = [...(result.events ?? [])].reverse().find((ev) => ev?.type === 'turn/end');
|
|
213
|
+
job.stopReason = lastEnd?.data?.reason?.kind ?? job.stopReason;
|
|
214
|
+
job.status = job.stopReason === 'completed' ? 'done' : 'failed';
|
|
215
|
+
if (job.status === 'failed' && !job.error) job.error = `turn ended with reason: ${job.stopReason ?? 'unknown'}`;
|
|
216
|
+
})
|
|
217
|
+
.catch((err) => {
|
|
218
|
+
job.status = job.status === 'cancelled' ? 'cancelled' : 'failed';
|
|
219
|
+
job.error = err?.message ?? String(err);
|
|
220
|
+
})
|
|
221
|
+
.finally(async () => {
|
|
222
|
+
job.endedAt = new Date().toISOString();
|
|
223
|
+
job.currentTool = null;
|
|
224
|
+
try { await harness.close(); } catch {}
|
|
225
|
+
// Delivery completeness is separate from execution status: a job can be
|
|
226
|
+
// done yet fail to report Diff/Tests/Risks. Parse whatever final message
|
|
227
|
+
// the worker produced so the orchestrator can decide whether to accept.
|
|
228
|
+
const parsed = parseDeliveryReport(job.result ?? '');
|
|
229
|
+
job.delivery_complete = parsed.complete;
|
|
230
|
+
job.delivery_missing = parsed.missing;
|
|
231
|
+
job.delivery_metadata = formatDeliveryMetadata(parsed);
|
|
232
|
+
// Canonical structured outcome (shared workflow layer) + terminal phase.
|
|
233
|
+
job.outcome = buildOutcome({
|
|
234
|
+
result: job.result ?? '',
|
|
235
|
+
deliveryMeta: job.delivery_metadata,
|
|
236
|
+
executionStatus: job.status === 'done' ? 'completed' : 'failed',
|
|
237
|
+
stopReason: job.stopReason,
|
|
238
|
+
deliveryMissing: job.delivery_missing,
|
|
239
|
+
});
|
|
240
|
+
job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
|
|
241
|
+
// Read-only after-snapshot of the workspace: bounded, redacted patch.
|
|
242
|
+
const baseline = await job.baselinePromise;
|
|
243
|
+
job.workspaceDiff = baseline.kind === 'git'
|
|
244
|
+
? await captureWorkspaceDiff({ cwd: workspace, baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
|
|
245
|
+
: baseline;
|
|
246
|
+
publishStatus();
|
|
247
|
+
for (const w of job.waiters.splice(0)) w();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
publishStatus();
|
|
251
|
+
return job;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function waitJob(id, timeoutMs) {
|
|
255
|
+
const job = jobs.get(id);
|
|
256
|
+
if (!job) throw new Error(`no such job: ${id}`);
|
|
257
|
+
if (job.status !== 'running') return job;
|
|
258
|
+
await Promise.race([
|
|
259
|
+
new Promise((res) => job.waiters.push(res)),
|
|
260
|
+
timeoutMs ? new Promise((res) => setTimeout(res, timeoutMs)) : new Promise(() => {}),
|
|
261
|
+
]);
|
|
262
|
+
return job;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function cancelJob(id) {
|
|
266
|
+
const job = jobs.get(id);
|
|
267
|
+
if (!job) throw new Error(`no such job: ${id}`);
|
|
268
|
+
if (job.status !== 'running') return job;
|
|
269
|
+
job.status = 'cancelled';
|
|
270
|
+
job.phase = JOB_PHASES.CANCELLED;
|
|
271
|
+
job.error = 'cancelled by request';
|
|
272
|
+
try { await job.harness.close(); } catch {}
|
|
273
|
+
publishStatus();
|
|
274
|
+
return job;
|
|
275
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// MCP-side workflow runtime wiring: builds the shared workflow runtime with
|
|
2
|
+
// the real Hub / Standalone attempt adapters and the git-worktree workspace
|
|
3
|
+
// adapter. This is the only place that knows which transport executes an
|
|
4
|
+
// attempt; the MCP server (src/server.mjs) only does schema, validation,
|
|
5
|
+
// policy gating and formatting.
|
|
6
|
+
//
|
|
7
|
+
// Behaviour decisions (escalation, review, candidate, queue, cancellation)
|
|
8
|
+
// live entirely in workflow-runtime.mjs; the adapters here just execute.
|
|
9
|
+
|
|
10
|
+
import { createWorkflowRuntime } from './workflow-runtime.mjs';
|
|
11
|
+
import { normalizeGlobalConfig } from './policy.mjs';
|
|
12
|
+
import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
|
|
13
|
+
import {
|
|
14
|
+
createIsolatedWorkspace,
|
|
15
|
+
cleanupIsolatedWorkspace,
|
|
16
|
+
inspectRepository,
|
|
17
|
+
captureCandidate as captureIsolationCandidate,
|
|
18
|
+
} from './workspace-isolation.mjs';
|
|
19
|
+
import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
|
|
20
|
+
import { hub } from './hub-client.mjs';
|
|
21
|
+
|
|
22
|
+
const SESSION_CONFIG_KEYS = [
|
|
23
|
+
'default_tier', 'default_effort', 'mode', 'default_timeout_seconds',
|
|
24
|
+
'tier_policy', 'escalate_on_failure', 'collaboration_mode', 'main_agent_mode',
|
|
25
|
+
'flash_state', 'pro_state', 'pro_reviews_flash',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Merge only defined session overrides onto the live global config before the
|
|
30
|
+
* workflow snapshots its policy. This keeps dsh_worker_config authoritative
|
|
31
|
+
* for escalation/review decisions instead of applying it only to the MCP gate.
|
|
32
|
+
*/
|
|
33
|
+
export function buildEffectiveRuntimeConfig(globalRaw = {}, session = {}) {
|
|
34
|
+
const patch = {};
|
|
35
|
+
for (const key of SESSION_CONFIG_KEYS) {
|
|
36
|
+
if (session?.[key] !== undefined) patch[key] = session[key];
|
|
37
|
+
}
|
|
38
|
+
if (session?.enabled === false) patch.subagents_enabled = false;
|
|
39
|
+
return normalizeGlobalConfig({ ...globalRaw, ...patch });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Translate a role/workflow attempt into the legacy model-class slot needed by
|
|
44
|
+
* Standalone and DeepSeek Official Hub routing. A user-requested strong worker
|
|
45
|
+
* starts on Pro; every escalated worker attempt also uses the strong slot.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveAttemptTier({ role = 'worker', attempt = 0, modelClassHint } = {}) {
|
|
48
|
+
if (role === 'reviewer') return 'pro';
|
|
49
|
+
if (Number.isInteger(attempt) && attempt > 0) return 'pro';
|
|
50
|
+
if (modelClassHint === 'pro') return 'pro';
|
|
51
|
+
return 'flash';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Map a Hub/Standalone job view into the AttemptResult the runtime expects. */
|
|
55
|
+
export function attemptFromView(view, spec) {
|
|
56
|
+
const role = view?.role ?? spec.role ?? 'worker';
|
|
57
|
+
const logicalAttempt = spec.attempt ?? 0;
|
|
58
|
+
const source = view?.selection_source ?? null;
|
|
59
|
+
const transportTrace = view?.selection_trace ?? buildDirectSelectionTrace({
|
|
60
|
+
role,
|
|
61
|
+
logicalAttempt,
|
|
62
|
+
modelClassHint: spec.model_class_hint ?? null,
|
|
63
|
+
strategy: source ?? 'transport-selection',
|
|
64
|
+
candidateSet: logicalAttempt > 0 ? 'escalation' : 'primary',
|
|
65
|
+
provider: view?.provider ?? null,
|
|
66
|
+
model: view?.model ?? null,
|
|
67
|
+
source: source ?? 'transport-selection',
|
|
68
|
+
escalationReason: spec.escalation_reason ?? null,
|
|
69
|
+
});
|
|
70
|
+
const selectionTrace = enrichSelectionTrace(transportTrace, {
|
|
71
|
+
role,
|
|
72
|
+
logicalAttempt,
|
|
73
|
+
modelClassHint: spec.model_class_hint ?? null,
|
|
74
|
+
escalationReason: spec.escalation_reason ?? null,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
id: view?.id ?? spec.id,
|
|
78
|
+
role,
|
|
79
|
+
// `view.attempt` may be an adapter routing-attempt (see below); the
|
|
80
|
+
// workflow's logical attempt number is always the spec value.
|
|
81
|
+
attempt: logicalAttempt,
|
|
82
|
+
provider: view?.provider ?? null,
|
|
83
|
+
model: view?.model ?? null,
|
|
84
|
+
selection_source: source,
|
|
85
|
+
selection_trace: selectionTrace,
|
|
86
|
+
status: view?.status ?? 'failed',
|
|
87
|
+
result: view?.result ?? null,
|
|
88
|
+
stopReason: view?.stopReason ?? null,
|
|
89
|
+
outcome: view?.outcome ?? null,
|
|
90
|
+
usage: view?.tokens ?? null,
|
|
91
|
+
error: view?.error ?? null,
|
|
92
|
+
error_code: view?.error_code ?? view?.code ?? null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function timedOutAttempt(view, spec, timeoutMs) {
|
|
97
|
+
return {
|
|
98
|
+
...attemptFromView(view, spec),
|
|
99
|
+
status: 'failed',
|
|
100
|
+
stopReason: 'timeout',
|
|
101
|
+
error: `attempt timed out after ${Math.ceil(timeoutMs / 1000)}s and was cancelled before any retry`,
|
|
102
|
+
timed_out: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the workflow runtime used by the MCP server.
|
|
108
|
+
*
|
|
109
|
+
* deps:
|
|
110
|
+
* getSessionConfig() -> session-level config (effort, timeout, mode...)
|
|
111
|
+
* resolveMode() -> 'hub' | 'standalone'
|
|
112
|
+
* presetForTier(tier) -> hub agent preset id for a tier slot (optional)
|
|
113
|
+
* readGlobalConfig() -> raw global config reader
|
|
114
|
+
* buildReviewTask(task, view) -> reviewer prompt builder
|
|
115
|
+
* attemptTimeoutMs() -> default attempt execution timeout (optional)
|
|
116
|
+
*/
|
|
117
|
+
export function buildMcpWorkflowRuntime(deps) {
|
|
118
|
+
const { getSessionConfig, resolveMode, presetForTier, readGlobalConfig, buildReviewTask } = deps;
|
|
119
|
+
const getConfig = () => buildEffectiveRuntimeConfig(readGlobalConfig(), getSessionConfig?.() ?? {});
|
|
120
|
+
const getRuntimeControls = () => ({
|
|
121
|
+
max_parallel: getConfig().execution?.max_parallel ?? 3,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const executeAttempt = async (spec) => {
|
|
125
|
+
const session = getSessionConfig?.() ?? {};
|
|
126
|
+
const effort = spec.effort ?? session.default_effort ?? 'max';
|
|
127
|
+
const timeoutMs = (deps.attemptTimeoutMs?.() ?? (session.default_timeout_seconds ?? 1800) * 1000);
|
|
128
|
+
const tier = resolveAttemptTier({ role: spec.role, attempt: spec.attempt, modelClassHint: spec.model_class_hint });
|
|
129
|
+
const delivery = spec.role === 'reviewer' || spec.delivery === 'review' ? 'review' : 'coding';
|
|
130
|
+
const source = spec.source ?? 'api';
|
|
131
|
+
const preset = presetForTier?.(tier);
|
|
132
|
+
|
|
133
|
+
if ((await resolveMode()) === 'hub') {
|
|
134
|
+
// Hub follow-dsh currently uses `attempt` to choose primary vs escalation
|
|
135
|
+
// model policy. For an explicit worker+pro hint, route selection through
|
|
136
|
+
// the strong pool while preserving the workflow's logical attempt number
|
|
137
|
+
// in the normalized AttemptResult.
|
|
138
|
+
const routingAttempt = spec.role === 'worker' && spec.attempt === 0 && spec.model_class_hint === 'pro'
|
|
139
|
+
? 1
|
|
140
|
+
: spec.attempt;
|
|
141
|
+
const spawned = await hub.spawn({
|
|
142
|
+
task: spec.task,
|
|
143
|
+
tier,
|
|
144
|
+
role: spec.role,
|
|
145
|
+
attempt: routingAttempt,
|
|
146
|
+
effort,
|
|
147
|
+
cwd: spec.cwd,
|
|
148
|
+
source,
|
|
149
|
+
preset,
|
|
150
|
+
delivery,
|
|
151
|
+
});
|
|
152
|
+
spec.onAttemptStarted?.(spawned.id);
|
|
153
|
+
|
|
154
|
+
const deadline = Date.now() + timeoutMs;
|
|
155
|
+
let resolved = spawned;
|
|
156
|
+
while (resolved?.status === 'running') {
|
|
157
|
+
const remainingMs = deadline - Date.now();
|
|
158
|
+
if (remainingMs <= 0) {
|
|
159
|
+
let cancelled = resolved;
|
|
160
|
+
try { cancelled = await hub.cancel(spawned.id); } catch {}
|
|
161
|
+
return timedOutAttempt(cancelled, spec, timeoutMs);
|
|
162
|
+
}
|
|
163
|
+
const waitSeconds = Math.max(1, Math.min(600, Math.ceil(remainingMs / 1000)));
|
|
164
|
+
resolved = await hub.get(spawned.id, waitSeconds);
|
|
165
|
+
}
|
|
166
|
+
return attemptFromView(resolved, spec);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const job = startJob({
|
|
170
|
+
task: spec.task,
|
|
171
|
+
tier,
|
|
172
|
+
role: spec.role,
|
|
173
|
+
attempt: spec.attempt,
|
|
174
|
+
effort,
|
|
175
|
+
cwd: spec.cwd,
|
|
176
|
+
timeoutMs,
|
|
177
|
+
source,
|
|
178
|
+
delivery,
|
|
179
|
+
modelClassHint: spec.model_class_hint ?? null,
|
|
180
|
+
escalationReason: spec.escalation_reason ?? null,
|
|
181
|
+
});
|
|
182
|
+
spec.onAttemptStarted?.(job.id);
|
|
183
|
+
await waitJob(job.id, timeoutMs);
|
|
184
|
+
if (job.status === 'running') {
|
|
185
|
+
await cancelJob(job.id).catch(() => {});
|
|
186
|
+
return timedOutAttempt(jobView(job, { withResult: true }), spec, timeoutMs);
|
|
187
|
+
}
|
|
188
|
+
return attemptFromView(jobView(job, { withResult: true }), spec);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const cancelAttempt = async (id) => {
|
|
192
|
+
if (String(id ?? '').startsWith('hub-')) {
|
|
193
|
+
try { await hub.cancel(id); } catch {}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try { await cancelJob(id); } catch {}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const allocateWorkspace = async (job) => {
|
|
200
|
+
const config = getConfig();
|
|
201
|
+
const isolation = config.execution?.isolation ?? 'worktree';
|
|
202
|
+
// Reviewer role / explicit review and shared mode never draft a candidate;
|
|
203
|
+
// they run in the requested workspace.
|
|
204
|
+
if (job.role === 'reviewer' || job.delivery === 'review' || isolation === 'shared') {
|
|
205
|
+
return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
|
|
206
|
+
}
|
|
207
|
+
// Coding worker under worktree isolation: fail closed when the workspace
|
|
208
|
+
// is not a git repo — never silently fall back to sharing the working tree.
|
|
209
|
+
const repo = await inspectRepository({ cwd: job.requested_cwd });
|
|
210
|
+
if (!repo.ok) {
|
|
211
|
+
return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `coding worker needs an isolated git worktree: ${repo.error ?? repo.reason}` };
|
|
212
|
+
}
|
|
213
|
+
const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: repo.baseRevision });
|
|
214
|
+
if (!created.ok) {
|
|
215
|
+
return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
execution_cwd: created.worktreePath,
|
|
220
|
+
base_revision: created.baseRevision,
|
|
221
|
+
isolation: 'worktree',
|
|
222
|
+
primary_workspace_dirty: repo.dirty === true,
|
|
223
|
+
handle: { worktreePath: created.worktreePath, repoRoot: created.repoRoot },
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const captureCandidate = async ({ cwd, baseRevision }) => captureIsolationCandidate({ worktreePath: cwd, baseRevision });
|
|
228
|
+
|
|
229
|
+
const releaseWorkspace = async (handle) => {
|
|
230
|
+
if (!handle) return { ok: true };
|
|
231
|
+
try {
|
|
232
|
+
const r = await cleanupIsolatedWorkspace({ worktreePath: handle.worktreePath, repoRoot: handle.repoRoot });
|
|
233
|
+
return { ok: r.ok, error: r.ok ? undefined : r.error };
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return { ok: false, error: err?.message ?? String(err) };
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const initialConfig = getConfig();
|
|
240
|
+
const runtime = createWorkflowRuntime(
|
|
241
|
+
{
|
|
242
|
+
executeAttempt,
|
|
243
|
+
cancelAttempt,
|
|
244
|
+
allocateWorkspace,
|
|
245
|
+
captureCandidate,
|
|
246
|
+
releaseWorkspace,
|
|
247
|
+
buildReviewTask,
|
|
248
|
+
getConfig,
|
|
249
|
+
getRuntimeControls,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
maxParallel: initialConfig.execution?.max_parallel ?? 3,
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return runtime;
|
|
257
|
+
}
|