@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 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 response=agentDoor('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});
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);
@@ -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]??null,context:context??process.env.SUPERCODE_CONTEXT});
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]??null,context:context??process.env.SUPERCODE_CONTEXT});
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
  }
@@ -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`
@@ -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
- retry=setTimeout(()=>wake(false),5000);retry.unref?.();
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);}};
@@ -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.13",
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.7",
28
+ "@volter/teams": "0.2.8",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },