@kasenri/dsh-orbit 0.5.7 → 0.5.9

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/lib/kernel.js CHANGED
@@ -5,9 +5,9 @@
5
5
  * read a clock, or use randomness. Runtime values (routes, timestamps, run ids)
6
6
  * are supplied by the supervisor, which owns orchestration and persistence.
7
7
  */
8
- import { MAX_CORRECTION_DEPTH, MAX_PLAN_STEPS, MAX_WATCHDOG_CALLS_PER_STEP, MIN_PLAN_STEPS, ORBIT_SCHEMA_VERSION, } from "./types.js";
8
+ import { MAX_CORRECTION_DEPTH, DEFAULT_LOOP_BUDGET, MAX_PLAN_STEPS, MAX_WATCHDOG_CALLS_PER_STEP, MIN_PLAN_STEPS, ORBIT_SCHEMA_VERSION, } from "./types.js";
9
9
  /** Capabilities a plan step may request. */
10
- export const ORBIT_CAPABILITIES = ['browser', 'web-api-recon'];
10
+ export const ORBIT_CAPABILITIES = ['filesystem', 'shell', 'web', 'browser'];
11
11
  const STEP_CAPABILITIES = new Set(ORBIT_CAPABILITIES);
12
12
  const PLAN_STEP_ID = /^P\d+$/u;
