open-tui-orchestrator 0.9.6
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/CHANGELOG.md +205 -0
- package/INSTALL-zh.md +96 -0
- package/INSTALL.md +96 -0
- package/LICENSE +48 -0
- package/README-zh.md +181 -0
- package/README.md +181 -0
- package/cli.mjs +37 -0
- package/docs/adapt.md +103 -0
- package/docs/assets/kimicode-agent-swarm-10-subagents.png +0 -0
- package/docs/auto-recovery.md +23 -0
- package/docs/caller-driven.md +121 -0
- package/docs/claude-adapter.md +25 -0
- package/docs/execution-contract.md +70 -0
- package/docs/inactive-windows.md +11 -0
- package/docs/kimi-adapter.md +27 -0
- package/docs/kimi-integration.md +56 -0
- package/docs/maintenance-lock.md +32 -0
- package/docs/openclaw-adapter.md +59 -0
- package/docs/openclaw-assessment-2026-09-06.md +59 -0
- package/docs/opencode-adapter.md +25 -0
- package/docs/pi-adapter.md +58 -0
- package/docs/public-readiness.md +63 -0
- package/docs/release-policy.md +39 -0
- package/docs/security-audit-2026-09-09.md +41 -0
- package/docs/trust-and-safety.md +64 -0
- package/docs/verification-2026-09-06.md +22 -0
- package/docs/verification-recovery-2026-09-06.md +36 -0
- package/orch.mjs +20 -0
- package/package.json +36 -0
- package/release.json +116 -0
- package/repair.mjs +228 -0
- package/scripts/adapt.mjs +35 -0
- package/scripts/agent-auth-prompt.txt +10 -0
- package/scripts/agent.mjs +1 -0
- package/scripts/core/adapt-lib.mjs +219 -0
- package/scripts/core/agent-auth-prompt.txt +10 -0
- package/scripts/core/agent-profiles/hermes.json +59 -0
- package/scripts/core/agent.mjs +1 -0
- package/scripts/core/checkpoint.mjs +38 -0
- package/scripts/core/claude-host.mjs +50 -0
- package/scripts/core/claude-runtime.mjs +111 -0
- package/scripts/core/contracts.mjs +161 -0
- package/scripts/core/host-cli.mjs +204 -0
- package/scripts/core/host-model.mjs +323 -0
- package/scripts/core/host-probe.mjs +16 -0
- package/scripts/core/inactive-window.mjs +32 -0
- package/scripts/core/inactive-window.ps1 +36 -0
- package/scripts/core/kimi-host.mjs +41 -0
- package/scripts/core/kimi-runtime.mjs +140 -0
- package/scripts/core/lease-lock.ps1 +32 -0
- package/scripts/core/leases.mjs +176 -0
- package/scripts/core/maintenance-lock.mjs +77 -0
- package/scripts/core/native-argv.mjs +9 -0
- package/scripts/core/network-policy.mjs +18 -0
- package/scripts/core/openclaw-bootstrap.mjs +25 -0
- package/scripts/core/openclaw-config.mjs +35 -0
- package/scripts/core/openclaw-host.mjs +29 -0
- package/scripts/core/openclaw-runtime.mjs +33 -0
- package/scripts/core/openclaw-window.mjs +44 -0
- package/scripts/core/opencode-host.mjs +80 -0
- package/scripts/core/opencode-runtime.mjs +131 -0
- package/scripts/core/orchestrate-sdk.mjs +2595 -0
- package/scripts/core/pi-host.mjs +29 -0
- package/scripts/core/pi-runtime.mjs +54 -0
- package/scripts/core/pi-shutdown.mjs +16 -0
- package/scripts/core/poll-windows.mjs +48 -0
- package/scripts/core/print-profile.mjs +79 -0
- package/scripts/core/print-runtime.mjs +106 -0
- package/scripts/core/pty-host.mjs +38 -0
- package/scripts/core/recovery.mjs +75 -0
- package/scripts/core/run-board.mjs +155 -0
- package/scripts/core/run-guardian.mjs +130 -0
- package/scripts/core/runner.mjs +274 -0
- package/scripts/core/runtime-context.mjs +23 -0
- package/scripts/core/unit-carrier.mjs +55 -0
- package/scripts/core/unit-command.mjs +96 -0
- package/scripts/core/unit-runtime.mjs +107 -0
- package/scripts/gate.mjs +162 -0
- package/scripts/host-cli.mjs +2 -0
- package/scripts/install-deps.mjs +58 -0
- package/scripts/maintenance-lock.mjs +46 -0
- package/scripts/network-policy.mjs +2 -0
- package/scripts/open-tui-orchestrator-force.mjs +239 -0
- package/scripts/open-tui-orchestrator-preflight.mjs +85 -0
- package/scripts/orchestrate-sdk.mjs +59 -0
- package/scripts/package-lock.json +242 -0
- package/scripts/package.json +9 -0
- package/scripts/platform-guard.mjs +23 -0
- package/scripts/poll-windows.mjs +8 -0
- package/scripts/release-integrity.mjs +94 -0
- package/scripts/runtime-context.mjs +2 -0
- package/scripts/sdk-dependency-check.mjs +32 -0
- package/scripts/todo-list.mjs +89 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {spawn} from 'node:child_process';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {atomicJson,processSnapshot,sameProcess} from './leases.mjs';
|
|
6
|
+
import {runDirectory} from './runner.mjs';
|
|
7
|
+
import {resolveRuntimeContext} from './runtime-context.mjs';
|
|
8
|
+
|
|
9
|
+
// Run guardian: a tiny detached process that outlives the engine. It watches the
|
|
10
|
+
// engine process plus summary.json; when the engine dies abnormally (process gone
|
|
11
|
+
// but the run never finalized) it relaunches `--resume-run <runId>` so surviving
|
|
12
|
+
// windows are adopted and remaining tasks continue — no user re-invocation needed.
|
|
13
|
+
// Fail-closed: a bounded restart budget, a run deadline, and snapshot failures all
|
|
14
|
+
// stop respawning instead of risking a duplicate or endless controller loop.
|
|
15
|
+
|
|
16
|
+
const ENTRY=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..','orchestrate-sdk.mjs');
|
|
17
|
+
|
|
18
|
+
export function guardianFile(workspace,runId){return path.join(runDirectory(workspace,runId),'guardian.json');}
|
|
19
|
+
|
|
20
|
+
export function readGuardianState(file){try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch{return null;}}
|
|
21
|
+
|
|
22
|
+
function validState(state,runId){
|
|
23
|
+
return !!state&&state.runId===runId&&Number.isSafeInteger(state.restarts)&&state.restarts>=0
|
|
24
|
+
&&Number.isSafeInteger(state.maxRestarts)&&Number.isSafeInteger(state.timeoutMs)&&state.timeoutMs>=60000
|
|
25
|
+
&&Number.isSafeInteger(state.createdAt)&&Array.isArray(state.engines)&&state.engines.length
|
|
26
|
+
&&state.engines.every(e=>e?.owner&&Number.isSafeInteger(Number(e.owner.ProcessId)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Spawn (or adopt) the guardian for a fresh run. Called by the engine once the
|
|
30
|
+
// run ledger exists; guardian failure must never break the run itself.
|
|
31
|
+
export function ensureRunGuardian({runId,workspace,timeoutMs,owner,agent,env=process.env,spawnImpl=spawn,snapshotImpl=processSnapshot,log=console.log}={}) {
|
|
32
|
+
if(env.ORCH_GUARDIAN==='0'||env.NODE_TEST_CONTEXT)return {disabled:true};
|
|
33
|
+
const dir=runDirectory(workspace,runId),file=guardianFile(workspace,runId);
|
|
34
|
+
const prev=readGuardianState(file);
|
|
35
|
+
if(prev&&Number.isSafeInteger(Number(prev.guardianPid))&&Number(prev.guardianPid)>0) {
|
|
36
|
+
try {
|
|
37
|
+
const p=snapshotImpl().get(Number(prev.guardianPid));
|
|
38
|
+
if(p&&/run-guardian\.mjs/i.test(String(p.CommandLine||''))&&String(p.CommandLine).includes(runId))return {already:true,pid:Number(prev.guardianPid)};
|
|
39
|
+
}catch{ /* snapshot unavailable: fall through and replace the suspect guardian */ }
|
|
40
|
+
}
|
|
41
|
+
const state={
|
|
42
|
+
runId,guardianPid:0,restarts:prev?.restarts||0,
|
|
43
|
+
maxRestarts:Math.max(0,Math.min(10,Number(env.ORCH_GUARDIAN_MAX_RESTARTS)||3)),
|
|
44
|
+
intervalMs:Math.max(500,Math.min(10000,Number(env.ORCH_GUARDIAN_INTERVAL_MS)||2000)),
|
|
45
|
+
timeoutMs:Math.max(60000,Number(timeoutMs)||21600000),
|
|
46
|
+
createdAt:prev?.createdAt||Date.now(),
|
|
47
|
+
engines:[...(Array.isArray(prev?.engines)?prev.engines:[]),{owner,startedAt:Date.now(),by:'initiator'}],
|
|
48
|
+
status:'watching',updatedAt:Date.now(),
|
|
49
|
+
};
|
|
50
|
+
atomicJson(file,state);
|
|
51
|
+
const out=fs.openSync(path.join(dir,'guardian.log'),'a');
|
|
52
|
+
const child=spawnImpl(process.execPath,[path.join(path.dirname(fileURLToPath(import.meta.url)),'run-guardian.mjs'),'--run-id',runId],
|
|
53
|
+
{cwd:workspace,env:{...env,ORCH_AGENT:agent,ORCH_RUN_ID:runId,WORKSPACE_DIR:workspace},detached:true,windowsHide:true,stdio:['ignore',out,out]});
|
|
54
|
+
fs.closeSync(out);
|
|
55
|
+
child.on?.('error',()=>{});child.unref?.();
|
|
56
|
+
state.guardianPid=Number(child?.pid)||0;state.updatedAt=Date.now();atomicJson(file,state);
|
|
57
|
+
log('[guardian] watching runId='+runId+' pid='+state.guardianPid);
|
|
58
|
+
return {spawned:true,pid:state.guardianPid};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseGuardianOptions(argv) {
|
|
62
|
+
const result={};
|
|
63
|
+
for(let i=0;i<argv.length;i++) {
|
|
64
|
+
if(argv[i]==='--run-id') {
|
|
65
|
+
const value=argv[++i];
|
|
66
|
+
if(!value||value.startsWith('--'))throw new Error('GUARDIAN_ARGUMENT_INVALID: --run-id is required');
|
|
67
|
+
result.runId=value;
|
|
68
|
+
} else throw new Error('GUARDIAN_ARGUMENT_INVALID: '+argv[i]);
|
|
69
|
+
}
|
|
70
|
+
if(!result.runId)throw new Error('GUARDIAN_ARGUMENT_INVALID: --run-id is required');
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function main(argv=process.argv.slice(2),hooks={}) {
|
|
75
|
+
const {runId}=parseGuardianOptions(argv);
|
|
76
|
+
const env=hooks.env||process.env;
|
|
77
|
+
const {workspace}=resolveRuntimeContext(env);
|
|
78
|
+
const dir=runDirectory(workspace,runId),stateFile=guardianFile(workspace,runId);
|
|
79
|
+
const now=hooks.now||(()=>Date.now()),sleep=hooks.sleep||(ms=>new Promise(r=>setTimeout(r,ms)));
|
|
80
|
+
const snapshot=hooks.snapshot||processSnapshot,spawnImpl=hooks.spawn||spawn;
|
|
81
|
+
const log=hooks.log||(m=>{console.log(m);try{fs.appendFileSync(path.join(dir,'guardian.log'),m+'\n');}catch{}});
|
|
82
|
+
const load=hooks.load||(()=>{try{return JSON.parse(fs.readFileSync(path.join(dir,'summary.json'),'utf8'));}catch{return null;}});
|
|
83
|
+
const readState=hooks.readState||(()=>readGuardianState(stateFile));
|
|
84
|
+
const writeState=hooks.writeState||(s=>{s.updatedAt=Date.now();atomicJson(stateFile,s);});
|
|
85
|
+
// The engine writes the state file just before spawning us; tolerate a race.
|
|
86
|
+
let state=null;
|
|
87
|
+
for(let i=0;i<20&&!state;i++){state=readState();if(!state)await sleep(500);}
|
|
88
|
+
if(!validState(state,runId)){log('[guardian] missing or invalid state; exit');process.exitCode=2;return;}
|
|
89
|
+
const finish=(status,code)=>{state.status=status;writeState(state);log('[guardian] '+status+' runId='+runId+' restarts='+state.restarts);process.exitCode=code;};
|
|
90
|
+
let engine=state.engines.at(-1).owner;
|
|
91
|
+
const guardianDeadline=state.createdAt+state.timeoutMs+600000;
|
|
92
|
+
while(now()<guardianDeadline) {
|
|
93
|
+
const sum=load();
|
|
94
|
+
if(sum&&sum.finishedAt){finish('done',0);return;}
|
|
95
|
+
if(sum===null){finish('error',2);return;}
|
|
96
|
+
let snap=null;
|
|
97
|
+
try{snap=snapshot();}catch{/* identity unverifiable: never respawn blind */}
|
|
98
|
+
if(snap===null){await sleep(state.intervalMs);continue;}
|
|
99
|
+
if(sameProcess(engine,snap.get(Number(engine.ProcessId)))){await sleep(state.intervalMs);continue;}
|
|
100
|
+
// Engine is gone but the run is not finalized: abnormal interruption.
|
|
101
|
+
if(now()>state.createdAt+state.timeoutMs){finish('deadline',1);return;}
|
|
102
|
+
if(state.restarts>=state.maxRestarts){finish('exhausted',1);return;}
|
|
103
|
+
await sleep(Math.min(5000*2**state.restarts,30000));
|
|
104
|
+
const again=load();
|
|
105
|
+
if(again?.finishedAt){finish('done',0);return;}
|
|
106
|
+
if(again===null){finish('error',2);return;}
|
|
107
|
+
try{snap=snapshot();}catch{snap=null;}
|
|
108
|
+
if(snap&&sameProcess(engine,snap.get(Number(engine.ProcessId))))continue;
|
|
109
|
+
const number=state.restarts+1;
|
|
110
|
+
log('[guardian] engine lost (pid='+engine.ProcessId+'); resuming run '+runId+' (restart '+number+'/'+state.maxRestarts+')');
|
|
111
|
+
let outFd;
|
|
112
|
+
try{outFd=fs.openSync(path.join(dir,'resumed-engine-'+number+'.log'),'a');}catch{outFd='ignore';}
|
|
113
|
+
const child=spawnImpl(process.execPath,[ENTRY,'--resume-run',runId],{
|
|
114
|
+
cwd:workspace,
|
|
115
|
+
env:{...env,ORCH_AGENT:String(again.agent||'codex'),ORCH_RUN_ID:runId,WORKSPACE_DIR:workspace},
|
|
116
|
+
detached:true,windowsHide:true,stdio:['ignore',outFd,outFd],
|
|
117
|
+
});
|
|
118
|
+
if(outFd!=='ignore')try{fs.closeSync(outFd);}catch{}
|
|
119
|
+
child?.on?.('error',()=>{});child?.unref?.();
|
|
120
|
+
let identity=null;
|
|
121
|
+
if(child&&child.pid){try{identity=snapshot().get(Number(child.pid))||null;}catch{identity=null;}}
|
|
122
|
+
state.restarts++;
|
|
123
|
+
if(identity){state.engines.push({owner:identity,startedAt:Date.now(),by:'guardian'});engine=identity;}
|
|
124
|
+
writeState(state);
|
|
125
|
+
if(!identity){log('[guardian] resumed engine vanished immediately; will re-evaluate');await sleep(state.intervalMs);}
|
|
126
|
+
}
|
|
127
|
+
finish('deadline',1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if(process.argv[1]&&path.resolve(process.argv[1]).toLowerCase()===fileURLToPath(import.meta.url).toLowerCase())main().catch(e=>{console.error('[guardian] FATAL: '+e.message);process.exitCode=2;});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {CORE_VERSION,ledgerPlan,levelWaves,verifyChecks,summaryComplete,TERMINAL,canonicalKey,requestTasks,acceptanceKeyDiff} from './contracts.mjs';
|
|
6
|
+
import {coordinatorDir,reserve,bind,release,prepare,atomicJson,launcherIdentity,processSnapshot,sameProcess,claimRun,releaseRun,waitBackoffMs} from './leases.mjs';
|
|
7
|
+
import {scheduleReady,recoveryTasks,taskPolicy,validateRecoverySummary} from './recovery.mjs';
|
|
8
|
+
import {readCheckpoints} from './checkpoint.mjs';
|
|
9
|
+
import {boardSignature,boardJson,boardSummaryLine} from './run-board.mjs';
|
|
10
|
+
// 队列退避参数(env 可调,测试提速用):首等 base、指数 ×1.7、封顶 max(抖动上界 ×1.2)。
|
|
11
|
+
const WAIT_BACKOFF={base:Math.max(50,Number(process.env.ORCH_WAIT_BACKOFF_MS||600)||600),max:0};
|
|
12
|
+
WAIT_BACKOFF.max=Math.max(WAIT_BACKOFF.base,Number(process.env.ORCH_WAIT_BACKOFF_MAX_MS||5000)||5000);
|
|
13
|
+
export {scheduleReady,recoveryTasks};
|
|
14
|
+
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
|
15
|
+
// Identity acquisition under load: a full-process snapshot can be slow or miss a
|
|
16
|
+
// just-spawned launcher, and one unlucky read used to burn the whole attempt
|
|
17
|
+
// budget as popup-failed ('Launcher process identity mismatch'). Retry within a
|
|
18
|
+
// short grace window before giving up; transient snapshot failures are retried,
|
|
19
|
+
// a persistent one still fails fast.
|
|
20
|
+
export async function identityWithGrace(hooks,la,{graceMs=10000,intervalMs=800}={}) {
|
|
21
|
+
const end=Date.now()+graceMs;let identity=null,lastError=null;
|
|
22
|
+
while(true){
|
|
23
|
+
try{identity=launcherIdentity(hooks.readPid(la.pidf),la.pidf);}catch(error){lastError=error;}
|
|
24
|
+
if(identity)return identity;
|
|
25
|
+
if(Date.now()>=end)break;
|
|
26
|
+
await pause(intervalMs);
|
|
27
|
+
}
|
|
28
|
+
if(lastError)throw lastError;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
export async function waitForProcessExit(expected,{timeoutMs=5000,intervalMs=100,snapshot=processSnapshot}={}) {
|
|
32
|
+
if(!expected||!Number.isFinite(Number(expected.ProcessId)))return true;
|
|
33
|
+
const timeout=Math.max(0,Math.min(15000,Number(timeoutMs)||0)),interval=Math.max(10,Math.min(1000,Number(intervalMs)||100));
|
|
34
|
+
const deadline=Date.now()+timeout;
|
|
35
|
+
while(true){
|
|
36
|
+
let current;
|
|
37
|
+
try{current=snapshot(Math.min(3000,Math.max(1,deadline-Date.now())));}catch{return false;}
|
|
38
|
+
if(!sameProcess(expected,current?.get(Number(expected.ProcessId))))return true;
|
|
39
|
+
if(Date.now()>=deadline)return false;
|
|
40
|
+
await pause(Math.min(interval,Math.max(1,deadline-Date.now())));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function coordinate(fn,deadline) {
|
|
44
|
+
while(Date.now()<deadline){try{return fn();}catch(e){if(!/busy|initializing|abandoned/.test(e.message))throw e;await pause(200);}}
|
|
45
|
+
throw new Error('Coordinator transaction timeout');
|
|
46
|
+
}
|
|
47
|
+
export function runDirectory(workspace,runId) {
|
|
48
|
+
if(!/^[a-zA-Z0-9_-]{1,100}$/.test(runId||''))throw new Error('A valid --run-id is required');
|
|
49
|
+
return path.join(workspace,'temp','orchestrator','runs',runId);
|
|
50
|
+
}
|
|
51
|
+
function loadChecks(file){return file?JSON.parse(fs.readFileSync(file,'utf8')):{};}
|
|
52
|
+
// 验收契约(含 command 类检查,会以控制器权限执行)在创建时锚定哈希:run 目录可被窗内 agent 写入,
|
|
53
|
+
// resume/verify 回读前比对,篡改即拒绝——不让被改写的 acceptance.json 借恢复流程获得执行权。
|
|
54
|
+
function checksHash(checks){return crypto.createHash('sha256').update(JSON.stringify(checks||{})).digest('hex');}
|
|
55
|
+
function blockCheckpoints(entry,block,sum) {
|
|
56
|
+
const out={};
|
|
57
|
+
for(const a of entry?.attempts||[]) {
|
|
58
|
+
try{Object.assign(out,readCheckpoints(a.file,{...a,runId:sum.runId}));}catch{for(const id of a.taskIds)out[id]={state:'invalid'};}
|
|
59
|
+
}
|
|
60
|
+
// Preserve read-only verification of historical protocol-1 runs.
|
|
61
|
+
if(!entry?.attempts&&entry?.receiptFile) {
|
|
62
|
+
try{for(const r of JSON.parse(fs.readFileSync(entry.receiptFile,'utf8')).tasks||[]){if(block.taskIds.includes(r.id))out[r.id]=r.runId===sum.runId&&r.token===entry.token&&r.status==='completed'?{state:'completed'}:{state:'invalid'};}}catch{}
|
|
63
|
+
}
|
|
64
|
+
for(const id of block.taskIds) {
|
|
65
|
+
if(!out[id]&&sum.taskResults[id]?.checkpoint)out[id]=sum.taskResults[id].checkpoint;
|
|
66
|
+
if(!out[id]&&sum.taskResults[id]?.status==='verified')out[id]={state:'invalid'};
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function blockStatus(ids,results) {
|
|
71
|
+
const statuses=ids.map(id=>results[id]?.status);
|
|
72
|
+
if(statuses.every(s=>s==='verified'))return 'verified';
|
|
73
|
+
if(statuses.includes('needs-reconciliation'))return 'needs-reconciliation';
|
|
74
|
+
if(statuses.includes('awaiting-verification'))return 'awaiting-verification';
|
|
75
|
+
return 'failed';
|
|
76
|
+
}
|
|
77
|
+
export function collectVerification(sum,checks,workspace) {
|
|
78
|
+
for(const b of sum.blocks) {
|
|
79
|
+
const e=sum.results[b.key];if(!e?.endedAt)continue;
|
|
80
|
+
if(['blocked','popup-failed','preflight-failed','cancelled'].includes(e.status)&&!e.attempts?.length)continue;
|
|
81
|
+
const cps=blockCheckpoints(e,b,sum);
|
|
82
|
+
for(const id of b.taskIds) {
|
|
83
|
+
const cp=cps[id];if(!cp){sum.taskResults[id]={status:'failed',reason:'No durable task checkpoint'};continue;}
|
|
84
|
+
if(cp.state==='invalid'){sum.taskResults[id]={status:'needs-reconciliation',reason:'Invalid checkpoint',checkpoint:cp};continue;}
|
|
85
|
+
const check=verifyChecks(taskPolicy(checks[id]).checks,workspace);
|
|
86
|
+
sum.taskResults[id]=check.status==='verified'?{...check,checkpoint:cp}:
|
|
87
|
+
cp.state==='started'?{status:'needs-reconciliation',reason:'Task started but result is unproven',verification:check,checkpoint:cp}:{...check,checkpoint:cp};
|
|
88
|
+
}
|
|
89
|
+
e.status=blockStatus(b.taskIds,sum.taskResults);
|
|
90
|
+
}
|
|
91
|
+
return sum;
|
|
92
|
+
}
|
|
93
|
+
export function verifyRun(workspace,runId,acceptanceFile) {
|
|
94
|
+
const dir=runDirectory(workspace,runId),file=path.join(dir,'summary.json');
|
|
95
|
+
// 机器可调用契约:未知 run 必须 fail-closed 且只回一行可读诊断(不许裸栈帧/ENOENT 甩路径)。
|
|
96
|
+
if(!fs.existsSync(file))throw new Error('Run not found: '+runId+' (no summary.json in '+dir+')');
|
|
97
|
+
const sum=JSON.parse(fs.readFileSync(file,'utf8'));
|
|
98
|
+
if(sum.runId!==runId||!sum.finishedAt)throw new Error('Run is not finalized');
|
|
99
|
+
if(sum.protocol===2)validateRecoverySummary(sum);
|
|
100
|
+
const checks=loadChecks(acceptanceFile||path.join(dir,'acceptance.json'));
|
|
101
|
+
if(sum.acceptanceSha256&&checksHash(checks)!==sum.acceptanceSha256)throw new Error('Acceptance contract changed since run creation; refusing to verify');
|
|
102
|
+
const claim=claimRun(sum.coordinator||coordinatorDir(workspace),'run:'+path.resolve(dir).toLowerCase());
|
|
103
|
+
try{
|
|
104
|
+
if(sum.tasks?.length){
|
|
105
|
+
const keyDiff=acceptanceKeyDiff(checks,sum.tasks);
|
|
106
|
+
if(keyDiff?.unknown.length)throw new Error('Acceptance keys do not match any task id: '+keyDiff.unknown.join(', ')+'(task ids: '+sum.tasks.map(t=>t.id).join(', ')+')');
|
|
107
|
+
if(keyDiff?.missing.length)console.warn('[orchestrator] acceptance has no checks for task(s): '+keyDiff.missing.join(', ')+';这些任务将停在 awaiting-verification');
|
|
108
|
+
}
|
|
109
|
+
collectVerification(sum,checks,workspace);sum.success=summaryComplete(sum,true);atomicJson(file,sum);return sum.success;
|
|
110
|
+
} finally{releaseRun(sum.coordinator||coordinatorDir(workspace),'run:'+path.resolve(dir).toLowerCase(),claim);}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function runWindows(request,hooks,options={}) {
|
|
114
|
+
const workspace=path.resolve(options.workspace),capacity=options.capacity;
|
|
115
|
+
// options.windowCap (ORCH_MAX_WINDOWS) throttles live-window admission only;
|
|
116
|
+
// planning keeps the full memory-derived capacity so block shape is unchanged.
|
|
117
|
+
const windowCap=Math.max(1,Math.min(capacity,Number(options.windowCap)||capacity));
|
|
118
|
+
const runId=options.runId||crypto.randomUUID(),dir=runDirectory(workspace,runId),summaryFile=path.join(dir,'summary.json');
|
|
119
|
+
const deadline=Date.now()+(options.timeoutMs||21600000),coordinator=options.coordinator||coordinatorDir(workspace);
|
|
120
|
+
const runKey='run:'+dir.toLowerCase(),claim=await coordinate(()=>claimRun(coordinator,runKey),deadline);
|
|
121
|
+
let sum;
|
|
122
|
+
try {
|
|
123
|
+
let checks;
|
|
124
|
+
if(options.resume) {
|
|
125
|
+
if(!fs.existsSync(summaryFile))throw new Error('Run not found: '+runId+' (no summary.json in '+dir+')');
|
|
126
|
+
sum=JSON.parse(fs.readFileSync(summaryFile,'utf8'));
|
|
127
|
+
if(sum.protocol!==2||!Array.isArray(sum.plan))throw new Error('This run has no recoverable task checkpoints');
|
|
128
|
+
if((sum.agent||'codex')!==(options.agent||'codex'))throw new Error('Resume agent must match the saved run');
|
|
129
|
+
if(path.resolve(sum.workspace)!==workspace||sum.runId!==runId)throw new Error('Run workspace/identity mismatch');
|
|
130
|
+
validateRecoverySummary(sum);
|
|
131
|
+
// 调用方自助通道:保存的运行用了调用方单元命令时,resume 必须重供同一条命令(防换命令重放副作用)。
|
|
132
|
+
{const savedUnit=sum.unitProfile||null,givenUnit=options.unitProfile||null;
|
|
133
|
+
if(savedUnit&&!givenUnit)throw new Error('This run used a caller-supplied unit command; re-supply the identical --unit-cmd/--unit-cmd-file to resume');
|
|
134
|
+
if(savedUnit&&givenUnit&&JSON.stringify(savedUnit)!==JSON.stringify(givenUnit))throw new Error('Resume unit command differs from the saved run; refusing to resume');}
|
|
135
|
+
if(sum.blocks.length>capacity)throw new Error('Current memory capacity is below the saved block count');
|
|
136
|
+
checks=loadChecks(options.acceptanceFile||path.join(dir,'acceptance.json'));
|
|
137
|
+
if(sum.acceptanceSha256&&checksHash(checks)!==sum.acceptanceSha256)throw new Error('Acceptance contract changed since run creation; refusing to resume');
|
|
138
|
+
sum.history.push({generation:sum.generation,finishedAt:sum.finishedAt,success:sum.success});
|
|
139
|
+
sum.generation++;delete sum.finishedAt;sum.success=false;
|
|
140
|
+
}else {
|
|
141
|
+
if(fs.existsSync(dir))throw new Error('Run ID already exists; use --resume-run or --verify-run');
|
|
142
|
+
fs.mkdirSync(dir,{recursive:true});checks=loadChecks(options.acceptanceFile);
|
|
143
|
+
atomicJson(path.join(dir,'manifest.json'),{runId,workspace,request,tasks:requestTasks(request),startedAt:Date.now()});
|
|
144
|
+
// The CLI may have already performed the deterministic preflight needed to
|
|
145
|
+
// decide whether a visible window is warranted. Reuse that exact plan so
|
|
146
|
+
// the policy decision and the launched plan cannot diverge after a second
|
|
147
|
+
// model classification or cache lookup.
|
|
148
|
+
const candidate=options.plan||await hooks.plan(request,capacity);
|
|
149
|
+
const {plan,tasks}=ledgerPlan(request,candidate,capacity,hooks.compact);
|
|
150
|
+
const recs=plan.map((b,i)=>({key:`B${i+1}`,title:b.title,b,dependsOn:b.dependsOn||[],keys:[...new Set([...(b.conflictKeys||[]),...b.tasks.flatMap(t=>hooks.infer(t.prompt||''))].map(k=>canonicalKey(k,workspace)))]}));
|
|
151
|
+
levelWaves(recs);
|
|
152
|
+
sum={protocol:2,version:CORE_VERSION,agent:options.agent||'codex',runId,workspace,request,capacity,coordinator,generation:1,history:[],startedAt:Date.now(),tasks,plan:recs,spawnMode:options.spawnMode||'window',
|
|
153
|
+
model:options.modelPlan?.model??null,effort:options.modelPlan?.effort??null,modelSource:options.modelPlan?.modelSource??null,effortSource:options.modelPlan?.effortSource??null,
|
|
154
|
+
blocks:recs.map(r=>({key:r.key,title:r.title,taskIds:r.b.tasks.flatMap(t=>t.sourceIds)})),results:{},taskResults:{}};
|
|
155
|
+
for(const r of recs){sum.results[r.key]={status:'pending',attempts:[]};for(const id of sum.blocks.find(b=>b.key===r.key).taskIds)sum.taskResults[id]={status:'pending'};}
|
|
156
|
+
sum.acceptanceSha256=checksHash(checks);
|
|
157
|
+
if(options.unitProfile)sum.unitProfile=options.unitProfile;
|
|
158
|
+
}
|
|
159
|
+
sum.owner=claim.owner;sum.coordinator=coordinator;
|
|
160
|
+
{
|
|
161
|
+
const keyDiff=acceptanceKeyDiff(checks,sum.tasks);
|
|
162
|
+
if(keyDiff?.unknown.length)throw new Error('Acceptance keys do not match any task id: '+keyDiff.unknown.join(', ')+'(task ids: '+sum.tasks.map(t=>t.id).join(', ')+')');
|
|
163
|
+
if(keyDiff?.missing.length)console.warn('[orchestrator] acceptance has no checks for task(s): '+keyDiff.missing.join(', ')+';这些任务将停在 awaiting-verification');
|
|
164
|
+
}
|
|
165
|
+
atomicJson(path.join(dir,'acceptance.json'),checks);
|
|
166
|
+
const eventsFile=path.join(dir,'events.jsonl');
|
|
167
|
+
let lastBoardSig='';
|
|
168
|
+
const emitBoard=()=>{try{const sig=boardSignature(sum);if(sig===lastBoardSig)return;lastBoardSig=sig;fs.appendFileSync(eventsFile,JSON.stringify(boardJson(sum))+'\n');console.log(boardSummaryLine(sum));}catch{/* board is best-effort; never fail the run */}};
|
|
169
|
+
const persist=()=>{atomicJson(summaryFile,sum);emitBoard();};
|
|
170
|
+
const mark=(r,status,reason)=>{sum.results[r.key]={...sum.results[r.key],status,reason};for(const id of sum.blocks.find(b=>b.key===r.key).taskIds)if(sum.taskResults[id]?.status!=='verified')sum.taskResults[id]={...sum.taskResults[id],status,reason};persist();};
|
|
171
|
+
persist();console.log(`[run] runId=${runId} blocks=${sum.blocks.length} capacity=${capacity} generation=${sum.generation} summary=${summaryFile}`);
|
|
172
|
+
// Auto-recovery: a detached guardian watches this controller and relaunches
|
|
173
|
+
// --resume-run if it dies before finalizing. Fresh runs only — a resumed run
|
|
174
|
+
// is already watched by the original guardian. Never let the guard fail a run.
|
|
175
|
+
if(!options.resume&&hooks.guardian)try{hooks.guardian({runId,workspace,coordinator,timeoutMs:options.timeoutMs||21600000,owner:claim.owner,dir});}catch(e){console.warn('[guardian] unavailable: '+e.message);}
|
|
176
|
+
// Optional spawn pacing (options.spawnStaggerMs, from ORCH_WINDOW_STAGGER_MS):
|
|
177
|
+
// serializes window births within this run so a burst of first turns cannot
|
|
178
|
+
// trip the agent account's per-minute rate limiter. 0 keeps the burst as-is.
|
|
179
|
+
const staggerMs=Math.max(0,Number(options.spawnStaggerMs||0));
|
|
180
|
+
let staggerChain=Promise.resolve();
|
|
181
|
+
const stagger=()=>{const turn=staggerChain;staggerChain=staggerChain.then(()=>pause(staggerMs));return turn;};
|
|
182
|
+
async function execute(r) {
|
|
183
|
+
const b=sum.blocks.find(b=>b.key===r.key),tasks=b.taskIds.map(id=>sum.tasks.find(t=>t.id===id));
|
|
184
|
+
const leaseId=crypto.createHash('sha256').update(runKey).digest('hex').slice(0,16)+'-'+r.key;
|
|
185
|
+
let attemptsThisController=0,spawnConfirmed=false;
|
|
186
|
+
const limit=Math.max(1,Math.min(3,Number(options.maxAttempts||2)));
|
|
187
|
+
while(Date.now()<deadline) {
|
|
188
|
+
let e=sum.results[r.key],la=e.launcherFiles,identity;spawnConfirmed=false;
|
|
189
|
+
if(la)try{identity=launcherIdentity(hooks.readPid(la.pidf),la.pidf);}catch{identity=undefined;}
|
|
190
|
+
const adopted=identity&&(!e.launcher||sameProcess(e.launcher,identity));
|
|
191
|
+
let active;
|
|
192
|
+
if(adopted) {
|
|
193
|
+
active=e.attempts.at(-1);console.log(`[resume] adopting live ${r.key}; no duplicate window`);
|
|
194
|
+
}else {
|
|
195
|
+
const cps=blockCheckpoints(e,b,sum);
|
|
196
|
+
const recovery=recoveryTasks(tasks,cps,checks,workspace);
|
|
197
|
+
Object.assign(sum.taskResults,recovery.results);
|
|
198
|
+
if(recovery.verified.length===tasks.length){e.status='verified';e.endedAt=Date.now();persist();return;}
|
|
199
|
+
if(recovery.uncertain.length){e.status='needs-reconciliation';e.reason='Uncertain tasks: '+recovery.uncertain.join(', ');e.endedAt=Date.now();persist();return;}
|
|
200
|
+
if(attemptsThisController>=limit){mark(r,'failed','Safe recovery attempt budget exhausted');return;}
|
|
201
|
+
if(attemptsThisController)await pause(Math.min(2000*attemptsThisController,Math.max(0,deadline-Date.now())));
|
|
202
|
+
let reserved=false,waitAttempt=0;
|
|
203
|
+
while(Date.now()<deadline) {
|
|
204
|
+
const result=await coordinate(()=>reserve(coordinator,{id:leaseId,runId,keys:r.keys},windowCap),deadline);
|
|
205
|
+
if(result.ok){reserved=true;break;}
|
|
206
|
+
// 排队退避(指数+抖动):不再每秒重试;等待状态仅在理由变化时重写,少刷账本。
|
|
207
|
+
const waitMsg='Waiting for '+result.reason,cur=sum.results[r.key];
|
|
208
|
+
if(!cur||cur.status!=='waiting'||cur.reason!==waitMsg)mark(r,'waiting',waitMsg);
|
|
209
|
+
await pause(Math.min(waitBackoffMs(waitAttempt++,WAIT_BACKOFF),Math.max(0,deadline-Date.now())));
|
|
210
|
+
}
|
|
211
|
+
if(!reserved){mark(r,'timeout','Window capacity/resource wait timed out');return;}
|
|
212
|
+
attemptsThisController++;
|
|
213
|
+
const number=e.attempts.length+1,token=runId+'-'+r.key+'-a'+number+'-'+crypto.randomBytes(6).toString('hex');
|
|
214
|
+
const attemptFile=path.join(dir,'attempts',r.key,String(number),'attempt.json');
|
|
215
|
+
active={file:attemptFile,runId,token,number,taskIds:recovery.pending,createdAt:Date.now()};
|
|
216
|
+
atomicJson(attemptFile,active);e.attempts.push(active);
|
|
217
|
+
const helper=fileURLToPath(new URL('./checkpoint.mjs',import.meta.url));
|
|
218
|
+
const command=(id,state)=>`node "${helper}" "${attemptFile}" ${id} ${state}`;
|
|
219
|
+
const body=options.auth+'\n本窗目录为 '+workspace+';所有工具 workdir 保持此目录,除非原始任务明确另指定。\n'+
|
|
220
|
+
'仅按顺序执行下列待办任务。每项开始前必须成功执行 started 命令,失败则停止该项,禁止重复执行。完成并自验后立即执行 completed 命令。每项都立即写检查点,不能等整块完成。任何项失败则停止后续任务,说明情况并退出本轮,不自行重跑整块。\n'+
|
|
221
|
+
'【硬规则】原始任务若包含要在 Windows 上执行的命令行(尤其含引号或 \\" 转义的 cmd 风格命令行),必须把整行原样交给 cmd /c 执行,禁止用 PowerShell 直接执行、也禁止按 PowerShell 语法改写参数——PowerShell 会吞掉或重排引号,导致目标程序收到的参数失真。\n'+
|
|
222
|
+
'已独立验收的任务禁止重做:'+recovery.verified.join(', ')+'\n'+
|
|
223
|
+
recovery.pending.map(id=>`任务 ${id}\n开始前:${command(id,'started')}\n原始任务:${tasks.find(t=>t.id===id).prompt}\n完成自验后:${command(id,'completed')}`).join('\n\n')+
|
|
224
|
+
'\n全部待办完成后,最后仅输出 __ORCH_DONE__ '+token;
|
|
225
|
+
try{la=hooks.launcher(r.key,hooks.prompt(body,token),runId+'-a'+number);}
|
|
226
|
+
catch(error){await coordinate(()=>release(coordinator,leaseId),deadline);throw error;}
|
|
227
|
+
e={...e,status:'launching',leaseId,token,launcherFiles:la,launcher:null,endedAt:null,startedAt:Date.now()};sum.results[r.key]=e;persist();
|
|
228
|
+
try {
|
|
229
|
+
hooks.register({id:leaseId,runId,title:r.title,keys:r.keys,pidFile:la.pidf,pid:0,pending:true,reservedAt:Date.now()});
|
|
230
|
+
await coordinate(()=>prepare(coordinator,leaseId,la.pidf),deadline);
|
|
231
|
+
await stagger();
|
|
232
|
+
if(!await hooks.spawn(la.lp,r.title,la.pidf,1,token))throw new Error('Visible window did not start');
|
|
233
|
+
spawnConfirmed=true;
|
|
234
|
+
// 秒退单元:结果文件已落盘时无需身份绑定(launcher 已退出,身份无法取得),直接进入结果读取路径。
|
|
235
|
+
if(fs.existsSync(la.rf)){
|
|
236
|
+
console.log(`[run] ${r.key} launcher already finished; reading its result without identity binding`);
|
|
237
|
+
identity=null;
|
|
238
|
+
}else{
|
|
239
|
+
identity=await identityWithGrace(hooks,la);
|
|
240
|
+
if(!identity)throw new Error('Launcher process identity mismatch');
|
|
241
|
+
await coordinate(()=>bind(coordinator,leaseId,identity),deadline);hooks.commit(leaseId,identity.ProcessId);
|
|
242
|
+
}
|
|
243
|
+
}catch(error){e.status='popup-failed';e.reason=error.message;}
|
|
244
|
+
}
|
|
245
|
+
e=sum.results[r.key];e.launcher=identity||e.launcher;
|
|
246
|
+
try {
|
|
247
|
+
if(identity||fs.existsSync(la.rf)) {
|
|
248
|
+
e.status='running';persist();
|
|
249
|
+
const session=await hooks.session('【本块唯一标识】'+active.token,Math.min(90000,Math.max(0,deadline-Date.now())),la.rf);
|
|
250
|
+
const result=session?await hooks.wait(session,Math.max(0,deadline-Date.now()),la.rf,'【本块唯一标识】'+active.token,la.pidf):{done:fs.existsSync(la.rf),exitCode:hooks.exitCode(la.rf)};
|
|
251
|
+
e.exitCode=result.exitCode??hooks.exitCode(la.rf);e.status=result.done?'awaiting-verification':'timeout';
|
|
252
|
+
}
|
|
253
|
+
}catch(error){e.status='failed';e.reason=error.message;}
|
|
254
|
+
finally {
|
|
255
|
+
if(!identity&&la&&spawnConfirmed&&!fs.existsSync(la.rf))try{identity=await identityWithGrace(hooks,la,{graceMs:4000,intervalMs:300});}catch{identity=undefined;}
|
|
256
|
+
if(identity)await hooks.close(identity.ProcessId,identity);
|
|
257
|
+
if(identity&&!(await waitForProcessExit(identity))){e.status='failed';e.reason='Launcher still alive; lease retained';persist();return;}
|
|
258
|
+
await coordinate(()=>release(coordinator,e.leaseId||leaseId),Date.now()+30000);
|
|
259
|
+
hooks.unregister(e.leaseId||leaseId);if(la)hooks.cleanup(la);
|
|
260
|
+
e.endedAt=Date.now();active.endedAt=e.endedAt;active.exitCode=e.exitCode;
|
|
261
|
+
collectVerification(sum,checks,workspace);persist();
|
|
262
|
+
}
|
|
263
|
+
if(e.status==='verified')return;
|
|
264
|
+
if(!identity&&e.attempts.length&&e.reason==='Visible window did not start'){mark(r,'popup-failed',e.reason);return;}
|
|
265
|
+
}
|
|
266
|
+
mark(r,'timeout','Run deadline exhausted');
|
|
267
|
+
}
|
|
268
|
+
await scheduleReady(sum.plan,async r=>{try{await execute(r);}catch(e){mark(r,'failed',e.message);}},key=>sum.results[key]?.status,
|
|
269
|
+
(r,failed)=>mark(r,'blocked','Predecessors not verified: '+failed.join(', ')));
|
|
270
|
+
for(const r of sum.plan)if(!TERMINAL.has(sum.results[r.key]?.status))mark(r,'failed','No terminal result');
|
|
271
|
+
sum.finishedAt=Date.now();sum.success=summaryComplete(sum,true);persist();
|
|
272
|
+
console.log(`[run] finalized runId=${runId} success=${sum.success} summary=${summaryFile}`);return sum;
|
|
273
|
+
}finally{await coordinate(()=>releaseRun(coordinator,runKey,claim),Date.now()+30000);}
|
|
274
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Resolve once at the caller's entry point. Never substitute the install directory
|
|
4
|
+
// or CODEX_HOME for the directory that initiated this run.
|
|
5
|
+
export function resolveRuntimeContext(env = process.env, cwd = process.cwd()) {
|
|
6
|
+
const workspace = path.resolve(cwd, String(env.WORKSPACE_DIR || '').trim() || '.');
|
|
7
|
+
const temp = path.join(workspace, 'temp');
|
|
8
|
+
const override = String(env.ORCH_STATE_DIR || '').trim();
|
|
9
|
+
const state = override ? resolveStateOverride(temp, override) : path.join(temp, 'orchestrator');
|
|
10
|
+
return { workspace, temp, state };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveStateOverride(temp, override) {
|
|
14
|
+
// Runtime state is part of the initiating workspace. Relative overrides are
|
|
15
|
+
// rooted at workspace/temp, while absolute overrides are accepted only when
|
|
16
|
+
// they already point below that directory.
|
|
17
|
+
const candidate = path.isAbsolute(override) ? path.resolve(override) : path.resolve(temp, override);
|
|
18
|
+
const relative = path.relative(temp, candidate);
|
|
19
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
20
|
+
throw new Error(`ORCH_STATE_DIR must stay under the initiating workspace temp directory: ${candidate}`);
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
// 单元载体(carrier)解析——工具适配 agent 的载体分级层。
|
|
7
|
+
//
|
|
8
|
+
// stdio = 无控制台的隐藏进程(print 类 CLI:自带一次性输出模式,不需要终端)
|
|
9
|
+
// pty = ConPTY 伪终端宿主(TUI 类 CLI:需要真实终端,工具给它一个真实但完全不可见的控制台)
|
|
10
|
+
//
|
|
11
|
+
// 两载体共享同一条契约:同样的启动器 ps1、pidf、进程身份、租约、恢复/收编。
|
|
12
|
+
// PTY 引擎解析顺序:工具自带安装(scripts/node_modules,dev 仓库与发行副本同构)
|
|
13
|
+
// → 常规包解析。绝不静默借用其它 agent 安装目录里的副本(各副本独立升级、独立封印)。
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const CORE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function normalizeUnitCarrier(value) {
|
|
18
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
19
|
+
if (!v) return null;
|
|
20
|
+
if (v === 'pty' || v === 'stdio') return v;
|
|
21
|
+
throw new Error('UNSUPPORTED_UNIT_CARRIER: ' + value + ' (use pty|stdio)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 优先级:显式 ORCH_UNIT_CARRIER > agent 画像声明(registry/unit profile)> stdio 兜底。
|
|
25
|
+
export function effectiveUnitCarrier(env = process.env, agentConfig = {}) {
|
|
26
|
+
return normalizeUnitCarrier(env.ORCH_UNIT_CARRIER)
|
|
27
|
+
|| normalizeUnitCarrier(agentConfig.carrier)
|
|
28
|
+
|| 'stdio';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ptyCandidates() {
|
|
32
|
+
return [
|
|
33
|
+
path.join(CORE_DIR, '..', 'node_modules', '@lydell', 'node-pty', 'index.js'),
|
|
34
|
+
'@lydell/node-pty',
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ptyAvailability() {
|
|
39
|
+
for (const candidate of ptyCandidates()) {
|
|
40
|
+
try {
|
|
41
|
+
const resolved = path.isAbsolute(candidate) ? candidate : require.resolve(candidate);
|
|
42
|
+
if (fs.existsSync(resolved)) return { ok: true, resolved };
|
|
43
|
+
} catch { /* try the next candidate */ }
|
|
44
|
+
}
|
|
45
|
+
return { ok: false, tried: ptyCandidates() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function loadPtyEngine() {
|
|
49
|
+
const availability = ptyAvailability();
|
|
50
|
+
if (!availability.ok) {
|
|
51
|
+
throw new Error('PTY_CARRIER_UNAVAILABLE: @lydell/node-pty not found (tried: ' + availability.tried.join(' | ')
|
|
52
|
+
+ '). Run `npm run setup` (or npm install --prefix scripts) to materialize the PTY engine, or fall back with ORCH_UNIT_CARRIER=stdio / --mode window.');
|
|
53
|
+
}
|
|
54
|
+
return require(availability.resolved);
|
|
55
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeUnitCarrier } from './unit-carrier.mjs';
|
|
4
|
+
|
|
5
|
+
// 调用方自助通道(caller-driven / delegate 模式)第一半:单元命令画像的解析与展开。
|
|
6
|
+
//
|
|
7
|
+
// 调用方(任意 agent)给出"一个执行单元怎么跑"的命令模板,编排器只负责确定性机制
|
|
8
|
+
// (载具/身份/租约/看板/守护/恢复/验收)。与 print-class 画像(探测 + 解析自检 + 活验的
|
|
9
|
+
// 富路径)不同,这里是"自证"的瘦路径:不 probe、不解析 --help/--version、无名单限制。
|
|
10
|
+
//
|
|
11
|
+
// 画像 schema(v1):
|
|
12
|
+
// { schema:1, id, label, bin,
|
|
13
|
+
// args:[...], // 模板;占位符 {promptFile} / {prompt} / {workspace}
|
|
14
|
+
// promptDelivery:"file"|"arg"|"stdin",
|
|
15
|
+
// modelArgs:["--model"]|null, // 可选:按 ORCH_MODEL 注入
|
|
16
|
+
// effortArgs:["--effort"]|null, // 可选:按 ORCH_EFFORT 注入
|
|
17
|
+
// carrier:"pty"|"stdio"|null, // 可选:单元载体覆盖(默认按 agent 画像;TUI 类 CLI 用 pty)
|
|
18
|
+
// timeoutMs:number|null } // 可选:单单元超时(当前由轮询/调度统一控制)
|
|
19
|
+
//
|
|
20
|
+
// 规则:file=提示词落文件并把路径代入 {promptFile};arg=提示词作为最后一个 argv 元素;
|
|
21
|
+
// stdin=提示词写进程 stdin。三者互斥,缺占位符时以 promptDelivery 为准。
|
|
22
|
+
|
|
23
|
+
export const UNIT_PROMPT_FILE_TOKEN = '{promptFile}';
|
|
24
|
+
export const UNIT_PROMPT_TOKEN = '{prompt}';
|
|
25
|
+
export const UNIT_WORKSPACE_TOKEN = '{workspace}';
|
|
26
|
+
|
|
27
|
+
export function normalizeUnitProfile(input) {
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
29
|
+
throw new Error('UNIT_CMD_INVALID: profile must be a JSON object');
|
|
30
|
+
}
|
|
31
|
+
const bin = String(input.bin || '').trim();
|
|
32
|
+
if (!bin) throw new Error('UNIT_CMD_INVALID: bin (executable path or name) is required');
|
|
33
|
+
if (/\.(cmd|bat)$/i.test(bin)) {
|
|
34
|
+
throw new Error('UNIT_CMD_INVALID: bin must be a real executable (Windows cannot spawn .cmd/.bat directly without a shell); point to the underlying .exe');
|
|
35
|
+
}
|
|
36
|
+
const id = String(input.id || 'custom').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '') || 'custom';
|
|
37
|
+
const label = String(input.label || id);
|
|
38
|
+
const args = (Array.isArray(input.args) ? input.args : []).map((a) => String(a));
|
|
39
|
+
const modelArgs = Array.isArray(input.modelArgs) && input.modelArgs.length ? input.modelArgs.map(String) : null;
|
|
40
|
+
const effortArgs = Array.isArray(input.effortArgs) && input.effortArgs.length ? input.effortArgs.map(String) : null;
|
|
41
|
+
const rawTimeout = Number(input.timeoutMs);
|
|
42
|
+
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : null;
|
|
43
|
+
|
|
44
|
+
const hasFile = args.some((a) => a.includes(UNIT_PROMPT_FILE_TOKEN));
|
|
45
|
+
const hasPrompt = args.some((a) => a.includes(UNIT_PROMPT_TOKEN));
|
|
46
|
+
if (hasFile && hasPrompt) throw new Error('UNIT_CMD_INVALID: args must not use both {promptFile} and {prompt}');
|
|
47
|
+
const requested = String(input.promptDelivery || '').trim().toLowerCase();
|
|
48
|
+
const promptDelivery = requested || (hasFile ? 'file' : hasPrompt ? 'arg' : '');
|
|
49
|
+
if (!['file', 'arg', 'stdin'].includes(promptDelivery)) {
|
|
50
|
+
throw new Error('UNIT_CMD_INVALID: promptDelivery must be file|arg|stdin, or thread the prompt through {promptFile}/{prompt} in args');
|
|
51
|
+
}
|
|
52
|
+
if (promptDelivery === 'file' && !hasFile) throw new Error('UNIT_CMD_INVALID: promptDelivery=file requires {promptFile} in args');
|
|
53
|
+
if (promptDelivery === 'arg' && hasFile) throw new Error('UNIT_CMD_INVALID: promptDelivery=arg must not use {promptFile}');
|
|
54
|
+
if (promptDelivery === 'stdin' && (hasFile || hasPrompt)) throw new Error('UNIT_CMD_INVALID: promptDelivery=stdin must not use prompt placeholders');
|
|
55
|
+
|
|
56
|
+
return { schema: 1, id, label, bin, args, promptDelivery, modelArgs, effortArgs, timeoutMs, source: 'caller' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadUnitProfile(env = process.env) {
|
|
60
|
+
const file = String(env.ORCH_UNIT_CMD_FILE || '').trim();
|
|
61
|
+
const inline = String(env.ORCH_UNIT_CMD || '').trim();
|
|
62
|
+
if (!file && !inline) return null;
|
|
63
|
+
if (file && inline) throw new Error('UNIT_CMD_INVALID: provide --unit-cmd or --unit-cmd-file, not both');
|
|
64
|
+
let raw = inline;
|
|
65
|
+
if (file) {
|
|
66
|
+
const resolved = path.resolve(file);
|
|
67
|
+
try { raw = fs.readFileSync(resolved, 'utf8'); }
|
|
68
|
+
catch (error) { throw new Error('UNIT_CMD_UNREADABLE: ' + resolved + ' (' + (error?.message || String(error)) + ')'); }
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try { parsed = JSON.parse(String(raw).replace(/^\uFEFF/, '')); }
|
|
72
|
+
catch (error) { throw new Error('UNIT_CMD_INVALID: not valid JSON (' + (error?.message || String(error)) + ')'); }
|
|
73
|
+
return normalizeUnitProfile(parsed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 展开单元 argv:占位符代入 + 可选的 model/effort 注入;arg 投递时提示词固定为最后一个 argv 元素。
|
|
77
|
+
export function expandUnitArgs(config, { prompt = '', promptFile = '', workspace = '' } = {}) {
|
|
78
|
+
const flagValue = (flag, value) => (Array.isArray(flag) && flag.length && value && String(value).trim() && !/\s/.test(String(value).trim()) ? [...flag, String(value).trim()] : []);
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const arg of config.args || []) {
|
|
81
|
+
if (config.promptDelivery === 'arg' && String(arg).includes(UNIT_PROMPT_TOKEN)) continue;
|
|
82
|
+
out.push(String(arg)
|
|
83
|
+
.replaceAll(UNIT_PROMPT_FILE_TOKEN, promptFile)
|
|
84
|
+
.replaceAll(UNIT_PROMPT_TOKEN, prompt)
|
|
85
|
+
.replaceAll(UNIT_WORKSPACE_TOKEN, workspace));
|
|
86
|
+
}
|
|
87
|
+
out.push(...flagValue(config.modelArgs, process.env.ORCH_MODEL), ...flagValue(config.effortArgs, process.env.ORCH_EFFORT));
|
|
88
|
+
if (config.promptDelivery === 'arg') out.push(prompt);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 面向 doctor/status 的一行摘要(不含提示词内容)。
|
|
93
|
+
export function describeUnitProfile(config) {
|
|
94
|
+
if (!config) return null;
|
|
95
|
+
return { id: config.id, label: config.label, bin: config.bin, args: config.args, promptDelivery: config.promptDelivery };
|
|
96
|
+
}
|