@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/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
- server.registerTool('dsh_run_worker', {
180
- title: 'Run DSH worker (blocking)',
181
- 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.',
182
- inputSchema: {
183
- task: z.string().describe('Full task description for the worker, self-contained'),
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 hint = resolveRoleTierHint(role, legacy_tier ?? tier);
195
- if (!hint.ok) return text({ error: hint.error, code: hint.code, note: 'Resolve the conflict on the caller side: pass only role, or only tier.' });
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 (role === undefined) {
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 spec = {
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
- delivery: effRole === 'reviewer' ? 'review' : 'coding',
218
- model_class_hint: effTier,
219
- task,
220
- cwd: workDir,
221
- effort: e,
222
- source: ORCHESTRATOR,
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
- const wf = workflowRuntime.start(spec);
225
- await workflowRuntime.wait(wf.id, timeout * 1000);
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: effRole === 'reviewer' ? 'review complete' : 'workflow complete' });
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 globalConfig = currentGlobalConfig();
394
- const hint = resolveRoleTierHint(role, legacy_tier ?? tier);
395
- if (!hint.ok) return text({ error: hint.error, code: hint.code, note: 'Resolve the conflict on the caller side: pass only role, or only tier.' });
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
- return text(await hub.get(job_id, wait_seconds).catch((e) => ({ error: e.message })));
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
- return text(await hub.cancel(job_id).catch((e) => ({ error: e.message })));
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());
@@ -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
- return {
146
- id: idFactory(),
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.error = alloc.error ?? alloc.reason;
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 policy = resolveModelPolicy(config, 'worker', { attempt });
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
- return normalizeReview({ attemptResult: ar, beforeCandidate, afterCandidate });
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
  }