@volter/supercode-orchestrator 0.5.176 → 0.5.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Teams identity, orchestrator intent and assignments: one boundary, joined by the immutable agent id.
2
2
  import { DatabaseSync } from 'node:sqlite';
3
- import { mkdirSync } from 'node:fs';
3
+ import { mkdirSync, existsSync } from 'node:fs';
4
4
  import { join, basename } from 'node:path';
5
5
  import { createHash, randomBytes } from 'node:crypto';
6
6
  import { runtimeDoor } from './board/runtime-door.mjs';
@@ -18,10 +18,15 @@ function store(root){
18
18
  CREATE TABLE IF NOT EXISTS agent_declaration_heads(org TEXT NOT NULL,key TEXT NOT NULL,agent_id TEXT NOT NULL,generation INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(org,key));
19
19
  CREATE TABLE IF NOT EXISTS agent_assignments(org TEXT NOT NULL,id TEXT NOT NULL,agent_id TEXT NOT NULL,kind TEXT NOT NULL,target TEXT NOT NULL,role TEXT NOT NULL,state TEXT NOT NULL,started_at INTEGER NOT NULL,ended_at INTEGER,PRIMARY KEY(org,id));
20
20
  CREATE INDEX IF NOT EXISTS agent_active_assignments ON agent_assignments(org,agent_id,state);
21
+ CREATE INDEX IF NOT EXISTS agent_card_assignments ON agent_assignments(kind,target);
22
+ CREATE TABLE IF NOT EXISTS agent_card_assignment_changes(sequence INTEGER PRIMARY KEY AUTOINCREMENT,target TEXT NOT NULL,payload TEXT NOT NULL,recorded INTEGER NOT NULL DEFAULT 0);
23
+ CREATE INDEX IF NOT EXISTS agent_card_assignment_changes_pending ON agent_card_assignment_changes(recorded,target,sequence);
24
+ CREATE TABLE IF NOT EXISTS agent_card_scope_transitions(target TEXT PRIMARY KEY,from_org TEXT NOT NULL,to_org TEXT NOT NULL,changed_at INTEGER NOT NULL);
21
25
  CREATE TABLE IF NOT EXISTS agent_end_outbox(org TEXT NOT NULL,agent_id TEXT NOT NULL,reason TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(org,agent_id));
22
26
  CREATE TABLE IF NOT EXISTS agent_runtime_sessions(org TEXT NOT NULL,runtime_ref TEXT NOT NULL,session_ref TEXT NOT NULL,PRIMARY KEY(org,runtime_ref));
23
27
  CREATE TABLE IF NOT EXISTS agent_runs(org TEXT NOT NULL,agent_id TEXT NOT NULL,session_ref TEXT NOT NULL,machine TEXT,run_id TEXT,profile_json TEXT,ended_at INTEGER,end_reason TEXT,main INTEGER NOT NULL DEFAULT 1,starter_session TEXT,usage_json TEXT,PRIMARY KEY(org,session_ref));
24
28
  CREATE TABLE IF NOT EXISTS agent_usage_unread(org TEXT NOT NULL,session_ref TEXT NOT NULL,stamp TEXT NOT NULL,PRIMARY KEY(org,session_ref));`);
29
+ db.prepare('DELETE FROM agent_card_assignment_changes WHERE recorded=1').run();
25
30
  return db;
26
31
  }
27
32
  const digest=value=>createHash('sha256').update(value).digest('hex');
@@ -34,10 +39,50 @@ const minted=async(key,machine)=>{
34
39
  throw new Error(`agent id mint unavailable: ${reply.stderr.slice(-500)}`);
35
40
  };
36
41
  export function agentIntent(root,org,id){const db=store(root);try{const row=db.prepare('SELECT * FROM agent_intents WHERE org=? AND id=?').get(org,id);return row?{...row,profile:JSON.parse(row.profile_json),assignments:db.prepare("SELECT * FROM agent_assignments WHERE org=? AND agent_id=? AND state='active'").all(org,id)}:null;}finally{db.close();}}
42
+ /** Ownership moves and their board obligation commit together in the assignment owner's store. */
43
+ function retainCardAgentChange(db,old,to,profile){
44
+ if(old?.kind!=='card'||old.agent_id===to)return;
45
+ const fromProfile=db.prepare('SELECT profile_ref FROM agent_intents WHERE org=? AND id=?').get(old.org,old.agent_id)?.profile_ref??null;
46
+ db.prepare('INSERT INTO agent_card_assignment_changes(target,payload) VALUES (?,?)').run(old.target,JSON.stringify({org:old.org,assignment:old.id,role:old.role,from:old.agent_id,to,profile:profile??null,from_profile:fromProfile,changed_at:Date.now()}));
47
+ }
37
48
  export function assignAgent(root,org,id,{kind,target,role}){
38
- const db=store(root),key=digest(JSON.stringify([kind,target,role]));try{db.prepare("INSERT INTO agent_assignments(org,id,agent_id,kind,target,role,state,started_at) VALUES (?,?,?,?,?,?,'active',?) ON CONFLICT(org,id) DO UPDATE SET agent_id=excluded.agent_id,state='active',ended_at=NULL").run(org,key,id,kind,target,role,Date.now());return key;}finally{db.close();}
49
+ const db=store(root),key=digest(JSON.stringify([kind,target,role]));try{
50
+ db.exec('BEGIN IMMEDIATE');
51
+ if(kind==='card'&&org==='local'&&db.prepare('SELECT 1 FROM agent_card_scope_transitions WHERE target=?').get(target)){
52
+ console.warn(`card agent rejected: ${target} has left local scope`);
53
+ throw Object.assign(new Error('card agent scope was adopted; local declarations are refused'),{code:'card_scope_adopted'});
54
+ }
55
+ const old=db.prepare('SELECT * FROM agent_assignments WHERE org=? AND id=?').get(org,key);
56
+ db.prepare("INSERT INTO agent_assignments(org,id,agent_id,kind,target,role,state,started_at) VALUES (?,?,?,?,?,?,'active',?) ON CONFLICT(org,id) DO UPDATE SET agent_id=excluded.agent_id,state='active',ended_at=NULL").run(org,key,id,kind,target,role,Date.now());
57
+ const profile=db.prepare('SELECT profile_ref FROM agent_intents WHERE org=? AND id=?').get(org,id)?.profile_ref;
58
+ retainCardAgentChange(db,old,id,profile);
59
+ db.exec('COMMIT');return key;
60
+ }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
39
61
  }
40
62
  export function activeAgentAssignments(root){const db=store(root);try{return db.prepare("SELECT * FROM agent_assignments WHERE state='active'").all();}finally{db.close();}}
63
+ /** Retained card assignments, including ends a board still owes to its publication log. */
64
+ export function cardAgentAssignments(root,target=null){const db=store(root);try{return db.prepare(`SELECT a.*,i.profile_ref AS profile FROM agent_assignments a LEFT JOIN agent_intents i ON i.org=a.org AND i.id=a.agent_id WHERE a.kind='card'${target===null?'':' AND a.target=?'} ORDER BY a.target,a.role,a.org,a.id`).all(...(target===null?[]:[target]));}finally{db.close();}}
65
+ /** Adoption remains authoritative after its delivery receipt is acknowledged. One read pins scope and native state. */
66
+ export function cardAgentScopeTransition(root,target){
67
+ const path=join(root,'agent-state.sqlite');if(!existsSync(path))return null;
68
+ const db=new DatabaseSync(path,{readOnly:true});
69
+ try{
70
+ db.exec('PRAGMA busy_timeout=5000; BEGIN');
71
+ if(!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_card_scope_transitions'").get())return null;
72
+ const transition=db.prepare('SELECT from_org,to_org AS org,changed_at FROM agent_card_scope_transitions WHERE target=?').get(target);
73
+ if(!transition)return null;
74
+ const assignments=db.prepare("SELECT a.*,i.profile_ref AS profile FROM agent_assignments a LEFT JOIN agent_intents i ON i.org=a.org AND i.id=a.agent_id WHERE a.kind='card' AND a.target=? AND a.org=? ORDER BY a.role,a.id").all(target,transition.org);
75
+ return {...transition,assignments};
76
+ }finally{db.close();}
77
+ }
78
+ /** A board consumes these in receipt order before mirroring the assignment's present owner or end. */
79
+ export function pendingCardAgentChanges(root,target){const db=store(root);try{return db.prepare('SELECT sequence,payload FROM agent_card_assignment_changes WHERE recorded=0 AND target=? ORDER BY sequence').all(target).map(row=>({receipt:Number(row.sequence),...JSON.parse(row.payload)}));}finally{db.close();}}
80
+ /** The board committed facts and dedupe watermark before acknowledging; only pending receipts remain here. */
81
+ export function acknowledgeCardAgentChanges(root,receipts){const db=store(root);try{
82
+ db.exec('BEGIN IMMEDIATE');
83
+ for(const receipt of receipts)db.prepare('DELETE FROM agent_card_assignment_changes WHERE sequence=?').run(receipt);
84
+ db.exec('COMMIT');
85
+ }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}}
41
86
  export async function reassignAgent(root,org,assignment,to,{context}={}) {
42
87
  const target=(await agentDoor('management',[to],{context})).data;
43
88
  const db=store(root);
@@ -48,6 +93,7 @@ export async function reassignAgent(root,org,assignment,to,{context}={}) {
48
93
  db.exec('BEGIN IMMEDIATE');
49
94
  const moved=db.prepare("UPDATE agent_assignments SET agent_id=? WHERE org=? AND id=? AND agent_id=? AND state='active'").run(to,org,assignment,old.agent_id);
50
95
  if(moved.changes!==1)throw new Error('assignment changed while reassigning it');
96
+ retainCardAgentChange(db,old,to,target.profile);
51
97
  db.prepare('DELETE FROM agent_end_outbox WHERE org=? AND agent_id=?').run(org,to);
52
98
  if(old.agent_id!==to&&!db.prepare("SELECT 1 FROM agent_assignments WHERE org=? AND agent_id=? AND state='active'").get(org,old.agent_id))
53
99
  db.prepare('INSERT OR IGNORE INTO agent_end_outbox(org,agent_id,reason,created_at) VALUES (?,?,?,?)').run(org,old.agent_id,`last assignment reassigned to ${to}`,Date.now());
@@ -58,11 +104,12 @@ export async function reassignAgent(root,org,assignment,to,{context}={}) {
58
104
  export function finishAgentAssignment(root,org,assignment,reason){
59
105
  const db=store(root);try{
60
106
  db.exec('BEGIN IMMEDIATE');
61
- const row=db.prepare("SELECT * FROM agent_assignments WHERE org=? AND id=? AND state='active'").get(org,assignment);
62
- if(!row){db.exec('COMMIT');return;}
107
+ const row=db.prepare('SELECT * FROM agent_assignments WHERE org=? AND id=?').get(org,assignment);
108
+ if(!row||row.state!=='active'){db.exec('COMMIT');return row??null;}
63
109
  const at=Date.now();db.prepare("UPDATE agent_assignments SET state='ended',ended_at=? WHERE org=? AND id=?").run(at,org,assignment);
64
110
  if(!db.prepare("SELECT 1 FROM agent_assignments WHERE org=? AND agent_id=? AND state='active'").get(org,row.agent_id))db.prepare('INSERT OR IGNORE INTO agent_end_outbox(org,agent_id,reason,created_at) VALUES (?,?,?,?)').run(org,row.agent_id,reason,at);
65
111
  db.exec('COMMIT');
112
+ return {...row,state:'ended',ended_at:at};
66
113
  }catch(error){try{db.exec('ROLLBACK');}catch{}throw error;}finally{db.close();}
67
114
  }
68
115
  /** Mint and retain intent before any network call. Offline declarations keep their immutable id for sync. */
@@ -136,6 +183,10 @@ async function localAgent(options,{key,name,declaration,assignment,parent=null,s
136
183
  }
137
184
  export async function localCardAgent(options){
138
185
  const {root,org,server,profiles,card,role,profile}=options;
186
+ if(org==='local'&&cardAgentScopeTransition(root,card)){
187
+ console.warn(`card agent rejected: ${card} has left local scope`);
188
+ throw Object.assign(new Error('card agent scope was adopted; local declarations are refused'),{code:'card_scope_adopted'});
189
+ }
139
190
  // A card role's assignment the manager moved to another agent (`workflow reassign --agent`) runs as that agent: the
140
191
  // active assignment names it, not the key's own head, and an agent whose end was synchronized here is not used.
141
192
  const held=activeAgentAssignments(root).find(item=>item.org===org&&item.kind==='card'&&item.target===card&&item.role===role);
@@ -216,8 +267,18 @@ function adoptLocalOrg(root,scope){
216
267
  }
217
268
  for(const head of db.prepare("SELECT * FROM agent_declaration_heads WHERE org='local'").all())
218
269
  db.prepare('INSERT OR IGNORE INTO agent_declaration_heads(org,key,agent_id,generation) VALUES (?,?,?,?)').run(scope.org,rekey(head.key),ids.get(head.agent_id)??head.agent_id,head.generation);
219
- for(const [old,id] of ids)for(const table of ['agent_assignments','agent_runs','agent_end_outbox'])
220
- db.prepare(`UPDATE OR IGNORE ${table} SET org=?,agent_id=? WHERE org='local' AND agent_id=?`).run(scope.org,id,old);
270
+ // An earlier transition is permanent: even an old writer's stray local row cannot migrate it again.
271
+ db.prepare("DELETE FROM agent_assignments WHERE org='local' AND kind='card' AND target IN (SELECT target FROM agent_card_scope_transitions)").run();
272
+ const cards=db.prepare("SELECT DISTINCT target FROM agent_assignments WHERE org='local' AND kind='card'").all();
273
+ // Local transfers cannot describe the destination's native ownership. Drop them in the adoption transaction.
274
+ db.prepare("DELETE FROM agent_card_assignment_changes WHERE json_extract(payload,'$.org')='local'").run();
275
+ for(const table of ['agent_assignments','agent_runs','agent_end_outbox'])for(const {agent_id:old} of db.prepare(`SELECT DISTINCT agent_id FROM ${table} WHERE org='local'`).all())
276
+ db.prepare(`UPDATE OR IGNORE ${table} SET org=?,agent_id=? WHERE org='local' AND agent_id=?`).run(scope.org,ids.get(old)??old,old);
277
+ for(const card of cards){
278
+ const transition={kind:'agent_assignment_adopted',org:scope.org,from_org:'local',changed_at:Date.now()};
279
+ db.prepare('INSERT INTO agent_card_scope_transitions(target,from_org,to_org,changed_at) VALUES (?,?,?,?)').run(card.target,transition.from_org,transition.org,transition.changed_at);
280
+ db.prepare('INSERT INTO agent_card_assignment_changes(target,payload) VALUES (?,?)').run(card.target,JSON.stringify(transition));
281
+ }
221
282
  for(const table of ['agent_runtime_sessions','agent_usage_unread'])db.prepare(`UPDATE OR IGNORE ${table} SET org=? WHERE org='local'`).run(scope.org);
222
283
  for(const table of ['agent_intents','agent_declaration_heads','agent_assignments','agent_runs','agent_end_outbox','agent_runtime_sessions','agent_usage_unread'])db.prepare(`DELETE FROM ${table} WHERE org='local'`).run();
223
284
  db.exec('COMMIT');
@@ -1,9 +1,16 @@
1
1
  // Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id. The card's agent is
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
- import { agentScope, localCardAgent, finishAgentAssignment, activeAgentAssignments, handOffAgents } from '../agents.mjs';
4
+ import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
5
5
  import { event, json, tx, view } from './store.mjs';
6
6
  import { workflowFor } from './workflow.mjs';
7
+ import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
8
+
9
+ function admitsScope(board,target,org){
10
+ if(org!=='local'||!cardAgentScopeTransition(board.root,target))return true;
11
+ console.warn(`card agent mirror rejected: ${target} has left local scope`);
12
+ return false;
13
+ }
7
14
 
8
15
  export async function declareCardAgentEffect(board,effect) {
9
16
  const task=view(board,db=>db.prepare('SELECT t.*,c.subtask_of,c.archived,c.sponsor FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
@@ -11,34 +18,84 @@ export async function declareCardAgentEffect(board,effect) {
11
18
  const entry=workflowFor(board.root),profile=task.assignee??effect.profile??entry.params.default_assignee;
12
19
  // The reasons already told since the card's last declaration: each is recorded once, so a retry that alternates
13
20
  // between two refusals writes nothing (a write wakes the dispatcher, which would retry at once).
14
- const told=view(board,db=>new Set(db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='agent_declaration_pending' AND id>COALESCE((SELECT max(id) FROM task_events WHERE task_id=? AND kind='agent_declared'),0)").all(effect.id,effect.id).map(row=>json(row.payload,{})?.reason)));
21
+ const told=view(board,db=>new Set(db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='agent_declaration_pending' AND id>MAX(COALESCE((SELECT max(id) FROM task_events WHERE task_id=? AND kind='agent_declared'),0),COALESCE((SELECT declared_event_id FROM supercode_card_agent_facts WHERE task_id=?),0))").all(effect.id,effect.id,effect.id).map(row=>json(row.payload,{})?.reason)));
22
+ let org;
15
23
  try {
16
24
  if(!entry.profiles[profile])throw new Error('card agent declaration needs a named profile');
17
25
  // the context the card's creator named, kept with the declaration: never the context of whoever retries it
18
26
  if(!effect.context)throw new Error('the card agent declaration recorded no Teams context; it is declared again by a call that names one');
19
27
  const context=effect.context,scope=agentScope(context);
28
+ org=scope.org;
29
+ if(!admitsScope(board,effect.id,org))return true;
20
30
  const options={root:board.root,org:scope.org,server:scope.server,profiles:entry.profiles,
21
31
  profile,card:effect.id,role:effect.role,sponsor:task.sponsor??null,context,starterSession:effect.starter_session};
22
32
  const agent=await localCardAgent(options);
23
33
  if(task.archived||['done','cancelled','archived'].includes(task.status))finishAgentAssignment(board.root,scope.org,agent.assignment,`card ${effect.id} ${task.status}`);
24
34
  // A daemon not running keeps the declaration here; every delivery pass hands it on again.
25
35
  const waiting=await handOffAgents(board.root,{context});
26
- tx(board,db=>event(db,effect.id,'agent_declared',{agent:agent.identity.id,org:scope.org,role:effect.role,profile,assignment:agent.assignment,handed_to_machine:!waiting.length}));
36
+ // Hand-off yielded: an assignment may have ended or moved while it ran. The retained owner row supplies state.
37
+ const assignment=cardAgentAssignments(board.root,effect.id).find(row=>row.org===scope.org&&row.id===agent.assignment);
38
+ if(!assignment)throw new Error('card agent declaration has no retained assignment receipt');
39
+ recordCardAgentChanges(board,effect.id);
40
+ const state=assignment.agent_id===agent.identity.id?assignment.state:'ended';
41
+ tx(board,db=>{if(admitsScope(board,effect.id,org))event(db,effect.id,'agent_declared',{agent:agent.identity.id,org:scope.org,role:effect.role,profile:agent.identity.profile??profile,assignment:agent.assignment,state,handed_to_machine:!waiting.length});});
27
42
  return true;
28
43
  }catch(error) {
44
+ if(org==='local'&&!admitsScope(board,effect.id,org))return true;
29
45
  if(!told.has(error.message))tx(board,db=>event(db,effect.id,'agent_declaration_pending',{role:effect.role,profile,reason:error.message}));
30
46
  return false;
31
47
  }
32
48
  }
33
49
 
50
+ /** A failed board write leaves the owner's receipt pending. A failed acknowledgement repeats no board event. */
51
+ export function recordCardAgentChanges(board,target){
52
+ const changes=pendingCardAgentChanges(board.root,target);
53
+ const transition=cardAgentScopeTransition(board.root,target);
54
+ const missingScope=transition&&!view(board,db=>db.prepare('SELECT 1 FROM supercode_card_agent_scopes WHERE task_id=?').get(target));
55
+ if(!changes.length&&!missingScope)return;
56
+ const recorded=(db,change)=>cardAgentReceiptRecorded(db,target,change.receipt);
57
+ if(missingScope||view(board,db=>changes.some(change=>!recorded(db,change))))tx(board,db=>{
58
+ if(transition)syncCardAgentScope(db,target,transition);
59
+ for(const change of changes)if(!recorded(db,change)&&admitsScope(board,target,change.org))event(db,target,change.kind??'agent_assignment_reassigned',change);
60
+ });
61
+ acknowledgeCardAgentChanges(board.root,changes.map(change=>change.receipt));
62
+ }
63
+
64
+ /** Mirror a retained assignment (including reviewer roles created on the launch/adoption path) once per state. */
65
+ export function recordCardAgentAssignment(board,assignment){
66
+ if(!assignment)return;
67
+ recordCardAgentChanges(board,assignment.target);
68
+ if(!admitsScope(board,assignment.target,assignment.org))return;
69
+ const payload=db=>{
70
+ const previous=cardAgentFacts(db,assignment.target).find(agent=>agent.org===assignment.org&&agent.id===assignment.agent_id&&agent.assignment===assignment.id);
71
+ const value={agent:assignment.agent_id,org:assignment.org,role:assignment.role,profile:assignment.profile??previous?.profile??null,assignment:assignment.id,state:assignment.state};
72
+ return previous&&previous.role===value.role&&previous.profile===value.profile&&previous.state===value.state?null:value;
73
+ };
74
+ if(!view(board,payload))return;
75
+ tx(board,db=>{const value=payload(db);if(value&&admitsScope(board,assignment.target,assignment.org))event(db,assignment.target,'agent_declared',value);});
76
+ }
77
+
78
+ /** End in the assignment owner, then retain that receipt in the card's own transaction. Already ended rows repair
79
+ * a missed board write on retry; a matching recorded end writes nothing and wakes no dispatcher. */
80
+ export function finishCardAgentAssignment(board,assignment,reason){
81
+ recordCardAgentChanges(board,assignment.target);
82
+ if(!admitsScope(board,assignment.target,assignment.org))return;
83
+ const ended=assignment.state==='ended'?assignment:finishAgentAssignment(board.root,assignment.org,assignment.id,reason);
84
+ if(ended?.state!=='ended')return;
85
+ const recorded=db=>cardAgentFacts(db,assignment.target).some(agent=>agent.org===ended.org&&agent.id===ended.agent_id&&agent.assignment===ended.id&&agent.state==='ended');
86
+ if(view(board,recorded))return;
87
+ tx(board,db=>{if(!recorded(db)&&admitsScope(board,assignment.target,assignment.org))event(db,assignment.target,'agent_assignment_ended',{
88
+ agent:ended.agent_id,org:ended.org,role:ended.role,profile:ended.agent_id===assignment.agent_id?assignment.profile??null:null,assignment:ended.id,state:'ended',ended_at:ended.ended_at});});
89
+ }
90
+
34
91
  /** The card agents of `ids` whose cards are done, cancelled or archived end their assignments, and an agent left with
35
92
  * none has its end handed to this machine's daemon: run by the verb that closed the card, so no dispatcher is needed. */
36
93
  export async function endFinishedAgents(board,ids){
37
- const wanted=new Set(ids.map(String)),assignments=activeAgentAssignments(board.root).filter(item=>item.kind==='card'&&wanted.has(String(item.target)));
94
+ const wanted=new Set(ids.map(String)),assignments=cardAgentAssignments(board.root).filter(item=>wanted.has(String(item.target)));
38
95
  if(!assignments.length)return [];
39
96
  for(const assignment of assignments){
40
97
  const task=view(board,db=>db.prepare('SELECT t.status,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(assignment.target));
41
- if(task&&(task.archived||['done','cancelled','archived'].includes(task.status)))finishAgentAssignment(board.root,assignment.org,assignment.id,`card ${assignment.target} ${task.archived?'archived':task.status}`);
98
+ if(task&&(assignment.state==='ended'||task.archived||['done','cancelled','archived'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
42
99
  }
43
100
  return await handOffAgents(board.root);
44
101
  }
package/board/cli.mjs CHANGED
@@ -32,6 +32,7 @@ import { actorOf, CALLER_RUN_ADDRESS, SESSION_CURRENT_RUNS, SESSION_RUNS, sessio
32
32
  import { isLocalWorkspace } from './workspace.mjs';
33
33
  import { openZtrackBoard } from './ztrack.mjs';
34
34
  import * as engine from './engine.mjs';
35
+ import { recordCardAgentChanges } from './agent-declarations.mjs';
35
36
 
36
37
  // A mailbox read through the mailbox's own command, awaited (never a wait that holds the event loop). Gap A31: a
37
38
  // helper process until the daemon owns mail (order 3 PR 4).
@@ -665,7 +666,7 @@ export async function runVerb(argv) {
665
666
  const assignment=activeAgentAssignments(root).find(item=>item.org===scope.org&&item.kind==='card'&&item.target===id&&item.role===role);
666
667
  if(!assignment)throw new Error('declare the card role before reassigning its assignment');
667
668
  const changed=await reassignAgent(root,scope.org,assignment.id,args.agent,{context});
668
- withBoard(b=>tx(b,db=>event(db,id,'agent_assignment_reassigned',{assignment:assignment.id,role,from:assignment.agent_id,to:changed.agent_id})));
669
+ withBoard(b=>recordCardAgentChanges(b,id));
669
670
  say(args,`Reassigned ${id}/${role} to agent ${changed.agent_id}; sessions keep their identity`,{ok:true,assignment:changed});return 0;
670
671
  } need(2, 'a card id and a profile (or none)'); await withBoardAsync((b) => engine.set(b, id, 'assignee', rest[1] === 'none' ? null : rest[1])); say(args, `Assigned ${id} to ${rest[1] === 'none' ? '(nobody)' : rest[1]}`, { ok: true }); return 0;
671
672
  case 'set-goal': {
@@ -30,7 +30,8 @@ import { boardLabel, boardName } from '@volter/teams/board-name';
30
30
  let fleetRoot = null;
31
31
  import { inspectCompletionEvidence, noCodeChange, openReviewPullRequests, reviewDue, withCompletionEvidence, reviewCandidate, workflowRevision, retainWorkflowTick } from './ci.mjs';
32
32
  import { runtimeDoor } from './runtime-door.mjs';
33
- import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, activeAgentAssignments,finishAgentAssignment,handOffAgents, agentRunForSession } from '../agents.mjs';
33
+ import { agentScope, localCardAgent, localReaderContext, localAgentSessions, runBudgetRefusal, runsAwaitingUsage, recordRunUsage, recordUsageUnread, agentPreamble, agentEnvironment, recordAgentRun, cardAgentAssignments,handOffAgents, agentRunForSession } from '../agents.mjs';
34
+ import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
34
35
  import { materializeAgentHome } from '../agent-home.mjs';
35
36
  import { memorySnapshot } from '../hermes-cli.mjs';
36
37
  import { createRequire } from 'node:module';
@@ -1014,6 +1015,7 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
1014
1015
  const agent=await localCardAgent({root:board.root,org:team.org,server:team.server,profiles:entry.profiles,
1015
1016
  profile:lane.name,card:id,role:spec.role??slot,sponsor:task.sponsor??null});
1016
1017
  assertRunCurrent(board,id,runId);
1018
+ recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
1017
1019
  // G3: a run of an agent over its budget does not start; the reason waits on the card until the window passes
1018
1020
  const budgets=[...(entry.profiles?.default?.budgets??[]),...(lane.name!=='default'?entry.profiles?.[lane.name]?.budgets??[]:[])];
1019
1021
  const refused=budgets.length?runBudgetRefusal(board.root,agent.identity.id,budgets,new Date().toISOString()):null;
@@ -1904,10 +1906,10 @@ async function synchronizeAgents(board, report) {
1904
1906
  await readRunUsage(board.root,org,row,run.ended_at);read++;
1905
1907
  }
1906
1908
  }
1907
- for(const assignment of activeAgentAssignments(board.root)){
1908
- if(assignment.kind!=='card')continue;
1909
+ for(const assignment of cardAgentAssignments(board.root)){
1909
1910
  const task=getTaskNow(board,assignment.target);
1910
- if(task&&(task.archived||['done','cancelled'].includes(task.status)))finishAgentAssignment(board.root,assignment.org,assignment.id,`card ${assignment.target} ${task.archived?'archived':task.status}`);
1911
+ if(task&&(assignment.state==='ended'||task.archived||['done','cancelled'].includes(task.status)))finishCardAgentAssignment(board,assignment,`card ${assignment.target} ${task.archived?'archived':task.status}`);
1912
+ else if(task)recordCardAgentAssignment(board,assignment);
1911
1913
  }
1912
1914
  // What a creating or closing verb could not hand on (no daemon running then) is handed on here.
1913
1915
  try{for(const pending of await handOffAgents(board.root))report.queued.push([pending.agent,pending.error]);}
@@ -2464,6 +2466,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
2464
2466
  progress.claimed = request !== null;
2465
2467
  // The adopted session joins the role's agent as a launch does: its identity minted on this host, never asked of Teams.
2466
2468
  const agent = lane ? await 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;
2469
+ if(agent)recordCardAgentAssignment(board,cardAgentAssignments(board.root,id).find(assignment=>assignment.org===team.org&&assignment.id===agent.assignment));
2467
2470
  // A session another agent already owns is refused before anything is recorded: the run would name one agent and the
2468
2471
  // agent store another, and every round after would trip on it.
2469
2472
  const owner = agent ? agentRunForSession(board.root, [`${harness}:${sessionId}`]) : null;
package/board/engine.mjs CHANGED
@@ -18,6 +18,7 @@ import { view, actorOf, principalOf, claimer, contention, event, json, newTaskId
18
18
  import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
19
19
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
20
20
  import { refuseCredentialDump, withheldIfDump } from './credential-guard.mjs';
21
+ import { AGENT_KIND_LIST, compactCardAgentEvents } from './published.mjs';
21
22
 
22
23
  export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
23
24
  export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
@@ -947,9 +948,14 @@ export function runs(db, id, { stateType = null, stateName = null } = {}) {
947
948
  return all(db, `SELECT * FROM task_runs WHERE task_id = ?${where} ORDER BY id`, ...(where ? [id, stateName] : [id])).map((r) => ({ ...r, metadata: json(r.metadata, null) }));
948
949
  }
949
950
 
950
- /** Delete the thread events of done and archived cards older than `seconds` (Hermes's `gc` of events). */
951
+ /** Retain assignment facts in checkpoints, then delete old thread events of done and archived cards. */
951
952
  export function gcEvents(board, seconds) {
952
- return tx(board, (db) => Number(run(db, "DELETE FROM task_events WHERE kind != 'created' AND created_at < ? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done', 'archived'))", now() - seconds).changes));
953
+ return tx(board, db=>{
954
+ const cut=now()-seconds;
955
+ const cards=all(db,`SELECT DISTINCT task_id FROM task_events WHERE kind IN (${AGENT_KIND_LIST.map(()=>'?').join(',')}) AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))`,...AGENT_KIND_LIST,cut);
956
+ let compacted=0;for(const card of cards)compacted+=compactCardAgentEvents(db,card.task_id);
957
+ return compacted+Number(run(db,"DELETE FROM task_events WHERE kind!='created' AND created_at<? AND task_id IN (SELECT id FROM tasks WHERE status IN ('done','archived'))",cut).changes);
958
+ });
953
959
  }
954
960
 
955
961
  // ------------------------------------------------------------------ diagnostics
@@ -6,6 +6,7 @@
6
6
  // publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
8
  import { executionOf } from './session-work.mjs';
9
+ import { cardAgentScopeTransition } from '../agents.mjs';
9
10
  export const PUBLICATION_SCHEMA = `
