@ran-sh/dsh-crew 0.3.8 → 0.4.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/README.md +17 -1
- package/README.zh.md +16 -1
- package/docs/gpt-relay-extension.md +103 -0
- package/docs/job-contracts.md +107 -0
- package/docs/readiness-matrix.md +73 -0
- package/official-web-bridge/lib/client.js +3446 -3446
- package/package.json +4 -1
- package/scripts/verify-official-bridge-e2e.mjs +34 -13
- package/src/extension-contract.mjs +78 -0
- package/src/failure-classification.mjs +29 -0
- package/src/hub/index.mjs +343 -62
- package/src/information-flow.mjs +67 -0
- package/src/install/npx-lifecycle.mjs +83 -3
- package/src/job-contracts.mjs +218 -0
- package/src/mcp-runtime.mjs +16 -8
- package/src/official-web-bridge.mjs +20 -5
- package/src/role-profiles.mjs +107 -0
- package/src/runtime-identity.mjs +6 -1
- package/src/server.mjs +141 -98
- package/src/workflow-runtime.mjs +109 -10
- package/src/workspace-context.mjs +146 -0
- package/src/workspace-readiness.mjs +32 -0
package/src/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
|
|
6
6
|
import { startJob, waitJob, cancelJob, listJobs, getJob, jobView } from './jobs.mjs';
|
|
7
7
|
import { hubStatus, hub } from './hub-client.mjs';
|
|
8
8
|
import { hubCompatibilityMessage, resolveHubExecutionMode } from './hub-compatibility.mjs';
|
|
9
|
-
import { RUNTIME_VERSION } from './runtime-identity.mjs';
|
|
9
|
+
import { RUNTIME_VERSION, getHubRuntimeIdentity } from './runtime-identity.mjs';
|
|
10
10
|
import { resolveWorkerModel } from './model-routing.mjs';
|
|
11
11
|
import { runtimeActivationMetadata } from './runtime-controls.mjs';
|
|
12
12
|
import { buildConfigReadinessMatrix } from './config-readiness.mjs';
|
|
@@ -22,12 +22,32 @@ import {
|
|
|
22
22
|
shouldAutoReview,
|
|
23
23
|
} from './policy.mjs';
|
|
24
24
|
import { buildMcpWorkflowRuntime } from './mcp-runtime.mjs';
|
|
25
|
+
import { buildReviewTask } from './information-flow.mjs';
|
|
26
|
+
import { projectWorkflowView } from './job-contracts.mjs';
|
|
27
|
+
import { loadRoleProfiles, resolveRoleProfile } from './role-profiles.mjs';
|
|
28
|
+
import { loadWorkspaceContexts, resolveWorkspaceContext, buildWorkspaceTask, addContextReferences, isSafeBranchName } from './workspace-context.mjs';
|
|
29
|
+
import { buildExtensionContract } from './extension-contract.mjs';
|
|
30
|
+
import { assessWorkspaceReadiness } from './workspace-readiness.mjs';
|
|
25
31
|
|
|
26
32
|
const server = new McpServer({ name: 'dsh-crew', version: RUNTIME_VERSION });
|
|
27
33
|
|
|
28
34
|
const tierSchema = z.enum(['flash', 'pro']).optional().describe('Legacy worker tier (compatibility only): Flash/Pro now act as a model-class hint, not a role. Prefer role=worker / role=reviewer; the backend resolves the actual provider/model from the Model Policy.');
|
|
29
35
|
const roleSchema = z.enum(['worker', 'reviewer']).optional().describe('Dispatch role: worker executes implementation / fixes / tests / search; reviewer independently reviews a completed implementation. A coding request defaults to worker; reviewer runs on explicit request or via the automatic review workflow.');
|
|
30
36
|
const effortSchema = z.enum(['off', 'high', 'max']).optional().describe('Reasoning effort for the worker. Omit to use the session default.');
|
|
37
|
+
const detailSchema = z.enum(['compact', 'full']).default('compact').describe('compact returns the bounded Result Contract; full explicitly includes raw workflow/candidate details for debugging.');
|
|
38
|
+
const profileSchema = z.string().max(64).optional().describe('Versioned Worker/Reviewer profile id; defaults by role.');
|
|
39
|
+
const workspaceIdSchema = z.string().max(64).optional().describe('Workspace Context id from Crew-owned workspaces.json.');
|
|
40
|
+
const contextRefsSchema = z.array(z.string().max(256)).max(32).optional().describe('Additional workspace-relative instruction references; contents are not copied.');
|
|
41
|
+
const clientJobIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/).optional().describe('Optional caller id echoed in events and evidence; Crew still assigns its own workflow id.');
|
|
42
|
+
const workspaceSchema = z.object({
|
|
43
|
+
repo_root: z.string().optional(),
|
|
44
|
+
branch: z.string().refine(isSafeBranchName, 'invalid git branch name').optional(),
|
|
45
|
+
worktree: z.enum(['auto', 'existing', 'none']).optional(),
|
|
46
|
+
}).optional().describe('Per-job workspace overrides. auto isolates coding Workers; existing/none use the supplied workspace.');
|
|
47
|
+
const constraintsSchema = z.object({
|
|
48
|
+
timeout_seconds: z.number().int().positive().max(7200).optional(),
|
|
49
|
+
allow_fallback: z.boolean().optional(),
|
|
50
|
+
}).optional().describe('Per-job constraints override profile and session defaults.');
|
|
31
51
|
|
|
32
52
|
// Session-level configuration. This MCP server process lives exactly as long
|
|
33
53
|
// as one Claude Code / Codex session, so plain memory IS session scope.
|
|
@@ -144,29 +164,6 @@ async function resolveMode() {
|
|
|
144
164
|
return decision.mode;
|
|
145
165
|
}
|
|
146
166
|
|
|
147
|
-
/** Reviewer prompt built only from structured outcome + sanitized candidate. */
|
|
148
|
-
function buildReviewTask(task, view) {
|
|
149
|
-
const parts = [
|
|
150
|
-
'You are the automatic reviewer of a completed worker implementation. REVIEW ONLY: inspect the candidate and report findings. Do NOT modify any files unless the user explicitly asks for fixes.',
|
|
151
|
-
'',
|
|
152
|
-
'Original task:',
|
|
153
|
-
task,
|
|
154
|
-
];
|
|
155
|
-
const o = view?.outcome;
|
|
156
|
-
if (o) {
|
|
157
|
-
parts.push('', 'Worker outcome:');
|
|
158
|
-
parts.push(`task_status=${o.task_status} tests_status=${o.tests_status ?? 'none'} delivery=${o.delivery?.complete ? 'complete' : 'incomplete'}`);
|
|
159
|
-
}
|
|
160
|
-
const c = view?.candidate;
|
|
161
|
-
if (c) {
|
|
162
|
-
parts.push('', 'Candidate changed files:', Array.isArray(c.changed_files) && c.changed_files.length ? c.changed_files.join('\n') : '(none)');
|
|
163
|
-
if (c.base_revision) parts.push(`Base revision: ${c.base_revision}`);
|
|
164
|
-
if (c.patch) parts.push('', 'Candidate patch (sanitized):', String(c.patch).slice(0, 8000));
|
|
165
|
-
}
|
|
166
|
-
parts.push('', 'Report: 1) does the implementation satisfy the task, 2) concrete issues (bugs, style, risks), 3) suggested fixes. End your message with ## Review Findings / ## Evidence / ## Risks / ## Verdict (approved | needs changes | rejected).');
|
|
167
|
-
return parts.join('\n');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
167
|
const workflowRuntime = buildMcpWorkflowRuntime({
|
|
171
168
|
getSessionConfig: () => sessionConfig,
|
|
172
169
|
resolveMode,
|
|
@@ -176,61 +173,107 @@ const workflowRuntime = buildMcpWorkflowRuntime({
|
|
|
176
173
|
attemptTimeoutMs: () => (sessionConfig.default_timeout_seconds ?? 1800) * 1000,
|
|
177
174
|
});
|
|
178
175
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
role: roleSchema,
|
|
185
|
-
tier: tierSchema,
|
|
186
|
-
legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro), forwarded verbatim by deprecated ds-flash/ds-pro aliases; only influences which model class backs a worker role, never the role gate'),
|
|
187
|
-
effort: effortSchema,
|
|
188
|
-
cwd: z.string().optional().describe('Workspace directory for the worker (defaults to current project)'),
|
|
189
|
-
timeout_seconds: z.number().int().positive().max(7200).optional(),
|
|
190
|
-
},
|
|
191
|
-
}, async ({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds }) => {
|
|
192
|
-
if (!sessionConfig.enabled) return dispatchDisabled();
|
|
176
|
+
function dispatchError(code, details = {}) {
|
|
177
|
+
return { ok: false, response: text({ error: details.error ?? code, code, ...details }) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace: workspaceOverride, constraints }) {
|
|
193
181
|
const globalConfig = currentGlobalConfig();
|
|
194
|
-
const
|
|
195
|
-
if (!
|
|
182
|
+
const profileRegistry = loadRoleProfiles();
|
|
183
|
+
if (!profileRegistry.ok) return dispatchError('PROFILE_FILE_INVALID', { errors: profileRegistry.errors });
|
|
184
|
+
const profileRole = profile ? profileRegistry.profiles?.[profile]?.role : undefined;
|
|
185
|
+
const requestedRole = role ?? profileRole;
|
|
186
|
+
const hint = resolveRoleTierHint(requestedRole, legacy_tier ?? tier);
|
|
187
|
+
if (!hint.ok) return dispatchError(hint.code, { error: hint.error });
|
|
196
188
|
const effRole = hint.role;
|
|
189
|
+
const resolvedProfile = resolveRoleProfile(profileRegistry, profile, effRole);
|
|
190
|
+
if (!resolvedProfile.ok) return dispatchError(resolvedProfile.code, resolvedProfile);
|
|
191
|
+
|
|
197
192
|
let effTier;
|
|
198
193
|
let decision;
|
|
199
|
-
if (
|
|
194
|
+
if (requestedRole === undefined) {
|
|
200
195
|
decision = chooseDefaultTier(globalConfig, legacy_tier ?? tier, sessionConfig);
|
|
201
|
-
if (!decision.ok) return policyRejection(decision);
|
|
196
|
+
if (!decision.ok) return { ok: false, response: policyRejection(decision) };
|
|
202
197
|
effTier = decision.tier;
|
|
203
198
|
} else {
|
|
204
199
|
decision = canDispatchRole(globalConfig, effRole, true, sessionConfig);
|
|
205
|
-
if (!decision.ok) return policyRejection(decision);
|
|
200
|
+
if (!decision.ok) return { ok: false, response: policyRejection(decision) };
|
|
206
201
|
if (effRole === 'reviewer') effTier = 'pro';
|
|
207
202
|
else if (legacy_tier !== undefined) effTier = legacy_tier;
|
|
208
203
|
else if (tier !== undefined) effTier = tier;
|
|
209
204
|
else { const slot = chooseDefaultTier(globalConfig, undefined, sessionConfig); effTier = slot.ok ? slot.tier : 'flash'; }
|
|
210
205
|
}
|
|
211
|
-
const workDir = cwd ?? process.cwd();
|
|
212
|
-
const e = effort ?? sessionConfig.default_effort;
|
|
213
|
-
const timeout = timeout_seconds ?? sessionConfig.default_timeout_seconds;
|
|
214
206
|
|
|
215
|
-
const
|
|
207
|
+
const workDir = workspaceOverride?.repo_root ?? cwd ?? process.cwd();
|
|
208
|
+
const workspace = resolveWorkspaceContext(loadWorkspaceContexts(), { workspace_id, cwd: workDir });
|
|
209
|
+
if (!workspace.ok) return dispatchError(workspace.code, workspace);
|
|
210
|
+
const withRefs = addContextReferences(workspace.context, context_refs, { cwd: workDir });
|
|
211
|
+
if (!withRefs.ok) return dispatchError(withRefs.code);
|
|
212
|
+
const profileValue = resolvedProfile.profile;
|
|
213
|
+
const effectiveTimeout = constraints?.timeout_seconds ?? timeout_seconds ?? profileValue.timeout_seconds ?? sessionConfig.default_timeout_seconds;
|
|
214
|
+
const requestedIsolation = workspaceOverride?.worktree === 'auto'
|
|
215
|
+
? 'worktree'
|
|
216
|
+
: workspaceOverride?.worktree === 'existing' || workspaceOverride?.worktree === 'none'
|
|
217
|
+
? 'shared'
|
|
218
|
+
: profileValue.isolation;
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
216
221
|
role: effRole,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
timeout: effectiveTimeout,
|
|
223
|
+
spec: {
|
|
224
|
+
client_job_id: job_id ?? null,
|
|
225
|
+
role: effRole,
|
|
226
|
+
delivery: effRole === 'reviewer' ? 'review' : 'coding',
|
|
227
|
+
model_class_hint: effTier,
|
|
228
|
+
task: buildWorkspaceTask(task, withRefs.context),
|
|
229
|
+
cwd: workDir,
|
|
230
|
+
effort: effort ?? sessionConfig.default_effort,
|
|
231
|
+
source: ORCHESTRATOR,
|
|
232
|
+
profile_id: resolvedProfile.profile_id,
|
|
233
|
+
requested_isolation: requestedIsolation,
|
|
234
|
+
workspace_branch: workspaceOverride?.branch ?? withRefs.context?.default_branch ?? null,
|
|
235
|
+
timeout_seconds: effectiveTimeout,
|
|
236
|
+
allow_fallback: constraints?.allow_fallback ?? profileValue.fallback,
|
|
237
|
+
routing: profileValue.routing,
|
|
238
|
+
review_strictness: profileValue.review_strictness,
|
|
239
|
+
workspace_context: withRefs.context,
|
|
240
|
+
},
|
|
223
241
|
};
|
|
224
|
-
|
|
225
|
-
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
server.registerTool('dsh_run_worker', {
|
|
245
|
+
title: 'Run DSH worker (blocking)',
|
|
246
|
+
description: 'Delegate a task to a DSH (DeepSeek Harness) coding agent and wait for its final result. The worker is a full DSH agent with its own tools and sandbox. Pass role=worker for implementation and role=reviewer for an independent review pass. Disabled roles are refused by the DSH Crew policy; Manual roles only run when explicitly requested. Blocks until the worker finishes.',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
task: z.string().describe('Full task description for the worker, self-contained'),
|
|
249
|
+
role: roleSchema,
|
|
250
|
+
tier: tierSchema,
|
|
251
|
+
legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro), forwarded verbatim by deprecated ds-flash/ds-pro aliases; only influences which model class backs a worker role, never the role gate'),
|
|
252
|
+
effort: effortSchema,
|
|
253
|
+
cwd: z.string().optional().describe('Workspace directory for the worker (defaults to current project)'),
|
|
254
|
+
timeout_seconds: z.number().int().positive().max(7200).optional(),
|
|
255
|
+
job_id: clientJobIdSchema,
|
|
256
|
+
workspace: workspaceSchema,
|
|
257
|
+
constraints: constraintsSchema,
|
|
258
|
+
profile: profileSchema,
|
|
259
|
+
workspace_id: workspaceIdSchema,
|
|
260
|
+
context_refs: contextRefsSchema,
|
|
261
|
+
detail: detailSchema,
|
|
262
|
+
},
|
|
263
|
+
}, async ({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace, constraints, detail }) => {
|
|
264
|
+
if (!sessionConfig.enabled) return dispatchDisabled();
|
|
265
|
+
const prepared = prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, timeout_seconds, profile, workspace_id, context_refs, job_id, workspace, constraints });
|
|
266
|
+
if (!prepared.ok) return prepared.response;
|
|
267
|
+
const wf = workflowRuntime.start(prepared.spec);
|
|
268
|
+
await workflowRuntime.wait(wf.id, prepared.timeout * 1000);
|
|
226
269
|
const view = workflowRuntime.get(wf.id, { withResult: true });
|
|
227
270
|
if (view.status === 'running') {
|
|
228
|
-
return text({ ...view, note: `still running after ${timeout}s; poll with dsh_worker_result`, status: 'running' });
|
|
271
|
+
return text({ ...projectWorkflowView(view, { detail }), note: `still running after ${prepared.timeout}s; poll with dsh_worker_result`, status: 'running' });
|
|
229
272
|
}
|
|
230
273
|
if (view.phase === 'failed') {
|
|
231
|
-
return text({ ...view, note: 'workflow failed — see error / error_code.', status: 'failed' });
|
|
274
|
+
return text({ ...projectWorkflowView(view, { detail }), note: 'workflow failed — see error / error_code.', status: 'failed' });
|
|
232
275
|
}
|
|
233
|
-
return text({ ...view, note:
|
|
276
|
+
return text({ ...projectWorkflowView(view, { detail }), note: prepared.role === 'reviewer' ? 'review complete' : 'workflow complete' });
|
|
234
277
|
});
|
|
235
278
|
|
|
236
279
|
server.registerTool('dsh_worker_config', {
|
|
@@ -333,6 +376,21 @@ async function buildConfigReport() {
|
|
|
333
376
|
providerCatalogChecked,
|
|
334
377
|
providerCatalogBody,
|
|
335
378
|
});
|
|
379
|
+
const roleProfiles = loadRoleProfiles();
|
|
380
|
+
const workspaceReadiness = await assessWorkspaceReadiness({ cwd: process.cwd() });
|
|
381
|
+
const extensionContract = buildExtensionContract({
|
|
382
|
+
config: {
|
|
383
|
+
...globalConfig,
|
|
384
|
+
subagents_enabled: sessionConfig.enabled !== false && globalConfig.subagents_enabled !== false,
|
|
385
|
+
worker_state: globalConfig.worker_state,
|
|
386
|
+
review_state: globalConfig.review_state,
|
|
387
|
+
escalate_on_failure: sessionConfig.escalate_on_failure ?? legacy.escalate_on_failure,
|
|
388
|
+
},
|
|
389
|
+
readinessMatrix,
|
|
390
|
+
workspace: workspaceReadiness,
|
|
391
|
+
profiles: roleProfiles,
|
|
392
|
+
runtime: getHubRuntimeIdentity(),
|
|
393
|
+
});
|
|
336
394
|
return {
|
|
337
395
|
enabled: sessionConfig.enabled,
|
|
338
396
|
default_tier: sessionConfig.default_tier ?? legacy.default_tier,
|
|
@@ -371,6 +429,8 @@ async function buildConfigReport() {
|
|
|
371
429
|
runtime_controls: runtimeControls,
|
|
372
430
|
activation_boundaries: activationBoundaries,
|
|
373
431
|
readiness_matrix: readinessMatrix,
|
|
432
|
+
role_profiles: roleProfiles,
|
|
433
|
+
extension_contract: extensionContract,
|
|
374
434
|
hub_reachable: hubCompatibility.reachable,
|
|
375
435
|
hub_compatible: hubCompatibility.compatible,
|
|
376
436
|
hub_compatibility: hubCompatibility,
|
|
@@ -387,39 +447,18 @@ server.registerTool('dsh_spawn_worker', {
|
|
|
387
447
|
legacy_tier: tierSchema.describe('Legacy model-class hint (flash | pro) — only influences the model class backing a worker role, never the role gate'),
|
|
388
448
|
effort: effortSchema,
|
|
389
449
|
cwd: z.string().optional(),
|
|
450
|
+
job_id: clientJobIdSchema,
|
|
451
|
+
workspace: workspaceSchema,
|
|
452
|
+
constraints: constraintsSchema,
|
|
453
|
+
profile: profileSchema,
|
|
454
|
+
workspace_id: workspaceIdSchema,
|
|
455
|
+
context_refs: contextRefsSchema,
|
|
390
456
|
},
|
|
391
|
-
}, async ({ task, role, tier, legacy_tier, effort, cwd }) => {
|
|
457
|
+
}, async ({ task, role, tier, legacy_tier, effort, cwd, profile, workspace_id, context_refs, job_id, workspace, constraints }) => {
|
|
392
458
|
if (!sessionConfig.enabled) return dispatchDisabled();
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const effRole = hint.role;
|
|
397
|
-
let effTier;
|
|
398
|
-
let decision;
|
|
399
|
-
if (role === undefined) {
|
|
400
|
-
decision = chooseDefaultTier(globalConfig, legacy_tier ?? tier, sessionConfig);
|
|
401
|
-
if (!decision.ok) return policyRejection(decision);
|
|
402
|
-
effTier = decision.tier;
|
|
403
|
-
} else {
|
|
404
|
-
decision = canDispatchRole(globalConfig, effRole, true, sessionConfig);
|
|
405
|
-
if (!decision.ok) return policyRejection(decision);
|
|
406
|
-
if (effRole === 'reviewer') effTier = 'pro';
|
|
407
|
-
else if (legacy_tier !== undefined) effTier = legacy_tier;
|
|
408
|
-
else if (tier !== undefined) effTier = tier;
|
|
409
|
-
else { const slot = chooseDefaultTier(globalConfig, undefined, sessionConfig); effTier = slot.ok ? slot.tier : 'flash'; }
|
|
410
|
-
}
|
|
411
|
-
const workDir = cwd ?? process.cwd();
|
|
412
|
-
const e = effort ?? sessionConfig.default_effort;
|
|
413
|
-
const spec = {
|
|
414
|
-
role: effRole,
|
|
415
|
-
delivery: effRole === 'reviewer' ? 'review' : 'coding',
|
|
416
|
-
model_class_hint: effTier,
|
|
417
|
-
task,
|
|
418
|
-
cwd: workDir,
|
|
419
|
-
effort: e,
|
|
420
|
-
source: ORCHESTRATOR,
|
|
421
|
-
};
|
|
422
|
-
const wf = workflowRuntime.start(spec);
|
|
459
|
+
const prepared = prepareDispatch({ task, role, tier, legacy_tier, effort, cwd, profile, workspace_id, context_refs, job_id, workspace, constraints });
|
|
460
|
+
if (!prepared.ok) return prepared.response;
|
|
461
|
+
const wf = workflowRuntime.start(prepared.spec);
|
|
423
462
|
return text({ ...workflowRuntime.get(wf.id), workflow_id: wf.id, note: 'started in background; poll with dsh_worker_status / dsh_worker_result' });
|
|
424
463
|
});
|
|
425
464
|
|
|
@@ -437,41 +476,45 @@ server.registerTool('dsh_worker_result', {
|
|
|
437
476
|
inputSchema: {
|
|
438
477
|
job_id: z.string(),
|
|
439
478
|
wait_seconds: z.number().int().min(0).max(7200).default(0).describe('0 = return current state immediately'),
|
|
479
|
+
after_sequence: z.number().int().min(0).default(0).describe('Return canonical events after this cursor for incremental watch.'),
|
|
480
|
+
detail: detailSchema,
|
|
440
481
|
},
|
|
441
|
-
}, async ({ job_id, wait_seconds }) => {
|
|
482
|
+
}, async ({ job_id, wait_seconds, after_sequence, detail }) => {
|
|
442
483
|
if (job_id.startsWith('wf-')) {
|
|
443
484
|
if (wait_seconds > 0) await workflowRuntime.wait(job_id, wait_seconds * 1000);
|
|
444
485
|
const view = workflowRuntime.get(job_id, { withResult: true });
|
|
445
486
|
if (!view) return text({ error: `no such workflow: ${job_id}` });
|
|
446
|
-
return text(view);
|
|
487
|
+
return text(projectWorkflowView(view, { detail, afterSequence: after_sequence }));
|
|
447
488
|
}
|
|
448
489
|
if (job_id.startsWith('hub-')) {
|
|
449
490
|
const status = await hubStatus();
|
|
450
491
|
if (!status.compatible) return text({ error: hubCompatibilityMessage(status), code: status.code, hub_compatibility: status });
|
|
451
|
-
|
|
492
|
+
const view = await hub.get(job_id, wait_seconds).catch((e) => ({ error: e.message }));
|
|
493
|
+
return text(projectWorkflowView(view, { detail, afterSequence: after_sequence }));
|
|
452
494
|
}
|
|
453
495
|
if (!getJob(job_id)) return text({ error: `no such job: ${job_id} (expected a wf- workflow id)` });
|
|
454
496
|
const job = await waitJob(job_id, wait_seconds > 0 ? wait_seconds * 1000 : 1);
|
|
455
|
-
return text(jobView(job, { withResult: true }));
|
|
497
|
+
return text(projectWorkflowView(jobView(job, { withResult: true }), { detail, afterSequence: after_sequence }));
|
|
456
498
|
});
|
|
457
499
|
|
|
458
500
|
server.registerTool('dsh_worker_cancel', {
|
|
459
501
|
title: 'Cancel DSH worker',
|
|
460
502
|
description: 'Cancel a worker workflow (stops the active attempt, never starts escalation/review, releases its worktree). Accepts workflow ids (wf-...), Hub attempt ids (hub-...) and legacy standalone ids (job-...).',
|
|
461
|
-
inputSchema: { job_id: z.string() },
|
|
462
|
-
}, async ({ job_id }) => {
|
|
503
|
+
inputSchema: { job_id: z.string(), detail: detailSchema },
|
|
504
|
+
}, async ({ job_id, detail }) => {
|
|
463
505
|
if (job_id.startsWith('wf-')) {
|
|
464
506
|
const view = await workflowRuntime.cancel(job_id);
|
|
465
507
|
if (!view) return text({ error: `no such workflow: ${job_id}` });
|
|
466
|
-
return text({ ...view, note: 'cancelled' });
|
|
508
|
+
return text({ ...projectWorkflowView(view, { detail }), note: 'cancelled' });
|
|
467
509
|
}
|
|
468
510
|
if (job_id.startsWith('hub-')) {
|
|
469
511
|
const status = await hubStatus();
|
|
470
512
|
if (!status.compatible) return text({ error: hubCompatibilityMessage(status), code: status.code, hub_compatibility: status });
|
|
471
|
-
|
|
513
|
+
const view = await hub.cancel(job_id).catch((e) => ({ error: e.message }));
|
|
514
|
+
return text(projectWorkflowView(view, { detail }));
|
|
472
515
|
}
|
|
473
516
|
if (!getJob(job_id)) return text({ error: `no such job: ${job_id} (expected a wf- workflow id)` });
|
|
474
|
-
return text(jobView(await cancelJob(job_id), { withResult: true }));
|
|
517
|
+
return text(projectWorkflowView(jobView(await cancelJob(job_id), { withResult: true }), { detail }));
|
|
475
518
|
});
|
|
476
519
|
|
|
477
520
|
await server.connect(new StdioServerTransport());
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { resolveModelPolicy, shouldAutoReview, getRoleState } from './policy.mjs
|
|
|
18
18
|
import { buildOutcome, decideNextStep, JOB_PHASES, canTransition } from './workflow.mjs';
|
|
19
19
|
import { parseDeliveryReport } from './delivery.mjs';
|
|
20
20
|
import { classifyFailure } from './failure-classification.mjs';
|
|
21
|
+
import { createCanonicalJobEvent } from './job-contracts.mjs';
|
|
21
22
|
|
|
22
23
|
export const WORKFLOW_ERROR_CODES = {
|
|
23
24
|
ISOLATION_UNAVAILABLE: 'ISOLATION_UNAVAILABLE',
|
|
@@ -111,6 +112,19 @@ export function createWorkflowRuntime(adapters, {
|
|
|
111
112
|
const queue = [];
|
|
112
113
|
let active = 0;
|
|
113
114
|
let maxParallelLimit = normalizeMaxParallel(maxParallel);
|
|
115
|
+
let nestedIdFactory = null;
|
|
116
|
+
|
|
117
|
+
function nextWorkflowId() {
|
|
118
|
+
if (nestedIdFactory) return nestedIdFactory();
|
|
119
|
+
const generated = idFactory();
|
|
120
|
+
// A few embedders provide a factory-builder for deterministic tests.
|
|
121
|
+
// Normalize that legacy shape once while keeping the public id a string.
|
|
122
|
+
if (typeof generated === 'function') {
|
|
123
|
+
nestedIdFactory = generated;
|
|
124
|
+
return nestedIdFactory();
|
|
125
|
+
}
|
|
126
|
+
return generated;
|
|
127
|
+
}
|
|
114
128
|
|
|
115
129
|
function log(msg) { logger?.debug?.(`workflow: ${msg}`); }
|
|
116
130
|
|
|
@@ -140,16 +154,38 @@ export function createWorkflowRuntime(adapters, {
|
|
|
140
154
|
log(`${job.id} ${to}`);
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
function recordCanonical(job, type, data = {}, attempt = null, at = clock()) {
|
|
158
|
+
job.canonical_event_sequence += 1;
|
|
159
|
+
job.canonical_events.push(createCanonicalJobEvent({
|
|
160
|
+
jobId: job.id,
|
|
161
|
+
type,
|
|
162
|
+
sequence: job.canonical_event_sequence,
|
|
163
|
+
at,
|
|
164
|
+
role: job.role,
|
|
165
|
+
attempt,
|
|
166
|
+
data,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
function createJob(spec) {
|
|
144
171
|
const now = clock();
|
|
145
|
-
|
|
146
|
-
id:
|
|
172
|
+
const job = {
|
|
173
|
+
id: nextWorkflowId(),
|
|
174
|
+
client_job_id: spec.client_job_id ?? null,
|
|
147
175
|
role: spec.role ?? 'worker',
|
|
148
176
|
delivery: spec.delivery === 'review' ? 'review' : 'coding',
|
|
149
177
|
model_class_hint: spec.model_class_hint === 'pro' ? 'pro' : spec.model_class_hint === 'flash' ? 'flash' : null,
|
|
150
178
|
original_task: spec.task,
|
|
151
179
|
source: spec.source ?? 'api',
|
|
152
180
|
requested_cwd: spec.cwd,
|
|
181
|
+
requested_isolation: spec.requested_isolation ?? null,
|
|
182
|
+
workspace_branch: spec.workspace_branch ?? null,
|
|
183
|
+
timeout_seconds: spec.timeout_seconds ?? null,
|
|
184
|
+
profile_id: spec.profile_id ?? null,
|
|
185
|
+
allow_fallback: spec.allow_fallback !== false,
|
|
186
|
+
routing: spec.routing ?? 'auto',
|
|
187
|
+
review_strictness: spec.review_strictness ?? null,
|
|
188
|
+
workspace_context: spec.workspace_context ? { ...spec.workspace_context } : null,
|
|
153
189
|
effort: spec.effort ?? 'max',
|
|
154
190
|
phase: JOB_PHASES.CREATED,
|
|
155
191
|
status: 'running',
|
|
@@ -170,12 +206,16 @@ export function createWorkflowRuntime(adapters, {
|
|
|
170
206
|
retain_workspace: false,
|
|
171
207
|
workspace_retained: false,
|
|
172
208
|
events: [{ at: now, phase: JOB_PHASES.CREATED, type: 'created' }],
|
|
209
|
+
canonical_events: [],
|
|
210
|
+
canonical_event_sequence: 0,
|
|
173
211
|
createdAt: now,
|
|
174
212
|
startedAt: now,
|
|
175
213
|
endedAt: null,
|
|
176
214
|
workspaceHandle: null,
|
|
177
215
|
waiters: [],
|
|
178
216
|
};
|
|
217
|
+
recordCanonical(job, 'job.created', { client_job_id: job.client_job_id }, null, now);
|
|
218
|
+
return job;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
function releaseSlot() {
|
|
@@ -227,6 +267,9 @@ export function createWorkflowRuntime(adapters, {
|
|
|
227
267
|
job.error = err?.message ?? String(err);
|
|
228
268
|
job.error_code = err?.code ?? err?.policyCode ?? job.error_code ?? null;
|
|
229
269
|
job.phase = JOB_PHASES.FAILED;
|
|
270
|
+
recordCanonical(job, 'job.failed', {
|
|
271
|
+
error_code: job.error_code,
|
|
272
|
+
});
|
|
230
273
|
setTerminal(job);
|
|
231
274
|
}
|
|
232
275
|
|
|
@@ -259,10 +302,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
259
302
|
config = adapters.getConfig?.() ?? {};
|
|
260
303
|
const alloc = await adapters.allocateWorkspace?.(job);
|
|
261
304
|
if (alloc && alloc.ok === false) {
|
|
262
|
-
job.
|
|
263
|
-
job.phase = JOB_PHASES.FAILED;
|
|
264
|
-
if (alloc.reason) job.error_code = alloc.reason;
|
|
265
|
-
setTerminal(job);
|
|
305
|
+
failJob(job, Object.assign(new Error(alloc.error ?? alloc.reason), { code: alloc.reason }));
|
|
266
306
|
return;
|
|
267
307
|
}
|
|
268
308
|
if (alloc && alloc.ok) {
|
|
@@ -272,12 +312,16 @@ export function createWorkflowRuntime(adapters, {
|
|
|
272
312
|
job.base_revision = alloc.base_revision ?? null;
|
|
273
313
|
job.primary_workspace_dirty = alloc.primary_workspace_dirty === true;
|
|
274
314
|
}
|
|
315
|
+
recordCanonical(job, 'job.started', {
|
|
316
|
+
isolation: job.isolation,
|
|
317
|
+
base_revision: job.base_revision,
|
|
318
|
+
});
|
|
275
319
|
transition(job, JOB_PHASES.RUNNING, 'start');
|
|
276
320
|
|
|
277
321
|
if (isReviewJob) {
|
|
278
322
|
transition(job, JOB_PHASES.REVIEWING, 'explicit reviewer');
|
|
279
323
|
const before = alloc?.ok && alloc.isolation === 'worktree' ? await safeCapture(adapters, job.execution_cwd, job.base_revision) : null;
|
|
280
|
-
const reviewTask = adapters.buildReviewTask(job.original_task, null);
|
|
324
|
+
const reviewTask = adapters.buildReviewTask(job.original_task, null, { strictness: job.review_strictness ?? 'standard' });
|
|
281
325
|
const review = await runReviewerAttempt(job, reviewTask, config, before);
|
|
282
326
|
job.review = review;
|
|
283
327
|
if (job.review) transition(job, JOB_PHASES.READY, 'review complete');
|
|
@@ -290,10 +334,20 @@ export function createWorkflowRuntime(adapters, {
|
|
|
290
334
|
for (;;) {
|
|
291
335
|
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
292
336
|
if (attempt > 0) transition(job, JOB_PHASES.RUNNING, `escalated attempt ${attempt}`);
|
|
293
|
-
const
|
|
337
|
+
const resolvedPolicy = resolveModelPolicy(config, 'worker', { attempt });
|
|
338
|
+
const fallbackPolicy = job.allow_fallback === false
|
|
339
|
+
? { ...resolvedPolicy, escalation: { ...resolvedPolicy.escalation, enabled: false } }
|
|
340
|
+
: resolvedPolicy;
|
|
341
|
+
const policy = job.routing === 'stable'
|
|
342
|
+
? { ...fallbackPolicy, adaptive: { ...fallbackPolicy.adaptive, enabled: false } }
|
|
343
|
+
: fallbackPolicy;
|
|
294
344
|
const attemptId = attemptIdFor(adapters, job.id, attempt === 0 ? '' : String(attempt));
|
|
295
345
|
job.current_attempt_id = attemptId;
|
|
296
346
|
job.events.push({ at: clock(), phase: job.phase, type: 'attempt/start', attempt, escalation_reason: escalationReason });
|
|
347
|
+
recordCanonical(job, 'worker.started', {
|
|
348
|
+
attempt_id: attemptId,
|
|
349
|
+
escalation_reason: escalationReason,
|
|
350
|
+
}, attempt);
|
|
297
351
|
const ar = await adapters.executeAttempt({
|
|
298
352
|
id: attemptId,
|
|
299
353
|
workflowId: job.id,
|
|
@@ -302,6 +356,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
302
356
|
task: job.original_task,
|
|
303
357
|
cwd: job.execution_cwd,
|
|
304
358
|
effort: job.effort,
|
|
359
|
+
timeout_seconds: job.timeout_seconds,
|
|
305
360
|
policy,
|
|
306
361
|
source: job.source,
|
|
307
362
|
model_class_hint: job.model_class_hint,
|
|
@@ -314,6 +369,18 @@ export function createWorkflowRuntime(adapters, {
|
|
|
314
369
|
if (job.cancelling) { cancelWorkflow(job); return; }
|
|
315
370
|
const attemptView = attemptRecord(ar, attempt);
|
|
316
371
|
job.attempts.push(attemptView);
|
|
372
|
+
recordCanonical(job, 'model.selected', {
|
|
373
|
+
provider: attemptView.provider,
|
|
374
|
+
model: attemptView.model,
|
|
375
|
+
source: attemptView.selection_source,
|
|
376
|
+
fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
|
|
377
|
+
}, attempt);
|
|
378
|
+
recordCanonical(job, 'worker.completed', {
|
|
379
|
+
attempt_id: attemptView.id,
|
|
380
|
+
status: attemptView.status,
|
|
381
|
+
stop_reason: attemptView.stopReason,
|
|
382
|
+
error_code: attemptView.error_code,
|
|
383
|
+
}, attempt);
|
|
317
384
|
if (ar.infra === true) {
|
|
318
385
|
failJob(job, Object.assign(new Error(ar.error ?? 'infrastructure failure'), { code: WORKFLOW_ERROR_CODES.ATTEMPT_INFRA_FAILURE }));
|
|
319
386
|
return;
|
|
@@ -360,6 +427,11 @@ export function createWorkflowRuntime(adapters, {
|
|
|
360
427
|
return;
|
|
361
428
|
}
|
|
362
429
|
if (decision.step === 'escalate') {
|
|
430
|
+
recordCanonical(job, 'model.fallback', {
|
|
431
|
+
reason: decision.reason,
|
|
432
|
+
from_attempt: attempt,
|
|
433
|
+
to_attempt: attempt + 1,
|
|
434
|
+
}, attempt);
|
|
363
435
|
transition(job, JOB_PHASES.ESCALATING, decision.reason);
|
|
364
436
|
escalationReason = decision.reason;
|
|
365
437
|
attempt += 1;
|
|
@@ -368,7 +440,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
368
440
|
if (decision.step === 'review') {
|
|
369
441
|
transition(job, JOB_PHASES.REVIEWING, 'automatic review');
|
|
370
442
|
const before = job.candidate;
|
|
371
|
-
const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null });
|
|
443
|
+
const reviewTask = adapters.buildReviewTask(job.original_task, { outcome, candidate: job.candidate ?? null }, { strictness: job.review_strictness ?? 'standard' });
|
|
372
444
|
const review = await runReviewerAttempt(job, reviewTask, config, before, job.execution_cwd, job.base_revision);
|
|
373
445
|
job.review = review;
|
|
374
446
|
transition(job, JOB_PHASES.READY, decision.reason);
|
|
@@ -401,6 +473,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
401
473
|
const attemptId = attemptIdFor(adapters, job.id, 'review');
|
|
402
474
|
job.current_attempt_id = attemptId;
|
|
403
475
|
job.events.push({ at: clock(), phase: job.phase, type: 'review/start' });
|
|
476
|
+
recordCanonical(job, 'review.started', { attempt_id: attemptId }, 0);
|
|
404
477
|
const ar = await adapters.executeAttempt({
|
|
405
478
|
id: attemptId,
|
|
406
479
|
workflowId: job.id,
|
|
@@ -420,13 +493,30 @@ export function createWorkflowRuntime(adapters, {
|
|
|
420
493
|
job.current_attempt_id = null;
|
|
421
494
|
const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
|
|
422
495
|
job.attempts.push(attemptView);
|
|
496
|
+
recordCanonical(job, 'model.selected', {
|
|
497
|
+
provider: attemptView.provider,
|
|
498
|
+
model: attemptView.model,
|
|
499
|
+
source: attemptView.selection_source,
|
|
500
|
+
fallback_reason: attemptView.selection_trace?.fallback_reason ?? null,
|
|
501
|
+
}, 0);
|
|
423
502
|
if (ar.status === 'failed' && attemptView.error_code) job.error_code = attemptView.error_code;
|
|
424
503
|
const afterCandidate = job.isolation === 'worktree' ? await safeCapture(adapters, cwd, baseRevision) : null;
|
|
425
|
-
|
|
504
|
+
const review = normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
|
|
505
|
+
recordCanonical(job, 'review.completed', {
|
|
506
|
+
attempt_id: attemptView.id,
|
|
507
|
+
status: review.status,
|
|
508
|
+
verdict: review.verdict,
|
|
509
|
+
error_code: attemptView.error_code,
|
|
510
|
+
}, 0);
|
|
511
|
+
return review;
|
|
426
512
|
}
|
|
427
513
|
|
|
428
514
|
function finalize(job) {
|
|
429
515
|
transition(job, JOB_PHASES.COMPLETED, job.review ? 'reviewed' : 'verified');
|
|
516
|
+
recordCanonical(job, 'job.completed', {
|
|
517
|
+
reviewed: !!job.review,
|
|
518
|
+
task_status: job.outcome?.task_status ?? null,
|
|
519
|
+
});
|
|
430
520
|
setTerminal(job);
|
|
431
521
|
}
|
|
432
522
|
|
|
@@ -464,6 +554,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
464
554
|
if (activeAttempt) adapters.cancelAttempt?.(activeAttempt).catch(() => {});
|
|
465
555
|
job.phase = JOB_PHASES.CANCELLED;
|
|
466
556
|
job.status = 'cancelled';
|
|
557
|
+
recordCanonical(job, 'job.cancelled', { active_attempt_id: activeAttempt ?? null });
|
|
467
558
|
job.endedAt = clock();
|
|
468
559
|
job.current_attempt_id = null;
|
|
469
560
|
for (const w of job.waiters.splice(0)) w();
|
|
@@ -481,6 +572,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
481
572
|
});
|
|
482
573
|
const v = {
|
|
483
574
|
id: job.id,
|
|
575
|
+
client_job_id: job.client_job_id,
|
|
484
576
|
role: job.role,
|
|
485
577
|
phase: job.phase,
|
|
486
578
|
status: job.status,
|
|
@@ -488,6 +580,11 @@ export function createWorkflowRuntime(adapters, {
|
|
|
488
580
|
current_model: job.attempts[job.attempts.length - 1]?.model ?? null,
|
|
489
581
|
model_class_hint: job.model_class_hint,
|
|
490
582
|
source: job.source,
|
|
583
|
+
profile_id: job.profile_id,
|
|
584
|
+
routing: job.routing,
|
|
585
|
+
workspace_context: job.workspace_context ? { ...job.workspace_context } : null,
|
|
586
|
+
workspace_branch: job.workspace_branch,
|
|
587
|
+
timeout_seconds: job.timeout_seconds,
|
|
491
588
|
requested_cwd: job.requested_cwd,
|
|
492
589
|
execution_cwd: job.execution_cwd,
|
|
493
590
|
isolation: job.isolation,
|
|
@@ -505,6 +602,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
505
602
|
decision: job.decision,
|
|
506
603
|
candidate_available: !!job.candidate,
|
|
507
604
|
review_status: job.review ? job.review.status ?? null : null,
|
|
605
|
+
event_cursor: job.canonical_events.at(-1)?.sequence ?? 0,
|
|
508
606
|
};
|
|
509
607
|
if (withResult) {
|
|
510
608
|
v.child_attempts = job.attempts.map((a) => ({
|
|
@@ -520,6 +618,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
520
618
|
v.candidate = job.candidate;
|
|
521
619
|
v.review = job.review;
|
|
522
620
|
v.events = job.events;
|
|
621
|
+
v.canonical_events = job.canonical_events.map((event) => ({ ...event, data: { ...event.data } }));
|
|
523
622
|
}
|
|
524
623
|
return v;
|
|
525
624
|
}
|