@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/README.md +27 -19
- package/cordis.patch.yml +2 -2
- package/lib/activation.js +12 -11
- package/lib/capabilities.js +23 -0
- package/lib/client.js +47 -77
- package/lib/decisions.js +4 -4
- package/lib/dsh-host.js +112 -30
- package/lib/evidence.js +24 -0
- package/lib/guard.js +17 -17
- package/lib/index.js +64 -43
- package/lib/kernel.js +33 -21
- package/lib/pipeline-guard.js +16 -4
- package/lib/routes.js +58 -56
- package/lib/sanitize.js +3 -2
- package/lib/service.js +75 -15
- package/lib/settlement.js +4 -1
- package/lib/state-store.js +15 -7
- package/lib/supervisor.js +135 -40
- package/lib/tool.js +10 -11
- package/lib/types.js +6 -5
- package/package.json +1 -1
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 = ['
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/lib/pipeline-guard.js
CHANGED
|
@@ -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:
|
|
24
|
-
'
|
|
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}
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
...(typeof effort === 'string' && effort.trim() !== '' ? { reasoningEffort: effort.trim() } : {}),
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
commander: routeFromSelection(input.settings?.commander) ?? input.configRoutes
|
|
65
|
-
executor:
|
|
66
|
-
routeFromSelection(input.
|
|
67
|
-
routeFromSelection(input.
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
65
|
+
return { ok: false, action: 'resume', message: 'ORBIT_RUN_NOT_FOUND: 没有可继续的持久化 Run。' };
|
|
36
66
|
const supervisor = this.supervisorFor(dir);
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/lib/state-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|