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,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {probeCli} from './host-probe.mjs';
|
|
4
|
+
export function resolvePiHost(env=process.env) {
|
|
5
|
+
const explicit=env.ORCH_CLI||env.PI_EXE;
|
|
6
|
+
const candidates=explicit?[path.resolve(explicit)]:String(env.PATH||env.Path||'').split(path.delimiter).flatMap(dir=>['pi.exe','pi.cmd','pi'].map(n=>path.join(dir,n)));
|
|
7
|
+
const rejected=[];
|
|
8
|
+
for(const file of [...new Set(candidates)]) {
|
|
9
|
+
if(!fs.existsSync(file)||!fs.statSync(file).isFile())continue;
|
|
10
|
+
let bin=file,prefixArgs=[];
|
|
11
|
+
if(!/\.exe$/i.test(file)) {
|
|
12
|
+
let entry=/\.[cm]?js$/i.test(file)?file:null;
|
|
13
|
+
if(!entry){const shim=fs.readFileSync(file,'utf8');const match=shim.match(/node_modules[\\/](@(?:earendil-works|mariozechner))[\\/]pi-coding-agent[\\/](dist[\\/](?:bundle[\\/])?cli\.js)/);if(match)entry=path.join(path.dirname(file),'node_modules',match[1],'pi-coding-agent',...match[2].split(/[\\/]/));}
|
|
14
|
+
if(!entry||!fs.existsSync(entry))continue;
|
|
15
|
+
const sibling=path.join(path.dirname(file),'node.exe');bin=fs.existsSync(sibling)?sibling:process.execPath;prefixArgs=[entry];
|
|
16
|
+
}
|
|
17
|
+
const label=[bin,...prefixArgs].join(' ');
|
|
18
|
+
const help=probeCli(bin,[...prefixArgs,'--help'],{env});
|
|
19
|
+
if(help.status!==0||!/pi - AI coding assistant/.test(help.stdout)||!['--session','--extension','--approve'].every(flag=>help.stdout.includes(flag))){rejected.push(`${label}: --help ${help.error?'probe error '+help.error:'exit '+help.status+'; banner/flags mismatch'}`);continue;}
|
|
20
|
+
const versionResult=probeCli(bin,[...prefixArgs,'--version'],{env});
|
|
21
|
+
const version=versionResult.stdout.trim();if(versionResult.status!==0||!/^\d+\.\d+\.\d+$/.test(version)){rejected.push(`${label}: --version ${versionResult.error?'probe error '+versionResult.error:'exit '+versionResult.status+'; unusable output '+JSON.stringify(version.slice(0,120))}`);continue;}
|
|
22
|
+
const [major,minor,patch]=version.split('.').map(Number);if(major===0&&(minor<84||(minor===84&&patch<4))){rejected.push(`${label}: version ${version} below floor 0.84.4`);continue;}
|
|
23
|
+
return {agent:'pi',config:{id:'pi',label:'pi agent',version,bin,prefixArgs,available:true,native:prefixArgs.length===0,modelEnv:'ORCH_MODEL',effortEnv:'ORCH_EFFORT'}};
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`CLI_MISSING: pi; no supported installed pi CLI found. No fallback or automatic install. Tried: ${rejected.join(' | ')||'no candidate found on disk'}`);
|
|
26
|
+
}
|
|
27
|
+
export function piArgs(config,{model,effort}={}) {
|
|
28
|
+
return [...(config.prefixArgs||[]),'--approve','--offline',...(model?['--model',model]:[]),...(effort?['--thinking',effort]:[])];
|
|
29
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {piArgs} from './pi-host.mjs';
|
|
6
|
+
import {nativeArgumentLines} from './native-argv.mjs';
|
|
7
|
+
export function piAssistantText(raw) {
|
|
8
|
+
let final='';
|
|
9
|
+
for(const line of raw.split(/\r?\n/))try{
|
|
10
|
+
const event=JSON.parse(line),message=event.message||event;
|
|
11
|
+
if(message.role==='assistant') {
|
|
12
|
+
if(message.stopReason==='error'||message.stopReason==='aborted')throw new Error('PI_RESPONSE_FAILED');
|
|
13
|
+
const text=(message.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('');if(text)final=text;
|
|
14
|
+
}
|
|
15
|
+
}catch(error){if(error.message==='PI_RESPONSE_FAILED')throw error;}
|
|
16
|
+
return final;
|
|
17
|
+
}
|
|
18
|
+
export async function runPiPlanner(config,prompt,cwd) {
|
|
19
|
+
const args=[...piArgs(config,{model:process.env.ORCH_MODEL,effort:process.env.ORCH_EFFORT}),'--print','--mode','json','--no-session','--no-tools','--no-extensions','--no-skills','--no-context-files','--',prompt];
|
|
20
|
+
return new Promise((resolve,reject)=>{
|
|
21
|
+
const child=spawn(config.bin,args,{cwd,env:process.env,windowsHide:true,shell:false,stdio:['ignore','pipe','pipe']});
|
|
22
|
+
let raw='',errorText='',timedOut=false;
|
|
23
|
+
const timer=setTimeout(()=>{timedOut=true;child.kill();},Number(process.env.ORCH_DECOMPOSE_TIMEOUT_MS||60000));
|
|
24
|
+
child.stdout.on('data',b=>{raw+=b;if(raw.length>8000000)child.kill();});child.stderr.on('data',b=>{errorText+=b;});
|
|
25
|
+
child.on('error',e=>{clearTimeout(timer);reject(e);});
|
|
26
|
+
child.on('exit',code=>{clearTimeout(timer);try{if(timedOut||code!==0)throw new Error(timedOut?'PI_PLANNER_TIMEOUT':'PI_PLANNER_FAILED: exit '+code);const final=piAssistantText(raw);if(!final)throw new Error('PI_PLANNER_EMPTY_RESPONSE');resolve({final});}catch(e){reject(e);}});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const quote=s=>"'"+String(s).replaceAll("'","''")+"'";
|
|
30
|
+
export function writePiLauncher(config,{key,prompt,suffix,workspace,temp}) {
|
|
31
|
+
const stem=String(key+'-'+suffix).replace(/[^a-zA-Z0-9_-]/g,'-');
|
|
32
|
+
const token=/【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_-]*)/.exec(prompt)?.[1];
|
|
33
|
+
if(!token)throw new Error('Pi window requires a completion token');
|
|
34
|
+
fs.mkdirSync(temp,{recursive:true});
|
|
35
|
+
const lp=path.join(temp,'win-launch-'+stem+'.ps1'),pidf=path.join(temp,'window-'+stem+'.pid'),pf=path.join(temp,'agent-win-'+stem+'.md'),rf=path.join(temp,'win-'+stem+'.result.json');
|
|
36
|
+
const piSession=path.join(temp,'orchestrator','pi','sessions',stem+'.jsonl');fs.mkdirSync(path.dirname(piSession),{recursive:true});
|
|
37
|
+
fs.writeFileSync(pf,prompt,'utf8');
|
|
38
|
+
const args=[...piArgs(config,{model:process.env.ORCH_MODEL,effort:process.env.ORCH_EFFORT}),'--session',piSession,'--extension',fileURLToPath(new URL('./pi-shutdown.mjs',import.meta.url)),'--','@'+pf];
|
|
39
|
+
const body=["$ErrorActionPreference = 'Stop'",`$PID | Set-Content -LiteralPath ${quote(pidf)} -Encoding UTF8`,
|
|
40
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8','$OutputEncoding = [System.Text.Encoding]::UTF8','chcp 65001 | Out-Null',
|
|
41
|
+
`Set-Location -LiteralPath ${quote(workspace)}`,`$env:ORCH_WINDOW = '1'`,`$env:ORCH_PI_TOKEN = ${quote(token)}`,`$env:ORCH_PI_RESULT = ${quote(rf)}`,
|
|
42
|
+
'$code = 1','try {','$agentArgs = @('+args.map(quote).join(',')+')',...nativeArgumentLines(),`& ${quote(config.bin)} @agentArgs`,'$code = $LASTEXITCODE','} catch { $code = 1 }',
|
|
43
|
+
`if (-not (Test-Path -LiteralPath ${quote(rf)})) { @{__EXIT__=$code;__DONE__=$true;token=${quote(token)}} | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(rf)} -Encoding UTF8 }`,'exit 0'].join('\r\n');
|
|
44
|
+
fs.writeFileSync(lp,'\ufeff'+body,'utf8');return {lp,pidf,pf,rf,piSession};
|
|
45
|
+
}
|
|
46
|
+
export async function waitPiResult(rf,timeoutMs,pidf) {
|
|
47
|
+
const end=Date.now()+timeoutMs;
|
|
48
|
+
while(Date.now()<end) {
|
|
49
|
+
if(fs.existsSync(rf)){try{const r=JSON.parse(fs.readFileSync(rf,'utf8').replace(/^\uFEFF/,''));return {done:true,exitCode:r.__EXIT__,text:r.__REPORT__||''};}catch{}}
|
|
50
|
+
if(fs.existsSync(pidf)){const pid=Number(fs.readFileSync(pidf,'utf8').replace(/^\uFEFF/,''));try{process.kill(pid,0);}catch{return {done:true,exitCode:1,text:'Pi launcher exited without result'};}}
|
|
51
|
+
await new Promise(r=>setTimeout(r,300));
|
|
52
|
+
}
|
|
53
|
+
return {done:false,exitCode:null,text:'Pi completion timeout'};
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Pi's settled event happens after automatic retries/compaction and queued work.
|
|
4
|
+
export default function orchestratorCompletion(pi) {
|
|
5
|
+
pi.on('agent_settled',async(_event,ctx)=>{
|
|
6
|
+
if(!ctx.isIdle())return;
|
|
7
|
+
const file=process.env.ORCH_PI_RESULT,token=process.env.ORCH_PI_TOKEN;if(!file||!token)return;
|
|
8
|
+
const messages=ctx.sessionManager.getBranch().filter(e=>e.type==='message'&&e.message?.role==='assistant');
|
|
9
|
+
const last=messages.at(-1)?.message;
|
|
10
|
+
const text=(last?.content||[]).filter(c=>c.type==='text').map(c=>c.text).join('');
|
|
11
|
+
const ok=!['error','aborted'].includes(last?.stopReason)&&text.includes('__ORCH_DONE__ '+token);
|
|
12
|
+
fs.mkdirSync(path.dirname(file),{recursive:true});const tmp=file+'.'+process.pid+'.tmp';
|
|
13
|
+
fs.writeFileSync(tmp,JSON.stringify({__EXIT__:ok?0:1,__DONE__:true,__REPORT__:text,token}));fs.renameSync(tmp,file);
|
|
14
|
+
ctx.shutdown();
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {performance} from 'node:perf_hooks';
|
|
5
|
+
import {resolveRuntimeContext} from './runtime-context.mjs';
|
|
6
|
+
import {runDirectory} from './runner.mjs';
|
|
7
|
+
import {processSnapshot,sameProcess} from './leases.mjs';
|
|
8
|
+
import {TERMINAL,SUCCESS,summaryComplete} from './contracts.mjs';
|
|
9
|
+
export {TERMINAL,SUCCESS,summaryComplete};
|
|
10
|
+
export const summaryTerminal=sum=>summaryComplete(sum,false);
|
|
11
|
+
export const failures=sum=>Object.entries(sum?.taskResults||{}).filter(([,r])=>!SUCCESS.has(r.status)).map(([id,r])=>id+':'+r.status);
|
|
12
|
+
export function loadSummary(workspace,runId){try{return JSON.parse(fs.readFileSync(path.join(runDirectory(workspace,runId),'summary.json'),'utf8'));}catch{return null;}}
|
|
13
|
+
export function parsePollingOptions(argv,env=process.env){
|
|
14
|
+
const result={runId:env.ORCH_RUN_ID,timeoutMs:1800000,intervalMs:5000};
|
|
15
|
+
const fields={'--run-id':'runId','--timeoutMs':'timeoutMs','--intervalMs':'intervalMs'};
|
|
16
|
+
for(let i=0;i<argv.length;i++){
|
|
17
|
+
const flag=argv[i];if(flag==='--require-all-success')continue;
|
|
18
|
+
const field=Object.hasOwn(fields,flag)?fields[flag]:null,value=argv[++i];
|
|
19
|
+
if(!field||value===undefined||!value.trim()||value.startsWith('--'))throw new Error('POLL_ARGUMENT_INVALID: '+flag);
|
|
20
|
+
result[field]=field==='runId'?value:Number(value);
|
|
21
|
+
}
|
|
22
|
+
if(!result.runId)throw new Error('POLL_ARGUMENT_INVALID: --run-id is required');
|
|
23
|
+
for(const key of ['timeoutMs','intervalMs'])if(!Number.isSafeInteger(result[key])||result[key]<(key==='timeoutMs'?0:1)||result[key]>2147483647)throw new Error('POLL_ARGUMENT_INVALID: '+key);
|
|
24
|
+
result.intervalMs=Math.max(500,result.intervalMs);return result;
|
|
25
|
+
}
|
|
26
|
+
export async function main(argv=process.argv.slice(2),hooks={}) {
|
|
27
|
+
const {runId,timeoutMs,intervalMs:interval}=parsePollingOptions(argv);
|
|
28
|
+
const {workspace}=resolveRuntimeContext();runDirectory(workspace,runId);
|
|
29
|
+
const now=hooks.now||(()=>performance.now()),sleep=hooks.sleep||(ms=>new Promise(r=>setTimeout(r,ms)));
|
|
30
|
+
const snapshot=hooks.snapshot||processSnapshot,load=hooks.load||loadSummary,log=hooks.log||console.log,error=hooks.error||console.error;
|
|
31
|
+
const deadline=now()+timeoutMs;
|
|
32
|
+
let stable=0;
|
|
33
|
+
while(now()<deadline) {
|
|
34
|
+
const sum=load(workspace,runId);
|
|
35
|
+
if(sum?.runId===runId&&sum.finishedAt&&summaryTerminal(sum)) {
|
|
36
|
+
const remaining=deadline-now();if(remaining<=0)break;
|
|
37
|
+
const processes=snapshot(Math.max(1,Math.ceil(remaining)));
|
|
38
|
+
if(now()>=deadline)break;
|
|
39
|
+
const alive=Object.values(sum.results).some(r=>r.launcher&&sameProcess(r.launcher,processes.get(r.launcher.ProcessId)));
|
|
40
|
+
if(!alive&&++stable>=2){const ok=summaryComplete(sum,true);log(JSON.stringify({runId,success:ok,failures:failures(sum)}));process.exitCode=ok?0:1;return;}
|
|
41
|
+
if(alive)stable=0;
|
|
42
|
+
}else stable=0;
|
|
43
|
+
log('[poll-windows] runId='+runId+' waiting for finalized inventory and closed windows');
|
|
44
|
+
await sleep(Math.min(interval,Math.max(0,deadline-now())));
|
|
45
|
+
}
|
|
46
|
+
error('[poll-windows] timeout runId='+runId);process.exitCode=1;
|
|
47
|
+
}
|
|
48
|
+
if(process.argv[1]&&path.resolve(process.argv[1]).toLowerCase()===fileURLToPath(import.meta.url).toLowerCase())main().catch(e=>{console.error(e.message);process.exitCode=2;});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {probeCli} from './host-probe.mjs';
|
|
5
|
+
|
|
6
|
+
// print-class 通用适配:任何"一次性提示词 → stdout 终文本 → 自行退出"形态的 agent CLI
|
|
7
|
+
// 都可以用一份 JSON 画像(profile)接入,不必手写专用适配器。画像由 `node adapt.mjs`
|
|
8
|
+
// 探测生成(scripts/core/agent-profiles/<id>.json),也可以在适配目录里手写。
|
|
9
|
+
//
|
|
10
|
+
// 画像 schema(v1):
|
|
11
|
+
// { schema:1, id, label, bin:[候选名...], helpFlags:[必须出现在 --help 的旗标...],
|
|
12
|
+
// versionPattern:'正则(含捕获组)', probePrompt:'探活提示词',
|
|
13
|
+
// args:{ approve:[自动批准旗标...], extra:[附加旗标...], model:['--model']|null,
|
|
14
|
+
// effort:['--effort']|null, prompt:['--message']|null } }
|
|
15
|
+
// prompt 旗标取值为提示词本身(提示词跟在旗标之后,作为最后一个 argv 元素)。
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_PROFILE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'agent-profiles');
|
|
18
|
+
|
|
19
|
+
export function profilesDir(env = process.env) {
|
|
20
|
+
const override = String(env.ORCH_AGENT_PROFILES_DIR || '').trim();
|
|
21
|
+
return override ? path.resolve(override) : DEFAULT_PROFILE_DIR;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadProfile(id, env = process.env) {
|
|
25
|
+
const file = path.join(profilesDir(env), String(id || '') + '.json');
|
|
26
|
+
try {
|
|
27
|
+
const profile = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
28
|
+
if (profile && profile.schema === 1 && profile.id && Array.isArray(profile.bin) && profile.bin.length) return profile;
|
|
29
|
+
return null;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function listProfiles(env = process.env) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readdirSync(profilesDir(env)).filter((f) => f.endsWith('.json')).map((f) => f.replace(/\.json$/, ''));
|
|
36
|
+
} catch { return []; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function profileArgs(config, {model, effort} = {}) {
|
|
40
|
+
const p = config?.profile || {};
|
|
41
|
+
const a = p.args || {};
|
|
42
|
+
const flagValue = (flag, value) => (Array.isArray(flag) && flag.length && value && String(value).trim() && !/\s/.test(String(value)) ? [...flag, String(value).trim()] : []);
|
|
43
|
+
return [
|
|
44
|
+
...(config.prefixArgs || []),
|
|
45
|
+
...(a.approve || []),
|
|
46
|
+
...(a.extra || []),
|
|
47
|
+
...flagValue(a.model, model),
|
|
48
|
+
...flagValue(a.effort, effort),
|
|
49
|
+
...(a.prompt || ['-p']),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveProfileHost(env = process.env, id) {
|
|
54
|
+
const profile = loadProfile(id, env);
|
|
55
|
+
if (!profile) return null;
|
|
56
|
+
const explicit = env.ORCH_CLI || env[String(id).toUpperCase() + '_EXE'];
|
|
57
|
+
const names = profile.bin;
|
|
58
|
+
const candidates = explicit
|
|
59
|
+
? [path.resolve(explicit)]
|
|
60
|
+
: String(env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).flatMap((dir) => names.map((n) => path.join(dir, n)));
|
|
61
|
+
const rejected = [];
|
|
62
|
+
for (const file of [...new Set(candidates)]) {
|
|
63
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
|
|
64
|
+
// .mjs/.cjs 形态:以 node + 脚本运行(本地 Node CLI 的常见发布形态)。
|
|
65
|
+
let bin = file, prefixArgs = [];
|
|
66
|
+
if (/\.m?js$/i.test(file)) {
|
|
67
|
+
const sibling = path.join(path.dirname(file), 'node.exe');
|
|
68
|
+
bin = fs.existsSync(sibling) ? sibling : process.execPath; prefixArgs = [file];
|
|
69
|
+
}
|
|
70
|
+
const help = probeCli(bin, [...prefixArgs, '--help'], {env});
|
|
71
|
+
const missing = (profile.helpFlags || []).filter((f) => !help.out.includes(f));
|
|
72
|
+
if (help.status !== 0 || missing.length) { rejected.push(`${file}: --help ${help.error ? 'probe error ' + help.error : 'exit ' + help.status}${missing.length ? '; missing ' + missing.join(' ') : ''}`); continue; }
|
|
73
|
+
const versionResult = probeCli(bin, [...prefixArgs, '--version'], {env});
|
|
74
|
+
const version = new RegExp(profile.versionPattern || '(\\d+\\.\\d+\\.\\d+)').exec(String(versionResult.stdout || ''))?.[1];
|
|
75
|
+
if (versionResult.status !== 0 || !version) { rejected.push(`${file}: --version ${versionResult.error ? 'probe error ' + versionResult.error : 'exit ' + versionResult.status + '; unusable output ' + JSON.stringify(String(versionResult.stdout || '').trim().slice(0, 120))}`); continue; }
|
|
76
|
+
return { agent: id, config: { id, label: profile.label || id, version, bin, prefixArgs, available: true, native: prefixArgs.length === 0, completionMarker: '__ORCH_DONE__', modelEnv: 'ORCH_MODEL', effortEnv: 'ORCH_EFFORT', profile, profileFile: path.join(profilesDir(env), id + '.json'), reusable: true } };
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`CLI_MISSING: ${id}; no supported installed ${id} CLI found (print-class profile ${path.join(profilesDir(env), id + '.json')}). No fallback or automatic install. Tried: ${rejected.join(' | ') || 'no candidate found on disk'}`);
|
|
79
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {spawn} from 'node:child_process';
|
|
4
|
+
import {profileArgs} from './print-profile.mjs';
|
|
5
|
+
import {nativeArgumentLines} from './native-argv.mjs';
|
|
6
|
+
|
|
7
|
+
// print-class 通用运行时:与 kimi/claude 适配器同构的启动器契约(tee 日志 + token 行抓取 +
|
|
8
|
+
// result 文件 + self-closed 标记),供任何带画像的 agent 使用。
|
|
9
|
+
|
|
10
|
+
const quote = (s) => "'" + String(s).replaceAll("'", "''") + "'";
|
|
11
|
+
|
|
12
|
+
export async function runProfilePlanner(config, prompt, cwd) {
|
|
13
|
+
const args = [...profileArgs(config, {model: process.env.ORCH_MODEL, effort: process.env.ORCH_EFFORT}), prompt];
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn(config.bin, args, { cwd, env: process.env, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
+
let raw = '', errorText = '', timedOut = false;
|
|
17
|
+
const timeoutMs = (() => { const v = Number(process.env.ORCH_DECOMPOSE_TIMEOUT_MS); return Number.isFinite(v) && v > 0 ? v : 240000; })();
|
|
18
|
+
const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
|
|
19
|
+
child.stdout.on('data', (b) => { raw += b; if (raw.length > 8000000) child.kill(); });
|
|
20
|
+
child.stderr.on('data', (b) => { errorText += b; });
|
|
21
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
22
|
+
child.on('exit', (code) => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
try {
|
|
25
|
+
if (timedOut || code !== 0) throw new Error((timedOut ? 'PLANNER_TIMEOUT' : 'PLANNER_FAILED: exit ' + code) + (errorText ? ': ' + String(errorText).slice(0, 300) : ''));
|
|
26
|
+
const final = String(raw || '').trim();
|
|
27
|
+
if (!final) throw new Error('PLANNER_EMPTY_RESPONSE' + (errorText ? ': ' + String(errorText).slice(0, 300) : ''));
|
|
28
|
+
resolve({ final });
|
|
29
|
+
} catch (e) { reject(e); }
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeProfileLauncher(config, { key, prompt, suffix, workspace, temp }) {
|
|
35
|
+
const stem = String(key + '-' + suffix).replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
36
|
+
const token = /【本块唯一标识】\s*([A-Za-z0-9][A-Za-z0-9_-]*)/.exec(prompt)?.[1];
|
|
37
|
+
if (!token) throw new Error((config.label || config.id) + ' window requires a completion token');
|
|
38
|
+
fs.mkdirSync(temp, { recursive: true });
|
|
39
|
+
const lp = path.join(temp, 'win-launch-' + stem + '.ps1');
|
|
40
|
+
const pidf = path.join(temp, 'window-' + stem + '.pid');
|
|
41
|
+
const pf = path.join(temp, 'agent-win-' + stem + '.md');
|
|
42
|
+
const rf = path.join(temp, 'win-' + stem + '.result.json');
|
|
43
|
+
const log = path.join(temp, 'win-' + stem + '.out.log');
|
|
44
|
+
fs.writeFileSync(pf, prompt, 'utf8');
|
|
45
|
+
const args = profileArgs(config, { model: process.env.ORCH_MODEL, effort: process.env.ORCH_EFFORT });
|
|
46
|
+
const body = [
|
|
47
|
+
"$ErrorActionPreference = 'Continue'",
|
|
48
|
+
'trap { exit 0 }',
|
|
49
|
+
`$PID | Set-Content -LiteralPath ${quote(pidf)} -Encoding UTF8`,
|
|
50
|
+
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
51
|
+
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
52
|
+
'chcp 65001 | Out-Null',
|
|
53
|
+
'[Console]::InputEncoding = [System.Text.Encoding]::UTF8',
|
|
54
|
+
'Write-Host "============================================="',
|
|
55
|
+
'Write-Host " ' + String(config.label || config.id).toUpperCase() + ' TASK RUNNING - closes automatically on completion"',
|
|
56
|
+
'Write-Host "============================================="',
|
|
57
|
+
`Set-Location -LiteralPath ${quote(workspace)}`,
|
|
58
|
+
`$env:ORCH_WINDOW = '1'`,
|
|
59
|
+
`$token = ${quote(token)}`,
|
|
60
|
+
`$log = ${quote(log)}`,
|
|
61
|
+
`$resultFile = ${quote(rf)}`,
|
|
62
|
+
"function Write-Result {",
|
|
63
|
+
" param([int]$code,[string]$report)",
|
|
64
|
+
" try {",
|
|
65
|
+
" $json = '{\"__EXIT__\":' + $code + ',\"__DONE__\":true,\"__REPORT__\":' + (ConvertTo-Json $report -Compress) + '}'",
|
|
66
|
+
" $d = Split-Path -Parent $resultFile; if ($d -and -not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Force -Path $d | Out-Null }",
|
|
67
|
+
" Set-Content -LiteralPath $resultFile -Value ($json + \"`r`n__EXIT__=\" + $code) -Encoding UTF8",
|
|
68
|
+
" try { Set-Content -LiteralPath ($resultFile + '.self-closed') -Value '1' -Encoding ASCII } catch { }",
|
|
69
|
+
" } catch { }",
|
|
70
|
+
"}",
|
|
71
|
+
"$p = (Get-Content -Raw -Encoding UTF8 '" + String(pf).replace(/'/g, "''") + "').Trim()",
|
|
72
|
+
"if ([string]::IsNullOrWhiteSpace($p)) { Write-Result 2 ''; exit 0 }",
|
|
73
|
+
"$agentArgs = @(" + args.map(quote).join(',') + ') + @($p)',
|
|
74
|
+
...nativeArgumentLines(),
|
|
75
|
+
`& ${quote(config.bin)} @agentArgs 2>&1 | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.TargetObject } else { $_ } } | Tee-Object -FilePath $log`,
|
|
76
|
+
'$code = $LASTEXITCODE',
|
|
77
|
+
"$report = ''",
|
|
78
|
+
"if (Test-Path -LiteralPath $log) {",
|
|
79
|
+
" $lines = Get-Content -LiteralPath $log -Encoding UTF8 -ErrorAction SilentlyContinue",
|
|
80
|
+
" $hit = $lines | Where-Object { $_.Contains($token) } | Select-Object -Last 1",
|
|
81
|
+
" if ($hit) { $report = $hit } else { $report = ($lines | Select-Object -Last 3) -join \"`n\" }",
|
|
82
|
+
"}",
|
|
83
|
+
'Write-Result $code $report',
|
|
84
|
+
'exit 0',
|
|
85
|
+
].join('\r\n');
|
|
86
|
+
fs.writeFileSync(lp, '\uFEFF' + body, 'utf8');
|
|
87
|
+
return { lp, pidf, pf, rf, log };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function waitProfileResult(rf, timeoutMs, pidf) {
|
|
91
|
+
const end = Date.now() + timeoutMs;
|
|
92
|
+
while (Date.now() < end) {
|
|
93
|
+
if (fs.existsSync(rf)) {
|
|
94
|
+
try {
|
|
95
|
+
const r = JSON.parse(fs.readFileSync(rf, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/)[0]);
|
|
96
|
+
return { done: true, exitCode: r.__EXIT__, text: r.__REPORT__ || '' };
|
|
97
|
+
} catch { /* keep polling */ }
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync(pidf)) {
|
|
100
|
+
const pid = Number(fs.readFileSync(pidf, 'utf8').replace(/^\uFEFF/, ''));
|
|
101
|
+
try { process.kill(pid, 0); } catch { return { done: true, exitCode: 1, text: 'Launcher exited without result' }; }
|
|
102
|
+
}
|
|
103
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
104
|
+
}
|
|
105
|
+
return { done: false, exitCode: null, text: 'Completion timeout' };
|
|
106
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PTY 载体宿主:把单个启动器(win-launch-*.ps1)放进 ConPTY 伪终端里运行。
|
|
3
|
+
// 需要终端的 TUI 类 agent 因此拿到真实控制台,而整条链完全不可见(无窗口、不抢焦点)。
|
|
4
|
+
// 独立进程:控制器死亡不影响本宿主;进程树(宿主→ps1→agent)、pidf、身份、租约、
|
|
5
|
+
// 恢复/收编契约与窗口模式同构。stdout/stderr 落在 ORCH_PTY_LOG 指向的日志文件里,
|
|
6
|
+
// 供失败诊断(该文件由控制器的清理钩子在块结束后回收)。
|
|
7
|
+
// 用法:node pty-host.mjs <win-launch-*.ps1> (cwd/env 由控制器给出)
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { loadPtyEngine } from './unit-carrier.mjs';
|
|
11
|
+
import { powershellExe } from './contracts.mjs';
|
|
12
|
+
|
|
13
|
+
const lp = String(process.argv[2] || '');
|
|
14
|
+
const logFile = String(process.env.ORCH_PTY_LOG || '').trim()
|
|
15
|
+
|| (lp ? path.join(path.dirname(lp), path.basename(lp).replace(/\.ps1$/, '') + '.pty.log') : '');
|
|
16
|
+
|
|
17
|
+
function fail(message) {
|
|
18
|
+
try { if (logFile) fs.appendFileSync(logFile, '[pty-host] ' + message + '\n', 'utf8'); } catch { /* best effort */ }
|
|
19
|
+
console.error(message);
|
|
20
|
+
process.exit(3);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!lp || !fs.existsSync(lp)) fail('PTY_HOST_USAGE: node pty-host.mjs <win-launch.ps1> (got: ' + lp + ')');
|
|
24
|
+
|
|
25
|
+
let pty;
|
|
26
|
+
try { pty = loadPtyEngine(); } catch (error) { fail(String(error && error.message || error)); }
|
|
27
|
+
|
|
28
|
+
let stream = null;
|
|
29
|
+
try { stream = fs.createWriteStream(logFile, { flags: 'a' }); } catch { /* diagnostics only */ }
|
|
30
|
+
|
|
31
|
+
const term = pty.spawn(powershellExe(), ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', lp], {
|
|
32
|
+
name: 'xterm-256color', cols: 200, rows: 50, cwd: process.cwd(), env: process.env,
|
|
33
|
+
});
|
|
34
|
+
term.onData((data) => { if (stream) { try { stream.write(data); } catch { /* best effort */ } } });
|
|
35
|
+
term.onExit(() => {
|
|
36
|
+
if (stream) { try { stream.end(); } catch { /* best effort */ } }
|
|
37
|
+
process.exit(0);
|
|
38
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {levelWaves,verifyChecks,requestTasks,computeMaxWindows} from './contracts.mjs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Validate persisted structure before recovery can change state or invoke tools.
|
|
6
|
+
export function validateRecoverySummary(sum) {
|
|
7
|
+
const fail=reason=>{throw new Error('Invalid recovery ledger: '+reason);};
|
|
8
|
+
const object=x=>x!==null&&typeof x==='object'&&!Array.isArray(x);
|
|
9
|
+
const strings=x=>Array.isArray(x)&&x.every(v=>typeof v==='string'&&v.length>0);
|
|
10
|
+
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
11
|
+
const exact=(ids,value)=>object(value)&&new Set(ids).size===ids.length&&Object.keys(value).length===ids.length&&ids.every(id=>Object.hasOwn(value,id)&&object(value[id]));
|
|
12
|
+
if(!object(sum)||sum.protocol!==2||typeof sum.request!=='string'||!Number.isSafeInteger(sum.generation)||sum.generation<1||!Array.isArray(sum.history))fail('invalid header');
|
|
13
|
+
if(sum.acceptanceSha256!==undefined&&(typeof sum.acceptanceSha256!=='string'||!/^[0-9a-f]{64}$/.test(sum.acceptanceSha256)))fail('invalid acceptance hash');
|
|
14
|
+
const tasks=requestTasks(sum.request);
|
|
15
|
+
if(!tasks.length||!Array.isArray(sum.tasks)||!same(sum.tasks.map(t=>({id:t?.id,prompt:t?.prompt})),tasks))fail('original task inventory mismatch');
|
|
16
|
+
if(!Array.isArray(sum.blocks)||!sum.blocks.length||sum.blocks.length>computeMaxWindows(os.totalmem()/1024**3)||!Array.isArray(sum.plan)||sum.plan.length!==sum.blocks.length)fail('block inventory mismatch');
|
|
17
|
+
const ids=tasks.map(t=>t.id),blockIds=sum.blocks.map(b=>b?.key);
|
|
18
|
+
if(!strings(blockIds)||!exact(blockIds,sum.results)||!exact(ids,sum.taskResults))fail('result inventory mismatch');
|
|
19
|
+
const covered=[];
|
|
20
|
+
// resolve 后的盘符根自带尾分隔符(D:\),再拼一个 sep 会变成 D:\\ 导致根工作区的
|
|
21
|
+
// 合法 launcherFiles 全被误判为逃逸(E 验收实跑发现);只在缺尾分隔符时补。
|
|
22
|
+
const resolvedWorkspace=path.resolve(sum.workspace);
|
|
23
|
+
const workspaceRoot=resolvedWorkspace.endsWith(path.sep)?resolvedWorkspace:resolvedWorkspace+path.sep;
|
|
24
|
+
for(let i=0;i<sum.blocks.length;i++) {
|
|
25
|
+
const block=sum.blocks[i],plan=sum.plan[i],entry=sum.results[block.key];
|
|
26
|
+
if(!strings(block.taskIds)||!block.taskIds.length||!object(plan)||plan.key!==block.key||!strings(plan.keys)||!strings(plan.dependsOn)||!Array.isArray(plan.b?.tasks))fail('invalid block or plan');
|
|
27
|
+
if(!plan.b.tasks.every(t=>strings(t?.sourceIds)&&t.sourceIds.length)||!same(plan.b.tasks.flatMap(t=>t.sourceIds),block.taskIds))fail('plan task coverage mismatch');
|
|
28
|
+
covered.push(...block.taskIds);
|
|
29
|
+
if(!Array.isArray(entry.attempts))fail('missing attempt inventory');
|
|
30
|
+
// launcherFiles 会被 resume 的 adopt/cleanup 直接使用(含 rmSync):每个路径都必须
|
|
31
|
+
// 解析到本工作区之内,否则被篡改的账本能借控制器之手删工作区外的任意文件。
|
|
32
|
+
for(const f of Object.values(entry.launcherFiles||{})) {
|
|
33
|
+
const resolved=typeof f==='string'&&f?path.resolve(f):'';
|
|
34
|
+
if(!resolved||!resolved.startsWith(workspaceRoot))fail('launcher files escape the workspace');
|
|
35
|
+
}
|
|
36
|
+
if(!entry.attempts.length&&(entry.launcherFiles||entry.launcher||entry.token||['launching','running','verified','awaiting-verification'].includes(entry.status)))fail('launched block has no attempt inventory');
|
|
37
|
+
for(const attempt of entry.attempts) {
|
|
38
|
+
if(!object(attempt)||attempt.runId!==sum.runId||typeof attempt.token!=='string'||!attempt.token||typeof attempt.file!=='string'||!attempt.file||!strings(attempt.taskIds)||!attempt.taskIds.length||new Set(attempt.taskIds).size!==attempt.taskIds.length||attempt.taskIds.some(id=>!block.taskIds.includes(id)))fail('invalid attempt identity or task coverage');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if(covered.length!==ids.length||new Set(covered).size!==ids.length||covered.some(id=>!ids.includes(id)))fail('incomplete or duplicate task coverage');
|
|
42
|
+
try{levelWaves(structuredClone(sum.plan));}catch(error){fail(error.message);}
|
|
43
|
+
return sum;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function scheduleReady(recs,execute,status,onBlocked=()=>{}) {
|
|
47
|
+
levelWaves(recs); // Validate the whole graph before any work starts.
|
|
48
|
+
const byKey=new Map(recs.map(r=>[r.key,r])),jobs=new Map();
|
|
49
|
+
const start=r=>{
|
|
50
|
+
if(jobs.has(r.key))return jobs.get(r.key);
|
|
51
|
+
const job=Promise.resolve().then(async()=>{
|
|
52
|
+
await Promise.all(r.dependsOn.map(id=>start(byKey.get(id))));
|
|
53
|
+
const failed=r.dependsOn.filter(id=>status(id)!=='verified');
|
|
54
|
+
if(failed.length)return onBlocked(r,failed);
|
|
55
|
+
return execute(r);
|
|
56
|
+
});
|
|
57
|
+
jobs.set(r.key,job);return job;
|
|
58
|
+
};
|
|
59
|
+
await Promise.all(recs.map(start));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const taskPolicy=value=>Array.isArray(value)?{checks:value,retrySafe:false}:{checks:value?.checks||[],retrySafe:value?.retrySafe===true};
|
|
63
|
+
export function recoveryTasks(tasks,checkpoints,checks,workspace) {
|
|
64
|
+
const result={verified:[],pending:[],uncertain:[],results:{}};
|
|
65
|
+
for(const task of tasks) {
|
|
66
|
+
const cp=checkpoints[task.id],policy=taskPolicy(checks[task.id]);
|
|
67
|
+
if(!cp){result.pending.push(task.id);continue;}
|
|
68
|
+
if(cp.state==='invalid'){result.uncertain.push(task.id);result.results[task.id]={status:'needs-reconciliation',reason:'Invalid checkpoint',checkpoint:cp};continue;}
|
|
69
|
+
const verification=verifyChecks(policy.checks,workspace);
|
|
70
|
+
if(verification.status==='verified'){result.verified.push(task.id);result.results[task.id]={...verification,checkpoint:cp};}
|
|
71
|
+
else if(policy.retrySafe){result.pending.push(task.id);}
|
|
72
|
+
else {result.uncertain.push(task.id);result.results[task.id]={status:'needs-reconciliation',reason:'Started task has no proven result and is not declared safe to retry',verification,checkpoint:cp};}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// 执行看板(run board):把运行账本投影成"块状态 + 任务状态"的实时视图。
|
|
5
|
+
// 数据源:summary.json(block 级权威)+ attempts/**/checkpoints(task 级真实进度,
|
|
6
|
+
// 因为 running 期间 taskResults 会滞后)。纯函数 + 只读文件访问,供引擎事件流、
|
|
7
|
+
// --watch 和 --status 共用。
|
|
8
|
+
|
|
9
|
+
const BLOCK_MARKS = { verified: '✓', running: '▶', launching: '…', waiting: '⏳', pending: '·', failed: '✗', timeout: '✗', 'popup-failed': '✗', 'needs-reconciliation': '⚠', blocked: '⊘', 'awaiting-verification': '◐' };
|
|
10
|
+
const TERMINAL = new Set(['verified', 'failed', 'blocked', 'cancelled', 'awaiting-verification', 'preflight-failed', 'popup-failed', 'timeout', 'needs-reconciliation']);
|
|
11
|
+
|
|
12
|
+
export function runDirectory(workspace, runId) {
|
|
13
|
+
return path.join(workspace, 'temp', 'orchestrator', 'runs', String(runId));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function boardSignature(sum, taskStates = {}) {
|
|
17
|
+
return JSON.stringify([
|
|
18
|
+
sum?.generation ?? null,
|
|
19
|
+
sum?.finishedAt ?? null,
|
|
20
|
+
(sum?.blocks || []).map(b => [b.key, sum?.results?.[b.key]?.status || 'pending', sum?.results?.[b.key]?.attempts?.length || 0]),
|
|
21
|
+
Object.values(sum?.taskResults || {}).map(t => t?.status || 'pending'),
|
|
22
|
+
Object.entries(taskStates).map(([id, st]) => id + ':' + st),
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function boardCounts(sum) {
|
|
27
|
+
const counts = { total: 0, verified: 0, running: 0, waiting: 0, failed: 0 };
|
|
28
|
+
for (const b of sum?.blocks || []) {
|
|
29
|
+
counts.total++;
|
|
30
|
+
const st = sum?.results?.[b.key]?.status || 'pending';
|
|
31
|
+
if (st === 'verified') counts.verified++;
|
|
32
|
+
else if (st === 'running' || st === 'launching' || st === 'awaiting-verification') counts.running++;
|
|
33
|
+
else if (st === 'pending' || st === 'waiting') counts.waiting++;
|
|
34
|
+
else counts.failed++;
|
|
35
|
+
}
|
|
36
|
+
return counts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function boardSummaryLine(sum, taskStates = {}) {
|
|
40
|
+
const c = boardCounts(sum);
|
|
41
|
+
const parts = (sum?.blocks || []).map(b => b.key + (BLOCK_MARKS[sum?.results?.[b.key]?.status || 'pending'] || '?'));
|
|
42
|
+
const active = [];
|
|
43
|
+
for (const [id, st] of Object.entries(taskStates)) if (st && st !== 'completed') active.push(id + ':' + st);
|
|
44
|
+
return `[board] run=${sum?.runId || '?'} gen=${sum?.generation ?? '?'} mode=${sum?.spawnMode || 'window'} ${c.verified}/${c.total} verified${c.waiting ? ' ' + c.waiting + ' waiting' : ''}${c.failed ? ' ' + c.failed + ' failed' : ''} [${parts.join(' ')}]${active.length ? ' active=' + active.join(',') : ''}${sum?.finishedAt ? ' finalized success=' + (sum?.success === true) : ''}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function boardFullText(sum, taskStates = {}) {
|
|
48
|
+
const c = boardCounts(sum);
|
|
49
|
+
const lines = [`── run ${sum?.runId || '?'} · mode=${sum?.spawnMode || 'window'} · gen=${sum?.generation ?? '?'} · ${c.verified}/${c.total} verified${sum?.finishedAt ? ' · finalized success=' + (sum?.success === true) : ''} ──`];
|
|
50
|
+
for (const b of sum?.blocks || []) {
|
|
51
|
+
const e = sum?.results?.[b.key] || {};
|
|
52
|
+
const st = e.status || 'pending';
|
|
53
|
+
const mark = BLOCK_MARKS[st] || '?';
|
|
54
|
+
const tasks = (b.taskIds || []).map(id => {
|
|
55
|
+
const t = sum?.taskResults?.[id] || {};
|
|
56
|
+
const cp = taskStates[id] ? ' ' + taskStates[id] : '';
|
|
57
|
+
const tm = t.status === 'verified' ? '✓' : t.status ? (t.status[0] || '?') : '·';
|
|
58
|
+
return id + tm + cp;
|
|
59
|
+
}).join(' ');
|
|
60
|
+
const reason = e.reason ? ' (' + String(e.reason).slice(0, 80) + ')' : '';
|
|
61
|
+
lines.push(' ' + b.key.padEnd(3) + (b.title || '').padEnd(22) + mark + ' ' + st.padEnd(20) + tasks + reason);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function boardJson(sum, taskStates = {}) {
|
|
67
|
+
return {
|
|
68
|
+
ts: Date.now(),
|
|
69
|
+
runId: sum?.runId ?? null,
|
|
70
|
+
generation: sum?.generation ?? null,
|
|
71
|
+
spawnMode: sum?.spawnMode || 'window',
|
|
72
|
+
finished: !!sum?.finishedAt,
|
|
73
|
+
success: sum?.success ?? null,
|
|
74
|
+
blocks: (sum?.blocks || []).map(b => ({
|
|
75
|
+
key: b.key,
|
|
76
|
+
title: b.title,
|
|
77
|
+
status: sum?.results?.[b.key]?.status || 'pending',
|
|
78
|
+
attempts: sum?.results?.[b.key]?.attempts?.length || 0,
|
|
79
|
+
reason: sum?.results?.[b.key]?.reason || null,
|
|
80
|
+
})),
|
|
81
|
+
tasks: Object.entries(sum?.taskResults || {}).map(([id, t]) => ({ id, status: t?.status || 'pending', checkpoint: taskStates[id] || null })),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function taskCheckpointStates(runDir, sum) {
|
|
86
|
+
const states = {};
|
|
87
|
+
for (const b of sum?.blocks || []) {
|
|
88
|
+
for (const a of sum?.results?.[b.key]?.attempts || []) {
|
|
89
|
+
for (const id of a.taskIds || []) {
|
|
90
|
+
try {
|
|
91
|
+
const f = path.join(path.dirname(String(a.file)), 'checkpoints', id + '.json');
|
|
92
|
+
const cp = JSON.parse(fs.readFileSync(f, 'utf8').replace(/^\uFEFF/, ''));
|
|
93
|
+
if (cp?.state) states[id] = cp.state;
|
|
94
|
+
} catch { /* not yet written */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return states;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function readBoardSummary(runDir) {
|
|
102
|
+
try { return JSON.parse(fs.readFileSync(path.join(runDir, 'summary.json'), 'utf8').replace(/^\uFEFF/, '')); } catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readBoard(workspace, runId) {
|
|
106
|
+
const dir = runDirectory(workspace, runId);
|
|
107
|
+
const sum = readBoardSummary(dir);
|
|
108
|
+
if (!sum) return null;
|
|
109
|
+
return { dir, sum, taskStates: taskCheckpointStates(dir, sum) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function boardFinished(sum) {
|
|
113
|
+
return !!sum?.finishedAt && !!sum?.blocks?.every(b => TERMINAL.has(sum?.results?.[b.key]?.status || ''));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --watch:只读流式看板。TTY 原地重绘完整看板;管道/--jsonl 输出机器可读行。
|
|
117
|
+
// 机器可调用契约:未知 run 不许无限挂起。首次读到看板前有一个上界(graceMs /
|
|
118
|
+
// ORCH_WATCH_GRACE_MS,默认 60s——覆盖「--run-windows 先规划、后建 run 目录」的竞态),
|
|
119
|
+
// 超时即 fail-closed 一行诊断;已读到的运行中途丢 summary.json 也立刻报错,不静默停摆。
|
|
120
|
+
export async function watchRun({ workspace, runId, intervalMs = 800, jsonl = false, out = process.stdout, graceMs = Number(process.env.ORCH_WATCH_GRACE_MS ?? 60000) }) {
|
|
121
|
+
const write = s => out.write(s);
|
|
122
|
+
const tty = !!out.isTTY && !jsonl;
|
|
123
|
+
const dir = runDirectory(workspace, runId);
|
|
124
|
+
const grace = Number.isFinite(graceMs) && graceMs >= 0 ? graceMs : 60000;
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
let lastSig = '', paintedLines = 0, announcedFinal = false, seen = false;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const b = readBoard(workspace, runId);
|
|
129
|
+
if (b) {
|
|
130
|
+
seen = true;
|
|
131
|
+
const sig = boardSignature(b.sum, b.taskStates);
|
|
132
|
+
if (sig !== lastSig) {
|
|
133
|
+
lastSig = sig;
|
|
134
|
+
if (jsonl) write(JSON.stringify(boardJson(b.sum, b.taskStates)) + '\n');
|
|
135
|
+
else if (tty) {
|
|
136
|
+
const text = boardFullText(b.sum, b.taskStates) + '\n';
|
|
137
|
+
if (paintedLines) write('\x1b[' + paintedLines + 'A\x1b[J');
|
|
138
|
+
write(text);
|
|
139
|
+
paintedLines = text.split('\n').length - 1;
|
|
140
|
+
} else write(boardSummaryLine(b.sum, b.taskStates) + '\n');
|
|
141
|
+
}
|
|
142
|
+
if (b.sum.finishedAt) {
|
|
143
|
+
if (!jsonl && !tty && !announcedFinal) { announcedFinal = true; write('[board] run=' + runId + ' finalized success=' + (b.sum.success === true) + '\n'); }
|
|
144
|
+
return b.sum.success === true ? 0 : 1;
|
|
145
|
+
}
|
|
146
|
+
} else if (seen) {
|
|
147
|
+
throw new Error('Run ' + runId + ' lost its summary.json while being watched (dir: ' + dir + ')');
|
|
148
|
+
} else if (Date.now() - startedAt >= grace) {
|
|
149
|
+
throw new Error(fs.existsSync(dir)
|
|
150
|
+
? 'Run ' + runId + ' has no readable summary.json (dir exists: ' + dir + ')'
|
|
151
|
+
: 'Run not found: ' + runId + ' (no run directory ' + dir + ')');
|
|
152
|
+
}
|
|
153
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
154
|
+
}
|
|
155
|
+
}
|