@volter/supercode-orchestrator 0.4.160 → 0.5.1

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/board/engine.mjs CHANGED
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
13
13
  import { checkWorkspace, assertWorkspaceOwner, declareWorkspace, isLocalWorkspace } from './workspace.mjs';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
- import { view, actorOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
15
+ import { view, actorOf, principalOf, claimer, event, json, newTaskId, now, tx } from './store.mjs';
16
16
  import { completionPhase, inspectCompletionEvidence, observeCompletionEvidence, withCompletionEvidence } from './ci.mjs';
17
17
  import { addDependency, retireSupersededRuns, refreshCardInputs, requestSessionClose, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
18
18
 
@@ -37,7 +37,8 @@ export function getTask(db, id) {
37
37
  const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
38
38
  if (!row) return null;
39
39
  const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
40
- return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
40
+ return { ...row, workspace_anchor: ours?.workspace_anchor ?? null, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), every: ours?.every ?? null, skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null,
41
+ tags: json(ours?.tags, null) ?? (row.tenant ? [row.tenant] : []), sponsor: ours?.sponsor ?? null, sponsor_evidence: json(ours?.sponsor_evidence, null), requested_by_unverified: json(ours?.requested_by_unverified, null) };
41
42
  }
42
43
 
43
44
  function must(db, id) {
@@ -201,6 +202,10 @@ export function createCard(board, spec) {
201
202
  if (initial === 'running' && (spec.noStart || spec.adopt)) throw new Error('A held or awaiting-adoption card cannot be created initially running.');
202
203
  if (initial && !['blocked', 'running'].includes(initial)) throw new Error('initial status is blocked or running');
203
204
  if (spec.provider && !spec.model) throw new Error('a provider needs a model');
205
+ // Several ordered tags (C4); Hermes's single `tenant` is the first of them, as its export lowers them.
206
+ const tags = [...new Set([spec.tenant, ...(spec.tags ?? [])].filter((tag) => typeof tag === 'string' && tag.trim()).map((tag) => tag.trim()))];
207
+ // A sponsor is a person, never an agent or an automation (Teams refuses any principal that is not a person).
208
+ if (spec.sponsor && /^(agent_|svc_|service_)/.test(spec.sponsor)) throw new Error('a sponsor is a person; an agent or an automation cannot sponsor a card');
204
209
  return tx(board, (db) => {
205
210
  if (spec.idempotencyKey) {
206
211
  const got = one(db, "SELECT id FROM tasks WHERE idempotency_key = ? AND status != 'archived' ORDER BY created_at DESC LIMIT 1", spec.idempotencyKey);
@@ -238,18 +243,19 @@ export function createCard(board, spec) {
238
243
  tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, reasoning_effort, session_id,
239
244
  project_id, goal_mode, goal_max_turns)
240
245
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
241
- id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0, actorOf() ?? spec.createdBy ?? 'user', t, ws.kind, ws.path,
242
- spec.branch ?? null, spec.tenant ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
246
+ id, title, spec.body ?? null, spec.assignee || null, 'todo', Number(spec.priority) || 0, spec.createdBy ?? principalOf() ?? (actorOf() ? `session:${actorOf()}` : 'unidentified'), t, ws.kind, ws.path,
247
+ spec.branch ?? null, tags[0] ?? null, spec.idempotencyKey ?? null, spec.maxRuntime ?? null,
243
248
  spec.skills?.length ? JSON.stringify(spec.skills) : null, spec.maxRetries ?? null, spec.model ?? null, spec.provider ?? null,
244
249
  spec.reasoning ?? null, spec.sessionId ?? null, spec.project ?? null, spec.goal ? 1 : 0, spec.goal?.maxTurns ?? null);
245
250
  const machine = spec.machine ?? arc?.machine ?? null;
246
- run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root) VALUES (?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null);
251
+ run(db, 'INSERT OR REPLACE INTO supercode_cards (task_id,machine,subtask_of,workspace_anchor,workspace_root,tags,sponsor,sponsor_evidence,requested_by_unverified) VALUES (?,?,?,?,?,?,?,?,?)', id, machine, arc?.id ?? null, declared.repository, checked?.mainRoot ?? null,
252
+ tags.length ? JSON.stringify(tags) : null, spec.sponsor ?? null, spec.sponsorEvidence ? JSON.stringify(spec.sponsorEvidence) : null, spec.requestedByUnverified ? JSON.stringify(spec.requestedByUnverified) : null);
247
253
  for (const p of parents) addDependency(db, entry, id, p);
248
254
  // where it starts is the workflow's `initial` (Hermes: triage, blocked, todo behind open parents, else ready)
249
255
  // the create's own words: whether it asked to be a goal card (null: the workflow decides), triage, its arc
250
256
  const status = startNew(db, board, entry, getTask(db, id), { triage: Boolean(spec.triage), initial_status: initial,
251
257
  every: spec.every ?? null, goal: spec.goal === undefined || spec.goal === null ? null : spec.goal !== false, subtask_of: arc?.id ?? null });
252
- event(db, id, 'created', { creator: actorOf(), assignee: spec.assignee || null, status, parents, tenant: spec.tenant ?? null, ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
258
+ event(db, id, 'created', { creator: actorOf(), created_by: one(db, 'SELECT created_by FROM tasks WHERE id=?', id)?.created_by ?? null, assignee: spec.assignee || null, status, parents, tags, ...(spec.sponsor ? { sponsor: spec.sponsor } : {}), ...(spec.requestedByUnverified ? { requested_by_unverified: spec.requestedByUnverified } : {}), ...(machine ? { machine } : {}), ...(arc ? { subtask_of: arc.id } : {}) });
253
259
  if ((spec.noStart || spec.adopt) && !arc) event(db, id, 'dispatch_held', { session: spec.adopt ?? null, reason: spec.adopt ? 'awaiting adoption' : 'created with --no-start' });
254
260
  if (parents.length) inheritSubscriptions(db, id, parents);
255
261
  if (arc) {
@@ -259,9 +265,19 @@ export function createCard(board, spec) {
259
265
  }
260
266
  if (initial === 'running' && arc && entry.workflow.run) setStatus(db, entry.workflow, id, 'running');
261
267
  else if (initial === 'running') claimIn(db, id, { ttl: claimTtl() });
268
+ // The creation declares its role even when dispatch is held. The existing durable effect queue
269
+ // keeps public-door I/O outside this transaction and retains an offline declaration for retry.
270
+ let declaration;
271
+ if(!arc) {
272
+ const creator=/^sc:[^:]+:(claude-code|codex|gemini|grok|pi|hermes|opencode|supercode):(.+)$/.exec(actorOf()??'');
273
+ const effect={kind:'declare_card_agent',id,role:'implementer',profile:spec.assignee??null,
274
+ context:process.env.SUPERCODE_CONTEXT??null,starter_session:creator?`${creator[1]}:${creator[2]}`:null};
275
+ const inserted=run(db,'INSERT INTO supercode_effects(effect) VALUES(?)',JSON.stringify(effect));
276
+ declaration={...effect,durableId:Number(inserted.lastInsertRowid)};
277
+ }
262
278
  // the body's task lines, as subtasks the creator filed, in this same write
263
279
  const subtasks = steps.map((step) => createCard(board, { ...step, subtaskOf: id, assignee: spec.assignee || null }).id);
264
- return { id, existing: false, ...(subtasks.length ? { subtasks } : {}) };
280
+ return { id, existing: false, effects:declaration?[declaration]:[], ...(subtasks.length ? { subtasks } : {}) };
265
281
  });
266
282
  }
267
283
 
@@ -460,6 +476,10 @@ export async function runEffects(board, effects = [], { retryNotices = false } =
460
476
  if (!await sessionNotice(board, e)) continue;
461
477
  }
462
478
  else if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
479
+ else if (e.kind === 'declare_card_agent') {
480
+ const {declareCardAgentEffect}=await import('./agent-declarations.mjs');
481
+ if(!declareCardAgentEffect(board,e))continue;
482
+ }
463
483
  else if (e.kind === 'board_message') {
464
484
  const { sendBoardMessages } = await import('./dispatch.mjs');
465
485
  await sendBoardMessages(board, { undelivered: [] }, e.id);
@@ -536,7 +556,7 @@ export const requestReview = (board, id, { summary = null, metadata = null, revi
536
556
  act(board, id, 'request_review', { roles, payload: { summary, metadata, reviewer, force, run_id: expectedRunId, commit, ci_run, branch, pr, pr_head } });
537
557
  export const requestChanges = (board, id, { reason = null, reviewer = null, roles } = {}) => act(board, id, 'request_changes', { roles, payload: { reason, reviewer } });
538
558
  export const reopen = (board, id, { author = 'user', reason = null, roles } = {}) => act(board, id, 'reopen', { roles, payload: { author, reason } });
539
- export const reopenReview = (board, id, { roles } = {}) => act(board, id, 'reopen_review', { roles });
559
+ export const reopenReview = (board, id, { roles, reason = null } = {}) => act(board, id, 'reopen_review', { roles, payload: { reason } });
540
560
  export const schedule = (board, id, reason = null, { roles } = {}) => act(board, id, 'schedule', { roles, payload: { reason } });
541
561
  export const promote = (board, id, { actor = 'user', reason = null, force = false, roles } = {}) => act(board, id, 'promote', { roles, payload: { actor, reason, force } });
542
562
  export const reclaim = (board, id, reason = null, { roles } = {}) => act(board, id, 'reclaim', { roles, payload: { reason } });
@@ -683,7 +703,7 @@ export const subscriptions = (db, id = null) => (id ? all(db, 'SELECT * FROM kan
683
703
  // ------------------------------------------------------------------ reading
684
704
 
685
705
  export function listCards(db, { status = null, assignee = null, machine = null, includeArchived = false } = {}) {
686
- const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
706
+ const rows = all(db, `SELECT t.*, c.machine, c.subtask_of, c.stage, c.tags, c.sponsor FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id
687
707
  WHERE (? IS NULL OR t.status = ? OR c.stage = ?) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND (? OR t.status != 'archived')
688
708
  ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, assignee, assignee, machine, machine, includeArchived ? 1 : 0);
689
709
  return rows.map((r) => ({ ...r, skills: json(r.skills, []) ?? [] }));
@@ -742,7 +762,7 @@ export function cardContext(db, id) {
742
762
 
743
763
 
744
764
  /** Hermes's swarm: a completed root as the shared blackboard, workers after it, a verifier after them all, a synthesizer last. */
745
- export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = 'swarm-orchestrator', priority = 0, idempotencyKey = null }) {
765
+ export function swarm(board, { goal, workers, verifier, synthesizer, tenant = null, createdBy = null, priority = 0, idempotencyKey = null }) {
746
766
  if (!String(goal ?? '').trim()) throw new Error('a swarm has a goal');
747
767
  if (!workers.length) throw new Error('a swarm has at least one --worker PROFILE:TITLE[:SKILL,SKILL]');
748
768
  const protocol = (root) => `\n\n## Swarm protocol\n- Swarm root / shared blackboard: \`${root}\`.\n- Read sibling/parent handoffs from the board's context before working.\n`
@@ -0,0 +1,59 @@
1
+ // A projection of retained Teams events. Dispatch reads this projection, never a periodically fetched catalogue.
2
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { eventClient } from '@volter/teams/event-client';
6
+ const pathOf=root=>join(root,'.session-events.json');
7
+ const normalize=value=>String(value??'').toLowerCase().replace(/[^a-z0-9-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'');
8
+ const wait=signal=>new Promise(resolve=>{const timer=setTimeout(resolve,5000);signal.addEventListener('abort',()=>{clearTimeout(timer);resolve();},{once:true});});
9
+
10
+ export function followFleet({root,context,supercodeHome,wake,log}){
11
+ const abort=new AbortController();let resources={},cursor=null,connected=false,snapshot=null;
12
+ const persist=()=>{
13
+ const path=pathOf(root),temporary=`${path}.${randomUUID()}.tmp`;
14
+ writeFileSync(temporary,JSON.stringify({pid:process.pid,connected,cursor,resources}),{mode:0o600});renameSync(temporary,path);
15
+ };
16
+ const apply=(target,frame)=>{
17
+ const event=frame.data;if(!event||!['session','machine'].includes(event.resource?.kind))return false;
18
+ const key=`${event.resource.kind}:${event.resource.id}`,old=target[key];
19
+ if(old&&old.sequence>=frame.sequence)return false;
20
+ target[key]={sequence:frame.sequence,data:event.data};return true;
21
+ };
22
+ void(async()=>{
23
+ while(!abort.signal.aborted){
24
+ try{
25
+ const {team}=eventClient({context,supercodeHome});
26
+ // Every connection begins with a complete, ACL-filtered snapshot. A process restart cannot trust an old cache.
27
+ for await(const frame of team.events.follow({kinds:['session.started','session.ended','session.changed','resource.changed']}, {signal:abort.signal,after:cursor})){
28
+ if(frame.type==='snapshot_begin'){snapshot={};connected=false;persist();}
29
+ else if(frame.type==='snapshot_item')apply(snapshot??resources,frame);
30
+ else if(frame.type==='snapshot_end'){resources=snapshot??resources;snapshot=null;cursor=frame.cursor;connected=true;persist();wake('session-events-snapshot');}
31
+ else if(frame.type==='event'){const changed=apply(resources,frame);cursor=frame.cursor;connected=true;persist();if(changed)wake('session-events');}
32
+ else if(frame.type==='checkpoint'){const recovered=!connected;cursor=frame.cursor;connected=true;persist();if(recovered)wake('session-events-reconnected');}
33
+ else if(frame.terminal){cursor=null;connected=false;persist();wake('session-events-unavailable');break;}
34
+ }
35
+ }catch(error){if(!abort.signal.aborted)log(`dispatch session events unavailable: ${error.code??error.message}`);if(error.status===409)cursor=null;}
36
+ connected=false;persist();wake('session-events-unavailable');if(!abort.signal.aborted)await wait(abort.signal);
37
+ }
38
+ })();
39
+ return {close(){abort.abort();connected=false;persist();}};
40
+ }
41
+
42
+ export function readEventFleet(root){
43
+ let cache;try{cache=JSON.parse(readFileSync(pathOf(root),'utf8'));process.kill(cache.pid,0);}catch{return {ok:false,alive:new Set(),online:new Set(),sessions:[],native:new Map(),error:'published session events unavailable',presenceKnown:false};}
44
+ const machines=new Map(),online=new Set();
45
+ for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('machine:')){machines.set(value.data.id,normalize(value.data.name));if(cache.connected&&value.data.linked)online.add(normalize(value.data.name));}
46
+ const sessions=[],native=new Map(),alive=new Set();
47
+ for(const [key,value] of Object.entries(cache.resources??{}))if(key.startsWith('session:')){
48
+ for(const binding of value.data.bindings??[]){
49
+ const machine=machines.get(binding.machine_id);if(!machine||!binding.harness||!binding.native_session_id)continue;
50
+ const address=`sc:${machine}:${binding.harness}:${binding.native_session_id}`;
51
+ const known=cache.connected&&online.has(machine),state=known?(binding.state==='running'?true:binding.state==='stopped'?false:null):null;
52
+ const previous=native.get(address),states=[...(previous?.states??[]),state];
53
+ const combined=states.includes(true)?true:states.includes(null)?null:states.every(value=>value===false)?false:null;
54
+ native.set(address,{alive:combined,states});if(combined===true)alive.add(address);
55
+ sessions.push({address,machine,harness:binding.harness,session_id:binding.native_session_id,pane:binding.pane??null,name:binding.name??null,state:binding.state,identity:value.data.agent_identity??null});
56
+ }
57
+ }
58
+ return {ok:!!cache.connected,presenceKnown:!!cache.connected,online,alive,native,sessions,error:cache.connected?null:'published session events disconnected'};
59
+ }
@@ -0,0 +1,45 @@
1
+ // App-owned source names/maps live here; the Teams core knows only a scoped event publisher.
2
+ import { createHash } from 'node:crypto';
3
+ import { eventClient } from '@volter/teams/event-client';
4
+ import { boardSourceName, recordCardPublications } from './published.mjs';
5
+ import { listBoards, openBoard } from './store.mjs';
6
+
7
+ export function boardEventRelay({root,context,supercodeHome,log=()=>{}}) {
8
+ let work=null,dirty=false,closed=false,retry=null,reconcile=true;const sources=new Map();
9
+ const run=async()=>{
10
+ if(reconcile){
11
+ // Establish every native source head before any network operation. A
12
+ // delivery retry cannot leave later boards without their initial inventory.
13
+ for(const slug of listBoards(root)){const board=openBoard(root,slug);try{board.tx(db=>recordCardPublications(db,null));}finally{board.close();}}
14
+ reconcile=false;
15
+ }
16
+ const {team,context:audience}=eventClient({context,supercodeHome});
17
+ for(const slug of listBoards(root)){
18
+ const board=openBoard(root,slug);
19
+ try{
20
+ const key=JSON.stringify({server:audience.server,org:audience.org,principal:audience.principal,board:slug});
21
+ let source=sources.get(key);
22
+ if(!source){source=(await team.events.createSource({name:boardSourceName(root,slug)})).data.id;sources.set(key,source);}
23
+ const destination=createHash('sha256').update(`${key}:${source}`).digest('hex');
24
+ for(;;){
25
+ const rows=board.db.prepare('SELECT * FROM supercode_publications p WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_receipts r WHERE r.event_id=p.id AND r.destination=?) ORDER BY sequence LIMIT 32').all(destination);if(!rows.length)break;
26
+ for(const row of rows){
27
+ if(closed)return;
28
+ const id=`event_${createHash('sha256').update(`${row.id}:${destination}`).digest('hex')}`;
29
+ await team.events.publish(source,{id,kind:row.kind,resource:{kind:'task',id:`${source}:${row.resource_id}`},revision:row.sequence,data:{board:slug,card:JSON.parse(row.snapshot)},actor:{session:row.actor?`session:${row.actor}`:null}},{idempotencyKey:id});
30
+ board.tx(db=>db.prepare('INSERT OR IGNORE INTO supercode_publication_receipts VALUES (?,?)').run(row.id,destination));
31
+ }
32
+ }
33
+ }finally{board.close();}
34
+ }
35
+ };
36
+ const wake=(refresh=true)=>{
37
+ reconcile ||= refresh;dirty=true;if(work||closed)return work;
38
+ work=(async()=>{while(dirty&&!closed){dirty=false;await run();}})().catch(error=>{
39
+ log(`board event delivery pending: ${error.code??error.message}`);
40
+ // Retry durable delivery, not a read of the board's current state.
41
+ retry=setTimeout(()=>wake(false),5000);retry.unref?.();
42
+ }).finally(()=>{work=null;});return work;
43
+ };
44
+ return {wake,close(){closed=true;clearTimeout(retry);}};
45
+ }
@@ -0,0 +1,104 @@
1
+ // Public native workflow observation. Filesystem custody authorizes this door;
2
+ // Teams separately authorizes the Org event and its installed subscribers.
3
+ import { watch, existsSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { dirname, basename, join, resolve } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { listBoards, openBoard, backingPaths } from './store.mjs';
7
+ import { recordCardPublications } from './published.mjs';
8
+
9
+ const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
10
+ const cursorOf = (home, boards) => Buffer.from(JSON.stringify({version:1,home,boards})).toString('base64url');
11
+ const resync = reason => Object.assign(new Error(reason), {code:'resync_required'});
12
+
13
+ /** Snapshot once, then replay only committed publications on backing notifications. */
14
+ export async function* followBoardPublications({root,after,signal}) {
15
+ const home=identity(root), positions={}, watchers=new Map();
16
+ let dirty=true, wake=null, failure=null, inventory=null;
17
+ const notify=()=>{dirty=true;wake?.();};
18
+ const directory=(path,accept)=>{
19
+ if(!existsSync(path))return;
20
+ if(watchers.has(path)){watchers.get(path).accept.push(accept);return;}
21
+ const entry={accept:[accept],handle:null};
22
+ entry.handle=watch(path,(_kind,name)=>{if(name==null||entry.accept.some(fn=>fn(String(name))))notify();});
23
+ entry.handle.on('error',error=>{failure=error;notify();});watchers.set(path,entry);
24
+ };
25
+ const file=path=>directory(dirname(path),name=>name===basename(path));
26
+ const register=slugs=>{
27
+ for(const entry of watchers.values())entry.accept=[];
28
+ directory(root,name=>['kanban.db','kanban.db-wal','kanban'].includes(name));
29
+ directory(join(root,'kanban'),name=>['boards','ztrack.json','board'].includes(name));
30
+ directory(join(root,'kanban','boards'),()=>true);
31
+ for(const slug of slugs){
32
+ const paths=backingPaths(root,slug);
33
+ file(paths.sqlite);file(`${paths.sqlite}-wal`);file(paths.ztrack);
34
+ directory(paths.files,name=>name.endsWith('.json'));
35
+ try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);file(`${document}.archive.md`);}catch{/* other backing */}
36
+ }
37
+ };
38
+ const aborted=()=>wake?.();signal?.addEventListener('abort',aborted);
39
+ try {
40
+ if(after){
41
+ let decoded;try{decoded=JSON.parse(Buffer.from(after,'base64url').toString());}catch{throw resync('Malformed workflow event cursor');}
42
+ if(decoded.version!==1||decoded.home!==home||!decoded.boards||typeof decoded.boards!=='object')throw resync('Workflow event cursor belongs to another source');
43
+ Object.assign(positions,decoded.boards);inventory=Object.keys(positions).sort().join('\0');
44
+ } else yield {type:'snapshot_begin'};
45
+ let initial=!after,checkpointPending=!!after;
46
+ while(!signal?.aborted){
47
+ if(failure)throw failure;
48
+ dirty=false;
49
+ const slugs=listBoards(root).sort(),current=slugs.join('\0');register(slugs);
50
+ if(inventory!==null&&current!==inventory)throw resync('Workflow board inventory changed');
51
+ inventory=current;
52
+ for(const slug of slugs){
53
+ const board=openBoard(root,slug);
54
+ try {
55
+ // External native writers do not share our transaction wrapper. Their
56
+ // filesystem event reconciles once into the same retained source log.
57
+ const boundary=board.tx(db=>{
58
+ recordCardPublications(db,null);
59
+ const log=db.prepare('SELECT id,pruned_through FROM supercode_publication_log').get();
60
+ const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
61
+ const heads=initial?db.prepare('SELECT sequence FROM supercode_publication_heads ORDER BY resource_id').all().map(row=>row.sequence):[];
62
+ return {log:log.id,floor:Number(log.pruned_through),high,heads};
63
+ });
64
+ const position=positions[slug];
65
+ if(initial){
66
+ // The transaction's watermark fences concurrent mutations. Replay
67
+ // after it repairs any head updated while this inventory is read.
68
+ for(const sequence of boundary.heads){
69
+ const row=board.db.prepare('SELECT snapshot FROM supercode_publications WHERE sequence=?').get(sequence);
70
+ if(!row)throw resync('Workflow snapshot unavailable');
71
+ yield {type:'snapshot_item',data:{board:slug,card:JSON.parse(row.snapshot)}};
72
+ }
73
+ positions[slug]={log:boundary.log,sequence:boundary.high};
74
+ }else{
75
+ // A pruned floor above the cursor means changes it never read are gone; a new log id means a new store.
76
+ if(!position||position.log!==boundary.log||!Number.isSafeInteger(position.sequence)||position.sequence<boundary.floor||position.sequence>boundary.high)throw resync(`Workflow publication log changed: ${slug}`);
77
+ for(;;){
78
+ const rows=board.db.prepare('SELECT * FROM supercode_publications WHERE sequence>? ORDER BY sequence LIMIT 32').all(positions[slug].sequence);if(!rows.length)break;
79
+ for(const row of rows){positions[slug].sequence=row.sequence;yield {type:'event',id:row.id,kind:row.kind,cursor:cursorOf(home,positions),data:{board:slug,card:JSON.parse(row.snapshot)}};}
80
+ }
81
+ }
82
+ }finally{board.close();}
83
+ }
84
+ if(initial){initial=false;yield {type:'snapshot_end',cursor:cursorOf(home,positions)};dirty=true;}
85
+ else if(checkpointPending){checkpointPending=false;yield {type:'checkpoint',cursor:cursorOf(home,positions)};}
86
+ if(!dirty&&!signal?.aborted)await new Promise(done=>{wake=done;if(dirty||signal?.aborted)done();});
87
+ wake=null;
88
+ }
89
+ }finally{signal?.removeEventListener('abort',aborted);for(const {handle} of watchers.values())handle.close();}
90
+ }
91
+
92
+ export async function streamBoardPublications(options) {
93
+ if(options.once&&options.after)throw new Error('--once requires a fresh snapshot; omit --after');
94
+ const abort=new AbortController();const stop=()=>abort.abort();
95
+ process.once('SIGINT',stop);process.once('SIGTERM',stop);
96
+ try {
97
+ for await(const frame of followBoardPublications({...options,signal:abort.signal})){
98
+ if(!process.stdout.write(`${JSON.stringify(frame)}\n`))await new Promise(done=>process.stdout.once('drain',done));
99
+ if(options.once&&frame.type==='snapshot_end')break;
100
+ }
101
+ }catch(error){if(error.code==='resync_required'){process.stdout.write(`${JSON.stringify({type:'resync_required',reason:error.message})}\n`);return 1;}throw error;}
102
+ finally{process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);}
103
+ return 0;
104
+ }
@@ -0,0 +1,57 @@
1
+ // Service supervision retains evidence; only a manager/creator act releases dispatch.
2
+ import { event, json, now } from './store.mjs';
3
+ import { statusOf, creatorOf } from './workflow.mjs';
4
+
5
+ export const monitorsRuns = entry => entry?.params?.run_failure_policy !== 'retry';
6
+ export const failedObservation = name => ['exited', 'session_closed', 'session_lost', 'spawn_failed', 'orphaned', 'timed_out', 'stale'].includes(name);
7
+
8
+ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId = null) {
9
+ const task = db.prepare('SELECT * FROM tasks WHERE id=?').get(id);
10
+ runId ??= task?.current_run_id;
11
+ if (!task || !runId) return false;
12
+ const row = db.prepare('SELECT * FROM task_runs WHERE id=?').get(runId);
13
+ if (!row || row.task_id !== id) return false;
14
+ const meta = json(row.metadata, {}) ?? {}, session = meta.supercode ?? {};
15
+ const prior = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='run_incident' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
16
+ const continued = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='session_continued' AND run_id=? ORDER BY id DESC LIMIT 1").get(id,runId);
17
+ if (prior && !(continued?.id > prior.id)) return false;
18
+ const reason = evidence.error ?? row.error ?? `run ${runId} ended (${row.outcome ?? row.status}) without moving its card`;
19
+ const payload = { observation: name, run: runId, status: statusOf(db, task), reason,
20
+ session: session.session_id ?? null, machine: session.machine ?? null, pane: session.pane ?? null,
21
+ launch_key: session.launch_key ?? null, pid: session.pid ?? null, evidence };
22
+ const incident = event(db, id, 'run_incident', payload, runId);
23
+ event(db, id, 'dispatch_held', { reason, incident, run: runId });
24
+ meta.supercode = { ...session, incident, monitor_held: true, state: 'failed' };
25
+ db.prepare(`UPDATE task_runs SET ended_at=COALESCE(ended_at,?),status=CASE WHEN ended_at IS NULL THEN 'failed' ELSE status END,
26
+ outcome=COALESCE(outcome,'failed'),error=COALESCE(error,?),metadata=?,claim_lock=NULL,claim_expires=NULL WHERE id=?`)
27
+ .run(now(), reason, JSON.stringify(meta), runId);
28
+ if (task.current_run_id === runId) db.prepare('UPDATE tasks SET current_run_id=NULL,claim_lock=NULL,claim_expires=NULL,worker_pid=NULL,last_failure_error=? WHERE id=?').run(reason, id);
29
+ const text = `${id}: run #${runId} stopped: ${reason}. Incident event ${incident}. Evidence: ${JSON.stringify(payload)}\nThe dispatcher will start nothing. The manager or creator decides whether to start, resume or block the card; this incident remains recorded.`;
30
+ db.prepare('INSERT INTO task_comments(task_id,author,body,created_at) VALUES (?,?,?,?)').run(id, 'dispatcher', text, now());
31
+ const creator = creatorOf(db, id);
32
+ const reachableCreator = db.prepare("SELECT chat_id FROM kanban_notify_subs WHERE task_id=? AND json_extract(delivery_metadata,'$.role')='creator' AND platform='supercode' LIMIT 1").get(id)?.chat_id;
33
+ const to = [].concat(entry.params.managers ?? []).find(Boolean) ?? reachableCreator ?? (creator?.startsWith('session:sc:') ? creator.slice(8) : creator?.startsWith('sc:') ? creator : null);
34
+ if (to) event(db, id, 'board_message', { to, text, incident }, null);
35
+ else event(db, id, 'incident_delivery_pending', { incident, reason: 'no manager or reachable creator configured; incident retained on card' });
36
+ return true;
37
+ }
38
+
39
+ /** Older ended runs are audited before eligibility: a completed run is not permission to repeat. */
40
+ export function auditEndedRuns(db, board, entry) {
41
+ if (!monitorsRuns(entry)) return;
42
+ for (const task of db.prepare(`SELECT t.*,r.id run_id,r.outcome,r.error,r.metadata run_metadata FROM tasks t JOIN task_runs r ON r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id)
43
+ WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status NOT IN ('done','archived')`).all()) {
44
+ const session = json(task.run_metadata, {})?.supercode;
45
+ const status = statusOf(db, task);
46
+ if (session?.status && session.status !== status) continue; // a card transition intentionally advances its work
47
+ const incident = db.prepare("SELECT id FROM task_events WHERE task_id=? AND run_id=? AND kind='run_incident'").get(task.id, task.run_id);
48
+ const release = db.prepare("SELECT id FROM task_events WHERE task_id=? AND kind='dispatch_released' ORDER BY id DESC LIMIT 1").get(task.id);
49
+ if (incident && session?.monitor_held && release?.id > incident.id) continue;
50
+ const decided = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND kind='dispatch_released' AND json_extract(payload,'$.ended_run')=?").get(task.id, task.run_id);
51
+ if (decided) continue; // its manager or creator ended it by an explicit act
52
+ const audited = db.prepare("SELECT 1 FROM task_events WHERE task_id=? AND run_id=? AND kind='audited'").get(task.id,task.run_id);
53
+ if (audited) continue; // Every explicitly schedules the next successful audit
54
+ if (['blocked','paused','reconfigured','review_passed','review','review_requested','audited'].includes(task.outcome)) continue;
55
+ surfaceIncident(db, board, entry, task.id, 'run_ended', { error: task.error, outcome: task.outcome }, task.run_id);
56
+ }
57
+ }
@@ -0,0 +1,86 @@
1
+ // The card writer commits the full snapshot and a retained delivery obligation together.
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { realpathSync } from 'node:fs';
4
+ export const PUBLICATION_SCHEMA = `
5
+ CREATE TABLE IF NOT EXISTS supercode_publication_receipts (
6
+ event_id TEXT NOT NULL, destination TEXT NOT NULL, PRIMARY KEY(event_id,destination)
7
+ );
8
+ CREATE TABLE IF NOT EXISTS supercode_publications (
9
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, resource_id TEXT NOT NULL,
10
+ kind TEXT NOT NULL, snapshot TEXT NOT NULL, actor TEXT, created_at INTEGER NOT NULL
11
+ );
12
+ CREATE TABLE IF NOT EXISTS supercode_publication_heads (
13
+ resource_id TEXT PRIMARY KEY, digest TEXT NOT NULL, sequence INTEGER NOT NULL, last_event_id INTEGER
14
+ );
15
+ CREATE TABLE IF NOT EXISTS supercode_publication_log (
16
+ id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL DEFAULT 0
17
+ );`;
18
+ // Every card's head stays; older changes beyond this many are pruned in one batch once twice as many accumulate.
19
+ // A reader whose cursor falls below the pruned floor takes a new snapshot.
20
+ const RETAINED_CHANGES = 1024;
21
+ const hash = value => createHash('sha256').update(value).digest('hex');
22
+ const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
23
+ const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
24
+ const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',resumed:'resumed',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
25
+
26
+ /**
27
+ * The ontology's Task status (contracts/ontology.md: todo, running, review, done, cancelled) of a card in a board lane.
28
+ * The lanes are the card machine's Hermes-shaped states: waiting lanes are `todo` (blocked is the blocked_by relation,
29
+ * ready is derived), and an archived card is `done` when it was completed, else `cancelled`.
30
+ */
31
+ export function taskStatusOf(lane, completedAt) {
32
+ if (lane === 'running' || lane === 'review' || lane === 'done') return lane;
33
+ if (lane === 'archived') return completedAt ? 'done' : 'cancelled';
34
+ return 'todo';
35
+ }
36
+
37
+ export function cardSnapshot(db,row) {
38
+ const own=db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(row.id)??{};
39
+ const comments=db.prepare('SELECT id,author,body,created_at FROM task_comments WHERE task_id=? ORDER BY id').all(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
40
+ const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
41
+ const reviews=db.prepare("SELECT id,kind,payload,created_at FROM task_events WHERE task_id=? AND kind IN ('review_requested','approved','review_approved','changes_requested','escalated') ORDER BY id").all(row.id).map(event=>{const payload=parse(event.payload,{});return {id:event.id,verdict:verdicts[event.kind],by:payload.reviewer??payload.by??payload.profile,reason:payload.reason,at:at(event.created_at)};});
42
+ const attempts=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(row.id).map(run=>{
43
+ const native=parse(run.metadata,{})?.supercode??null;
44
+ const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
45
+ const metadata=parse(run.metadata,{});delete metadata.supercode;
46
+ return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
47
+ });
48
+ const lane=own.stage??row.status;
49
+ return {id:row.id,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),lane,
50
+ priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
51
+ tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
52
+ blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),every:own.every??null,
53
+ workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
54
+ parents:db.prepare('SELECT parent_id FROM task_links WHERE child_id=? ORDER BY parent_id').all(row.id).map(link=>link.parent_id),
55
+ reviews,children:db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(row.id).map(link=>link.child_id),comments,attempts};
56
+ }
57
+
58
+ /** Runs inside the backing's native transaction, including files and ztrack projections. */
59
+ export function recordCardPublications(db,actor) {
60
+ const rows=db.prepare('SELECT * FROM tasks ORDER BY id').all(),present=new Set();let changed=0;
61
+ for(const row of rows){
62
+ present.add(row.id);const snapshot=JSON.stringify(cardSnapshot(db,row)),digest=hash(snapshot);
63
+ const previous=db.prepare('SELECT digest,last_event_id FROM supercode_publication_heads WHERE resource_id=?').get(row.id);
64
+ if(previous?.digest===digest)continue;
65
+ const last=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1').get(row.id);
66
+ const kind=`task.${previous?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
67
+ const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,row.id,kind,snapshot,actor,Date.now());
68
+ db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id) VALUES (?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id').run(row.id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
69
+ }
70
+ for(const old of db.prepare('SELECT resource_id,digest FROM supercode_publication_heads').all()){
71
+ if(present.has(old.resource_id)||old.digest==='removed')continue;
72
+ const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,old.resource_id,'task.changed',JSON.stringify({id:old.resource_id,removed:true}),actor,Date.now());
73
+ db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=? WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),old.resource_id);changed++;
74
+ }
75
+ db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
76
+ const history=Number(db.prepare('SELECT count(*) AS n FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads)').get().n);
77
+ if(history>2*RETAINED_CHANGES){
78
+ const cut=Number(db.prepare('SELECT sequence FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads) ORDER BY sequence DESC LIMIT 1 OFFSET ?').get(RETAINED_CHANGES).sequence);
79
+ const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
80
+ db.prepare(`DELETE FROM supercode_publication_receipts WHERE event_id IN (${old})`).run(cut);
81
+ db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut);
82
+ db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
83
+ }
84
+ return changed;
85
+ }
86
+ export const boardSourceName = (root,slug) => `board:${hash(realpathSync(root)).slice(0,24)}:${slug}`;
@@ -0,0 +1,12 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ const entry = fileURLToPath(new URL('../bin/runtime-door.mjs',import.meta.url));
4
+
5
+ /** Public machine RPC only; no Teams binary or implementation/state-file access. */
6
+ export function runtimeDoor(machine,method,params={}, {env=process.env,timeout=60_000,as=null}={}) {
7
+ const result = spawnSync(process.execPath,[entry],{
8
+ input:JSON.stringify({machine,method,params}),encoding:'utf8',timeout,
9
+ env:{...env,...(as?{SUPERCODE_CALLER:as}:{})},maxBuffer:512*1024*1024,
10
+ });
11
+ return {code:result.status,stdout:result.stdout??'',stderr:(result.stderr??'')+(result.error?.message??'')};
12
+ }
package/board/store.mjs CHANGED
@@ -17,6 +17,7 @@ import { DatabaseSync } from 'node:sqlite';
17
17
  import { openZtrackBoard } from './ztrack.mjs';
18
18
  import { isFilesBoard, openFilesBoard } from './files.mjs';
19
19
  import { signalBoardWrite } from './wake.mjs';
20
+ import { PUBLICATION_SCHEMA, recordCardPublications } from './published.mjs';
20
21
 
21
22
  export const DEFAULT_BOARD = 'default';
22
23
 
@@ -73,7 +74,8 @@ const OURS = `CREATE TABLE IF NOT EXISTS supercode_cards (task_id TEXT PRIMARY K
73
74
  CREATE TABLE IF NOT EXISTS supercode_effects (id INTEGER PRIMARY KEY AUTOINCREMENT, effect TEXT NOT NULL);`;
74
75
  // `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
75
76
  // `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
76
- const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER' };
77
+ // `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `sponsor`, `sponsor_evidence`, `requested_by_unverified`: C4
78
+ const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: 'TEXT' };
77
79
 
78
80
  // ------------------------------------------------------------------ backings
79
81
  //
@@ -134,6 +136,7 @@ export function listBoards(root) {
134
136
  export function layOut(db, { fresh }) {
135
137
  if (fresh) db.exec(SCHEMA);
136
138
  db.exec(OURS);
139
+ db.exec(PUBLICATION_SCHEMA);
137
140
  const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
138
141
  for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
139
142
  }
@@ -171,9 +174,21 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
171
174
  if (!kept) mkdirSync(dirname(path), { recursive: true });
172
175
  const db = new DatabaseSync(path);
173
176
  db.exec('PRAGMA foreign_keys = OFF');
174
- const fresh = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get();
175
- if (fresh && !create) { db.close(); throw new Error(`${path} is not a board`); }
176
- layOut(db, { fresh });
177
+ // The check and the layout are one write transaction: a board another process is laying out is not mistaken for a
178
+ // laid-out one (its `tasks` table made, the rest not), and a layout cut short by that process leaves nothing behind.
179
+ for (;;) {
180
+ try {
181
+ db.exec('BEGIN IMMEDIATE');
182
+ const fresh = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get();
183
+ if (fresh && !create) { db.exec('ROLLBACK'); db.close(); throw new Error(`${path} is not a board`); }
184
+ layOut(db, { fresh });
185
+ db.exec('COMMIT');
186
+ break;
187
+ } catch (error) {
188
+ try { db.exec('ROLLBACK'); } catch {}
189
+ if (!/locked|busy/i.test(error.message)) throw error;
190
+ }
191
+ }
177
192
  return { db, root, slug, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
178
193
  }
179
194
 
@@ -182,11 +197,22 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
182
197
  * A read inside the transaction is the state it writes over.
183
198
  */
184
199
  export const tx = (board, fn) => {
185
- const result = board.tx(fn);
200
+ const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actor);return result;});
186
201
  signalBoardWrite(board.root, actor);
187
202
  return result;
188
203
  };
189
- export const view = (board, fn) => fn(board.db);
204
+ // A read that meets another process's write is retried as a write is (sqliteTx): a dispatcher service and a CLI share
205
+ // the board, and a lost read must not become a launch failure. Inside a transaction its retry is the transaction's.
206
+ const pause = new Int32Array(new SharedArrayBuffer(4));
207
+ export const view = (board, fn) => {
208
+ for (;;) {
209
+ try { return fn(board.db); }
210
+ catch (error) {
211
+ if (inTx.has(board.db) || !/locked|busy/i.test(error.message)) throw error;
212
+ Atomics.wait(pause, 0, 0, 10);
213
+ }
214
+ }
215
+ };
190
216
 
191
217
  // A deferred native transaction, with the whole operation retried on an optimistic conflict.
192
218
  // There is no separate application writer lock or contention timeout. A write inside one is part of it.
@@ -226,6 +252,13 @@ export function json(text, fallback = null) {
226
252
  let actor = null;
227
253
  export const actorOf = () => actor;
228
254
  export function setActor(address) { actor = address || null; }
255
+ // C4: the principal the caller acts as (`agent:<id>`, `automation:<name>`, `person:<id>`, `session:<address>`): what a
256
+ // card's `created_by` records, and what a relaunched agent still matches when it acts on the cards it created.
257
+ let principal = null;
258
+ export const principalOf = () => principal;
259
+ export function setPrincipal(ref) { principal = ref || null; }
260
+ /** Whether a recorded creator is the caller: its principal, its session address, or that session as an actor. */
261
+ export const isCaller = (ref) => Boolean(ref) && [principal, actor, actor ? `session:${actor}` : null].includes(ref);
229
262
 
230
263
  export function event(db, taskId, kind, payload = null, runId = null) {
231
264
  // Session observations belong to the run machine. Legacy runs without a machine state retain
package/board/tools.mjs CHANGED
@@ -181,6 +181,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
181
181
  maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
182
182
  goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
183
183
  });
184
+ await engine.runEffects(board,made.effects??[]);
184
185
  return ok({ task_id: made.id, existing: made.existing, status: tx(board, (db) => engine.getTask(db, made.id)).status });
185
186
  }
186
187
  case 'kanban_unblock':