@volter/supercode-orchestrator 0.5.207 → 0.5.209
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/README.md +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +125 -91
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +354 -84
- package/board/engine.mjs +77 -50
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +3 -1
- package/board/listing.mjs +13 -11
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +2 -2
- package/board/published.mjs +124 -33
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +95 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +58 -16
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +119 -48
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +108 -30
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +2 -2
- package/source-lock.mjs +7 -2
- package/worker-route.mjs +31 -3
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ import { join } from 'node:path';
|
|
|
32
32
|
|
|
33
33
|
const root = process.env.SUPERCODE_ORCHESTRATOR_HOME || join(homedir(), '.supercode/orchestrator');
|
|
34
34
|
const { state, vault } = await load(root); // strict; secrets in `vault`, never `state`
|
|
35
|
-
await save(state, root); //
|
|
35
|
+
await save(state, root); // runtime state; preserves workflow.yaml
|
|
36
36
|
|
|
37
37
|
const { state: hermesState } = await compile('hermes', process.env.HERMES_HOME); // a home as a state value
|
|
38
38
|
await closeWorld(); // close the shared door when done
|
|
@@ -57,6 +57,14 @@ Rules the codec keeps (enforced in Rust, observed here):
|
|
|
57
57
|
since import (UNI-22); it copies the store byte for byte when they did not.
|
|
58
58
|
- Files the IR does not model are never touched by `save` and are carried by path on a migration.
|
|
59
59
|
|
|
60
|
+
Runtime `save` and load-time credential normalization leave `workflow.yaml` untouched,
|
|
61
|
+
including a symlink or an absent file. The board reads policy from the live home;
|
|
62
|
+
an older runtime snapshot cannot replace an edited cap. For intentional full-home
|
|
63
|
+
creation or replacement, use `orchestrationSave` (which writes the workflow by
|
|
64
|
+
default), `supercode orchestration save --from-json`, or `importFrom`.
|
|
65
|
+
Other SDK callers that persist runtime state set `preserve_workflow: true` on
|
|
66
|
+
`orchestrationSave`. See [workflow ownership](../../docs/adr/0067-live-workflow-ownership.md).
|
|
67
|
+
|
|
60
68
|
## Board machine-call deadlines
|
|
61
69
|
|
|
62
70
|
The board owner's machine calls share one timeout budget across channel admission,
|
|
@@ -197,7 +205,7 @@ Tiers on the way out:
|
|
|
197
205
|
too, rather than written as its own reference.
|
|
198
206
|
|
|
199
207
|
Secrets never enter the model: any string under a credential-shaped key — openclaw's own rule
|
|
200
|
-
(`
|
|
208
|
+
(`sdk/interchange/orchestration-openclaw.mjs`: the lower-cased key ENDS WITH `token|key|secret|password|
|
|
201
209
|
credential`), minus the names it spells that way for non-secrets (`sessionKey`, `store_key`, …) —
|
|
202
210
|
moves to the vault as a `{dotenv}` ref and is re-inlined on export where openclaw reads it.
|
|
203
211
|
|
|
@@ -713,3 +721,23 @@ authority, in-progress-operation refusals and already-issued-request boundaries.
|
|
|
713
721
|
[Source observations](../../docs/evidence/board-session-detach-2026-10-09.md)
|
|
714
722
|
cover actual help, compilation and read-only retained records. Live handback,
|
|
715
723
|
return through adoption and released availability remain unqualified.
|
|
724
|
+
|
|
725
|
+
## Readable card ids
|
|
726
|
+
|
|
727
|
+
Every card has a `readable_id` beside its number and native id. Create one with
|
|
728
|
+
`supercode workflow create "Voice calls" --readable-id voice-calls --root HOME`,
|
|
729
|
+
or let the board derive it from the title. Names use lowercase kebab-case, start
|
|
730
|
+
with a letter, have at most 32 characters and are unique among open cards.
|
|
731
|
+
Automatic collisions gain a numeric suffix; explicit duplicates are refused.
|
|
732
|
+
|
|
733
|
+
`workflow list` and `show` display the name. `workflow specify CARD --readable-id
|
|
734
|
+
voice-calls --root HOME` changes it; the same `readable_id` property is editable
|
|
735
|
+
in a ztrack card's metadata in `arcs.md`. A title edit keeps its existing id.
|
|
736
|
+
Older boards fill missing names from leading title slugs before other title words.
|
|
737
|
+
|
|
738
|
+
Generated branches use `vo-42-voice-calls`. Card agents become
|
|
739
|
+
`implementer-voice-calls` or `reviewer-voice-calls`, with the same immutable agent
|
|
740
|
+
id and sessions; the ordinary delivery pass sends existing agents' renames to
|
|
741
|
+
the machine. The Workplace updates its existing principal and spoken name.
|
|
742
|
+
Existing worktrees, explicit branches and personal naming overrides keep their
|
|
743
|
+
normal protections. Merge and release remain separate from this source behavior.
|
package/agents.mjs
CHANGED
|
@@ -162,7 +162,7 @@ export function agentRunForKey(root,org,agent,key) {
|
|
|
162
162
|
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();}};
|
|
163
163
|
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();}};
|
|
164
164
|
function recordDeclaration(root,org,id,declaration){
|
|
165
|
-
const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json
|
|
165
|
+
const db=store(root);try{db.prepare('UPDATE agent_intents SET declaration_json=?,declared=0 WHERE org=? AND id=? AND declaration_json IS NOT ?').run(JSON.stringify(declaration),org,id,JSON.stringify(declaration));}finally{db.close();}
|
|
166
166
|
}
|
|
167
167
|
/** The identity a run starts as, never asking Teams: the daemon-minted id and retained intent. Its declaration is handed
|
|
168
168
|
* to this machine's daemon (handOffAgents), which declares it to Teams under the same id. Nothing is granted from this
|
|
@@ -177,9 +177,11 @@ async function localAgent(options,{key,name,declaration,assignment,parent=null,s
|
|
|
177
177
|
// is minted in its place, with fresh memory.
|
|
178
178
|
if(key!=='root'&&endedHere(root,org,intent.id))intent=await nextAgentGeneration(root,intent,machine);
|
|
179
179
|
const above=parent??(key==='root'?null:{id:rootIntent.id,path:basename(root)});
|
|
180
|
+
// Standing agents retain their names. Card declarations follow the card's editable readable id.
|
|
181
|
+
if(key!=='root'&&declaration.kind!=='card'){const held=agentIntent(root,org,intent.id);if(held?.declared){try{name=JSON.parse(held.declaration_json??'{}').name??name;}catch{/* its declaration unread: the name given */}}}
|
|
180
182
|
if(key!=='root')recordDeclaration(root,org,intent.id,{...declaration,name,parent:above?.id??null});
|
|
181
183
|
const identity={id:intent.id,kind:'agent',org,name,path:above?`${above.path}/${name}`:name,parent:above?.id??null,
|
|
182
|
-
lineage:{starter},
|
|
184
|
+
lineage:{starter},profile,state:'active',
|
|
183
185
|
synced:!!intent.declared,main_session:null,revision:0};
|
|
184
186
|
const assigned=assignAgent(root,org,intent.id,assignment);
|
|
185
187
|
return {identity,intended:intent.intended,assignment:assigned,assignments:[assignment],org,synced:false};
|
|
@@ -196,11 +198,13 @@ export async function localCardAgent(options){
|
|
|
196
198
|
if(held&&held.agent_id!==declarationHead(root,org,`${server}/${org}/card/${card}/${role}`)&&!endedHere(root,org,held.agent_id)){
|
|
197
199
|
const record=localAgentRecord(root,org,held.agent_id);
|
|
198
200
|
const identity=record?{...record.identity,synced:true}:{id:held.agent_id,kind:'agent',org,name:held.agent_id,path:held.agent_id,parent:null,
|
|
199
|
-
lineage:{starter:null},
|
|
201
|
+
lineage:{starter:null},profile,state:'active',synced:true,main_session:null,revision:0};
|
|
200
202
|
return {identity,intended:record?.intended??resolveAgentProfile(profiles,profile),assignment:held.id,assignments:[{kind:'card',target:card,role}],org,synced:false};
|
|
201
203
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
// The immutable card/role key keeps the same identity when its readable name changes.
|
|
205
|
+
const handle=options.handle??null,readableId=options.readableId??null;
|
|
206
|
+
return localAgent(options,{key:`card/${card}/${role}`,name:`${role}-${readableId??handle??card}`,assignment:{kind:'card',target:card,role},
|
|
207
|
+
declaration:{kind:'card',card,...(handle?{handle}:{}),...(readableId?{readableId}:{}),role,profile,starterSession:options.starterSession??null},
|
|
204
208
|
starter:options.starterSession?{kind:'session',id:options.starterSession}:{kind:'automation',id:`board:${basename(options.root)}`}});
|
|
205
209
|
}
|
|
206
210
|
export async function localProfileAgent(options){
|
|
@@ -208,7 +212,7 @@ export async function localProfileAgent(options){
|
|
|
208
212
|
const starter={kind:'automation',id:`orchestrator:${basename(root)}`};
|
|
209
213
|
if(declared&&declared!==profile){
|
|
210
214
|
const decl=declarations[declared]??{};
|
|
211
|
-
return localAgent(
|
|
215
|
+
return localAgent(options,{key:`agent/${declared}`,name:declared,assignment:{kind:'agent',target:declared,role:'declared'},
|
|
212
216
|
declaration:{kind:'declared',agent:declared,profile},starter});
|
|
213
217
|
}
|
|
214
218
|
if(profile===rootProfile)return localAgent(options,{key:'root',name:basename(root),assignment:{kind:'profile',target:profile,role:'profile'},declaration:{kind:'root',profile},starter});
|
|
@@ -220,11 +224,22 @@ export async function localProfileAgent(options){
|
|
|
220
224
|
export function localAgentRecord(root,org,id){
|
|
221
225
|
const intent=agentIntent(root,org,id);if(!intent)return null;
|
|
222
226
|
const declaration=intent.declaration_json?JSON.parse(intent.declaration_json):{};
|
|
223
|
-
const name=declaration.kind==='card'?`${declaration.
|
|
227
|
+
const name=declaration.name??(declaration.kind==='card'?`${declaration.role}-${declaration.readableId??declaration.handle??declaration.card}`:declaration.kind==='profile'?declaration.profile:declaration.kind==='declared'?declaration.agent:basename(root));
|
|
224
228
|
return {identity:{id,kind:'agent',org,name,path:declaration.kind==='root'?name:`${basename(root)}/${name}`,parent:null,lineage:{starter:null},
|
|
225
|
-
|
|
229
|
+
profile:intent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0},
|
|
226
230
|
intended:intent.profile,assignments:intent.assignments,org,synced:!!intent.declared};
|
|
227
231
|
}
|
|
232
|
+
/** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
|
|
233
|
+
export function renameCardAgent(root, assignment, readableId) {
|
|
234
|
+
const intent=agentIntent(root,assignment.org,assignment.agent_id);
|
|
235
|
+
if(!intent?.declaration_json)return false;
|
|
236
|
+
const declaration=JSON.parse(intent.declaration_json);
|
|
237
|
+
if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
|
|
238
|
+
const name=`${assignment.role}-${readableId}`;
|
|
239
|
+
if(declaration.name===name&&declaration.readableId===readableId)return false;
|
|
240
|
+
recordDeclaration(root,assignment.org,assignment.agent_id,{...declaration,name,readableId});
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
228
243
|
/** The id a home's mail agent runs as, read and never minted: its own declared identity (`agent/<name>`) where the home
|
|
229
244
|
* holds one, else its profile's (`profile/<profile>`), as localProfileAgent keys them, each at its current generation.
|
|
230
245
|
* Null when the home holds neither, or has no agent store. A channel names its agent by this id (agent-channel). */
|
|
@@ -315,7 +330,7 @@ export async function localForkAgent(options){
|
|
|
315
330
|
const db=store(root);try{db.prepare('UPDATE agent_intents SET profile_json=? WHERE org=? AND id=?').run(JSON.stringify(intended),org,intent.id);}finally{db.close();}
|
|
316
331
|
recordDeclaration(root,org,intent.id,{kind:'fork',profile:parentIntent.profile_ref,name,parent,starterSession:session});
|
|
317
332
|
const assignment=assignAgent(root,org,intent.id,{kind:'fork',target:session,role:key});
|
|
318
|
-
const identity={id:intent.id,kind:'agent',org,name,path:name,parent,lineage:{starter:{kind:'session',id:session}},
|
|
333
|
+
const identity={id:intent.id,kind:'agent',org,name,path:name,parent,lineage:{starter:{kind:'session',id:session}},
|
|
319
334
|
profile:parentIntent.profile_ref,state:'active',synced:!!intent.declared,main_session:null,revision:0};
|
|
320
335
|
return {identity,intended,assignment,assignments:[{kind:'fork',target:session,role:key}],org,synced:false};
|
|
321
336
|
}
|
|
@@ -369,11 +384,11 @@ async function handOffOrg(root,scope,waiting){
|
|
|
369
384
|
for(const row of rows){
|
|
370
385
|
const declaration=JSON.parse(row.declaration_json);
|
|
371
386
|
const body={org:scope.org,id:row.id,name:declaration.name??declaration.profile,parent:declaration.parent??null,profile:declaration.profile,
|
|
372
|
-
...(declaration.kind==='root'?{root:true}:{})
|
|
387
|
+
...(declaration.kind==='root'?{root:true}:{}),
|
|
373
388
|
...(declaration.starterSession?{starter_session:declaration.starterSession}:{}),key:digest(row.key)};
|
|
374
389
|
const reply=await runtimeDoor(null,'harness.v1.agents.declare',body,{timeout:15_000});
|
|
375
390
|
if(reply.code!==0){waiting.push({agent:row.id,error:`not handed to this machine's daemon: ${reply.stderr.trim().split('\n').at(-1)}`});break;}
|
|
376
|
-
db=store(root);try{db.prepare('UPDATE agent_intents SET declared=1 WHERE org=? AND id=?').run(scope.org,row.id);}finally{db.close();}
|
|
391
|
+
db=store(root);try{db.prepare('UPDATE agent_intents SET declared=1 WHERE org=? AND id=? AND declaration_json=?').run(scope.org,row.id,row.declaration_json);}finally{db.close();}
|
|
377
392
|
}
|
|
378
393
|
for(const row of ends){
|
|
379
394
|
db=store(root);let active;
|
|
@@ -469,7 +484,7 @@ export function runBudgetRefusal(root,agent,budgets,now){
|
|
|
469
484
|
}
|
|
470
485
|
export function agentPreamble(agent,{main=true,previous=[]}={}){
|
|
471
486
|
const {identity:a,intended,assignments=[]}=agent;
|
|
472
|
-
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.':''}\
|
|
487
|
+
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.':''}\nParent: ${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'):''}`;
|
|
473
488
|
}
|
|
474
489
|
/** The agent's reader context this home already holds (a session of it was bound here before), read from the contexts
|
|
475
490
|
* file without a request, or null. A launch on this machine hands it to the session, so its Teams verbs run delegated
|
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { activeAgentAssignments, localAgentConversation } from '../agents.mjs';
|
|
8
|
-
import { connectionCall, connectionCanRun, connectionPath, generateModuleConfig, privateJson } from '../connection.mjs';
|
|
8
|
+
import { SERVICE_APPS, SERVICE_PROGRAM, connectionCall, connectionCanRun, connectionPath, generateModuleConfig, privateJson } from '../connection.mjs';
|
|
9
9
|
|
|
10
10
|
const {values}=parseArgs({options:{root:{type:'string'},connection:{type:'string'}}});
|
|
11
11
|
const path=connectionPath(values.root,values.connection),folder=dirname(path),lock=join(folder,'runner.lock');
|
|
@@ -25,10 +25,15 @@ const save=()=>{record.health=health;record.raised=raised;privateJson(path,recor
|
|
|
25
25
|
const teamPath=`/api/v1/teams/${encodeURIComponent(record.teams.team)}`;
|
|
26
26
|
const issue=(name,reason)=>{health[name]={state:'unavailable',reason,at:new Date().toISOString()};save();};
|
|
27
27
|
const recover=name=>{health[name]={state:'current',at:new Date().toISOString()};save();};
|
|
28
|
+
// The child a grant runs in: a module its own program; a Workplace service registration the one service, which the agents
|
|
29
|
+
// registration hosts (cards and machines start nothing of their own).
|
|
30
|
+
const childOf=grant=>SERVICE_APPS.includes(grant.name)?'agents':grant.name;
|
|
28
31
|
function start(grant){
|
|
32
|
+
if(SERVICE_APPS.includes(grant.name)&&grant.name!=='agents')return;
|
|
29
33
|
if(stopped||children.has(grant.name)||!existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)))return;
|
|
30
34
|
const config=generateModuleConfig(values.root,record,grant);
|
|
31
|
-
const
|
|
35
|
+
const program=SERVICE_APPS.includes(grant.name)?SERVICE_PROGRAM:join(record.moduleRoot,grant.name,'main.mjs');
|
|
36
|
+
const child=spawn(process.execPath,[program,config],{stdio:['ignore','pipe','pipe'],env:{...process.env,SUPERCODE_ORCHESTRATOR_HOME:values.root,HERMES_HOME:values.root}});
|
|
32
37
|
children.set(grant.name,child);let buffer='';
|
|
33
38
|
child.stdout.on('data',chunk=>{
|
|
34
39
|
process.stdout.write(chunk);if(stopped)return;buffer+=chunk.toString();let at;
|
|
@@ -101,7 +106,8 @@ async function renew(){
|
|
|
101
106
|
let changed=false;
|
|
102
107
|
if(Date.parse(grant.rh2.expiresAt)-Date.now()<=7*86400000){grant.rh2={...grant.rh2,...(await connectionCall(record,'rh2','POST',`/api/v3/automation/installations/${grant.rh2.installationId}/renew`,{},grant.rh2.accessToken)).data};changed=true;save();}
|
|
103
108
|
if(grant.teams)changed=await renewTeams(grant.teams)||changed;
|
|
104
|
-
|
|
109
|
+
// A renewed credential reaches its program by a restart with a regenerated config (the service's, for its registrations).
|
|
110
|
+
if(changed){const host=record.grants.find(g=>g.name===childOf(grant));if(host&&existsSync(join(values.root,'apps',`${host.name}.${record.id}.json`))){await stopChild(host.name);start(host);}}
|
|
105
111
|
if(!grant.teams?.pendingRenewal)recover(`expiry.${grant.name}`);
|
|
106
112
|
}catch(error){issue(`expiry.${grant.name}`,error.code??'renewal-unavailable');}
|
|
107
113
|
}
|
|
@@ -109,12 +115,8 @@ async function renew(){
|
|
|
109
115
|
}
|
|
110
116
|
for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>void stop());
|
|
111
117
|
for(const grant of record.grants)start(grant);
|
|
112
|
-
let lastRenew=0,lastStanding=0
|
|
113
|
-
|
|
114
|
-
const keys=(await connectionCall(record,'rh2','GET','/.well-known/jwks.json')).keys;
|
|
115
|
-
await connectionCall(record,'teams','POST',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-keyring`,{keys,results},record.issuerCredential.credential.token);
|
|
116
|
-
lastKeys=Date.now();
|
|
117
|
-
}
|
|
118
|
+
let lastRenew=0,lastStanding=0;
|
|
119
|
+
// Teams verifies the Workplace's tokens against its own JWKS (ADR 0070): nothing is relayed for them here.
|
|
118
120
|
while(!stopped){
|
|
119
121
|
try{
|
|
120
122
|
const installed=record.grants.filter(grant=>existsSync(join(values.root,'apps',`${grant.name}.${record.id}.json`)));
|
|
@@ -123,19 +125,8 @@ while(!stopped){
|
|
|
123
125
|
const pendingRenewalDue=[record.issuerCredential,...installed.map(grant=>grant.teams)].some(value=>value?.pendingRenewal&&!(value.renewalRetryAt>Date.now()));
|
|
124
126
|
if(pendingRenewalDue||Date.now()-lastRenew>3600000){await renew();lastRenew=Date.now();}
|
|
125
127
|
if(Date.now()-lastStanding>15000){standing();lastStanding=Date.now();}
|
|
126
|
-
// A restart may have outlived the key lease; publish before a twenty-second idle poll can delay admission.
|
|
127
|
-
if(Date.now()-lastKeys>=30000)await publishIssuerKeys();
|
|
128
|
-
const pending=(await connectionCall(record,'teams','GET',`${teamPath}/app-installations/${record.issuerCredential.installation.id}/issuer-checks`,undefined,record.issuerCredential.credential.token)).data.checks;
|
|
129
|
-
const results=await Promise.all(pending.map(async check=>{
|
|
130
|
-
// Every generation starts its own status read AFTER this poll received it. Never share a token decision,
|
|
131
|
-
// even if two generations of the same token occur in one batch; only this generation may use this answer.
|
|
132
|
-
const generation=check.id;
|
|
133
|
-
const status=(await connectionCall(record,'rh2','POST','/api/v3/delegated/status',{token:check.token})).data;
|
|
134
|
-
return {id:generation,live:status?.live===true};
|
|
135
|
-
}));
|
|
136
|
-
if(results.length||Date.now()-lastKeys>=30000)await publishIssuerKeys(results);
|
|
137
128
|
recover('issuer');await syncAlerts();
|
|
138
|
-
}catch(error){issue('issuer',error.code??error.status??'
|
|
129
|
+
}catch(error){issue('issuer',error.code??error.status??'connection-unavailable');await syncAlerts();}
|
|
139
130
|
await new Promise(done=>setTimeout(done,2000));
|
|
140
131
|
}
|
|
141
132
|
async function stop(){if(stopped)return;stopped=true;await Promise.all([...children.keys()].map(stopChild));record.stoppedAt=new Date().toISOString();save();unlinkSync(lock);process.exit(0);}
|
package/bin/orchestrator.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// A reader such as `head` may close its pipe before the final flush. Finish the command and its document work;
|
|
3
|
+
// a closed output pipe is not an unhandled runtime error. Other stream failures retain their original error.
|
|
4
|
+
for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error; });
|
|
2
5
|
// before anything loads node:sqlite: Node's notice that it is experimental is not this command's to print
|
|
3
6
|
try { await import('@volter/supercode-harness-sdk/runtime-warnings'); } catch { /* a harness SDK without it: the notice is printed */ }
|
|
4
7
|
// Check before handing off or importing board/runtime dependencies.
|
|
@@ -36,10 +39,20 @@ if (process.argv[2] === 'workflow') {
|
|
|
36
39
|
} else if (process.argv[2] === 'connect') {
|
|
37
40
|
const {parseArgs}=await import('node:util');
|
|
38
41
|
const {connectWorkplace}=await import('../connection.mjs');
|
|
39
|
-
const
|
|
40
|
-
options['
|
|
41
|
-
|
|
42
|
+
const {SERVICE_REGISTRATIONS}=await import('../connection.mjs');
|
|
43
|
+
const options=Object.fromEntries(['root','ticket-file','modules-root','source','context','teams-token-file','teams-url','team','floor','standing-file','alert-role','webhook-url','webhook-listen'].map(key=>[key,{type:'string'}]));
|
|
44
|
+
options['no-start']={type:'boolean'};options.registrations={type:'boolean'};
|
|
45
|
+
try{
|
|
46
|
+
const {values}=parseArgs({args:process.argv.slice(3),options});
|
|
47
|
+
if(values.registrations)process.stdout.write(`${SERVICE_REGISTRATIONS}\n`);
|
|
48
|
+
else process.stdout.write(JSON.stringify(await connectWorkplace(values),null,2)+'\n');
|
|
49
|
+
}
|
|
42
50
|
catch(error){process.stderr.write(`connect: ${error.message}\n`);process.exitCode=1;}
|
|
51
|
+
} else if (process.argv[2] === 'disconnect') {
|
|
52
|
+
const {parseArgs}=await import('node:util');
|
|
53
|
+
const {disconnectWorkplace}=await import('../connection.mjs');
|
|
54
|
+
try{const {values}=parseArgs({args:process.argv.slice(3),options:{root:{type:'string'},connection:{type:'string'}}});process.stdout.write(JSON.stringify(await disconnectWorkplace(values),null,2)+'\n');}
|
|
55
|
+
catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
|
|
43
56
|
} else if (process.argv[2] === 'agent-channel') {
|
|
44
57
|
await import('./agent-channel.mjs');
|
|
45
58
|
} else {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// minted and recorded here, and its declaration handed to this machine's daemon, which declares it in Teams: whoever ran
|
|
3
3
|
// the effect (the creating command, or a delivery pass retrying it) makes no Teams call (D137).
|
|
4
4
|
import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
|
|
5
|
-
import { event, json, tx, view } from './store.mjs';
|
|
5
|
+
import { cardHandleOf, readableIdOf, event, json, tx, view } from './store.mjs';
|
|
6
6
|
import { workflowFor } from './workflow.mjs';
|
|
7
7
|
import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
|
|
8
8
|
|
|
@@ -13,7 +13,7 @@ function admitsScope(board,target,org){
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export async function declareCardAgentEffect(board,effect) {
|
|
16
|
-
const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived
|
|
16
|
+
const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
|
|
17
17
|
if(!task||task.subtask_of)return true;
|
|
18
18
|
const entry=workflowFor(board.root),profile=task.assignee??effect.profile??entry.params.default_assignee;
|
|
19
19
|
// The reasons already told since the card's last declaration: each is recorded once, so a retry that alternates
|
|
@@ -28,7 +28,7 @@ export async function declareCardAgentEffect(board,effect) {
|
|
|
28
28
|
org=scope.org;
|
|
29
29
|
if(!admitsScope(board,effect.id,org))return true;
|
|
30
30
|
const options={root:board.root,org:scope.org,server:scope.server,profiles:entry.profiles,
|
|
31
|
-
profile,card:effect.id,
|
|
31
|
+
profile,card:effect.id,handle:cardHandleOf(board,effect.id),readableId:readableIdOf(board,effect.id),role:effect.role,context,starterSession:effect.starter_session};
|
|
32
32
|
const agent=await localCardAgent(options);
|
|
33
33
|
if(task.archived||['done','cancelled','archived'].includes(task.status))finishAgentAssignment(board.root,scope.org,agent.assignment,`card ${effect.id} ${task.status}`);
|
|
34
34
|
// A daemon not running keeps the declaration here; every delivery pass hands it on again.
|
package/board/args.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// A workflow verb's arguments, as the CLI and the board's owner both read them.
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
|
|
4
|
-
export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding']);
|
|
5
|
-
export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'reclaim', 'dry-run',
|
|
4
|
+
export const MULTI = new Set(['parent', 'skill', 'worker', 'child', 'ids', 'rm', 'tag', 'pr', 'pr-head', 'reviewed-sha', 'finding', 'source']);
|
|
5
|
+
export const FLAGS = new Set(['json', 'triage', 'no-notify', 'no-start', 'all', 'force', 'help', 'mine', 'archived', 'subtasks', 'reclaim', 'dry-run',
|
|
6
6
|
'once', 'goal', 'no-goal', 'off', 'switch', 'delete', 'no-attachments', 'include-logs', 'verbose', 'side']);
|
|
7
7
|
// flags that take every following word up to the next flag (Hermes's nargs='+')
|
|
8
8
|
export const GREEDY = new Set(['ids', 'rm']);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Native workflows declare orthogonal holds; Hermes declares lifecycle blocks.
|
|
2
|
+
// Readers must use the declaring workflow, including for legacy stored rows.
|
|
3
|
+
const parse = (value, fallback) => {
|
|
4
|
+
if (typeof value !== 'string') return value ?? fallback;
|
|
5
|
+
try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// The aliases belong to the board's task listings. Use the same hold precedence
|
|
9
|
+
// in SQL filters as in row projection, including a legacy blocked storage row.
|
|
10
|
+
export const cardStateSql = ({ nativeHolds = false } = {}) => nativeHolds ? `CASE
|
|
11
|
+
WHEN COALESCE(c.stage,t.status) IN ('done','cancelled','archived') OR t.status = 'archived' THEN t.status
|
|
12
|
+
WHEN c.paused IS NOT NULL AND c.paused != 'null' THEN 'paused'
|
|
13
|
+
WHEN CASE WHEN json_valid(c.blocked_by) THEN json_array_length(c.blocked_by) ELSE 0 END > 0 THEN 'blocked'
|
|
14
|
+
WHEN t.status IN ('paused','blocked') THEN CASE WHEN COALESCE(c.stage,t.status) IN ('paused','blocked') THEN 'todo' ELSE COALESCE(c.stage,t.status) END
|
|
15
|
+
ELSE t.status END` : 't.status';
|
|
16
|
+
|
|
17
|
+
export function cardState(task, { nativeHolds = false } = {}) {
|
|
18
|
+
const paused = parse(task.paused, null);
|
|
19
|
+
const blocked_by = parse(task.blocked_by, []);
|
|
20
|
+
const lane = task.stage ?? task.workflow_status ?? task.status;
|
|
21
|
+
const ended = ['done', 'cancelled', 'archived'].includes(lane) || task.status === 'archived';
|
|
22
|
+
const status = !nativeHolds ? task.status : ended ? task.status : paused ? 'paused' : blocked_by.length ? 'blocked'
|
|
23
|
+
: task.status === 'blocked' || task.status === 'paused' ? (['blocked', 'paused'].includes(lane) ? 'todo' : lane) : task.status;
|
|
24
|
+
return { ...task, status, paused, blocked_by };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const holdDescription = (task) => task.status === 'paused' && task.paused
|
|
28
|
+
? `paused by ${task.paused.by ?? 'unknown'}: ${task.paused.reason ?? 'reason not recorded'}`
|
|
29
|
+
: task.status === 'blocked' && task.blocked_by?.length ? `blocked by ${task.blocked_by.map(b => `${b.id}${b.reason ? `: ${b.reason}` : ''}`).join(', ')}` : null;
|
package/board/ci.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
8
8
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
9
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
10
|
import { githubFetch } from './github-rest.mjs';
|
|
11
|
+
import { cardHandleOf } from './store.mjs';
|
|
11
12
|
|
|
12
13
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
13
14
|
// waits on synchronously): Node takes a World's trust bundle and injector (NODE_EXTRA_CA_CERTS, NODE_OPTIONS), so a
|
|
@@ -285,8 +286,10 @@ const originOf = async (path) => /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/
|
|
|
285
286
|
async function repositoryWorkspace(board, id) {
|
|
286
287
|
const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
287
288
|
const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
|
|
288
|
-
const
|
|
289
|
-
if (!
|
|
289
|
+
const owner = task?.workspace_kind === 'scratch' ? parent : task;
|
|
290
|
+
if (!owner || owner.workspace_kind === 'scratch') return null;
|
|
291
|
+
// the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
|
|
292
|
+
const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
|
|
290
293
|
const here = isLocalWorkspace(workspace);
|
|
291
294
|
if (workspace.workspace_anchor) {
|
|
292
295
|
// A portable remote declaration supplies the source identity. Reviewing
|
|
@@ -371,17 +374,31 @@ function registeredBranches(board, id) {
|
|
|
371
374
|
if (!row.workspace_anchor || /^(\/|~|[A-Za-z]:[\\/])/.test(row.workspace_anchor)) continue;
|
|
372
375
|
out.push({ repository: row.workspace_anchor.replace(/^github\.com\//i, '').replace(/\.git$/, ''), branch: row.branch_name });
|
|
373
376
|
}
|
|
377
|
+
// and each repository and branch the arc declared as its own (arcSources)
|
|
378
|
+
for (const declared of arcSources(board.db, id)) if (!out.some((r) => r.repository.toLowerCase() === declared.repository.toLowerCase() && r.branch === declared.branch)) out.push(declared);
|
|
374
379
|
return out;
|
|
375
380
|
}
|
|
376
381
|
|
|
377
|
-
/**
|
|
382
|
+
/** The repositories and branches the arc itself declares it changes: its latest `comment --source owner/repo:branch`
|
|
383
|
+
* declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
|
|
384
|
+
* review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
|
|
385
|
+
export function arcSources(db, id) {
|
|
386
|
+
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
387
|
+
let declared = [];
|
|
388
|
+
try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
|
|
389
|
+
return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Legacy branches carry the globally unique native id. Keyed handles are local to a board, so their branches
|
|
393
|
+
* count only by exact recorded repository/branch identity (openReviewPullRequests), never by a handle prefix. */
|
|
378
394
|
export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`].some((prefix) => head === prefix || head.startsWith(`${prefix}-`) || head.startsWith(`${prefix}/`));
|
|
379
395
|
|
|
380
|
-
/** The branch names an arc declares as its work: its own and its subtasks' `--branch`,
|
|
381
|
-
* review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
|
|
396
|
+
/** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
|
|
397
|
+
* sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
|
|
382
398
|
export function declaredWorkBranches(db, id) {
|
|
383
399
|
const declared = db.prepare(`SELECT t.branch_name FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
384
400
|
WHERE (t.id=? OR c.subtask_of=?) AND t.status != 'cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
|
|
401
|
+
declared.push(...arcSources(db, id).map((b) => b.branch));
|
|
385
402
|
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
386
403
|
let reviewed = [];
|
|
387
404
|
try { reviewed = candidatePullRequests(row && JSON.parse(row.payload)?.ci).map((p) => p.branch); } catch { /* an unreadable payload names none */ }
|
|
@@ -405,6 +422,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
405
422
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
406
423
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
407
424
|
const repositoriesToRead = [...new Set([ownRepository, ...registered.map((r) => r.repository), ...(await knownRepositories({ fresh }))].filter(Boolean))];
|
|
425
|
+
const answered = answeredChanges(board, id);
|
|
408
426
|
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
409
427
|
const prs = [];
|
|
410
428
|
const hasWorkBranches = repository === ownRepository || registered.some(r => r.repository === repository);
|
|
@@ -441,11 +459,12 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
441
459
|
...(repository === ownRepository ? branch ? [branch] : [`wt/${id}`, `wt/${id.replace(/^t_/, 't-')}`] : []),
|
|
442
460
|
...registered.filter(r => r.repository === repository).map(r => r.branch),
|
|
443
461
|
])];
|
|
462
|
+
// A judged merged head is passed over, except while an answered changes-requested verdict makes it due again.
|
|
444
463
|
const histories = await readTogether(historical, async headBranch => {
|
|
445
464
|
if (headBranch === list.defaultBranch || list.items.some(pr => pr.headRefName === headBranch)) return [];
|
|
446
465
|
return (await mergedBranchPullRequests(repository, headBranch, { fresh })).filter(pr =>
|
|
447
466
|
!pr.isCrossRepository && pr.headRefName === headBranch && pr.headRefName !== pr.defaultBranch
|
|
448
|
-
&& pr.baseRefName === pr.defaultBranch && !judgedHead(board, id, pr.headRefOid));
|
|
467
|
+
&& pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
|
|
449
468
|
});
|
|
450
469
|
for (const history of histories) for (const pr of history) {
|
|
451
470
|
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
@@ -454,8 +473,8 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
454
473
|
});
|
|
455
474
|
const prs = groups.flat();
|
|
456
475
|
// A source declaration (an implementer's or manager's card comment, engine.comment) adds merged or moved PRs that
|
|
457
|
-
// discovery cannot see; it never hides what discovery found. Only repositories the card's workspace
|
|
458
|
-
// branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
476
|
+
// discovery cannot see; it never hides what discovery found. Only repositories the card's workspace, its declared
|
|
477
|
+
// sources or its tasks' registered branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
459
478
|
// declared one, so a push after the declaration is reviewed instead of failing every tick.
|
|
460
479
|
const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
461
480
|
const repositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
@@ -475,6 +494,8 @@ export async function noCodeChange(board, id) {
|
|
|
475
494
|
const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
|
|
476
495
|
if (taskRow?.workspace_kind === 'scratch') {
|
|
477
496
|
if (board.db.prepare("SELECT 1 FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE c.subtask_of=? AND t.workspace_kind!='scratch' AND t.status!='cancelled' LIMIT 1").get(id)) return null;
|
|
497
|
+
// a scratch arc that declares repositories it changes carries code, never a disposition
|
|
498
|
+
if (arcSources(board.db, id).length) return null;
|
|
478
499
|
// Scratch declares an operational disposition rather than repository code.
|
|
479
500
|
// Closed tasks/results and the retained card thread are the review source;
|
|
480
501
|
// this does not invent a verdict or assert that an external act succeeded.
|
|
@@ -509,14 +530,24 @@ async function knownRepositories({ fresh = false } = {}) {
|
|
|
509
530
|
catch (error) { if (fresh) throw error; return []; }
|
|
510
531
|
}
|
|
511
532
|
|
|
512
|
-
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review:
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* at
|
|
518
|
-
*
|
|
533
|
+
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: its last verdict's findings are answered, or
|
|
534
|
+
* some head no verdict judged is new work.
|
|
535
|
+
* After changes are requested, the verdict is answered once every task it owns has been closed (`answeredChanges`):
|
|
536
|
+
* its review is due again at whatever heads the PRs have, the same ones included, since a finding about evidence (a
|
|
537
|
+
* recording, a card comment) is answered without a commit (VO-2858 t_cc590655: its one finding, t_95c21edb, was closed
|
|
538
|
+
* at 22:08Z on 2026-10-17 with all three heads unchanged, and the arc waited on "push a commit" until the manager's
|
|
539
|
+
* reopen_review).
|
|
540
|
+
* Before any verdict every unjudged head is new work. After a verdict, a PR it judged with a new head is, and so is a
|
|
541
|
+
* head committed after that verdict (a new PR carrying the fixes, or any PR after a verdict that named none). After
|
|
542
|
+
* changes are requested, a judged PR's new head whose source tree is the one the verdict judged is not new work (an
|
|
543
|
+
* empty commit answers nothing: VO-2858's 59f38683 carried db7d19a0's tree and was requested at once), and a PR that
|
|
544
|
+
* joins with a head committed before the verdict is not: the next run is the implementer's until it answers. After a
|
|
545
|
+
* pass nothing is owed by the implementer, so any new head of a PR it judged is due (acceptance needs the verdict on
|
|
546
|
+
* the current heads), and a PR that verdict's request did not name is due at whatever head it has: one that was a draft
|
|
547
|
+
* or stacked on another PR's branch when the request was made, and became the arc's once that PR merged (t_bbbd2086's
|
|
548
|
+
* #1355, committed 2 h before the pass that named only #1353). */
|
|
519
549
|
export async function reviewDue(board, id, prs) {
|
|
550
|
+
if (prs.length && answeredChanges(board, id)) return true;
|
|
520
551
|
const unjudged = prs.filter((p) => !judgedHead(board, id, p.head));
|
|
521
552
|
if (!unjudged.length) return false;
|
|
522
553
|
const voided = board.db.prepare("SELECT r.payload FROM task_events v JOIN task_events r ON r.id=json_extract(v.payload,'$.review_request') AND r.task_id=v.task_id WHERE v.task_id=? AND v.kind='review_voided' ORDER BY v.id DESC LIMIT 1").get(id);
|
|
@@ -530,23 +561,50 @@ export async function reviewDue(board, id, prs) {
|
|
|
530
561
|
ORDER BY r.id DESC LIMIT 1`).get(id);
|
|
531
562
|
if (!last) return true;
|
|
532
563
|
const judged = candidatePullRequests(JSON.parse(last.payload)?.ci);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
564
|
+
const judgedOf = (p) => judged.find((j) => j.pr.url === p.url) ?? null;
|
|
565
|
+
for (const p of prs) {
|
|
566
|
+
const j = judgedOf(p);
|
|
567
|
+
if (!j || p.head === j.commit) continue;
|
|
568
|
+
if (last.verdict === 'review_pass' || !await sameTree(p, j.commit)) return true;
|
|
569
|
+
}
|
|
570
|
+
if (last.verdict === 'review_pass' && unjudged.some((p) => !judgedOf(p))) return true;
|
|
571
|
+
// a PR the verdict judged was answered above, by its tree; any other is new work when committed after the verdict
|
|
572
|
+
for (const p of unjudged) if (!judgedOf(p) && (await commitTime(p)) > Number(last.verdict_at)) return true;
|
|
536
573
|
return false;
|
|
537
574
|
}
|
|
538
575
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
return
|
|
576
|
+
/** Whether the arc's last review event is changes requested and every task that verdict owns is closed now. A verdict
|
|
577
|
+
* owns the tasks filed or reopened between the request it answered and itself (`request-changes --finding` files each
|
|
578
|
+
* finding as a task just before the verdict; a reviewer may reopen one instead). A verdict that owns no task is never
|
|
579
|
+
* answered this way: only a new head is. Task state is read as every board reader reads it (statusOf: the card's
|
|
580
|
+
* workflow stage over its stored status). */
|
|
581
|
+
export function answeredChanges(board, id) {
|
|
582
|
+
const db = board.db;
|
|
583
|
+
const verdict = db.prepare("SELECT id, kind FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided') ORDER BY id DESC LIMIT 1").get(id);
|
|
584
|
+
if (verdict?.kind !== 'changes_requested') return false;
|
|
585
|
+
const asked = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_requested' AND id < ?").get(id, verdict.id)?.id ?? 0;
|
|
586
|
+
const owned = db.prepare(`SELECT COALESCE(c.stage, t.status) AS status FROM supercode_cards c JOIN tasks t ON t.id = c.task_id
|
|
587
|
+
WHERE c.subtask_of = ? AND EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = c.task_id AND e.kind IN ('created', 'reopened') AND e.id > ? AND e.id < ?)`).all(id, asked, verdict.id);
|
|
588
|
+
return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
|
|
548
589
|
}
|
|
549
590
|
|
|
591
|
+
const commitReads = new Map();
|
|
592
|
+
/** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
|
|
593
|
+
* is unknown, never evidence of old work or of a changed tree. */
|
|
594
|
+
async function commitRead({ url, head }) {
|
|
595
|
+
const key = `${githubBase()}\0${repositoryOf(url)}\0${head}`;
|
|
596
|
+
if (commitReads.has(key)) return commitReads.get(key);
|
|
597
|
+
const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`)).commit;
|
|
598
|
+
const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
|
|
599
|
+
if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
|
|
600
|
+
const read = { at, tree };
|
|
601
|
+
commitReads.set(key, read);
|
|
602
|
+
return read;
|
|
603
|
+
}
|
|
604
|
+
const commitTime = async (p) => (await commitRead(p)).at;
|
|
605
|
+
/** Whether the PR's head carries the same source tree as `commit` of the same PR. */
|
|
606
|
+
const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
|
|
607
|
+
|
|
550
608
|
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
|
|
551
609
|
async function requireCurrentReviewSet(board, id, urls) {
|
|
552
610
|
const current = await openReviewPullRequests(board, id, { fresh: true });
|
|
@@ -620,7 +678,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
620
678
|
const registered = registeredBranches(board, id);
|
|
621
679
|
const declared = (pr) => namedWorkBranch(id, pr.headRefName) || registered.some((r) => r.branch === pr.headRefName && r.repository.toLowerCase() === repositoryOf(pr.url).toLowerCase());
|
|
622
680
|
const stray = read.find((pr) => pr.state !== 'MERGED' && !declared(pr));
|
|
623
|
-
if (stray) throw new Error(`An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare;
|
|
681
|
+
if (stray) throw new Error(`An unmerged PR candidate needs a repository workspace: ${stray.url} (${stray.headRefName}) is on no branch the card or its tasks declare; declare it on the arc: comment ${id} "<why>" --source ${repositoryOf(stray.url)}:${stray.headRefName} (with every other source the arc changes)`);
|
|
624
682
|
}
|
|
625
683
|
// A verdict with no requested PR (a scratch arc) keeps its own contract. An arc's review
|
|
626
684
|
// always names every opened PR it changed, independent of deployment.
|
|
@@ -635,7 +693,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
635
693
|
const prs = await mapGithubSources(urls, (url, index) => verifyPullRequest(board, id, entry, own, url, stage === 'request_review' ? named[index] : null, stage, read[index]));
|
|
636
694
|
if (stage === 'request_review') {
|
|
637
695
|
// A review is asked for only while some PR has a head no verdict has judged.
|
|
638
|
-
if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('
|
|
696
|
+
if (!await reviewDue(board, id, prs.map((p) => ({ url: p.pr.url, head: p.commit })))) throw new Error('Every PR head has a verdict, carries the source tree its verdict judged, or predates it: push a commit that changes the source, or close the tasks that own the last verdict\'s findings, before requesting review.');
|
|
639
697
|
} else if (asked) {
|
|
640
698
|
const requested = candidatePullRequests(asked);
|
|
641
699
|
if (requested.length !== prs.length || requested.some((r) => !prs.some((p) => p.pr.url === r.pr.url && p.commit === r.commit))) throw new Error('The verdict covers the requested PRs at their requested heads; a PR whose head moved needs a new review request.');
|