10
11
  CREATE TABLE IF NOT EXISTS supercode_publications (
11
12
  sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, resource_id TEXT NOT NULL,
@@ -17,6 +18,13 @@ CREATE TABLE IF NOT EXISTS supercode_publication_heads (
17
18
  CREATE TABLE IF NOT EXISTS supercode_publication_log (
18
19
  id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL DEFAULT 0
19
20
  );
21
+ CREATE TABLE IF NOT EXISTS supercode_card_agent_facts (
22
+ task_id TEXT PRIMARY KEY, facts_json TEXT NOT NULL, receipt_sequence INTEGER NOT NULL DEFAULT 0,
23
+ declared_event_id INTEGER NOT NULL DEFAULT 0
24
+ );
25
+ CREATE TABLE IF NOT EXISTS supercode_card_agent_scopes (
26
+ task_id TEXT PRIMARY KEY, from_org TEXT NOT NULL, org TEXT NOT NULL, changed_at INTEGER NOT NULL
27
+ );
20
28
  -- keyless: a writing statement's own conflict clause (an upsert, OR REPLACE, OR ABORT) governs its triggers' inserts
21
29
  -- too, so a key here would fail the writer's statement; readers take the distinct ids
22
30
  CREATE TABLE IF NOT EXISTS supercode_publication_dirty (task_id TEXT NOT NULL);
@@ -24,8 +32,9 @@ CREATE INDEX IF NOT EXISTS supercode_publication_dirty_task ON supercode_publica
24
32
  CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_publication_heads(sequence);`;
25
33
 
26
34
  const REVIEW_KIND_LIST = ['review_requested', 'approved', 'review_approved', 'changes_requested', 'escalated'];
35
+ export const AGENT_KIND_LIST = ['agent_declared', 'agent_assignment_ended', 'agent_assignment_reassigned', 'agent_assignment_adopted'];
27
36
  // The tables a card's snapshot reads (cardSnapshot), each with the column naming its card; a link names two.
28
- const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comments', ['task_id']], ['task_runs', ['task_id']], ['task_links', ['parent_id', 'child_id']], ['task_events', ['task_id']]];
37
+ const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comments', ['task_id']], ['task_runs', ['task_id']], ['task_links', ['parent_id', 'child_id']], ['task_events', ['task_id']], ['supercode_card_agent_facts',['task_id']], ['supercode_card_agent_scopes',['task_id']]];
29
38
  /**
30
39
  * The triggers that mark a changed card, on a store that has these tables (CREATE … IF NOT EXISTS: laid out on every
31
40
  * open). A card already marked is not marked again, so a writer's many writes while the owner is away leave one mark.
@@ -35,20 +44,24 @@ const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comm
35
44
  export function publicationTriggers(db) {
36
45
  const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
37
46
  const first = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_publish_tasks_insert'").get();
38
- const reviews = `(${REVIEW_KIND_LIST.map((kind) => `'${kind}'`).join(',')})`;
47
+ const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST].map((kind) => `'${kind}'`).join(',')})`;
48
+ const agentMigration = tables.has('task_events') && !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'agent_assignment_adopted'");
49
+ const scopeMigration = !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_agent_scopes_insert'").get();
39
50
  db.exec('SAVEPOINT supercode_publication_marking');
40
51
  try {
52
+ // Existing event triggers only watched reviews. Replace that filter once, preserving the savepoint rollback.
53
+ if(agentMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
41
54
  for (const [table, columns] of MARKED) {
42
55
  if (!tables.has(table)) continue;
43
- // a task event changes a snapshot only when it is a review (the snapshot's `reviews`): an update marks when the
56
+ // Reviews and agent assignment events change a snapshot: an update marks when the
44
57
  // row was one before or is one after
45
- const when = (rows) => table === 'task_events' ? ` WHEN ${rows.map((row) => `${row}.kind IN ${reviews}`).join(' OR ')}` : '';
58
+ const when = (rows) => table === 'task_events' ? ` WHEN ${rows.map((row) => `${row}.kind IN ${snapshotEvents}`).join(' OR ')}` : '';
46
59
  const mark = (row) => columns.map((column) => `INSERT INTO supercode_publication_dirty (task_id) SELECT ${row}.${column} WHERE ${row}.${column} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM supercode_publication_dirty WHERE task_id = ${row}.${column});`).join(' ');
47
60
  db.exec(`CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_insert AFTER INSERT ON ${table}${when(['NEW'])} BEGIN ${mark('NEW')} END;
48
61
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
49
62
  CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
50
63
  }
51
- if (first && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
64
+ if ((first || agentMigration || scopeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
52
65
  SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
53
66
  EXCEPT SELECT task_id FROM supercode_publication_dirty`);
54
67
  db.exec('RELEASE supercode_publication_marking');
@@ -82,11 +95,76 @@ export function taskStatusOf(lane, completedAt) {
82
95
  }
83
96
 
84
97
  const REVIEW_KINDS=`(${REVIEW_KIND_LIST.map((kind) => `'${kind}'`).join(',')})`;
98
+ const AGENT_KINDS=`(${AGENT_KIND_LIST.map((kind) => `'${kind}'`).join(',')})`;
99
+ function agentFacts(db,taskId){
100
+ const checkpoint=db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?').get(taskId);
101
+ const agents=new Map(parse(checkpoint?.facts_json,[]).map(agent=>[JSON.stringify([agent.org,agent.assignment,agent.id]),agent]));
102
+ let scope=db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?').get(taskId)??null;
103
+ let receipt=checkpoint?.receipt_sequence??0,declared=checkpoint?.declared_event_id??0;
104
+ for(const event of db.prepare(`SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} ORDER BY id`).all(taskId)){
105
+ if(event.kind==='agent_declared')declared=Math.max(declared,event.id);
106
+ const value=parse(event.payload,{});
107
+ if(Number.isSafeInteger(value.receipt))receipt=Math.max(receipt,value.receipt);
108
+ if(event.kind==='agent_assignment_adopted'){
109
+ if(!scope&&value.from_org==='local'&&typeof value.org==='string'&&value.org!=='local')scope={from_org:'local',org:value.org,changed_at:value.changed_at};
110
+ continue;
111
+ }
112
+ if(scope&&value.org==='local'){console.warn(`card agent mirror rejected: ${taskId} has left local scope (event ${event.id})`);continue;}
113
+ const id=event.kind==='agent_assignment_reassigned'?value.to:value.agent;
114
+ if(typeof id!=='string'||typeof value.assignment!=='string'||typeof value.role!=='string')continue;
115
+ const org=value.org??null,key=JSON.stringify([org,value.assignment,id]),previous=agents.get(key);
116
+ if(event.kind==='agent_assignment_reassigned'&&typeof value.from==='string'&&value.from!==id){
117
+ const fromKey=JSON.stringify([org,value.assignment,value.from]),from=agents.get(fromKey);
118
+ agents.set(fromKey,{org,id:value.from,role:value.role,profile:value.from_profile??from?.profile??null,assignment:value.assignment,state:'ended'});
119
+ }
120
+ const state=event.kind==='agent_assignment_ended'||value.state==='ended'?'ended':'active';
121
+ if(state==='active')for(const agent of agents.values())if(agent.org===org&&agent.assignment===value.assignment&&agent.id!==id)agent.state='ended';
122
+ agents.set(key,{org,id,role:value.role,profile:value.profile??previous?.profile??null,assignment:value.assignment,state});
123
+ }
124
+ if(scope)for(const agent of agents.values())if(agent.org!==scope.org)agent.state='ended';
125
+ return {receipt,declared,scope,agents:[...agents.values()].sort((a,b)=>a.role.localeCompare(b.role)||a.assignment.localeCompare(b.assignment)||a.id.localeCompare(b.id))};
126
+ }
127
+ /** Facts plus only the events not yet checkpointed, independent of whether a run has started. */
128
+ export function cardAgentFacts(db,id){const facts=agentFacts(db,id);return facts.scope?facts.agents.filter(agent=>agent.org===facts.scope.org):facts.agents;}
129
+ export function cardAgentReceiptRecorded(db,id,receipt){
130
+ return Number(db.prepare('SELECT receipt_sequence FROM supercode_card_agent_facts WHERE task_id=?').get(id)?.receipt_sequence??0)>=receipt
131
+ ||Boolean(db.prepare(`SELECT 1 FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} AND json_extract(payload,'$.receipt')=?`).get(id,receipt));
132
+ }
133
+ /** Facts and the receipt watermark replace consumed event history together in the board transaction. */
134
+ function saveAgentFacts(db,id,facts){
135
+ if(facts.scope)db.prepare('INSERT INTO supercode_card_agent_scopes(task_id,from_org,org,changed_at) VALUES (?,?,?,?) ON CONFLICT(task_id) DO NOTHING').run(id,facts.scope.from_org,facts.scope.org,facts.scope.changed_at);
136
+ db.prepare('INSERT INTO supercode_card_agent_facts(task_id,facts_json,receipt_sequence,declared_event_id) VALUES (?,?,?,?) ON CONFLICT(task_id) DO UPDATE SET facts_json=excluded.facts_json,receipt_sequence=excluded.receipt_sequence,declared_event_id=excluded.declared_event_id').run(id,JSON.stringify(facts.agents),facts.receipt,facts.declared);
137
+ return Number(db.prepare(`DELETE FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS}`).run(id).changes);
138
+ }
139
+ export function compactCardAgentEvents(db,id){
140
+ if(!db.prepare(`SELECT 1 FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} LIMIT 1`).get(id))return 0;
141
+ return saveAgentFacts(db,id,agentFacts(db,id));
142
+ }
143
+ /** A permanent native transition closes local scope. Only destination-native assignments can be active afterward. */
144
+ export function syncCardAgentScope(db,id,transition){
145
+ if(!transition)return;
146
+ db.prepare('INSERT INTO supercode_card_agent_scopes(task_id,from_org,org,changed_at) VALUES (?,?,?,?) ON CONFLICT(task_id) DO NOTHING').run(id,transition.from_org,transition.org,transition.changed_at);
147
+ const facts=agentFacts(db,id);
148
+ if(facts.scope.org!==transition.org)throw new Error(`card ${id} has conflicting retained agent scopes`);
149
+ const agents=new Map(facts.agents.map(agent=>[JSON.stringify([agent.org,agent.assignment,agent.id]),{...agent,state:'ended'}]));
150
+ for(const row of transition.assignments){
151
+ const key=JSON.stringify([row.org,row.id,row.agent_id]),previous=agents.get(key);
152
+ agents.set(key,{org:row.org,id:row.agent_id,role:row.role,profile:row.profile??previous?.profile??null,assignment:row.id,state:row.state});
153
+ }
154
+ facts.agents=[...agents.values()].sort((a,b)=>a.role.localeCompare(b.role)||a.assignment.localeCompare(b.assignment)||a.id.localeCompare(b.id));
155
+ const previous=db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?').get(id);
156
+ if(previous?.facts_json===JSON.stringify(facts.agents)&&previous.receipt_sequence===facts.receipt&&previous.declared_event_id===facts.declared
157
+ &&!db.prepare(`SELECT 1 FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} LIMIT 1`).get(id))return;
158
+ saveAgentFacts(db,id,facts);
159
+ }
160
+ /** Organization scope stays internal; the publication contract has exactly these five fields. */
161
+ export function cardAgents(db,id){return cardAgentFacts(db,id).map(({org,...agent})=>agent);}
85
162
  /** A card's rows read one card at a time. */
86
163
  const perCard=db=>({
87
164
  own:id=>db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(id),
88
165
  comments:id=>db.prepare('SELECT id,author,body,created_at FROM task_comments WHERE task_id=? ORDER BY id').all(id),
89
166
  reviews:id=>db.prepare(`SELECT id,kind,payload,created_at FROM task_events WHERE task_id=? AND kind IN ${REVIEW_KINDS} ORDER BY id`).all(id),
167
+ agents:id=>cardAgents(db,id),
90
168
  attempts:id=>db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id').all(id),
91
169
  parents:id=>db.prepare('SELECT parent_id FROM task_links WHERE child_id=? ORDER BY parent_id').all(id).map(link=>link.parent_id),
92
170
  children:id=>db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(id).map(link=>link.child_id),
@@ -111,7 +189,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
111
189
  blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
112
190
  workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
113
191
  parents:rows.parents(row.id),
114
- reviews,children:rows.children(row.id),comments,attempts};
192
+ agents:rows.agents(row.id),reviews,children:rows.children(row.id),comments,attempts};
115
193
  }
116
194
 
117
195
  /**
@@ -119,7 +197,7 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
119
197
  * transaction: each marked card's snapshot when it differs from its published head, a removal when the card is gone.
120
198
  * The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
121
199
  */
122
- export function recordCardPublications(db,actor) {
200
+ export function recordCardPublications(db,actor,root) {
123
201
  const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
124
202
  if(!marked.length)return 0;
125
203
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
@@ -131,6 +209,8 @@ export function recordCardPublications(db,actor) {
131
209
  for(const id of marked){
132
210
  const row=task.get(id),previous=head.get(id);
133
211
  if(row){
212
+ const transition=cardAgentScopeTransition(root,id);
213
+ if(transition)syncCardAgentScope(db,id,transition);else compactCardAgentEvents(db,id);
134
214
  const snapshot=JSON.stringify(cardSnapshot(db,row,rows)),digest=hash(snapshot);
135
215
  if(previous?.digest===digest)continue;
136
216
  const last=lastEvent.get(id);
package/board/store.mjs CHANGED
@@ -425,7 +425,7 @@ export const tx = (board, fn) => {
425
425
  const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
426
426
  const started = performance.now();
427
427
  try {
428
- const result = board.tx(db => {const result=fn(db);const began=performance.now();try{recordCardPublications(db,actorOf());}finally{noteStep('board tx publications',performance.now()-began);}return result;});
428
+ const result = board.tx(db => {const result=fn(db);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
429
429
  signalBoardWrite(board.root, actorOf());
430
430
  return result;
431
431
  } finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
@@ -445,7 +445,7 @@ export function publishMarked(root) {
445
445
  // a mark in the store, or a backing edit not yet imported into its tables (a ztrack document): the transaction
446
446
  // imports the edit, which marks its cards, and publishes them
447
447
  const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
448
- if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf())));
448
+ if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf(),board.root)));
449
449
  if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
450
450
  } finally { board?.close(); }
451
451
  }
package/index.d.ts CHANGED
@@ -115,7 +115,13 @@ export function agentIntent(root: Path, org: string, id: string): Record<string,
115
115
  export function assignAgent(root: Path, org: string, id: string, assignment: {kind: string; target: string; role: string}): string;
116
116
  export function reassignAgent(root: Path, org: string, assignment: string, to: string, options?: {context?: string}): Promise<Record<string, unknown>>;
117
117
  export function activeAgentAssignments(root: Path): Array<Record<string, unknown>>;
118
- export function finishAgentAssignment(root: Path, org: string, assignment: string, reason: string): void;
118
+ /** Retained card assignments, including ended rows awaiting a board mirror. */
119
+ export function cardAgentAssignments(root: Path, target?: string | null): Array<Record<string, unknown>>;
120
+ /** Permanent offline adoption scope and its current destination-native assignments, read together. */
121
+ export function cardAgentScopeTransition(root: Path, target: string): {from_org: string; org: string; changed_at: number; assignments: Array<Record<string, unknown>>} | null;
122
+ export function pendingCardAgentChanges(root: Path, target: string): Array<{receipt: number; [key: string]: unknown}>;
123
+ export function acknowledgeCardAgentChanges(root: Path, receipts: number[]): void;
124
+ export function finishAgentAssignment(root: Path, org: string, assignment: string, reason: string): Record<string, unknown> | null;
119
125
  export function recordAgentRun(root: Path, org: string, id: string, run: {session: string; machine?: string | null; run: string | number; profile?: unknown; main?: boolean; starterSession?: string | null}): void;
120
126
  export function agentRunForSession(root: Path, references: Array<string | null | undefined>): Record<string, unknown> | null;
121
127
  export function agentPreamble(agent: LocalAgent, options?: {main?: boolean; previous?: Array<{session: string; machine?: string | null; ended_at?: number | null; end_reason?: string | null}>}): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.176",
3
+ "version": "0.5.178",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@volter/supercode-harness-sdk": "^0.3.100",
28
- "@volter/teams": "0.2.77",
28
+ "@volter/teams": "0.2.78",
29
29
  "@volter/ztrack": "^2.4.23",
30
30
  "yaml": "^2.9.0"
31
31
  },