13
13
  export function normalizeCapabilities(value) {
@@ -15,6 +15,12 @@ export function normalizeCapabilities(value) {
15
15
  return undefined;
16
16
  const result = [];
17
17
  for (const item of value) {
18
+ if (item === 'web-api-recon') {
19
+ for (const legacy of ['web', 'browser'])
20
+ if (!result.includes(legacy))
21
+ result.push(legacy);
22
+ continue;
23
+ }
18
24
  if (typeof item !== 'string' || !STEP_CAPABILITIES.has(item))
19
25
  continue;
20
26
  if (!result.includes(item))
@@ -22,8 +28,6 @@ export function normalizeCapabilities(value) {
22
28
  }
23
29
  if (result.length === 0)
24
30
  return undefined;
25
- if (result.includes('web-api-recon') && !result.includes('browser'))
26
- result.unshift('browser');
27
31
  return result;
28
32
  }
29
33
  export function normalizePlan(plan) {
@@ -46,6 +50,9 @@ export function normalizePlan(plan) {
46
50
  if (steps.some((step) => !step.goal)) {
47
51
  throw new Error('COMMANDER_PLAN_OUTPUT_INVALID: every step needs a goal');
48
52
  }
53
+ if (new Set(steps.map((step) => step.id)).size !== steps.length) {
54
+ throw new Error('COMMANDER_PLAN_OUTPUT_INVALID: 步骤 id 不得重复');
55
+ }
49
56
  return { summary: String(plan.summary ?? '').slice(0, 1000), steps };
50
57
  }
51
58
  export function correctionDepthOf(stepId) {
@@ -68,18 +75,6 @@ export function explicitLoopBudget(input) {
68
75
  throw new Error('ORBIT_LOOP_BUDGET_INVALID: approved_loop_count above 10 requires an explicit execution request');
69
76
  return raw;
70
77
  }
71
- export function estimateLoopCount(goal) {
72
- const text = goal.toLowerCase();
73
- if (/critical|migrate|migration|production|架构|重构/.test(text))
74
- return 6;
75
- if (/integration|联调|ui|high/.test(text))
76
- return 4;
77
- if (/feature|多文件|multi-file/.test(goal))
78
- return 3;
79
- if (/bug|fix|test/.test(text))
80
- return 2;
81
- return 1;
82
- }
83
78
  export function updateLoopBudget(state, budget) {
84
79
  if (budget < state.loop.used)
85
80
  throw new Error(`ORBIT_LOOP_BUDGET_BELOW_USED: requested ${budget}, already used ${state.loop.used}`);
@@ -129,7 +124,7 @@ export function correctionBlockCode(state, step) {
129
124
  }
130
125
  export function createInitialState(input) {
131
126
  const max = explicitLoopBudget({ approved_loop_count: input.approvedLoopCount, max_loops: input.maxLoops }) ??
132
- estimateLoopCount(input.goal);
127
+ DEFAULT_LOOP_BUDGET;
133
128
  return {
134
129
  schema_version: ORBIT_SCHEMA_VERSION,
135
130
  active_run_id: input.runId,
@@ -142,16 +137,18 @@ export function createInitialState(input) {
142
137
  goal: input.goal,
143
138
  goal_hash: hashGoal(input.goal),
144
139
  preset: input.preset ?? 'orbit-lite',
145
- routes: input.routes,
140
+ routes: structuredClone(input.routes),
146
141
  loop: { used: 0, max },
147
142
  approved_loop_count: max,
148
143
  remaining_budget: max,
149
144
  loop_count: 0,
150
145
  plan: { summary: '', steps: [] },
146
+ step_results: [],
151
147
  changed_files: [],
152
148
  test_summary: [],
153
149
  last_error: null,
154
150
  pending_user_reply: null,
151
+ ...(input.ownerSessionId === undefined ? {} : { owner_session_id: input.ownerSessionId }),
155
152
  user_hard_constraints: input.userHardConstraints ? [...input.userHardConstraints] : [],
156
153
  github_allowed: input.githubAllowed === true,
157
154
  interruption_retries: 0,
@@ -193,7 +190,7 @@ export function applyExecutorCapabilityUnavailable(state, stepId) {
193
190
  state.last_error = 'BROWSER_CAPABILITY_UNAVAILABLE';
194
191
  state.commander = {
195
192
  last_decision: state.commander?.last_decision,
196
- summary: `Executor could not run step ${stepId}: BROWSER_CAPABILITY_UNAVAILABLE (agent_browser tool is not registered).`,
193
+ summary: `Executor 无法执行步骤 ${stepId}BROWSER_CAPABILITY_UNAVAILABLE(配置的 Browser 工具不可用)。`,
197
194
  };
198
195
  state.phase = 'EVALUATE';
199
196
  }
@@ -215,6 +212,17 @@ export function clearExecutorChild(state) {
215
212
  state.child = undefined;
216
213
  state.interruption_retries = 0;
217
214
  }
215
+ export const MAX_STEP_RESULTS = 10;
216
+ /** Upsert one bounded durable result without allowing evidence to drive transitions. */
217
+ export function upsertStepResult(state, result) {
218
+ const results = state.step_results ?? [];
219
+ const index = results.findIndex((entry) => entry.step_id === result.step_id);
220
+ if (index === -1)
221
+ results.push(result);
222
+ else
223
+ results[index] = result;
224
+ state.step_results = results.slice(-MAX_STEP_RESULTS);
225
+ }
218
226
  /** Apply a real Executor success: consume one loop slot, then enter EVALUATE. */
219
227
  export function applyExecutorSuccess(state, input) {
220
228
  state.child = { ...(input.childId ? { id: input.childId } : {}), status: 'completed' };
@@ -262,7 +270,9 @@ export function applyFinalAppend(state, decision) {
262
270
  return 'budget_exhausted';
263
271
  }
264
272
  for (const item of appended.slice(0, remaining)) {
265
- const index = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id)).length;
273
+ let index = state.plan.steps.filter((candidate) => isBaseStepId(candidate.id)).length;
274
+ while (state.plan.steps.some((candidate) => candidate.id === `P${index}`))
275
+ index += 1;
266
276
  state.plan.steps.push({
267
277
  id: `P${index}`,
268
278
  goal: item.goal,
@@ -281,7 +291,9 @@ export function applyCorrectionStep(state, step, input) {
281
291
  step.status = 'needs_correction';
282
292
  const number = correctionDepthOf(step.id) + 2;
283
293
  const insertAt = state.plan.steps.indexOf(step) + 1;
284
- const correctionCapabilities = normalizeCapabilities(input.capabilities) ?? step.capabilities;
294
+ const correctionCapabilities = input.capabilities === undefined
295
+ ? step.capabilities
296
+ : normalizeCapabilities(input.capabilities);
285
297
  state.plan.steps.splice(insertAt, 0, {
286
298
  id: `${base}-${number}`,
287
299
  goal: input.nextGoal,
@@ -1,4 +1,5 @@
1
1
  import { guardBashCommand, guardReason, guardToolPath } from "./guard.js";
2
+ import { mutationTools } from "./capabilities.js";
2
3
  const WRITE_PATH_TOOLS = new Set(['write', 'edit', 'str_replace_editor']);
3
4
  /** Top-level autonomous mutation drivers that must not run beside an active Orbit run. */
4
5
  export const MUTATION_DRIVER_TOOLS = new Set(['create_goal', 'ralph', 'workflow']);
@@ -16,12 +17,13 @@ export function createOrbitPreExecuteHandler(service, options = {}) {
16
17
  const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
17
18
  const active = service.hasActiveRun(cwd);
18
19
  const args = (exec.arguments ?? {});
20
+ const mutating = mutationTools(service.browserToolNames()).has(exec.name);
19
21
  // Orbit owns the workspace: refuse to start another top-level mutation driver.
20
22
  if (active && MUTATION_DRIVER_TOOLS.has(exec.name)) {
21
23
  return {
22
24
  kind: 'deny',
23
- reason: `ORBIT_MUTATION_DRIVER_CONFLICT: an active Orbit run owns this workspace, so ${exec.name} must not start. ` +
24
- 'Resume or stop the Orbit run first, or continue through orbit_controller.',
25
+ reason: `ORBIT_MUTATION_DRIVER_CONFLICT: 当前 workspace Orbit 持有,不能启动 ${exec.name}。` +
26
+ '请先 resume stop 当前 Run,或通过 orbit_controller 继续。',
25
27
  };
26
28
  }
27
29
  // Another driver already owns the workspace: refuse to start Orbit.
@@ -32,13 +34,23 @@ export function createOrbitPreExecuteHandler(service, options = {}) {
32
34
  if (competing) {
33
35
  return {
34
36
  kind: 'deny',
35
- reason: `ORBIT_MUTATION_DRIVER_CONFLICT: ${competing} already owns mutation in this workspace; stop it before starting Orbit.`,
37
+ reason: `ORBIT_MUTATION_DRIVER_CONFLICT: ${competing} 已持有此 workspace 的修改权,请先停止该 Driver。`,
36
38
  };
37
39
  }
38
40
  }
39
41
  }
40
42
  if (!active)
41
43
  return next();
44
+ // Runtime ownership fence: only the current Orbit Executor child may use
45
+ // mutation-capable tools in an Orbit-owned workspace.
46
+ if (mutating && !service.isMutationAuthorized(exec.agent, exec.name, cwd)) {
47
+ return {
48
+ kind: 'deny',
49
+ reason: `ORBIT_MUTATION_DRIVER_CONFLICT: 当前 workspace 由 Orbit 持有;只有当前 Orbit Executor 可以调用 ${exec.name}。`,
50
+ };
51
+ }
52
+ if (options.contentGuards === false)
53
+ return next();
42
54
  if (exec.name === 'bash') {
43
55
  const command = typeof args['command'] === 'string' ? args['command'] : '';
44
56
  const decision = guardBashCommand(command, { github_allowed: service.githubAllowed(cwd) });
@@ -59,7 +71,7 @@ export function createOrbitPreExecuteHandler(service, options = {}) {
59
71
  }
60
72
  // The browser tool can write its structured result to a caller path; route
61
73
  // that path through the same durable-state policy instead of a second copy.
62
- if (exec.name === 'agent_browser') {
74
+ if (service.browserToolNames().includes(exec.name)) {
63
75
  const outputPath = args['outputPath'];
64
76
  if (typeof outputPath === 'string' && outputPath.length > 0) {
65
77
  const decision = guardToolPath('write', outputPath, { cwd });
package/lib/routes.js CHANGED
@@ -1,80 +1,82 @@
1
- /**
2
- * Effective route resolution for a new Orbit run.
3
- *
4
- * Orbit stores only Commander and Watchdog itself (`orbit` settings namespace,
5
- * falling back to the composition `config.routes`). The Executor follows the
6
- * initiating Session's current model selection, read from the durable
7
- * `modelSelection` projection (the user's pending choice first, then the model
8
- * that actually served the last request) with the request-header seam and
9
- * `config.routes.executor` as compatibility fallbacks (headless, CLI, minimal
10
- * profiles, tests). Resolution happens exactly once per new run and is frozen
11
- * into `state.routes`.
12
- */
13
- /**
14
- * Resolve the optional session projection registry without an inject
15
- * declaration, exactly like the goal-driver lookup: minimal compositions
16
- * without the registry simply have no durable model selection.
17
- * @param ctx - any context whose `reflect` service lookup is available.
18
- * @returns the registry, or undefined when the composition omits it.
19
- */
1
+ /** Effective route resolution for a new Orbit run. */
20
2
  export function projectionRegistryOf(ctx) {
21
3
  const reflect = ctx?.reflect;
22
4
  const registry = reflect?.get('sessionProjections');
23
5
  return registry !== null && typeof registry === 'object' ? registry : undefined;
24
6
  }
25
- /**
26
- * Read one Session's durable model-selection state (`modelSelection` unit).
27
- * @param ctx - context carrying the projection registry.
28
- * @param session - the initiating Session, when one exists.
29
- * @returns the projection state, or undefined when it is unavailable.
30
- */
31
7
  export function sessionModelStateOf(ctx, session) {
32
8
  const state = projectionRegistryOf(ctx)?.stateOf?.(session, 'modelSelection');
33
9
  return state !== null && typeof state === 'object' ? state : undefined;
34
10
  }
35
- /**
36
- * Normalize one provider/model/effort candidate into a complete route.
37
- * @param selection - candidate fields from settings, the session model, or tests.
38
- * @returns the complete route, or undefined when provider/model are unusable.
39
- */
11
+ /** Read-only access to DSH's deployment model selection. */
12
+ export function agentDefaultSelectionOf(ctx) {
13
+ const reflect = ctx?.reflect;
14
+ const service = reflect?.get('agentDefaultModel');
15
+ if (service?.currentSelection === undefined)
16
+ return undefined;
17
+ try {
18
+ const selection = service.currentSelection();
19
+ return selection !== null && typeof selection === 'object' ? selection : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /** DSH selection intent: pending choice, last request, then deployment default. */
26
+ export function sessionModelSelectionOf(sources) {
27
+ const pending = sources.sessionModel?.pending;
28
+ if (pending !== undefined && pending !== null)
29
+ return pending;
30
+ const header = sources.requestHeader;
31
+ if (header?.config !== undefined) {
32
+ const effort = header.config.reasoningEffort;
33
+ const adapterDefaultEffort = header.adapterDefaults?.reasoningEffort === true;
34
+ return {
35
+ provider: header.config.provider,
36
+ model: header.config.model,
37
+ ...(effort === undefined || effort === '' || adapterDefaultEffort ? {} : { reasoningEffort: effort }),
38
+ };
39
+ }
40
+ return sources.agentDefault;
41
+ }
42
+ /** Normalize a user-owned selection; whitespace and malformed effort are invalid. */
40
43
  export function routeFromSelection(selection) {
41
44
  if (selection === undefined)
42
45
  return undefined;
43
- const { provider, model, reasoningEffort } = selection;
44
- if (typeof provider !== 'string' || provider === '')
46
+ const provider = typeof selection.provider === 'string' ? selection.provider.trim() : '';
47
+ const model = typeof selection.model === 'string' ? selection.model.trim() : '';
48
+ if (provider === '' || model === '')
45
49
  return undefined;
46
- if (typeof model !== 'string' || model === '')
50
+ const effort = selection.reasoningEffort;
51
+ if (effort !== undefined && typeof effort !== 'string')
47
52
  return undefined;
48
53
  return {
49
54
  provider,
50
55
  model,
51
- ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
56
+ ...(typeof effort === 'string' && effort.trim() !== '' ? { reasoningEffort: effort.trim() } : {}),
52
57
  };
53
58
  }
54
- /**
55
- * Resolve the three role routes for a NEW run:
56
- * Commander = settings (base = config) → config; Executor = session model
57
- * (pending → lastUsed) → request header → config; Watchdog = settings
58
- * (base = config) → config.
59
- * @param input - config fallback, settings section, and session model state.
60
- * @returns the complete frozen route set.
61
- */
59
+ const ROLE_LABELS = {
60
+ commander: 'Commander',
61
+ executor: 'Executor',
62
+ watchdog: 'Watchdog',
63
+ };
64
+ /** Resolve all three routes or fail before a durable run is created. */
62
65
  export function resolveEffectiveRoutes(input) {
63
- return {
64
- commander: routeFromSelection(input.settings?.commander) ?? input.configRoutes.commander,
65
- executor: routeFromSelection(input.sessionModel?.pending ?? undefined) ??
66
- routeFromSelection(input.sessionModel?.lastUsed ?? undefined) ??
67
- routeFromSelection(input.sessionSelection) ??
68
- input.configRoutes.executor,
69
- watchdog: routeFromSelection(input.settings?.watchdog) ?? input.configRoutes.watchdog,
66
+ const routes = {
67
+ commander: routeFromSelection(input.settings?.commander) ?? routeFromSelection(input.configRoutes?.commander),
68
+ executor: input.hasSession === true || input.sessionSelection !== undefined
69
+ ? routeFromSelection(input.sessionSelection)
70
+ : routeFromSelection(input.configRoutes?.executor),
71
+ watchdog: routeFromSelection(input.settings?.watchdog) ?? routeFromSelection(input.configRoutes?.watchdog),
70
72
  };
73
+ const missing = ['commander', 'executor', 'watchdog'].filter((role) => routes[role] === undefined);
74
+ if (missing.length > 0) {
75
+ throw new Error(`ORBIT_ROLE_MODEL_CONFIGURATION_REQUIRED: Orbit 尚未完成角色模型配置:${missing.map((role) => `${ROLE_LABELS[role]} 未选择`).join(';')}。` +
76
+ '请先在 Orbit 模型菜单中选择;无 Web 设置界面的 profile 可显式配置 routes。');
77
+ }
78
+ return structuredClone(routes);
71
79
  }
72
- /**
73
- * Read the initiating Session's current model selection from the public
74
- * request-header seam (`Agent.session.requestHeader().config`).
75
- * @param agent - initiating agent, or undefined outside a boundary.
76
- * @returns the selection route, or undefined when no header exists.
77
- */
78
80
  export function sessionSelectionOf(agent) {
79
- return routeFromSelection(agent?.session?.requestHeader?.()?.config);
81
+ return agent?.session?.requestHeader?.()?.config;
80
82
  }
package/lib/sanitize.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Secret redaction shared by Orbit state, logs, and watchdog prompts. */
2
2
  const SECRET_PATTERNS = [
3
3
  /(authorization\s*:\s*(?:bearer\s+)?)[^\s,;]+/gi,
4
- /((?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|cookie|secret|private[_-]?key)\s*[=:]\s*)[^\s,;]+/gi,
4
+ /((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|cookie|secret|private[_-]?key)\s*[=:]\s*)[^\s,;]+/gi,
5
5
  /(\b(?:sk|pk|ghp|github_pat)_[A-Za-z0-9_-]{8,})/g,
6
6
  /(\bBearer\s+)[A-Za-z0-9._-]+/gi,
7
7
  ];
@@ -32,7 +32,8 @@ export function truncateSafe(value, max = 4000) {
32
32
  const redacted = redactText(value);
33
33
  if (redacted.length <= max)
34
34
  return redacted;
35
- return `${redacted.slice(0, max)}\n...[truncated]`;
35
+ const marker = '\n...[truncated]';
36
+ return `${redacted.slice(0, Math.max(0, max - marker.length))}${marker}`;
36
37
  }
37
38
  export function looksLikeSecretKey(key) {
38
39
  return SENSITIVE_KEY.test(key);
package/lib/service.js CHANGED
@@ -1,4 +1,5 @@
1
- import { accessSync, constants, mkdirSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
2
3
  import { Service } from '@deepseek-ai/cordis';
3
4
  import { DshOrbitHost } from "./dsh-host.js";
4
5
  import { OrbitStateStore } from "./state-store.js";
@@ -6,6 +7,7 @@ import { OrbitSupervisor } from "./supervisor.js";
6
7
  export class OrbitService extends Service {
7
8
  host;
8
9
  config;
10
+ executions = new Map();
9
11
  constructor(ctx, config) {
10
12
  super(ctx, 'orbit');
11
13
  this.host = new DshOrbitHost(ctx);
@@ -15,6 +17,7 @@ export class OrbitService extends Service {
15
17
  return new OrbitSupervisor(new OrbitStateStore(projectDir), this.host, {
16
18
  defaultRoutes: this.config.routes,
17
19
  ...(this.config.resolveRoutes ? { resolveRoutes: this.config.resolveRoutes } : {}),
20
+ ...(this.config.resolveOwnerSessionId ? { resolveOwnerSessionId: this.config.resolveOwnerSessionId } : {}),
18
21
  browserTools: this.config.browserTools,
19
22
  commanderReadOnlyTools: this.config.commanderReadOnlyTools,
20
23
  watchdogTools: this.config.watchdogTools,
@@ -23,21 +26,67 @@ export class OrbitService extends Service {
23
26
  });
24
27
  }
25
28
  resolveProjectDir(projectDir) {
26
- return projectDir ?? this.config.projectDir ?? process.cwd();
29
+ return resolve(projectDir ?? this.config.projectDir ?? process.cwd());
27
30
  }
28
31
  run(input, projectDir, signal) {
29
- return this.supervisorFor(this.resolveProjectDir(projectDir)).bootstrap(input, signal);
32
+ const dir = this.resolveProjectDir(projectDir);
33
+ return this.execute(dir, signal, (activeSignal) => this.supervisorFor(dir).bootstrap(input, activeSignal));
34
+ }
35
+ async execute(dir, signal, operation) {
36
+ if (this.executions.has(dir))
37
+ return { ok: false, action: 'run', message: 'ORBIT_MUTATION_DRIVER_CONFLICT: 此 workspace 已有 Orbit 执行中的调用。' };
38
+ const controller = new AbortController();
39
+ const abort = () => controller.abort();
40
+ if (signal?.aborted)
41
+ controller.abort();
42
+ signal?.addEventListener('abort', abort, { once: true });
43
+ const result = Promise.resolve().then(() => operation(controller.signal));
44
+ this.executions.set(dir, { controller, result });
45
+ try {
46
+ const settled = await result;
47
+ if (settled.phase === 'SUCCESS' || settled.phase === 'STOPPED' || settled.phase === 'BUDGET_EXHAUSTED' || settled.message === 'ORBIT_ABORTED') {
48
+ await this.host.revokeWorkspace(dir);
49
+ }
50
+ return settled;
51
+ }
52
+ catch (error) {
53
+ await this.host.revokeWorkspace(dir);
54
+ throw error;
55
+ }
56
+ finally {
57
+ signal?.removeEventListener('abort', abort);
58
+ this.executions.delete(dir);
59
+ }
30
60
  }
31
61
  async resume(input, projectDir, signal) {
32
62
  const dir = this.resolveProjectDir(projectDir);
33
63
  const state = new OrbitStateStore(dir).readState();
34
64
  if (!state)
35
- return { ok: false, action: 'resume', message: 'ORBIT_RUN_NOT_FOUND: no durable run to resume.' };
65
+ return { ok: false, action: 'resume', message: 'ORBIT_RUN_NOT_FOUND: 没有可继续的持久化 Run。' };
36
66
  const supervisor = this.supervisorFor(dir);
37
- return supervisor.run(state, signal);
67
+ if (input.run_id && input.run_id !== state.run_id)
68
+ return { ok: false, action: 'resume', message: 'ORBIT_RUN_NOT_FOUND: Run id 不匹配。' };
69
+ if (state.phase === 'NEEDS_USER') {
70
+ if (state.owner_session_id !== undefined)
71
+ return this.run(input, dir, signal);
72
+ // Explicit resume is the only ownerless compatibility path; it never
73
+ // adopts the caller or stores its message as a user reply.
74
+ state.phase = state.plan.steps.length === 0 ? 'PLAN' : 'EXECUTE';
75
+ state.status = 'running';
76
+ }
77
+ return this.execute(dir, signal, (activeSignal) => supervisor.run(state, activeSignal));
38
78
  }
39
- stop(runId, projectDir) {
40
- return this.supervisorFor(this.resolveProjectDir(projectDir)).stop('stop', runId);
79
+ async stop(runId, projectDir) {
80
+ const dir = this.resolveProjectDir(projectDir);
81
+ const state = new OrbitStateStore(dir).readState();
82
+ if (runId && state?.run_id !== runId)
83
+ return { ok: false, action: 'stop', message: 'ORBIT_RUN_NOT_FOUND: Run id 不匹配。' };
84
+ const active = this.executions.get(dir);
85
+ active?.controller.abort();
86
+ if (active)
87
+ await active.result.catch(() => undefined);
88
+ await this.host.revokeWorkspace(dir);
89
+ return this.supervisorFor(dir).stop('stop', runId);
41
90
  }
42
91
  status(projectDir) {
43
92
  return this.supervisorFor(this.resolveProjectDir(projectDir)).status();
@@ -49,6 +98,12 @@ export class OrbitService extends Service {
49
98
  const state = new OrbitStateStore(this.resolveProjectDir(projectDir)).readState();
50
99
  return state !== null && state.driver_ownership !== 'CLOSED';
51
100
  }
101
+ isMutationAuthorized(agent, tool, projectDir) {
102
+ return this.host.isMutationAuthorized(agent, this.resolveProjectDir(projectDir), tool);
103
+ }
104
+ browserToolNames() {
105
+ return this.config.browserTools;
106
+ }
52
107
  githubAllowed(projectDir) {
53
108
  const state = new OrbitStateStore(this.resolveProjectDir(projectDir)).readState();
54
109
  return state?.github_allowed === true;
@@ -57,9 +112,8 @@ export class OrbitService extends Service {
57
112
  const dir = this.resolveProjectDir(projectDir);
58
113
  const checks = [];
59
114
  try {
60
- mkdirSync(`${dir}/.cx`, { recursive: true, mode: 0o700 });
61
- accessSync(`${dir}/.cx`, constants.W_OK);
62
- checks.push({ name: 'state-storage', status: 'pass', detail: `${dir}/.cx is writable` });
115
+ accessSync(existsSync(`${dir}/.cx`) ? `${dir}/.cx` : dir, constants.W_OK);
116
+ checks.push({ name: 'state-storage', status: 'pass', detail: '状态存储目录可写' });
63
117
  }
64
118
  catch (error) {
65
119
  checks.push({ name: 'state-storage', status: 'fail', detail: String(error) });
@@ -92,11 +146,17 @@ export class OrbitService extends Service {
92
146
  status: 'pass',
93
147
  detail: 'DshOrbitHost provides cancel/dispose/runtimeSnapshot for every role handle',
94
148
  });
95
- checks.push({
96
- name: 'role-routes',
97
- status: this.config.routes.commander.model && this.config.routes.executor.model ? 'pass' : 'fail',
98
- detail: `commander=${this.config.routes.commander.model} executor=${this.config.routes.executor.model} watchdog=${this.config.routes.watchdog.model}`,
99
- });
149
+ const reflect = this.ctx.reflect;
150
+ checks.push({ name: 'model-registry', status: reflect.get('llm') ? 'pass' : 'fail', detail: '使用 DSH 当前 LLM registry 校验模型,不内置角色模型。' });
151
+ checks.push({ name: 'orbit-settings', status: reflect.get('settings') ? 'pass' : 'warn', detail: 'Commander / Watchdog 使用用户 Orbit 设置或显式 profile routes。' });
152
+ try {
153
+ const routes = this.config.resolveRoutes?.();
154
+ const issues = routes ? await this.host.validateRoutes(routes) : ['未配置模型解析来源'];
155
+ checks.push({ name: 'role-model-configuration', status: issues.length ? 'warn' : 'pass', detail: issues.join(';') || '三角色用户模型配置可用。' });
156
+ }
157
+ catch (error) {
158
+ checks.push({ name: 'role-model-configuration', status: 'warn', detail: String(error) });
159
+ }
100
160
  checks.push({ name: 'tested-dsh-version', status: 'pass', detail: 'tested against @deepseek-ai/dsh 0.1.5-rc.2' });
101
161
  const status = checks.some((check) => check.status === 'fail') ? 'fail' : checks.some((check) => check.status === 'warn') ? 'warn' : 'pass';
102
162
  return { status, generatedAt: new Date().toISOString(), checks };
package/lib/settlement.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * `completed | aborted | error | blocked | max-tokens` (`interrupted` only
6
6
  * appears on cold-read synthesis). A child that is merely `idle` is NOT success.
7
7
  */
8
+ import { truncateSafe } from "./sanitize.js";
8
9
  export function classifyTurnSettlement(events) {
9
10
  let end;
10
11
  for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -22,11 +23,13 @@ export function classifyTurnSettlement(events) {
22
23
  case 'aborted':
23
24
  return { settlement: 'aborted', ...(reason.reason?.kind ? { cancelCause: reason.reason.kind } : {}) };
24
25
  case 'error':
25
- return { settlement: 'error', ...(reason.error?.message ? { errorMessage: reason.error.message } : {}) };
26
+ return { settlement: 'error', ...(reason.error?.message ? { errorMessage: truncateSafe(reason.error.message, 500) } : {}) };
26
27
  case 'blocked':
27
28
  return { settlement: 'blocked' };
28
29
  case 'max-tokens':
29
30
  return { settlement: 'max-tokens' };
31
+ case 'interrupted':
32
+ return { settlement: 'interrupted' };
30
33
  default:
31
34
  return { settlement: 'open' };
32
35
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
@@ -63,15 +63,23 @@ export class OrbitStateStore {
63
63
  }
64
64
  }
65
65
  readRawState() {
66
- try {
67
- const parsed = JSON.parse(readFileSync(this.statePath, 'utf8'));
68
- if (parsed !== null && typeof parsed === 'object')
69
- return parsed;
66
+ if (!existsSync(this.statePath))
70
67
  return null;
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(readFileSync(this.statePath, 'utf8'));
71
71
  }
72
- catch {
73
- return null;
72
+ catch (error) {
73
+ throw new Error(`ORBIT_STATE_INVALID: 无法解析 ${this.statePath}:${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
76
+ throw new Error(`ORBIT_STATE_INVALID: ${this.statePath} 不是有效的对象状态。`);
77
+ }
78
+ const schema = parsed['schema_version'];
79
+ if (typeof schema === 'number' && schema > ORBIT_SCHEMA_VERSION) {
80
+ throw new Error(`ORBIT_STATE_SCHEMA_UNSUPPORTED: ${this.statePath} 使用未来 schema_version=${schema}。`);
74
81
  }
82
+ return parsed;
75
83
  }
76
84
  readState() {
77
85
  const raw = this.readRawState();