@volter/supercode-orchestrator 0.5.13 → 0.5.15
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/agents.mjs +20 -3
- package/bin/agent-door.mjs +7 -3
- package/board/dispatch.mjs +4 -1
- package/board/event-relay.mjs +6 -2
- package/board/git-document.mjs +39 -3
- package/board/ztrack.mjs +1 -1
- package/loop.mjs +1 -1
- package/package.json +2 -2
package/agents.mjs
CHANGED
|
@@ -9,8 +9,10 @@ import { runtimeDoor } from './board/runtime-door.mjs';
|
|
|
9
9
|
import { resolveAgentProfile } from './agent-profiles.mjs';
|
|
10
10
|
import { compareAgentProfile } from '@volter/teams/agent-profile';
|
|
11
11
|
const entry=fileURLToPath(new URL('./bin/agent-door.mjs',import.meta.url));
|
|
12
|
+
// The caller each door names to Teams (X-Supercode-Caller): this host's own command words, then the door's operation.
|
|
13
|
+
const HOST_CALLER=(process.env.SUPERCODE_CALLER_NAME??`orchestrator ${process.argv.slice(2).filter(arg=>!arg.startsWith('-')).slice(0,2).filter(arg=>/^[a-z][a-z0-9-]{0,30}$/.test(arg)).join(' ')}`).slice(0,80);
|
|
12
14
|
export function agentDoor(op,args=[],{context,timeout=25000}={}){
|
|
13
|
-
const reply=spawnSync(process.execPath,[entry],{input:JSON.stringify({op,args,context}),encoding:'utf8',timeout,maxBuffer:4*1024*1024});
|
|
15
|
+
const reply=spawnSync(process.execPath,[entry],{input:JSON.stringify({op,args,context}),encoding:'utf8',timeout,maxBuffer:4*1024*1024,env:{...process.env,SUPERCODE_CALLER_NAME:`${HOST_CALLER} agent-door ${op}`}});
|
|
14
16
|
if(reply.status!==0){let detail;try{detail=JSON.parse(reply.stderr);}catch{detail={message:reply.error?.message??String(reply.stderr).slice(-1000)};}throw Object.assign(new Error(detail.message??'Teams agent door unavailable'),detail);}
|
|
15
17
|
return JSON.parse(reply.stdout);
|
|
16
18
|
}
|
|
@@ -365,8 +367,23 @@ export function recordNativeAgentSession(root,runtimeRef,session) {
|
|
|
365
367
|
return agentRunForSession(root,[runtimeRef]);
|
|
366
368
|
}
|
|
367
369
|
/** Join an observed native session through the public catalogue; unknown catalogue state remains pending. */
|
|
370
|
+
// A run whose session ownership Teams refused (a 4xx answer to the claim: not found, forbidden, conflict, signed out)
|
|
371
|
+
// is asked again after a growing wait (two minutes, doubling to an hour), not every round: each ask spends the shared
|
|
372
|
+
// credential's allowance. Anything else (the rate limit, a timeout, a 5xx, the local daemon's bind of a session just
|
|
373
|
+
// spawned) is not the run's own and is asked again next round.
|
|
374
|
+
const runRetry=new Map();
|
|
375
|
+
const retryKey=(org,row)=>`${org}/${row.agent_id}/${row.session_ref}`;
|
|
376
|
+
const refused=error=>error?.status>=400&&error.status<500&&error.status!==408&&error.status!==429;
|
|
368
377
|
export function synchronizeAgentRun(root,org,row,{context,intended}={}) {
|
|
369
|
-
const
|
|
378
|
+
try{const answer=joinAgentRun(root,org,row,{context,intended});runRetry.delete(retryKey(org,row));return answer;}
|
|
379
|
+
catch(error){
|
|
380
|
+
if(error?.claimRefused){const wait=Math.min((runRetry.get(retryKey(org,row))?.wait??60000)*2,3600000);runRetry.set(retryKey(org,row),{at:Date.now()+wait,wait});}
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function joinAgentRun(root,org,row,{context,intended}) {
|
|
385
|
+
const claim=(...args)=>{try{return agentDoor(...args);}catch(error){throw Object.assign(error,{claimRefused:refused(error)});}};
|
|
386
|
+
const response=claim('ownSession',[row.agent_id,{session:row.session_ref,main:row.main!==0&&row.main!==false,...(row.starter_session?{starter_session:row.starter_session}:{})},digest(`${org}/${row.agent_id}/${row.session_ref}`)],{context});
|
|
370
387
|
const intent=agentIntent(root,org,row.agent_id);
|
|
371
388
|
const runtime=agentDoor('bindContext',[row.agent_id,row.session_ref,row.machine,intent?.assignments??[],intended??intent?.profile.resolved],{context});
|
|
372
389
|
publishAgentRun(root,org,row.session_ref);
|
|
@@ -381,7 +398,7 @@ export function recordAgentRun(root,org,id,{session,machine=null,run,profile=nul
|
|
|
381
398
|
db.exec('COMMIT');
|
|
382
399
|
}catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
|
|
383
400
|
}
|
|
384
|
-
export function pendingAgentRuns(root,org){const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND published=0 ORDER BY rowid LIMIT 100').all(org);}finally{db.close();}}
|
|
401
|
+
export function pendingAgentRuns(root,org){const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND published=0 ORDER BY rowid LIMIT 100').all(org).filter(row=>!(runRetry.get(retryKey(org,row))?.at>Date.now()));}finally{db.close();}}
|
|
385
402
|
/** Session references come from the bound runtime, never from a model's requested agent id. */
|
|
386
403
|
export function agentRunForSession(root, references) {
|
|
387
404
|
const db = store(root);
|
package/bin/agent-door.mjs
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
import { fromContext } from '@volter/teams/context';
|
|
4
4
|
import { supercodeHome } from '@volter/teams/home';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { openMachine } from '@volter/teams/machine-door';
|
|
6
|
+
import { openMachine, normalMachineName } from '@volter/teams/machine-door';
|
|
7
|
+
import { hostname } from 'node:os';
|
|
7
8
|
import { StreamRpc } from '@volter/teams/client';
|
|
9
|
+
// This machine is reached through its own daemon's socket, never through the Teams server: the server route lists the
|
|
10
|
+
// org's machines and opens a channel on the shared credential's allowance for a request that never leaves the box.
|
|
11
|
+
const reach=machine=>machine&&normalMachineName(machine)!==normalMachineName(hostname())?machine:null;
|
|
8
12
|
let input='';for await(const chunk of process.stdin)input+=chunk;
|
|
9
13
|
try{
|
|
10
14
|
const {op,args=[],context}=JSON.parse(input);
|
|
@@ -28,14 +32,14 @@ try{
|
|
|
28
32
|
else if(op==='reader')result=await team.agents.delegateReader(args[0],{},{...options,ifMatch:args[1]});
|
|
29
33
|
else if(op==='bindContext'){
|
|
30
34
|
const key=createHash('sha256').update(`${scope.origin}/${scope.team_id}/${args[0]}`).digest('hex');
|
|
31
|
-
const channel=await openMachine({machine:args[2]
|
|
35
|
+
const channel=await openMachine({machine:reach(args[2]),context:context??process.env.SUPERCODE_CONTEXT});
|
|
32
36
|
try{const rpc=new StreamRpc(await channel.open('node'));result=await rpc.call('harness.v1.agents.bindSession',{session:args[1],context:`agent-${key.slice(0,32)}`,assignments:args[3]??[],intended:args[4]??null});}
|
|
33
37
|
finally{channel.close();}
|
|
34
38
|
}
|
|
35
39
|
else if(op==='readerContext'){
|
|
36
40
|
const identity=await team.agents.get(args[0],options);
|
|
37
41
|
const reader=await team.agents.delegateReader(args[0],{expires_in_ms:86400000},{...options,ifMatch:identity.data.revision});
|
|
38
|
-
const channel=await openMachine({machine:args[1]
|
|
42
|
+
const channel=await openMachine({machine:reach(args[1]),context:context??process.env.SUPERCODE_CONTEXT});
|
|
39
43
|
try{const rpc=new StreamRpc(await channel.open('node'));result=await rpc.call('harness.v1.agents.context',{origin:scope.origin,team:scope.team_id,agent:args[0],credential:reader.data});}
|
|
40
44
|
finally{channel.close();}
|
|
41
45
|
}
|
package/board/dispatch.mjs
CHANGED
|
@@ -1189,7 +1189,7 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1189
1189
|
for(const [org,context] of orgs)for(const row of pendingAgentRuns(board.root,org)){
|
|
1190
1190
|
const launch=agentRuns.find(run=>String(run.id)===String(row.run_id));
|
|
1191
1191
|
try{synchronizeAgentRun(board.root,org,row,{context,intended:json(launch?.metadata,{})?.supercode?.agent_profile_intent});}
|
|
1192
|
-
catch(error){report.queued.push([row.run_id,`agent session sync pending: ${error.message}`]);}
|
|
1192
|
+
catch(error){report.queued.push([row.run_id,`agent session sync pending: ${error.message}`]);if(error.code==='rate_limited')break;}
|
|
1193
1193
|
}
|
|
1194
1194
|
const deferred=view(board,db=>db.prepare("SELECT t.id,r.started_at FROM tasks t JOIN task_runs r ON r.id=t.current_run_id WHERE r.ended_at IS NULL AND json_extract(r.metadata,'$.supercode.launch_deferred')=1").all());
|
|
1195
1195
|
// A deferred launch was never issued (a reading or door was not yet available): it is the start its manager, creator
|
|
@@ -2192,6 +2192,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
2192
2192
|
try {
|
|
2193
2193
|
board = openBoard(root, slug);
|
|
2194
2194
|
board.label = boardLabel(root, slug);
|
|
2195
|
+
// A board document committed by a write whose push the remote refused (or never answered) is published by the
|
|
2196
|
+
// round, not left for the next write: a quiet board still reaches its remote.
|
|
2197
|
+
try { board.publishPending?.(); } catch (error) { report.queued.push(['board document', `push pending: ${error.message}`]); }
|
|
2195
2198
|
// what the dispatcher records on its own is the board's act
|
|
2196
2199
|
setActor(boardAddress(board.label));
|
|
2197
2200
|
// where the tick's time goes, phase by phase (ms), printed by `serve --verbose` and in `--json`
|
package/board/event-relay.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { boardSourceName, publishableCard, recordCardPublications } from './publ
|
|
|
6
6
|
import { listBoards, openBoard } from './store.mjs';
|
|
7
7
|
|
|
8
8
|
export function boardEventRelay({root,context,supercodeHome,log=()=>{}}) {
|
|
9
|
-
let work=null,dirty=false,closed=false,retry=null,reconcile=true;const sources=new Map();
|
|
9
|
+
let work=null,dirty=false,closed=false,retry=null,reconcile=true,heldUntil=0;const sources=new Map();
|
|
10
10
|
const run=async()=>{
|
|
11
11
|
if(reconcile){
|
|
12
12
|
// Establish every native source head before any network operation. A
|
|
@@ -37,10 +37,14 @@ export function boardEventRelay({root,context,supercodeHome,log=()=>{}}) {
|
|
|
37
37
|
};
|
|
38
38
|
const wake=(refresh=true)=>{
|
|
39
39
|
reconcile ||= refresh;dirty=true;if(work||closed)return work;
|
|
40
|
+
// Refused for the rate limit, delivery waits out the server's retry-after: a board write meanwhile would only ask
|
|
41
|
+
// again and spend the allowance the credential shares with every other caller.
|
|
42
|
+
if(Date.now()<heldUntil)return null;
|
|
40
43
|
work=(async()=>{while(dirty&&!closed){dirty=false;await run();}})().catch(error=>{
|
|
41
44
|
log(`board event delivery pending: ${error.code?`${error.code}: `:''}${error.message}`);
|
|
42
45
|
// Retry durable delivery, not a read of the board's current state.
|
|
43
|
-
|
|
46
|
+
const wait=error.code==='rate_limited'?Math.max(5000,Number(error.details?.retry_after_seconds??30)*1000):5000;heldUntil=Date.now()+wait;
|
|
47
|
+
clearTimeout(retry);retry=setTimeout(()=>{heldUntil=0;wake(false);},wait);retry.unref?.();
|
|
44
48
|
}).finally(()=>{work=null;});return work;
|
|
45
49
|
};
|
|
46
50
|
return {wake,close(){closed=true;clearTimeout(retry);}};
|
package/board/git-document.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
|
|
7
7
|
// either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
|
|
8
8
|
// checkout's history.
|
|
9
|
-
import { execFileSync } from 'node:child_process';
|
|
10
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
10
|
+
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { basename, dirname, relative } from 'node:path';
|
|
12
12
|
|
|
13
13
|
const git = (cwd, args, input) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], ...(input === undefined ? {} : { input }), timeout: 120_000 });
|
|
@@ -23,6 +23,10 @@ const indexed = (cwd, args) => {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const FETCH_EVERY_MS = 15_000;
|
|
27
|
+
/** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
|
|
28
|
+
const fetchedAt = (root) => { try { return statSync(git(root, ['rev-parse', '--git-path', 'FETCH_HEAD']).trim().replace(/^(?!\/)/, `${root}/`)).mtimeMs; } catch { return 0; } };
|
|
29
|
+
|
|
26
30
|
export class GitDocument {
|
|
27
31
|
constructor(paths, merge) {
|
|
28
32
|
this.paths = paths;
|
|
@@ -46,11 +50,16 @@ export class GitDocument {
|
|
|
46
50
|
this.warnedDetached = true;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
// Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from.
|
|
53
|
+
// Before a write: take what was pushed, so a merged pull request's edit is the document the write starts from. The
|
|
54
|
+
// remote is asked at most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it):
|
|
55
|
+
// a fetch on every write put a network round trip on each one. A checkout holding commits its background push did
|
|
56
|
+
// not land (refused: something was pushed meanwhile) takes what was pushed now and pushes before writing again.
|
|
50
57
|
pull() {
|
|
51
58
|
if (!this.usable) return;
|
|
52
59
|
const branch = this.branch();
|
|
53
60
|
if (!branch) return this.detached();
|
|
61
|
+
if (this.unpushed(branch)) return this.pushing ? undefined : this.publish(branch, 'board');
|
|
62
|
+
if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
|
|
54
63
|
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next write tries again
|
|
55
64
|
const behind = Number(git(this.root, ['rev-list', '--count', `HEAD..origin/${branch}`]).trim());
|
|
56
65
|
if (behind === 0) return;
|
|
@@ -72,6 +81,33 @@ export class GitDocument {
|
|
|
72
81
|
indexed(this.root, ['-c', `user.name=${who}`, '-c', 'user.email=board@supercode.invalid', 'commit', '--quiet', '--author', `${who} <board@supercode.invalid>`, '-m', message, '--', ...files]);
|
|
73
82
|
const branch = this.branch();
|
|
74
83
|
if (!branch) return this.detached();
|
|
84
|
+
// The push leaves the write's path: the commit is the write, and a push the remote refuses is taken up by the next
|
|
85
|
+
// write's pull (unpushed commits), which merges what was pushed and pushes before it writes.
|
|
86
|
+
// While it runs, this process's next write does not push the same commits beside it.
|
|
87
|
+
const pushing = spawn('git', ['push', '--quiet', 'origin', `HEAD:${branch}`], { cwd: this.root, stdio: 'ignore', detached: true });
|
|
88
|
+
this.pushing = pushing;
|
|
89
|
+
const done = () => { if (this.pushing === pushing) this.pushing = null; };
|
|
90
|
+
pushing.on('error', done); pushing.on('exit', done);
|
|
91
|
+
pushing.unref();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The board's round: publish what an earlier write committed and the remote has not taken (a refused or failed
|
|
95
|
+
* background push), with the same merge and three attempts a write's pull uses. */
|
|
96
|
+
publishPending() {
|
|
97
|
+
if (!this.usable || this.pushing) return;
|
|
98
|
+
const branch = this.branch();
|
|
99
|
+
if (!branch || !this.unpushed(branch)) return;
|
|
100
|
+
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) return; // offline: the next round tries again
|
|
101
|
+
if (this.unpushed(branch)) this.publish(branch, 'board');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether this checkout holds board commits the remote does not have. */
|
|
105
|
+
unpushed(branch) {
|
|
106
|
+
return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Push what this checkout holds, taking anything pushed meanwhile. */
|
|
110
|
+
publish(branch, who) {
|
|
75
111
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
76
112
|
if (quiet(this.root, ['push', '--quiet', 'origin', `HEAD:${branch}`]) !== null) return;
|
|
77
113
|
if (quiet(this.root, ['fetch', '--quiet', 'origin', branch]) === null) throw new Error('the board documents were committed here but could not be pushed: the remote did not answer');
|
package/board/ztrack.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function openZtrackBoard(marker, { create = false, document = null, seed
|
|
|
69
69
|
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, compatPath });
|
|
70
70
|
// A board made here is in Hermes's kanban.db at once, not after its first write.
|
|
71
71
|
if (create) backing.exportFollowing();
|
|
72
|
-
return { db: backing.db, tx: (fn) => backing.tx(fn), close: () => backing.close(), document: backing.path };
|
|
72
|
+
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), close: () => backing.close(), document: backing.path };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
class ZtrackBoard {
|
package/loop.mjs
CHANGED
|
@@ -200,7 +200,7 @@ export class Orchestrator {
|
|
|
200
200
|
for(const pending of synchronizeLocalAgents({root:this.state.root,profiles:this.state.profiles,context,declarations:this.state.agents??{}}))this.log('warn',`agent ${pending.agent} declaration pending: ${pending.error}`);
|
|
201
201
|
for(const row of pendingAgentRuns(this.state.root,scope.org)){
|
|
202
202
|
try{synchronizeAgentRun(this.state.root,scope.org,row,{context,intended:row.profile_json?JSON.parse(row.profile_json):undefined});}
|
|
203
|
-
catch(error){this.log('warn',`agent session ${row.session_ref}: ${error.message}`);}
|
|
203
|
+
catch(error){this.log('warn',`agent session ${row.session_ref}: ${error.message}`);if(error.code==='rate_limited')break;}
|
|
204
204
|
}
|
|
205
205
|
}catch(error){this.log('warn',`agent session synchronization: ${error.message}`);}
|
|
206
206
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.8",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|