@volter/supercode-orchestrator 0.5.36 → 0.5.38
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 +32 -10
- package/bin/agent-door.mjs +4 -1
- package/bin/machine-fleet.mjs +29 -0
- package/board/cli.mjs +1 -1
- package/board/dispatch.mjs +84 -95
- package/board/engine.mjs +1 -1
- package/board/machine-fleet.mjs +49 -0
- package/board/wake.mjs +2 -4
- package/index.d.ts +2 -2
- package/package.json +1 -1
- package/board/event-fleet.mjs +0 -66
package/agents.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
8
8
|
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
|
+
import { WorkspaceContexts } from '@volter/teams/context';
|
|
12
|
+
import { supercodeHome } from '@volter/teams/home';
|
|
11
13
|
const entry=fileURLToPath(new URL('./bin/agent-door.mjs',import.meta.url));
|
|
12
14
|
// The caller each door names to Teams (X-Supercode-Caller): this host's own command words, then the door's operation.
|
|
13
15
|
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);
|
|
@@ -22,11 +24,9 @@ export function teamsUnavailable(error){
|
|
|
22
24
|
if(error?.status!=null)return error.status>=500||error.status===401;
|
|
23
25
|
return !['forbidden','conflict','not_found','invalid_request','revision_mismatch','idempotency_conflict'].includes(error?.code);
|
|
24
26
|
}
|
|
25
|
-
/** The Org agent identity belongs to: the selected Teams context
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
catch(error){if(teamsUnavailable(error))return {server:'local',org:'local',principal:null,offline:true,reason:error.message};throw error;}
|
|
29
|
-
}
|
|
27
|
+
/** The Org agent identity belongs to: the selected Teams context as this home records it (read locally, no request),
|
|
28
|
+
* or this home's local store when none is selected. A context named but unreadable here is this home's error, and throws. */
|
|
29
|
+
export function agentScope(context){return agentDoor('scope',[],{context});}
|
|
30
30
|
function store(root){
|
|
31
31
|
mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'agent-state.sqlite'));
|
|
32
32
|
db.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;
|
|
@@ -260,27 +260,42 @@ export function ensureForkAgent(options) {
|
|
|
260
260
|
export function agentRunForKey(root,org,agent,key) {
|
|
261
261
|
const db=store(root);try{return db.prepare('SELECT * FROM agent_runs WHERE org=? AND agent_id=? AND run_id=? ORDER BY rowid DESC LIMIT 1').get(org,agent,key)??null;}finally{db.close();}
|
|
262
262
|
}
|
|
263
|
+
const declarationHead=(root,org,key)=>{const db=store(root);try{return db.prepare('SELECT agent_id FROM agent_declaration_heads WHERE org=? AND key=?').get(org,key)?.agent_id??null;}finally{db.close();}};
|
|
264
|
+
const endedHere=(root,org,id)=>{const db=store(root);try{return !!db.prepare('SELECT 1 FROM agent_runs WHERE org=? AND agent_id=? AND end_reason IS NOT NULL LIMIT 1').get(org,id);}finally{db.close();}};
|
|
263
265
|
function recordDeclaration(root,org,id,declaration){
|
|
264
266
|
const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json=? WHERE org=? AND id=? AND declared=0').run(JSON.stringify(declaration),org,id);}finally{db.close();}
|
|
265
267
|
}
|
|
266
|
-
/**
|
|
267
|
-
*
|
|
268
|
+
/** The identity a run starts as without asking Teams: the daemon-minted id and retained intent. An identity not yet
|
|
269
|
+
* declared is declared later under the same id (synchronizeLocalAgents). Nothing is granted from this local record;
|
|
270
|
+
* `identity.synced` says whether Teams has the declaration, and the record's own `synced: false` that Teams was not read. */
|
|
268
271
|
function localAgent(options,{key,name,declaration,assignment,parent=null,starter}){
|
|
269
272
|
const {root,org,server,profiles,profile,machine=null}=options;
|
|
270
273
|
const rootProfile=profiles.default?'default':profile;
|
|
271
274
|
const rootIntent=declareAgentIntent({root,org,server,profiles,profile:rootProfile,key:'root',machine,refresh:false});
|
|
272
275
|
recordDeclaration(root,org,rootIntent.id,{kind:'root',profile:rootProfile});
|
|
273
|
-
|
|
276
|
+
let intent=key==='root'?rootIntent:declareAgentIntent({root,org,server,profiles,profile,key,machine});
|
|
277
|
+
// An agent Teams ended is never started again: its end, once synchronized, is on its runs here (synchronizeAgentEnds),
|
|
278
|
+
// and the key's next generation is minted in its place, with fresh memory.
|
|
279
|
+
if(key!=='root'&&endedHere(root,org,intent.id))intent=nextAgentGeneration(root,intent,machine);
|
|
274
280
|
if(key!=='root')recordDeclaration(root,org,intent.id,declaration);
|
|
275
281
|
const above=parent??(key==='root'?null:{id:rootIntent.id,path:basename(root)});
|
|
276
282
|
const identity={id:intent.id,kind:'agent',org,name,path:above?`${above.path}/${name}`:name,parent:above?.id??null,
|
|
277
283
|
lineage:{starter},sponsor:options.sponsor??null,explicit_sponsor:options.sponsor??null,profile,state:'active',
|
|
278
|
-
synced
|
|
284
|
+
synced:!!intent.declared,main_session:null,revision:0};
|
|
279
285
|
const assigned=assignAgent(root,org,intent.id,assignment);
|
|
280
286
|
return {identity,intended:intent.intended,assignment:assigned,assignments:[assignment],org,synced:false};
|
|
281
287
|
}
|
|
282
288
|
export function localCardAgent(options){
|
|
283
|
-
const {card,role,profile}=options;
|
|
289
|
+
const {root,org,server,profiles,card,role,profile}=options;
|
|
290
|
+
// A card role's assignment the manager moved to another agent (`workflow reassign --agent`) runs as that agent: the
|
|
291
|
+
// active assignment names it, not the key's own head, and an agent whose end was synchronized here is not used.
|
|
292
|
+
const held=activeAgentAssignments(root).find(item=>item.org===org&&item.kind==='card'&&item.target===card&&item.role===role);
|
|
293
|
+
if(held&&held.agent_id!==declarationHead(root,org,`${server}/${org}/card/${card}/${role}`)&&!endedHere(root,org,held.agent_id)){
|
|
294
|
+
const record=localAgentRecord(root,org,held.agent_id);
|
|
295
|
+
const identity=record?{...record.identity,synced:true}:{id:held.agent_id,kind:'agent',org,name:held.agent_id,path:held.agent_id,parent:null,
|
|
296
|
+
lineage:{starter:null},sponsor:options.sponsor??null,explicit_sponsor:options.sponsor??null,profile,state:'active',synced:true,main_session:null,revision:0};
|
|
297
|
+
return {identity,intended:record?.intended??resolveAgentProfile(profiles,profile),assignment:held.id,assignments:[{kind:'card',target:card,role}],org,synced:false};
|
|
298
|
+
}
|
|
284
299
|
return localAgent(options,{key:`card/${card}/${role}`,name:`${card}-${role}`,assignment:{kind:'card',target:card,role},
|
|
285
300
|
declaration:{kind:'card',card,role,profile,sponsor:options.sponsor??null,starterSession:options.starterSession??null},
|
|
286
301
|
starter:options.starterSession?{kind:'session',id:options.starterSession}:{kind:'automation',id:`board:${basename(options.root)}`}});
|
|
@@ -459,6 +474,13 @@ export function agentPreamble(agent,{main=true,previous=[]}={}){
|
|
|
459
474
|
const {identity:a,intended,assignments=[]}=agent;
|
|
460
475
|
return `You are agent ${a.path} (${a.id}), ${main?'its main session':'a concurrent session'}.${a.synced===false?' This identity was minted on this machine and is not yet declared on a Teams server; it keeps this id when it is.':''}\nSponsor: ${a.sponsor??'none yet'}; parent: ${a.parent??'none'}; lineage: ${JSON.stringify(a.lineage)}.\nAssignments: ${assignments.map(value=>`${value.kind} ${value.target}, role ${value.role}`).join("; ")}; profile: ${a.profile}; permission layers: ${JSON.stringify(intended.resolved.permissions.layers)}.\nAsk again with supercode agent whoami. List your sessions with supercode agent sessions; read a transcript only when needed.${previous.length?'\nEarlier sessions:\n'+previous.map(s=>`${s.session} · ${s.machine??'unknown machine'} · ended ${s.ended_at??'live'} · ${s.end_reason??''}`).join('\n'):''}`;
|
|
461
476
|
}
|
|
477
|
+
/** The agent's reader context this home already holds (a session of it was bound here before), read from the contexts
|
|
478
|
+
* file without a request, or null. A launch on this machine hands it to the session, so its Teams verbs run delegated
|
|
479
|
+
* from the first turn; a context that has since expired fails there, as an absent one does. */
|
|
480
|
+
export function localReaderContext(org,id){
|
|
481
|
+
const contexts=new WorkspaceContexts(supercodeHome()).read().contexts??{};
|
|
482
|
+
return Object.entries(contexts).find(([,value])=>value?.principal_id===id&&value?.team_id===org)?.[0]??null;
|
|
483
|
+
}
|
|
462
484
|
export function agentProfileMismatch(intent,resolved){return compareAgentProfile(intent?.resolved??intent,resolved);}
|
|
463
485
|
|
|
464
486
|
export function agentEnvironment(agent,{root,context,session=null,main=true}={}) {
|
package/bin/agent-door.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The orchestrator's synchronous host reaches Teams through the installed neutral SDK.
|
|
3
|
-
import { fromContext } from '@volter/teams/context';
|
|
3
|
+
import { fromContext, WorkspaceContexts } from '@volter/teams/context';
|
|
4
4
|
import { supercodeHome } from '@volter/teams/home';
|
|
5
5
|
import { openMachine, normalMachineName } from '@volter/teams/machine-door';
|
|
6
6
|
import { hostname } from 'node:os';
|
|
@@ -11,6 +11,9 @@ const reach=machine=>machine&&normalMachineName(machine)!==normalMachineName(hos
|
|
|
11
11
|
let input='';for await(const chunk of process.stdin)input+=chunk;
|
|
12
12
|
try{
|
|
13
13
|
const {op,args=[],context}=JSON.parse(input);
|
|
14
|
+
// No context selected: identity is this home's own (Org `local`). A context that is named but unreadable is an error.
|
|
15
|
+
const named=context??process.env.SUPERCODE_CONTEXT??new WorkspaceContexts(supercodeHome()).read().current;
|
|
16
|
+
if(op==='scope'&&(!named||named==='local')){process.stdout.write(JSON.stringify({server:'local',org:'local',principal:null,offline:true,reason:'no Teams context is selected'})+'\n');process.exit(0);}
|
|
14
17
|
const {client,context:scope}=fromContext({supercodeHome:supercodeHome(),name:context??process.env.SUPERCODE_CONTEXT});
|
|
15
18
|
const team=client.team(scope.team_id),options={signal:AbortSignal.timeout(20000)};
|
|
16
19
|
let result;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One fleet reading for a synchronous board process: each named machine's panes and the harness session under each live
|
|
3
|
+
// pane, every machine read at once through its own door, each bounded by its own deadline. Prints one row per machine:
|
|
4
|
+
// { machine, panes, activities } when its door answered, { machine, error, code } when it did not.
|
|
5
|
+
import { openMachine } from '@volter/teams/machine-door';
|
|
6
|
+
import { StreamRpc, harnessTransport } from '@volter/teams/client';
|
|
7
|
+
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
8
|
+
let input = ''; for await (const chunk of process.stdin) input += chunk;
|
|
9
|
+
const { machines, deadlineMs } = JSON.parse(input);
|
|
10
|
+
const within = (promise, ms) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(`no answer within ${ms} ms`), { code: 'timeout' })), ms))]);
|
|
11
|
+
async function read({ name, at }) {
|
|
12
|
+
let channel;
|
|
13
|
+
try {
|
|
14
|
+
channel = await openMachine({ machine: at });
|
|
15
|
+
const panes = await new StreamRpc(await channel.open('node')).call('harness.v1.panes.list', {});
|
|
16
|
+
const live = (Array.isArray(panes) ? panes : []).filter((pane) => !pane.dead && pane.pid);
|
|
17
|
+
let activities = [];
|
|
18
|
+
if (live.length) {
|
|
19
|
+
const client = new SupercodeHarnessClient({ transport: harnessTransport(channel) });
|
|
20
|
+
try { await client.start?.(); activities = (await client.request('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }))?.activities ?? []; }
|
|
21
|
+
finally { await client.close?.(); }
|
|
22
|
+
}
|
|
23
|
+
return { machine: name, panes, activities };
|
|
24
|
+
} finally { channel?.close(); }
|
|
25
|
+
}
|
|
26
|
+
const rows = await Promise.all(machines.map((machine) => within(read(machine), deadlineMs)
|
|
27
|
+
.catch((error) => ({ machine: machine.name, error: String(error?.message ?? error).slice(0, 300), code: error?.code ?? 'unavailable' }))));
|
|
28
|
+
process.stdout.write(`${JSON.stringify(rows)}\n`);
|
|
29
|
+
process.exit(0);
|
package/board/cli.mjs
CHANGED
|
@@ -1440,7 +1440,7 @@ async function serve(root, args = {}) {
|
|
|
1440
1440
|
const running = installed();
|
|
1441
1441
|
log(`dispatching the boards of ${root} from ${localMachine()} (orchestrator ${running ?? 'unknown'})`);
|
|
1442
1442
|
events = watchBoard({ root, binary: supercodeBin(), env: commandEnv(), log });
|
|
1443
|
-
log('dispatch wakes on board writes, document edits
|
|
1443
|
+
log('dispatch wakes on board writes, document edits and board mail; the interval sweep reads each machine\'s own door');
|
|
1444
1444
|
const stuck = {};
|
|
1445
1445
|
const warned = {};
|
|
1446
1446
|
let pausedSaid = false;
|
package/board/dispatch.mjs
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
// subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
|
|
17
17
|
// launch and a crash between the two steps is answered by the next tick.
|
|
18
18
|
import { monitorsRuns, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
19
|
-
import {
|
|
20
|
-
let
|
|
19
|
+
import { readMachineFleet } from './machine-fleet.mjs';
|
|
20
|
+
let fleetRoot = null;
|
|
21
21
|
import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
22
22
|
import { runtimeDoor } from './runtime-door.mjs';
|
|
23
|
-
import {
|
|
23
|
+
import { agentScope, localCardAgent, localReaderContext, localAgentSessions, synchronizeLocalAgents, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, agentPreamble, agentEnvironment, recordAgentRun, pendingAgentRuns, synchronizeAgentRun, abandonAgentRun,activeAgentAssignments,finishAgentAssignment,synchronizeAgentEnds, agentRunForSession } from '../agents.mjs';
|
|
24
24
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
25
25
|
import { memorySnapshotSync } from '../hermes-cli.mjs';
|
|
26
26
|
import { createRequire } from 'node:module';
|
|
@@ -115,9 +115,22 @@ export function callerSession() {
|
|
|
115
115
|
return claude;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
/** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
|
|
119
|
+
* card of the home names or one of its notice subscribers is on. */
|
|
120
|
+
export function observeFleet(root = fleetRoot) {
|
|
121
|
+
const machines = new Set();
|
|
122
|
+
for (const slug of root ? listBoards(root) : []) {
|
|
123
|
+
try {
|
|
124
|
+
const b = openBoard(root, slug);
|
|
125
|
+
try {
|
|
126
|
+
const live = "t.status NOT IN ('done', 'cancelled', 'archived') AND NOT COALESCE(c.archived, 0)";
|
|
127
|
+
for (const { machine } of b.db.prepare(`SELECT DISTINCT c.machine FROM supercode_cards c JOIN tasks t ON t.id = c.task_id WHERE c.machine IS NOT NULL AND c.machine != '' AND ${live}`).all()) machines.add(normalMachine(machine));
|
|
128
|
+
for (const { chat_id: address } of b.db.prepare(`SELECT DISTINCT s.chat_id FROM kanban_notify_subs s JOIN tasks t ON t.id = s.task_id JOIN supercode_cards c ON c.task_id = t.id WHERE s.platform = 'supercode' AND s.chat_id LIKE 'sc:%' AND ${live}`).all()) machines.add(normalMachine(address.split(':')[1]));
|
|
129
|
+
} finally { b.close(); }
|
|
130
|
+
} catch { /* its own tick reports it */ }
|
|
131
|
+
}
|
|
132
|
+
const fleet = readMachineFleet(machines, localMachine());
|
|
133
|
+
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
|
|
121
134
|
notePresence(presenceReading);
|
|
122
135
|
return fleet;
|
|
123
136
|
}
|
|
@@ -139,24 +152,27 @@ const adoptedOnly = (s) => s?.mode === 'adopt' && !s.launch_key && s.surface !==
|
|
|
139
152
|
const addressOf = (s) => s?.address ?? (s?.session_id && s.machine ? `sc:${s.machine}:${s.harness ?? 'claude-code'}:${s.session_id}` : null);
|
|
140
153
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
141
154
|
|
|
142
|
-
// A
|
|
143
|
-
//
|
|
155
|
+
// A session in a live pane of its machine is the fleet's reading; any other run is read on its own machine: a headless
|
|
156
|
+
// worker by its pid, a pane by that machine's pane list, a session in no pane (an adopted native one) by that machine's
|
|
157
|
+
// harness discovery. Null only when the machine cannot answer.
|
|
144
158
|
const sessionAlive = (fleet, session) => fleet.native?.has(addressOf(session)) ? fleet.native.get(addressOf(session)).alive : unpublishedAlive(session);
|
|
159
|
+
/** A subscriber's address as the session it names, for sessionAlive (its machine, harness and native id). */
|
|
160
|
+
const sessionAt = (address) => { const m = /^sc:([^:]+):([^:]+):(.+)$/.exec(String(address)); return m && HARNESSES[m[2]] ? { address, machine: normalMachine(m[1]), harness: m[2], session_id: m[3] } : null; };
|
|
161
|
+
const subscriberAlive = (fleet, address) => fleet.alive.has(address) || (sessionAt(address) ? sessionAlive(fleet, sessionAt(address)) === true : false);
|
|
145
162
|
const paneLists = new Map();
|
|
146
|
-
// A run the agent sync holds names only its machine and native session (`harness:id`), never a pane or pid.
|
|
147
|
-
// fleet answers for a session it published; one it never published (a gateway worker in its profile's directory,
|
|
148
|
-
// outside every synced root) is read through its machine's native discovery, as a native close reads it.
|
|
163
|
+
// A run the agent sync holds names only its machine and native session (`harness:id`), never a pane or pid.
|
|
149
164
|
function syncRowAlive(fleet, row) {
|
|
150
165
|
const machine = row.machine || localMachine(), at = row.session_ref.indexOf(':');
|
|
151
|
-
|
|
152
|
-
const alive = sessionAlive(fleet, session);
|
|
153
|
-
if (alive !== null) return alive;
|
|
154
|
-
try { return nativeReading(nativeSessions(machine, session.harness), session).alive; } catch { return null; }
|
|
166
|
+
return sessionAlive(fleet, { machine, harness: row.session_ref.slice(0, at), session_id: row.session_ref.slice(at + 1), address: `sc:${machine}:${row.session_ref}` });
|
|
155
167
|
}
|
|
156
168
|
function unpublishedAlive(session) {
|
|
157
169
|
if (!session) return null;
|
|
158
170
|
if (session.surface === 'headless' && session.pid) return pidAlive(session.pid);
|
|
159
|
-
if (!session.
|
|
171
|
+
if (!session.machine) return null;
|
|
172
|
+
if (!session.pane) {
|
|
173
|
+
if (!session.session_id) return null;
|
|
174
|
+
try { return nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code'), session).alive; } catch { return null; }
|
|
175
|
+
}
|
|
160
176
|
let entry = paneLists.get(session.machine);
|
|
161
177
|
if (!entry || Date.now() - entry.at > 5000) {
|
|
162
178
|
const r = doorOn(session.machine, 'harness.v1.panes.list', {}, { timeout: 60_000 });
|
|
@@ -177,12 +193,14 @@ function nativeSessions(machine, harness) {
|
|
|
177
193
|
discoveries.set(key, { at: Date.now(), sessions });
|
|
178
194
|
return sessions;
|
|
179
195
|
}
|
|
196
|
+
// A native discovery is bounded as the fleet reading is: one that does not answer in time is unknown, read next round.
|
|
197
|
+
const NATIVE_DISCOVERY_MS = 15_000;
|
|
180
198
|
function discoverNative(machine, harness) {
|
|
181
199
|
if (offlineMachine(machine)) throw unavailable(`native liveness unavailable: machine ${normalMachine(machine)} is offline`);
|
|
182
200
|
// This machine's discovery is its harness door, as every other harness read here is; another machine's is supercode's fleet arm
|
|
183
201
|
const result = normalMachine(machine) === localMachine()
|
|
184
|
-
? doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], limit: 2000 }, { timeout:
|
|
185
|
-
: sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout:
|
|
202
|
+
? doorOn(machine, 'harness.v1.sessions.discover', { harnesses: [harness], limit: 2000 }, { timeout: NATIVE_DISCOVERY_MS })
|
|
203
|
+
: sc(['discover', '--on', machine, '--harness', harness, '--limit', '2000'], { timeout: NATIVE_DISCOVERY_MS });
|
|
186
204
|
const page = result.code === 0 ? lastJson(result.stdout) : null;
|
|
187
205
|
if (!Array.isArray(page?.sessions)) throw unavailable(`native liveness unavailable on ${machine}: ${(result.stderr || result.stdout).trim().slice(-300)}`);
|
|
188
206
|
return page.sessions;
|
|
@@ -256,8 +274,9 @@ export function lanesOf(profiles = {}) {
|
|
|
256
274
|
return out;
|
|
257
275
|
}
|
|
258
276
|
|
|
259
|
-
// The latest presence reading (observeFleet). A machine
|
|
260
|
-
// wait out its timeout, and one
|
|
277
|
+
// The latest presence reading (observeFleet). A machine that did not answer it (offline, or unread this round) is not
|
|
278
|
+
// asked again until the next reading: each remote call would wait out its timeout, and one silent machine held every
|
|
279
|
+
// close and launch on this one. Its door answers `machine_offline`, which every caller tries again next round.
|
|
261
280
|
let presenceReading = null;
|
|
262
281
|
const offlineMachine = (machine) => {
|
|
263
282
|
const m = normalMachine(machine ?? localMachine());
|
|
@@ -265,7 +284,7 @@ const offlineMachine = (machine) => {
|
|
|
265
284
|
};
|
|
266
285
|
/** A machine's public door (`harness.v1.*`), answered at once when the machine reads as offline. */
|
|
267
286
|
const doorOn = (machine, method, params, options) => offlineMachine(machine)
|
|
268
|
-
? { code: 1, stdout: '', stderr: `machine ${normalMachine(machine)}
|
|
287
|
+
? { code: 1, stdout: '', stderr: JSON.stringify({ code: 'machine_offline', message: `machine ${normalMachine(machine)} did not answer the fleet reading` }) }
|
|
269
288
|
: runtimeDoor(normalMachine(machine ?? localMachine()) === localMachine() ? null : machine, method, params, options);
|
|
270
289
|
const unavailable = message => Object.assign(new Error(message), { launchDeferred: true });
|
|
271
290
|
|
|
@@ -329,11 +348,11 @@ function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
329
348
|
} else extendClaim(db, id, { ttl: settings.ttl, reason: 'launch_receipt_pending' });
|
|
330
349
|
return;
|
|
331
350
|
}
|
|
332
|
-
// Availability is the
|
|
333
|
-
//
|
|
351
|
+
// Availability is the machine's own door (observeFleet). A machine whose door did not answer is unknown, not
|
|
352
|
+
// evidence that it left: its run is kept. Only a relay's positive answer that it is not linked reads as offline.
|
|
334
353
|
if (session?.session_id && session.surface !== 'headless') {
|
|
335
354
|
const reachable = fleet.online.has(session.machine);
|
|
336
|
-
if (!reachable && !fleet.presenceKnown) return;
|
|
355
|
+
if (!reachable && (!fleet.presenceKnown || fleet.unknown?.has(session.machine))) return;
|
|
337
356
|
if (!reachable) {
|
|
338
357
|
const next = {...session, offline_since: session.offline_since ?? t};
|
|
339
358
|
setRunSession(db,runRow.id,next);
|
|
@@ -684,28 +703,20 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
684
703
|
// the model it runs is recorded with the session, for caps that count by model
|
|
685
704
|
const meta = { status, role: spec.role, slot, model: task.model_override ?? lane.model ?? null, ...(spec.role === 'reviewer' ? { review_candidate: reviewCandidate(board, id) } : {}) };
|
|
686
705
|
try {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if(!team.offline){
|
|
693
|
-
try{
|
|
694
|
-
agent=ensureCardAgent(cardOptions);
|
|
695
|
-
previous=agentDoor('sessions',[agent.identity.id,{limit:200}],{context}).items??[];
|
|
696
|
-
reader=agentDoor('readerContext',[agent.identity.id,machine],{context,timeout:60000});
|
|
697
|
-
}catch(error){if(!teamsUnavailable(error))throw error;tx(board,db=>event(db,id,'agent_identity_local',{reason:error.message},runId));}
|
|
698
|
-
}
|
|
699
|
-
agent??=localCardAgent(cardOptions);
|
|
706
|
+
// Teams is never on a launch's path: the card's agent is its identity minted on this host under its retained intent,
|
|
707
|
+
// and its sessions are joined to it off this path, after the launch. No Teams answer starts, stops or holds a card.
|
|
708
|
+
const context=process.env.SUPERCODE_CONTEXT,team=agentScope(context);
|
|
709
|
+
const agent=localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
|
|
710
|
+
profile:lane.name,card:id,role:spec.role??slot,sponsor:task.sponsor??null});
|
|
700
711
|
// G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
|
|
701
712
|
const budgets=[...(entry.profiles?.default?.budgets??[]),...(lane.name!=='default'?entry.profiles?.[lane.name]?.budgets??[]:[])];
|
|
702
713
|
const refused=budgets.length?runBudgetRefusal(board.root,agent.identity.id,budgets,new Date().toISOString()):null;
|
|
703
714
|
if(refused)throw Object.assign(new Error(`not started, over budget: ${refused}`),{launchDeferred:true});
|
|
704
|
-
previous
|
|
715
|
+
const previous=localAgentSessions(board.root,team.org,agent.identity.id);
|
|
705
716
|
prompt=`${agentPreamble(agent,{previous:previous.map(item=>({session:item.session,machine:item.machine,ended_at:item.ended_at,end_reason:item.end_reason}))})}\n\n${prompt}`;
|
|
706
717
|
Object.assign(meta,{agent_id:agent.identity.id,agent_org:team.org,agent_context:context??null,
|
|
707
718
|
agent_profile_intent:structuredClone(agent.intended.resolved),
|
|
708
|
-
agent_env:agentEnvironment(agent,{root:board.root,context:
|
|
719
|
+
agent_env:agentEnvironment(agent,{root:board.root,context:normalMachine(task.machine??localMachine())===localMachine()?localReaderContext(team.org,agent.identity.id)??undefined:undefined})});
|
|
709
720
|
// The agent's own memory reaches every session of it: headless runs open its home; a pane, which may sit on
|
|
710
721
|
// another machine, gets the home's snapshot in its first prompt. A shared profile's memory is never used.
|
|
711
722
|
const agentHome=materializeAgentHome(board.root,team.org,agent.identity.id,lane);
|
|
@@ -718,14 +729,7 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
718
729
|
setRunSession(db,runId,{launch_deferred:false,launch_deferred_error:null,restart_authorized:false});
|
|
719
730
|
event(db,id,'restart_authorization_consumed',{},runId);
|
|
720
731
|
});
|
|
721
|
-
if(result?.session_id){
|
|
722
|
-
const ref=`${result.harness}:${result.session_id}`;
|
|
723
|
-
recordAgentRun(board.root,team.org,agent.identity.id,{session:ref,machine:result.machine,run:runId});
|
|
724
|
-
if(agent.synced!==false){
|
|
725
|
-
try{synchronizeAgentRun(board.root,team.org,{agent_id:agent.identity.id,session_ref:ref,machine:result.machine},{context,intended:meta.agent_profile_intent});}
|
|
726
|
-
catch(error){tx(board,db=>event(db,id,'agent_session_sync_pending',{session:ref,agent:agent.identity.id,error:error.message},runId));}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
732
|
+
if(result?.session_id)recordAgentRun(board.root,team.org,agent.identity.id,{session:`${result.harness}:${result.session_id}`,machine:result.machine,run:runId});
|
|
729
733
|
return result;
|
|
730
734
|
} catch(error) {
|
|
731
735
|
// An issued asynchronous launch may bind later. Its original permission
|
|
@@ -751,7 +755,7 @@ export function launchRun(board, entry, id, lane, fleet, settings) {
|
|
|
751
755
|
export async function startCard(board, id, { roles = ['manager'], lanes, settings } = {}) {
|
|
752
756
|
// the board's own published fleet projection, as a tick reads it: a manager's or creator's start that continues the
|
|
753
757
|
// slot's session checks that session through it
|
|
754
|
-
|
|
758
|
+
fleetRoot = board.root;
|
|
755
759
|
const entry = await loadWorkflow(board.root);
|
|
756
760
|
const fleet = observeFleet();
|
|
757
761
|
const before = getTaskNow(board, id), checked = checkWorkspace(board, before);
|
|
@@ -948,8 +952,11 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
948
952
|
const runRow = currentRun(db, id);
|
|
949
953
|
const session = runRow?.metadata?.supercode;
|
|
950
954
|
if (!session?.session_id || !['pane', 'native'].includes(session.surface) || session.offline_since != null) return;
|
|
951
|
-
if (session.surface === 'native' && fleet
|
|
952
|
-
|
|
955
|
+
if (session.surface === 'native' && sessionAlive(fleet, session) !== true) return;
|
|
956
|
+
// a session in no pane has its turn on its machine's discovery row, which sessionAlive just read
|
|
957
|
+
let nativeRow = null;
|
|
958
|
+
if (session.surface === 'native') try { nativeRow = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code'), session).row; } catch { /* unread: no turn */ }
|
|
959
|
+
const live = fleet.sessions.find((x) => x.address === addressOf(session)) ?? (nativeRow ? { address: addressOf(session), turn: nativeRow.activity?.turn ?? null } : undefined);
|
|
953
960
|
const observedAt = now();
|
|
954
961
|
const lastAtMs = Number(live?.last_message_at_ms) || updatedAt(session);
|
|
955
962
|
const lastAt = Math.floor(Number(lastAtMs) / 1000);
|
|
@@ -1004,7 +1011,7 @@ function observeIdle(board, fleet, settings, report, entry, effects) {
|
|
|
1004
1011
|
const observation = retain('dispatched', { turns_after: Number(stored?.goal_turns ?? 0), nudge_queued: r.effects.some(e => e.kind === 'send_to_actor'), run_ended: after?.id !== runRow.id });
|
|
1005
1012
|
event(db, id, 'session_idle_observed', observation, runRow.id);
|
|
1006
1013
|
if (after?.id === runRow.id) setRunSession(db, runRow.id, { goal_nudged_at: observedAt });
|
|
1007
|
-
report.notified.push([id, 'session_idle',
|
|
1014
|
+
report.notified.push([id, 'session_idle', addressOf(session)]);
|
|
1008
1015
|
} catch (error) {
|
|
1009
1016
|
retain('dispatch_failed', { error: String(error.message).slice(-200) });
|
|
1010
1017
|
report.failed.push([id, `session_idle: ${String(error.message).slice(-200)}`]);
|
|
@@ -1136,7 +1143,7 @@ function launcherCheck(db, task, lane, fleet) {
|
|
|
1136
1143
|
if (!task.machine) return 'the card names no machine; a pane session runs on the machine its card names';
|
|
1137
1144
|
const machine = normalMachine(task.machine);
|
|
1138
1145
|
if (!fleet.ok) return `supercode could not read the fleet: ${fleet.error}`;
|
|
1139
|
-
if (
|
|
1146
|
+
if (fleet.unknown?.has(machine)) return `machine ${machine}'s door did not answer; the card starts when it does`;
|
|
1140
1147
|
if (!fleet.online.has(machine)) return `machine ${machine} is offline; the card starts when it is back`;
|
|
1141
1148
|
// a card on this machine gets its workspace made here (scratch, dir or worktree, as Hermes makes it); another
|
|
1142
1149
|
// machine's card names its directory there
|
|
@@ -1281,7 +1288,7 @@ function bindLaunch(board, row, session, pane, sid) {
|
|
|
1281
1288
|
|
|
1282
1289
|
/** A fast worker can reach its first board door before open's reply. Bind only the daemon's keyed pane. */
|
|
1283
1290
|
export function reconcileCallerLaunch(board, sid) {
|
|
1284
|
-
|
|
1291
|
+
fleetRoot = board.root;
|
|
1285
1292
|
if (view(board, (db) => db.prepare(`SELECT 1 FROM task_runs r JOIN tasks t ON t.current_run_id=r.id
|
|
1286
1293
|
WHERE json_extract(r.metadata, '$.supercode.session_id')=? AND COALESCE(json_extract(r.metadata, '$.supercode.launch_pending'),0)=0`).get(sid))) return;
|
|
1287
1294
|
const rows = view(board, (db) => db.prepare(`SELECT r.id, r.task_id, r.metadata FROM task_runs r
|
|
@@ -1314,15 +1321,6 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1314
1321
|
for(const row of agentRuns){
|
|
1315
1322
|
const session=json(row.metadata,{})?.supercode;
|
|
1316
1323
|
if(!session?.session_id||!session.agent_org)continue;
|
|
1317
|
-
const observation=fleet.sessions?.find(item=>item.address===addressOf(session))?.identity;
|
|
1318
|
-
if(observation?.agent?.id===session.agent_id&&session.agent_profile_revision!==observation.revision){
|
|
1319
|
-
const mismatch=agentProfileMismatch(session.agent_profile_intent,observation.resolved);
|
|
1320
|
-
tx(board,db=>{
|
|
1321
|
-
// Rule 4: a run whose recorded profile differs from its agent's intent is shown and not counted.
|
|
1322
|
-
setRunSession(db,row.id,{agent_profile_recorded:observation.resolved,agent_profile_revision:observation.revision,agent_profile_mismatch:mismatch,counted:!mismatch.differences.length});
|
|
1323
|
-
event(db,row.task_id,mismatch.differences.length?'agent_profile_drift':'agent_profile_recorded',{agent:session.agent_id,session:session.session_id,comparison:mismatch},row.id);
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
1324
|
const ref=`${session.harness}:${session.session_id}`;
|
|
1327
1325
|
// An ended run whose session the agent store already holds, published, for another agent lost that claim when it
|
|
1328
1326
|
// ran: no round can change an ended run or a published owner, so the disagreement is history. It is said once on
|
|
@@ -1342,7 +1340,8 @@ function reconcileLaunches(board, fleet, report, entry, effects, settings, lanes
|
|
|
1342
1340
|
orgs.set(session.agent_org,session.agent_context);
|
|
1343
1341
|
}
|
|
1344
1342
|
// Identities minted while Teams was absent are declared under their ids before their sessions are joined.
|
|
1345
|
-
const context=process.env.SUPERCODE_CONTEXT
|
|
1343
|
+
const context=process.env.SUPERCODE_CONTEXT;
|
|
1344
|
+
let scope;try{scope=agentScope(context);}catch(error){scope={offline:true};report.failed.push(['agents',`agent declarations wait: ${error.message}`]);}
|
|
1346
1345
|
if(!scope.offline){
|
|
1347
1346
|
orgs.set(scope.org,orgs.get(scope.org)??context);
|
|
1348
1347
|
for(const pending of synchronizeLocalAgents({root:board.root,profiles:entry.profiles,context}))report.queued.push([pending.agent,`agent declaration pending: ${pending.error}`]);
|
|
@@ -1451,7 +1450,8 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1451
1450
|
if (lane.harness === 'codex' && lane.reasoning_effort) tail.push('-c', `model_reasoning_effort=${JSON.stringify(lane.reasoning_effort)}`);
|
|
1452
1451
|
const harness = HARNESSES[lane.harness] ? lane.harness : 'claude-code';
|
|
1453
1452
|
const how = HARNESSES[harness];
|
|
1454
|
-
|
|
1453
|
+
// A card on this machine opens through this machine's own daemon; only another machine is reached with `--on`.
|
|
1454
|
+
const base = ['open', '--new', how.program, '--detach', `--window=${window}`, ...(machine === localMachine() ? [] : ['--on', task.machine]), '--cwd', task.workspace_path, '--env', 'SUPERCODE_BOARD_PROCESS_GUARD=1',
|
|
1455
1455
|
...Object.entries(meta.agent_env??{}).flatMap(([key,value])=>['--env',`${key}=${value}`])];
|
|
1456
1456
|
// the slot's last session: kept when live, resumed when lost, never another slot's (a reviewer's is its own)
|
|
1457
1457
|
// a close its session's own command asked for is carried out first, so its pane is gone before the slot's next
|
|
@@ -1512,10 +1512,14 @@ function launchPane(board, task, runId, lane, fleet, { input = null, slot = 'wor
|
|
|
1512
1512
|
};
|
|
1513
1513
|
let session = null;
|
|
1514
1514
|
let replaced = null;
|
|
1515
|
-
|
|
1515
|
+
// A session in no pane is read on its machine before anything replaces it. One the board released (an adopted
|
|
1516
|
+
// session it did not launch, left running at its run's end) is its owner's while it runs: it is never resumed beside
|
|
1517
|
+
// itself, and the launch waits for the owner to end it.
|
|
1518
|
+
if (prev?.surface === 'native' && !prev.pane && (!prev.close_confirmed_at || prev.released)) {
|
|
1516
1519
|
const reading = nativeReading(nativeSessions(prev.machine, prev.harness), prev);
|
|
1517
1520
|
if (reading.alive == null) throw unavailable(`cannot establish native continuation liveness for ${addressOf(prev)}`);
|
|
1518
1521
|
fleet.native ??= new Map(); fleet.native.set(addressOf(prev), reading);
|
|
1522
|
+
if (reading.alive && prev.released) throw unavailable(`released session ${addressOf(prev)} still runs; the launch waits for its owner to end it`);
|
|
1519
1523
|
if (reading.alive && (prev.closed_at || prev.close_requested || prev.forgotten)) throw unavailable(`native session ${addressOf(prev)} is still live; its close must finish before replacement`);
|
|
1520
1524
|
}
|
|
1521
1525
|
if (prev?.forgotten) {
|
|
@@ -1765,25 +1769,13 @@ function adoptionOpenTo(db, id, request, open) {
|
|
|
1765
1769
|
return Boolean(claim) && claim.request === request && claim.lock === claimer();
|
|
1766
1770
|
}
|
|
1767
1771
|
|
|
1768
|
-
/**
|
|
1769
|
-
|
|
1770
|
-
* found through this machine's own doors: its panes, and the native session each pane's process runs. Only a full
|
|
1771
|
-
* address is looked up here; liveness is still verified below as for any other session.
|
|
1772
|
-
*/
|
|
1773
|
-
function localListing(who) {
|
|
1772
|
+
/** A session in no pane (a native process its machine's harness discovery holds as running), by its full address. */
|
|
1773
|
+
function nativeListing(who) {
|
|
1774
1774
|
const m = /^sc:([^:]+):([^:]+):(.+)$/.exec(String(who));
|
|
1775
|
-
if (!m ||
|
|
1776
|
-
const [, , harness,
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
const live = panes.filter((p) => !p.dead && p.pid);
|
|
1780
|
-
if (!live.length) return null;
|
|
1781
|
-
const r = runtimeDoor(null, 'harness.v1.sessions.activity_under', { pids: live.map((p) => p.pid) }, { timeout: 30_000 });
|
|
1782
|
-
const row = (r.code === 0 ? json(r.stdout.trim(), {})?.activities ?? [] : []).find((a) => a.activity?.session_id === sessionId && (a.activity?.harness ?? harness) === harness);
|
|
1783
|
-
// Not found in a pane of this machine: not listed, so a session opened moments ago is looked for again next tick.
|
|
1784
|
-
const pane = row ? live.find((p) => p.pid === row.pid)?.pane ?? null : null;
|
|
1785
|
-
if (!pane) return null;
|
|
1786
|
-
return { address: `sc:${localMachine()}:${harness}:${sessionId}`, machine: localMachine(), harness, session_id: sessionId, pane, name: null };
|
|
1775
|
+
if (!m || !HARNESSES[m[2]]) return null;
|
|
1776
|
+
const [, host, harness, session_id] = m;
|
|
1777
|
+
try { return nativeReading(nativeSessions(host, harness), { session_id, harness }).alive === true ? { address: who, machine: normalMachine(host), harness, session_id, pane: null, name: null } : null; }
|
|
1778
|
+
catch { return null; }
|
|
1787
1779
|
}
|
|
1788
1780
|
|
|
1789
1781
|
/** The verb an adoption with no current run fires: the workflow's own `adopt` where it declares one, else its dispatch start. */
|
|
@@ -1805,7 +1797,7 @@ function adoptedRole(db, board, entry, task, run) {
|
|
|
1805
1797
|
function adoptSession(board, id, who, { request = null, open = request, progress = {}, workspace = null, ttl, assignee = null, role = null, fleet = null, lanes = null } = {}) {
|
|
1806
1798
|
fleet ??= observeFleet();
|
|
1807
1799
|
if (!fleet.ok) throw new Error(`supercode could not read the fleet: ${fleet.error}`);
|
|
1808
|
-
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ??
|
|
1800
|
+
const found = fleet.sessions.find((s) => s.address === who || s.address.endsWith(`:${who}`) || s.name === who) ?? nativeListing(who);
|
|
1809
1801
|
if (!found) throw Object.assign(new Error(`the fleet lists no live session ${who}`), { notListed: true });
|
|
1810
1802
|
const [, host, harness, sessionId] = found.address.split(':');
|
|
1811
1803
|
if (!HARNESSES[harness]) throw new Error(`${found.address} is ${harness}; the board keeps and resumes ${Object.keys(HARNESSES).join(' and ')} sessions`);
|
|
@@ -1831,7 +1823,7 @@ function adoptSession(board, id, who, { request = null, open = request, progress
|
|
|
1831
1823
|
const agentRole = view(board, (db) => adoptedRole(db, board, entry0, card0, beforeRun)) ?? 'implementer';
|
|
1832
1824
|
const laneName = view(board, (db) => evaluate(entry0.workflow.roles?.[agentRole] ?? 'card.assignee', scopeOf(db, board, card0, { params: entry0.params }))) ?? assignee ?? card0?.assignee;
|
|
1833
1825
|
const lane = laneName ? lanes?.[laneName] : null, context = process.env.SUPERCODE_CONTEXT, team = agentScope(context);
|
|
1834
|
-
// The request is claimed before this adoption's first write outside the board (the agent
|
|
1826
|
+
// The request is claimed before this adoption's first write outside the board (the agent store, below), in
|
|
1835
1827
|
// a transaction that finds it still open and the card's run the one read: a request answered or claimed meanwhile, or
|
|
1836
1828
|
// a run that changed, ends it here with nothing written anywhere, and no other round acts on a request claimed here.
|
|
1837
1829
|
const stale = (db) => {
|
|
@@ -1845,11 +1837,8 @@ function adoptSession(board, id, who, { request = null, open = request, progress
|
|
|
1845
1837
|
event(db, id, 'adoption_claimed', { request, session: who, ...newClaim(ttl) });
|
|
1846
1838
|
});
|
|
1847
1839
|
progress.claimed = request !== null;
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
try { agent = ensureCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null, machine: normalMachine(host) === localMachine() ? null : host, context }); }
|
|
1851
|
-
catch (error) { if (!teamsUnavailable(error)) throw error; }
|
|
1852
|
-
}
|
|
1840
|
+
// The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
|
|
1841
|
+
const agent = lane ? localCardAgent({ root: board.root, org: team.org, server: team.server, profiles: entry0.profiles, profile: lane.name, card: id, role: agentRole, sponsor: card0?.sponsor ?? null }) : null;
|
|
1853
1842
|
// A session another agent already owns is refused before anything is recorded: the run would name one agent and the
|
|
1854
1843
|
// agent store another, and every round after would trip on it.
|
|
1855
1844
|
const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
|
|
@@ -2123,7 +2112,7 @@ function endWorkerSubscriptions(board, fleet, report, natively = new Map()) {
|
|
|
2123
2112
|
const ended = new Map();
|
|
2124
2113
|
for (const row of db.prepare("SELECT task_id, chat_id, thread_id FROM kanban_notify_subs WHERE platform = 'supercode' AND json_extract(delivery_metadata, '$.role') = 'worker'").all()) {
|
|
2125
2114
|
const address = row.chat_id, record = latest.get(address);
|
|
2126
|
-
if (!record || record.session.released || held.has(address) || (fleet.ok && fleet
|
|
2115
|
+
if (!record || record.session.released || held.has(address) || (fleet.ok && subscriberAlive(fleet, address))) continue;
|
|
2127
2116
|
const evidence = record.session.close_confirmed_at ? { by: 'board_close', run: record.run, closed_at: record.session.close_confirmed_at } : natively.get(address);
|
|
2128
2117
|
if (!evidence || !remove.run(row.task_id, address, row.thread_id).changes) continue;
|
|
2129
2118
|
if (!ended.has(address)) ended.set(address, { record, evidence, cards: [] });
|
|
@@ -2150,7 +2139,7 @@ async function notify(board, fleet, report, entry) {
|
|
|
2150
2139
|
// a session the fleet does not list this tick is told next tick: one reading is not a loss (a loaded box drops
|
|
2151
2140
|
// sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
|
|
2152
2141
|
// has its subscription moved when the new one starts
|
|
2153
|
-
if (role !== 'creator' && fleet.ok && !fleet
|
|
2142
|
+
if (role !== 'creator' && fleet.ok && !subscriberAlive(fleet, sub.chat_id)) return;
|
|
2154
2143
|
const kinds = heardBy(entry, role);
|
|
2155
2144
|
if (!kinds.length) return;
|
|
2156
2145
|
const events = board.db.prepare(`SELECT id, kind, payload, created_at, run_id FROM task_events WHERE task_id = ? AND id > ? AND kind IN (${kinds.map(() => '?').join(', ')}) ORDER BY id`)
|
|
@@ -2477,7 +2466,7 @@ const CORRUPT = /malformed|not a database|corrupt|disk image/i;
|
|
|
2477
2466
|
|
|
2478
2467
|
/** What a tick would start on each board of the home, planned through the workflow's `dispatch`, writing nothing. */
|
|
2479
2468
|
export async function plan({ root, lanes, settings, slugs = null, params = {} }) {
|
|
2480
|
-
|
|
2469
|
+
fleetRoot = root;
|
|
2481
2470
|
const loaded = await loadWorkflow(root, { fresh: true });
|
|
2482
2471
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
2483
2472
|
const fleet = Object.values(lanes).some((l) => l.surface === 'pane') ? observeFleet() : { ok: false, alive: new Set(), online: new Set(), sessions: [], error: 'no pane lanes' };
|
|
@@ -2587,10 +2576,10 @@ function capMax(cap, group, entry, lanes) {
|
|
|
2587
2576
|
* effects in `runEffects` (send_to_actor, session_notice, merge_reviewed, declare_card_agent), board mail,
|
|
2588
2577
|
* subscription notices (a doubled notice_delivered has been seen live), deferred-launch retries (two panes),
|
|
2589
2578
|
* launch-receipt work (cancelUnissuedLaunch, bindLaunch), the Teams agent-run sync in reconcileLaunches, and
|
|
2590
|
-
* board-only writes that do not re-check
|
|
2579
|
+
* board-only writes that do not re-check. Answers one report per board.
|
|
2591
2580
|
*/
|
|
2592
2581
|
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
2593
|
-
|
|
2582
|
+
fleetRoot = root;
|
|
2594
2583
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
2595
2584
|
const deployment = deploymentReceipt ? workflowRevision(root) : null;
|
|
2596
2585
|
const loaded = await loadWorkflow(root, { fresh: true });
|
package/board/engine.mjs
CHANGED
|
@@ -901,7 +901,7 @@ export function diagnose(db, task, { failureLimit = DEFAULT_FAILURE_LIMIT, stran
|
|
|
901
901
|
}
|
|
902
902
|
if (task.status === 'running' && fleet?.ok) {
|
|
903
903
|
const session = lastSession(db, task.id);
|
|
904
|
-
if (session?.machine && !fleet.online.has(session.machine)) out.push({ kind: 'machine_offline', severity: 'warning', title: `its machine ${session.machine} is offline`, data: { machine: session.machine }, actions: [] });
|
|
904
|
+
if (session?.machine && !fleet.online.has(session.machine) && !fleet.unknown?.has(session.machine)) out.push({ kind: 'machine_offline', severity: 'warning', title: `its machine ${session.machine} is offline`, data: { machine: session.machine }, actions: [] });
|
|
905
905
|
}
|
|
906
906
|
const order = ['critical', 'error', 'warning'];
|
|
907
907
|
return out.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The fleet as each machine the board works on answers for itself: its panes (`harness.v1.panes.list`) and the harness
|
|
2
|
+
// session beneath each live pane's process (`harness.v1.sessions.activity_under`). This is the reading the machine's
|
|
3
|
+
// connector publishes to Teams, taken at its source. Teams' session feed is for viewers; dispatch never waits on it
|
|
4
|
+
// (D137, D138).
|
|
5
|
+
//
|
|
6
|
+
// Every machine is read at once, each within DEADLINE_MS (bin/machine-fleet.mjs), so one hung machine or relay costs a
|
|
7
|
+
// reading that long, not the sum of them. A machine that answered is `online`. A remote machine that did not is
|
|
8
|
+
// `unknown` (its runs are kept, as an unread machine's always were), unless the relay positively answered that it is
|
|
9
|
+
// not linked (`machine_offline`): then it is offline. This machine's door not answering is no reading at all.
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const entry = fileURLToPath(new URL('../bin/machine-fleet.mjs', import.meta.url));
|
|
14
|
+
const DEADLINE_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
/** One reading of `machines` (normalized names) and this machine, `local`. */
|
|
17
|
+
export function readMachineFleet(machines, local) {
|
|
18
|
+
const names = [...new Set([local, ...machines])];
|
|
19
|
+
const reply = spawnSync(process.execPath, [entry], { input: JSON.stringify({ machines: names.map((name) => ({ name, at: name === local ? null : name })), deadlineMs: DEADLINE_MS }),
|
|
20
|
+
encoding: 'utf8', timeout: DEADLINE_MS + 5_000, maxBuffer: 64 * 1024 * 1024 });
|
|
21
|
+
let rows = null;
|
|
22
|
+
try { rows = JSON.parse(String(reply.stdout).trim().split('\n').pop()); } catch { /* no reading */ }
|
|
23
|
+
const online = new Set(), unknown = new Set(), alive = new Set(), native = new Map(), sessions = [];
|
|
24
|
+
if (!Array.isArray(rows)) {
|
|
25
|
+
const error = String(reply.stderr || reply.error?.message || 'no answer').trim().slice(-300);
|
|
26
|
+
return { ok: false, presenceKnown: false, online, unknown: new Set(names), alive, native, sessions, error: `the fleet reading did not answer: ${error}` };
|
|
27
|
+
}
|
|
28
|
+
let localError = null;
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
if (row.error) {
|
|
31
|
+
if (row.machine === local) localError = row.error;
|
|
32
|
+
else if (row.code !== 'machine_offline') unknown.add(row.machine);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
online.add(row.machine);
|
|
36
|
+
const live = (row.panes ?? []).filter((pane) => !pane.dead && pane.pid);
|
|
37
|
+
for (const { pid, activity } of row.activities ?? []) {
|
|
38
|
+
const pane = live.find((item) => item.pid === pid);
|
|
39
|
+
if (!pane || !activity?.session_id || !activity.harness) continue;
|
|
40
|
+
const address = `sc:${row.machine}:${activity.harness}:${activity.session_id}`, running = activity.presence === 'running';
|
|
41
|
+
native.set(address, { alive: running ? true : null, states: [running ? true : null] });
|
|
42
|
+
if (running) alive.add(address);
|
|
43
|
+
sessions.push({ address, machine: row.machine, harness: activity.harness, session_id: activity.session_id, pane: pane.pane, name: pane.title ?? pane.label ?? null,
|
|
44
|
+
state: running ? 'running' : 'unknown', turn: activity.turn ?? null, native_state: activity.evidence?.native_state ?? null });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const ok = online.has(local);
|
|
48
|
+
return { ok, presenceKnown: ok, online, unknown, alive, native, sessions, error: ok ? null : `machine ${local}'s door did not answer: ${localError ?? 'no row'}` };
|
|
49
|
+
}
|
package/board/wake.mjs
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { watch, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { followFleet } from './event-fleet.mjs';
|
|
7
6
|
import { boardEventRelay } from './event-relay.mjs';
|
|
8
7
|
import { fileURLToPath } from 'node:url';
|
|
9
8
|
|
|
@@ -27,7 +26,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
27
26
|
if (!pending) pending = { at, sources: new Set() };
|
|
28
27
|
pending.at = Math.min(pending.at, at);
|
|
29
28
|
pending.sources.add(source);
|
|
30
|
-
|
|
29
|
+
void relay?.wake();
|
|
31
30
|
waiter?.();
|
|
32
31
|
};
|
|
33
32
|
const directory = (path, key, accept) => {
|
|
@@ -94,7 +93,6 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
94
93
|
refresh();
|
|
95
94
|
const supercodeHome = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME,'supercode') : join(env.HOME ?? '', '.config','supercode'));
|
|
96
95
|
relay = boardEventRelay({root,supercodeHome,log});
|
|
97
|
-
const fleet = followFleet({root,supercodeHome,wake,log});
|
|
98
96
|
void relay.wake();
|
|
99
97
|
return {
|
|
100
98
|
refresh,
|
|
@@ -111,7 +109,7 @@ export function watchBoard({ root, binary, env, log = () => {} }) {
|
|
|
111
109
|
return result ? { at: result.at, sources: [...result.sources] } : null;
|
|
112
110
|
},
|
|
113
111
|
close() {
|
|
114
|
-
closed = true; waiter?.();
|
|
112
|
+
closed = true; waiter?.(); relay.close();
|
|
115
113
|
for (const handle of watchers.values()) handle.close();
|
|
116
114
|
watchers.clear();
|
|
117
115
|
},
|
package/index.d.ts
CHANGED
|
@@ -128,10 +128,10 @@ export function synchronizeAgentRun(root: Path, org: string, row: {agent_id: str
|
|
|
128
128
|
export function agentRunForSession(root: Path, references: Array<string | null | undefined>): Record<string, unknown> | null;
|
|
129
129
|
export function agentPreamble(agent: ReturnType<typeof ensureCardAgent>, options?: {main?: boolean; previous?: Array<{session: string; machine?: string | null; ended_at?: number | null; end_reason?: string | null}>}): string;
|
|
130
130
|
export function agentEnvironment(agent: ReturnType<typeof ensureCardAgent>, options: {root: Path; context?: string; session?: string | null; main?: boolean}): Record<string, string>;
|
|
131
|
-
/** The Org for agent identity; `offline` when
|
|
131
|
+
/** The Org for agent identity, read from this home's selected context without a request; `offline` when none is selected. Throws when a named context cannot be read. */
|
|
132
132
|
export function agentScope(context?: string): {server: string; org: string; principal: string | null; offline?: true; reason?: string};
|
|
133
133
|
export function teamsUnavailable(error: unknown): boolean;
|
|
134
|
-
/**
|
|
134
|
+
/** The identity a run starts as without asking Teams; `identity.synced` is false until Teams has its declaration, made later under the same id. */
|
|
135
135
|
export function localCardAgent(options: Parameters<typeof ensureCardAgent>[0]): ReturnType<typeof ensureCardAgent> & {synced: false};
|
|
136
136
|
export function localProfileAgent(options: Parameters<typeof ensureProfileAgent>[0]): ReturnType<typeof ensureCardAgent> & {synced: false};
|
|
137
137
|
export function localAgentRecord(root: Path, org: string, id: string): ReturnType<typeof ensureCardAgent> | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.38",
|
|
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": {
|
package/board/event-fleet.mjs
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// A projection of retained Teams events. Dispatch reads this projection, never a periodically fetched catalogue.
|
|
2
|
-
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { eventClient, retryAfterMs } from '@volter/teams/event-client';
|
|
6
|
-
const pathOf=root=>join(root,'.session-events.json');
|
|
7
|
-
const normalize=value=>String(value??'').toLowerCase().replace(/[^a-z0-9-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'');
|
|
8
|
-
const wait=(signal,ms)=>new Promise(resolve=>{const timer=setTimeout(resolve,ms);signal.addEventListener('abort',()=>{clearTimeout(timer);resolve();},{once:true});});
|
|
9
|
-
|
|
10
|
-
export function followFleet({root,context,supercodeHome,wake,log}){
|
|
11
|
-
const abort=new AbortController();let resources={},cursor=null,connected=false,snapshot=null;
|
|
12
|
-
const persist=()=>{
|
|
13
|
-
const path=pathOf(root),temporary=`${path}.${randomUUID()}.tmp`;
|
|
14
|
-
writeFileSync(temporary,JSON.stringify({pid:process.pid,connected,cursor,resources}),{mode:0o600});renameSync(temporary,path);
|
|
15
|
-
};
|
|
16
|
-
const apply=(target,frame)=>{
|
|
17
|
-
const event=frame.data;if(!event||!['session','machine'].includes(event.resource?.kind))return false;
|
|
18
|
-
const key=`${event.resource.kind}:${event.resource.id}`,old=target[key];
|
|
19
|
-
if(old&&old.sequence>=frame.sequence)return false;
|
|
20
|
-
target[key]={sequence:frame.sequence,data:event.data};return true;
|
|
21
|
-
};
|
|
22
|
-
void(async()=>{
|
|
23
|
-
// Refused for load, the follower waits out the server's retry_after, doubling while it is refused (to 5 min), and
|
|
24
|
-
// keeps its cursor: asking every 5 s spent the slot it waited for on the next refusal.
|
|
25
|
-
let refused=0;
|
|
26
|
-
while(!abort.signal.aborted){
|
|
27
|
-
let pause=5000;
|
|
28
|
-
try{
|
|
29
|
-
const {team}=eventClient({context,supercodeHome});
|
|
30
|
-
// Every connection begins with a complete, ACL-filtered snapshot. A process restart cannot trust an old cache.
|
|
31
|
-
for await(const frame of team.events.follow({kinds:['session.started','session.ended','session.changed','resource.changed']}, {signal:abort.signal,after:cursor,caller:'dispatch session-events'})){
|
|
32
|
-
if(frame.type==='snapshot_begin'){snapshot={};connected=false;persist();}
|
|
33
|
-
else if(frame.type==='snapshot_item')apply(snapshot??resources,frame);
|
|
34
|
-
else if(frame.type==='snapshot_end'){resources=snapshot??resources;snapshot=null;cursor=frame.cursor;connected=true;refused=0;persist();wake('session-events-snapshot');}
|
|
35
|
-
else if(frame.type==='event'){const changed=apply(resources,frame);cursor=frame.cursor;connected=true;persist();if(changed)wake('session-events');}
|
|
36
|
-
else if(frame.type==='checkpoint'){const recovered=!connected;cursor=frame.cursor;connected=true;refused=0;persist();if(recovered)wake('session-events-reconnected');}
|
|
37
|
-
else if(frame.terminal){cursor=null;connected=false;persist();wake('session-events-unavailable');break;}
|
|
38
|
-
}
|
|
39
|
-
}catch(error){
|
|
40
|
-
const asked=retryAfterMs(error);if(asked){pause=Math.min(300000,asked*2**refused);refused++;}
|
|
41
|
-
if(!abort.signal.aborted)log(`dispatch session events unavailable: ${error.code??error.message}${asked?` (retrying in ${Math.round(pause/1000)} s)`:''}`);if(error.status===409)cursor=null;
|
|
42
|
-
}
|
|
43
|
-
connected=false;persist();wake('session-events-unavailable');if(!abort.signal.aborted)await wait(abort.signal,pause);
|
|
44
|
-
}
|
|
45
|
-
})();
|
|
46
|
-
return {close(){abort.abort();connected=false;persist();}};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function readEventFleet(root){
|
|
50
|
-
let cache;try{cache=JSON.parse(readFileSync(pathOf(root),'utf8'));process.kill(cache.pid,0);}catch{return {ok:false,alive:new Set(),online:new Set(),sessions:[],native:new Map(),error:'published session events unavailable',presenceKnown:false};}
|
|
51
|
-
const machines=new Map(),online=new Set();
|
|
52
|
-
for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('machine:')){machines.set(value.data.id,normalize(value.data.name));if(cache.connected&&value.data.linked)online.add(normalize(value.data.name));}
|
|
53
|
-
const sessions=[],native=new Map(),alive=new Set();
|
|
54
|
-
for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('session:')){
|
|
55
|
-
for(const binding of value.data.bindings??[]){
|
|
56
|
-
const machine=machines.get(binding.machine_id);if(!machine||!binding.harness||!binding.native_session_id)continue;
|
|
57
|
-
const address=`sc:${machine}:${binding.harness}:${binding.native_session_id}`;
|
|
58
|
-
const known=cache.connected&&online.has(machine),state=known?(binding.state==='running'?true:binding.state==='stopped'?false:null):null;
|
|
59
|
-
const previous=native.get(address),states=[...(previous?.states??[]),state];
|
|
60
|
-
const combined=states.includes(true)?true:states.includes(null)?null:states.every(value=>value===false)?false:null;
|
|
61
|
-
native.set(address,{alive:combined,states});if(combined===true)alive.add(address);
|
|
62
|
-
sessions.push({address,machine,harness:binding.harness,session_id:binding.native_session_id,pane:binding.pane??null,name:binding.name??null,state:binding.state,turn:binding.turn??null,native_state:binding.evidence?.native_state??null,identity:value.data.agent_identity??null});
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return {ok:!!cache.connected,presenceKnown:!!cache.connected,online,alive,native,sessions,error:cache.connected?null:'published session events disconnected'};
|
|
66
|
-
}
|