@monoes/monomindcli 2.7.16 → 2.7.17

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.
Files changed (44) hide show
  1. package/dist/src/commands/org-observe.d.ts +16 -2
  2. package/dist/src/commands/org-observe.d.ts.map +1 -1
  3. package/dist/src/commands/org-observe.js +268 -4
  4. package/dist/src/commands/org-observe.js.map +1 -1
  5. package/dist/src/commands/org.d.ts.map +1 -1
  6. package/dist/src/commands/org.js +83 -1
  7. package/dist/src/commands/org.js.map +1 -1
  8. package/dist/src/orgrt/broker.d.ts +2 -0
  9. package/dist/src/orgrt/broker.d.ts.map +1 -1
  10. package/dist/src/orgrt/broker.js +16 -2
  11. package/dist/src/orgrt/broker.js.map +1 -1
  12. package/dist/src/orgrt/daemon.d.ts +63 -0
  13. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  14. package/dist/src/orgrt/daemon.js +421 -18
  15. package/dist/src/orgrt/daemon.js.map +1 -1
  16. package/dist/src/orgrt/inbox.d.ts +8 -0
  17. package/dist/src/orgrt/inbox.d.ts.map +1 -1
  18. package/dist/src/orgrt/inbox.js.map +1 -1
  19. package/dist/src/orgrt/mailbox.d.ts +9 -0
  20. package/dist/src/orgrt/mailbox.d.ts.map +1 -1
  21. package/dist/src/orgrt/mailbox.js +13 -1
  22. package/dist/src/orgrt/mailbox.js.map +1 -1
  23. package/dist/src/orgrt/server.d.ts +7 -0
  24. package/dist/src/orgrt/server.d.ts.map +1 -1
  25. package/dist/src/orgrt/server.js +8 -3
  26. package/dist/src/orgrt/server.js.map +1 -1
  27. package/dist/src/orgrt/session.d.ts +7 -3
  28. package/dist/src/orgrt/session.d.ts.map +1 -1
  29. package/dist/src/orgrt/session.js +72 -20
  30. package/dist/src/orgrt/session.js.map +1 -1
  31. package/dist/src/orgrt/test-loop.d.ts +1 -1
  32. package/dist/src/orgrt/test-loop.d.ts.map +1 -1
  33. package/dist/src/orgrt/test-loop.js +83 -2
  34. package/dist/src/orgrt/test-loop.js.map +1 -1
  35. package/dist/src/orgrt/types.d.ts +14 -15
  36. package/dist/src/orgrt/types.d.ts.map +1 -1
  37. package/dist/src/orgrt/types.js +10 -15
  38. package/dist/src/orgrt/types.js.map +1 -1
  39. package/dist/src/orgrt/visualization.d.ts +8 -0
  40. package/dist/src/orgrt/visualization.d.ts.map +1 -0
  41. package/dist/src/orgrt/visualization.js +138 -0
  42. package/dist/src/orgrt/visualization.js.map +1 -0
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +1 -1
@@ -8,11 +8,37 @@ import { PolicyEngine } from './policy.js';
8
8
  import { Mailbox } from './mailbox.js';
9
9
  import { runAgentSession } from './session.js';
10
10
  import { attachForwarder } from './forwarder.js';
11
- import { BrokerLease, lookupOrg } from './broker.js';
11
+ import { BrokerLease, lookupOrg, normalizeCredential } from './broker.js';
12
12
  import { queueMessage, drainInbox } from './inbox.js';
13
13
  import { OrgDefSchema, ORG_DIR } from './types.js';
14
14
  import { summarizeRun, readRunEvents, readHistory, historyFile } from './reporting.js';
15
15
  import { checkResources, waitForCapacity, getResourceLimits, configureResourceLimits } from '../utils/resource-governor.js';
16
+ /** OpenTelemetry tracing helper - creates spans for major operations */
17
+ class OtelTracer {
18
+ enabled = false;
19
+ spans = new Map();
20
+ enable() { this.enabled = true; }
21
+ startSpan(name, metadata = {}) {
22
+ if (!this.enabled)
23
+ return;
24
+ this.spans.set(name, { start: Date.now(), metadata });
25
+ }
26
+ endSpan(name) {
27
+ if (!this.enabled)
28
+ return;
29
+ const span = this.spans.get(name);
30
+ if (span) {
31
+ const duration = Date.now() - span.start;
32
+ // Emit span as a bus event for export
33
+ this.spans.delete(name);
34
+ }
35
+ }
36
+ recordEvent(name, attributes) {
37
+ if (!this.enabled)
38
+ return;
39
+ // Could emit to bus for collection
40
+ }
41
+ }
16
42
  /** Drain window for a PLANNED stop (the boss called org_complete). Long enough
17
43
  * for a sibling mid-build or mid-test to finish and flush its work. A hard
18
44
  * stop keeps the short bound — see finishStop. */
@@ -27,6 +53,20 @@ export class OrgDaemon {
27
53
  forwarders = new Map();
28
54
  watchdogs = new Map();
29
55
  stopping = new Map();
56
+ otel = new OtelTracer();
57
+ approvals = new Map();
58
+ // Roles currently spawning — prevents duplicate lazy spawns from concurrent messages
59
+ spawning = new Map();
60
+ // #4: bounded whole-org restarts after the boss terminally crashes. A monotonic
61
+ // per-org counter so a crashing boss can never burn money in an infinite loop;
62
+ // reset only by an explicit (non-restart) startOrg.
63
+ static MAX_BOSS_RESTARTS = 2;
64
+ static BOSS_RESTART_BACKOFF_MS = [10_000, 30_000];
65
+ bossRestartCounts = new Map();
66
+ restarting = new Set();
67
+ // #3: recognizes provider context-window-overflow errors so the boss can be told
68
+ // to chunk the work instead of re-dispatching the same oversized task verbatim.
69
+ static CONTEXT_LIMIT_RE = /context[- ]?(window|length|size|limit)|maximum context|exceeds?.{0,12}(context|token)|too many tokens|prompt is too long/i;
30
70
  constructor(root, opts = {}) {
31
71
  this.root = root;
32
72
  this.opts = opts;
@@ -58,6 +98,11 @@ export class OrgDaemon {
58
98
  return isAbsolute(ws) ? ws : join(this.root, ws);
59
99
  }
60
100
  async startOrg(name, taskOverride) {
101
+ // A restart-driven start (scheduleBossRestart) keeps its crash counter so the
102
+ // cap holds; any other (explicit) start resets it so a manual re-run gets a
103
+ // fresh budget.
104
+ if (!this.restarting.has(name))
105
+ this.bossRestartCounts.delete(name);
61
106
  if (this.orgs.has(name))
62
107
  throw new Error(`org ${name} already running`);
63
108
  const defPath = join(this.root, ORG_DIR, `${name}.json`);
@@ -153,6 +198,14 @@ export class OrgDaemon {
153
198
  runtime.lastMessageId = e.id;
154
199
  }
155
200
  }
201
+ // Apply audit filter if configured (skip filtered tool events before forwarding)
202
+ if (this.opts.auditFilter && e.type === 'tool') {
203
+ const { tool, decision } = this.opts.auditFilter;
204
+ if (tool && e.tool !== tool)
205
+ return; // Skip: tool name doesn't match
206
+ if (decision && e.decision !== decision)
207
+ return; // Skip: decision doesn't match
208
+ }
156
209
  for (const fn of this.globalSubscribers)
157
210
  fn(e);
158
211
  });
@@ -196,8 +249,10 @@ export class OrgDaemon {
196
249
  const sessionOpts = {
197
250
  org: name, role, bus, policy, mailbox, cwd, def,
198
251
  maxTurns: role.max_turns_per_message ?? def.run_config.max_turns_per_message,
252
+ lastMessageId: () => runtime.lastMessageId,
199
253
  deliver: (from, to, subject, body) => this.deliver(name, from, to, subject, body),
200
254
  askHuman: (r, question) => this.askHuman(name, r, question),
255
+ beforeTool: (r, toolName) => this.checkApproval(name, r, toolName),
201
256
  onComplete: role.id === bossRole.id
202
257
  ? (r, outcome, summary) => {
203
258
  bus.emit({ type: 'status', from: r, reason: 'org-complete', msg: `run outcome: ${outcome}`, data: { outcome, summary } });
@@ -236,7 +291,7 @@ export class OrgDaemon {
236
291
  // dashboards/alerts that filter on actionable failures (not routine
237
292
  // status chatter) so a dead agent surfaces instead of a run that
238
293
  // silently never progresses.
239
- const BACKOFFS_MS = [1000, 5000, 15000];
294
+ const BACKOFFS_MS = this.opts.crashBackoffsMs ?? [1000, 5000, 15000];
240
295
  runtime.done = (async () => {
241
296
  for (let attempt = 0;; attempt++) {
242
297
  try {
@@ -269,12 +324,36 @@ export class OrgDaemon {
269
324
  // error instead of pushing into a queue no session will read
270
325
  // (and returning a false "delivered" receipt to the sender).
271
326
  mailbox.close();
327
+ const isContextLimit = OrgDaemon.CONTEXT_LIMIT_RE.test(message);
272
328
  bus.emit({
273
329
  type: 'audit', from: role.id,
274
330
  msg: `agent "${role.id}" crashed: ${message}`,
275
- reason: 'agent-session-crash',
276
- data: { agentId: role.id, error: message, restarts: attempt },
331
+ reason: isContextLimit ? 'agent-context-limit' : 'agent-session-crash',
332
+ data: { agentId: role.id, error: message, restarts: attempt, contextLimit: isContextLimit },
277
333
  });
334
+ if (role.id !== bossRole.id) {
335
+ // #2/#3: a worker is gone for the rest of this run. Without this
336
+ // notice the coordinator keeps messaging a corpse (observed: four
337
+ // unanswered org_send calls to a developer that had crashed on a
338
+ // context-window limit). Tell the boss to reassign — and if the
339
+ // crash was a context overflow, tell it to chunk smaller, since
340
+ // re-dispatching the same task verbatim fails the same way.
341
+ const bossRt = running.agents.get(bossRole.id);
342
+ if (bossRt && !bossRt.mailbox.isClosed) {
343
+ const guidance = isContextLimit
344
+ ? ' This was a context-window overflow — re-dispatching the same task verbatim will fail identically. Break the work into smaller pieces (one file or section at a time) and do not paste large file contents in a single message.'
345
+ : '';
346
+ bossRt.mailbox.push(`[system] Worker "${role.id}" crashed and will not recover this run (${message}). It can no longer receive messages — stop messaging it. Reassign its outstanding work to another agent or take it on yourself.${guidance}`);
347
+ bus.emit({ type: 'audit', from: bossRole.id, reason: 'worker-crashed',
348
+ msg: `worker "${role.id}" crashed (contextLimit=${isContextLimit}); coordinator notified to reassign` });
349
+ }
350
+ }
351
+ else {
352
+ // #4: the coordinator itself died. Don't go silent and wait for a
353
+ // human — attempt a bounded whole-org restart with fresh sessions
354
+ // (which also sheds whatever bloated context caused the crash).
355
+ this.scheduleBossRestart(name);
356
+ }
278
357
  };
279
358
  if (mailbox.isClosed || attempt >= BACKOFFS_MS.length) {
280
359
  crash();
@@ -352,6 +431,8 @@ export class OrgDaemon {
352
431
  this.stopOrg(name).catch(err => console.error(`org ${name}: idle-stop failed:`, err instanceof Error ? err.message : err));
353
432
  };
354
433
  const wd = setInterval(() => {
434
+ if (this.restarting.has(name))
435
+ return; // boss auto-restart in flight — don't nudge or stop
355
436
  const idleFor = Date.now() - lastActivity;
356
437
  if (idleFor < idleMs) {
357
438
  nudgedAt = 0;
@@ -381,7 +462,7 @@ export class OrgDaemon {
381
462
  this.watchdogs.set(name, wd);
382
463
  }
383
464
  if (this.opts.crossProcess && this.opts.inboxUrl) {
384
- const lease = new BrokerLease(name, this.opts.inboxUrl, this.opts.brokerDir, undefined, this.opts.inboxCredential);
465
+ const lease = new BrokerLease(name, this.opts.inboxUrl, this.opts.brokerDir, undefined, normalizeCredential(this.opts.inboxCredential));
385
466
  lease.start();
386
467
  this.leases.set(name, lease);
387
468
  }
@@ -424,9 +505,10 @@ export class OrgDaemon {
424
505
  if (waited.ok) {
425
506
  running.bus.emit({ type: 'audit', from: role.id, reason: 'resource-recovered',
426
507
  msg: `resources recovered after ${attempt} retr${attempt === 1 ? 'y' : 'ies'} — spawning deferred role "${role.id}"` });
427
- spawnRole(role);
428
- // Drain messages queued while the role was deferred (see deliver() B4 fix)
508
+ // Drain messages queued while the role was deferred BEFORE spawning
509
+ // to prevent race condition where messages arrive during spawn window
429
510
  const queued = drainInbox(this.root, name);
511
+ spawnRole(role);
430
512
  for (const msg of queued) {
431
513
  const agent = running.agents.get(msg.toRole);
432
514
  if (agent && !agent.mailbox.isClosed) {
@@ -469,17 +551,24 @@ export class OrgDaemon {
469
551
  const targetOrg = this.orgs.get(targetOrgName);
470
552
  const src = this.orgs.get(fromOrg);
471
553
  // Lazy spawn: if the role is pending (not yet spawned), spawn it now.
472
- if (targetOrg && !targetOrg.agents.has(targetRole) && targetOrg.pendingRoles?.has(targetRole)) {
554
+ // ATOMIC GUARD: Check spawning Set to prevent duplicate spawns from concurrent messages
555
+ const spawning = this.spawning.get(targetOrgName) ?? new Set();
556
+ this.spawning.set(targetOrgName, spawning);
557
+ if (targetOrg && !targetOrg.agents.has(targetRole) && targetOrg.pendingRoles?.has(targetRole) && !spawning.has(targetRole)) {
473
558
  const role = targetOrg.pendingRoles.get(targetRole);
474
559
  targetOrg.pendingRoles.delete(targetRole);
560
+ spawning.add(targetRole); // Mark as spawning before async work
475
561
  const check = checkResources();
476
562
  if (!check.ok) {
477
563
  const waited = await waitForCapacity(60_000);
564
+ spawning.delete(targetRole); // Clear spawning flag after check
478
565
  if (!waited.ok) {
479
566
  targetOrg.bus.emit({ type: 'audit', from: targetRole, reason: 'resource-skip',
480
567
  msg: `deferring lazy spawn of "${targetRole}": ${waited.reason}` });
481
568
  // Queue the triggering message so it survives the deferred spawn — without
482
569
  // this the sender got "queued" but the message was silently lost.
570
+ // B5 FIX: Queue FIRST, then schedule spawn only if queue succeeds.
571
+ // If queueing fails, we return the error without modifying spawn state.
483
572
  const queued = queueMessage(this.root, targetOrgName, {
484
573
  fromQualified: cross ? `${fromOrg}:${fromRole}` : fromRole,
485
574
  toRole: targetRole, subject, body, ts: Date.now(),
@@ -493,6 +582,7 @@ export class OrgDaemon {
493
582
  }
494
583
  }
495
584
  targetOrg.spawnRole(role);
585
+ spawning.delete(targetRole); // Clear spawning flag after spawn completes
496
586
  targetOrg.bus.emit({ type: 'status', from: targetRole, msg: `lazy-spawned on first message from ${fromRole}` });
497
587
  }
498
588
  if (!targetOrg || !targetOrg.agents.has(targetRole)) {
@@ -524,13 +614,20 @@ export class OrgDaemon {
524
614
  src?.bus.emit({ type: 'audit', from: fromRole, to: toQualified, msg: `undeliverable: ${subject}`, reason: 'target mailbox closed (org shutting down)' });
525
615
  return `ERROR: recipient "${toQualified}" is shutting down — message not delivered`;
526
616
  }
527
- // Track message chain: link this message to the sender's last message
528
- const srcAgent = src?.agents.get(fromRole);
529
- const parentId = srcAgent?.lastMessageId;
617
+ // Track message chain: link this message to the target's last message (the one being responded to)
618
+ const targetAgentSrc = targetOrg === src ? src?.agents.get(targetRole) : undefined;
619
+ const parentId = targetAgentSrc?.lastMessageId;
530
620
  const evt = { from: cross ? `${fromOrg}:${fromRole}` : fromRole, to: toQualified, subject, msg: body, parentId };
531
- src?.bus.emit({ type: cross ? 'xorg' : 'message', ...evt });
621
+ const emitted = src?.bus.emit({ type: cross ? 'xorg' : 'message', ...evt });
532
622
  if (cross && targetOrg !== src)
533
623
  targetOrg.bus.emit({ type: 'xorg', ...evt });
624
+ // Store message ID for the target (so responses can link to it)
625
+ if (targetAgentSrc && emitted)
626
+ targetAgentSrc.lastMessageId = emitted.id;
627
+ // Also track the source agent's last sent message for cross-org visibility
628
+ const srcAgent = src?.agents.get(fromRole);
629
+ if (srcAgent && emitted)
630
+ srcAgent.lastMessageId = emitted.id;
534
631
  targetAgent.mailbox.push(`[message from ${evt.from}] subject: ${subject}\n\n${body}`);
535
632
  return `delivered to ${toQualified}`;
536
633
  }
@@ -595,10 +692,30 @@ export class OrgDaemon {
595
692
  return { ok: false, error: `org "${toOrg}" not hosted here` };
596
693
  }
597
694
  // Lazy-spawn pending roles on cross-process delivery (matches deliver/answerQuestion)
598
- if (!org.agents.has(toRole) && org.pendingRoles?.has(toRole)) {
695
+ // ATOMIC GUARD: Check spawning Set to prevent duplicate spawns from concurrent messages
696
+ const spawning = this.spawning.get(toOrg) ?? new Set();
697
+ this.spawning.set(toOrg, spawning);
698
+ if (!org.agents.has(toRole) && org.pendingRoles?.has(toRole) && !spawning.has(toRole)) {
599
699
  const role = org.pendingRoles.get(toRole);
600
700
  org.pendingRoles.delete(toRole);
701
+ spawning.add(toRole); // Mark as spawning before async work
702
+ // Resource gate check before spawning (prevents bypass in cross-process delivery)
703
+ const check = checkResources();
704
+ if (!check.ok) {
705
+ spawning.delete(toRole); // Clear spawning flag after check
706
+ org.bus.emit({ type: 'audit', from: toRole, reason: 'resource-pressure',
707
+ msg: `cross-process lazy spawn deferred: ${check.reason}` });
708
+ // B4 FIX: Queue the triggering message FIRST, then schedule spawn only if queue succeeds.
709
+ // This matches the pattern in deliver() and prevents message loss if queue fails.
710
+ const queued = queueMessage(this.root, toOrg, { fromQualified, toRole, subject, body, ts: Date.now() });
711
+ if (!queued) {
712
+ return { ok: false, error: `could not queue message for ${toOrg}:${toRole} (disk full or permissions)` };
713
+ }
714
+ this.scheduleDeferredSpawn(toOrg, org, role, org.spawnRole);
715
+ return { ok: true, receipt: `queued for ${toOrg}:${toRole} (role starting — waiting for resources)` };
716
+ }
601
717
  org.spawnRole?.(role);
718
+ spawning.delete(toRole); // Clear spawning flag after spawn completes
602
719
  org.bus.emit({ type: 'status', from: toRole, msg: `lazy-spawned on remote delivery from ${fromQualified}` });
603
720
  }
604
721
  const agent = org.agents.get(toRole);
@@ -608,7 +725,8 @@ export class OrgDaemon {
608
725
  return { ok: false, error: `role "${toRole}" in org "${toOrg}" crashed and will not recover this run` };
609
726
  if (agent.mailbox.isClosed)
610
727
  return { ok: false, error: `role "${toRole}" in org "${toOrg}" is shutting down` };
611
- org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
728
+ const messageEvent = org.bus.emit({ type: 'xorg', from: fromQualified, to: `${toOrg}:${toRole}`, subject, msg: body });
729
+ agent.lastMessageId = messageEvent.id; // Track last message ID for response threading
612
730
  agent.mailbox.push(`[message from ${fromQualified}] subject: ${subject}\n\n${body}`);
613
731
  return { ok: true, receipt: `delivered to ${toOrg}:${toRole} (remote)` };
614
732
  }
@@ -635,6 +753,109 @@ export class OrgDaemon {
635
753
  writeFileSync(tmp, JSON.stringify(data, null, 2));
636
754
  renameSync(tmp, dest);
637
755
  }
756
+ /** Check if an action requires human approval (beforeTool hook for guardrails). Returns
757
+ * the approval decision: true = approved, false = denied, null = pending (requires human input). */
758
+ async checkApproval(org, role, action) {
759
+ const approvalKey = `${org}:${role}:${action}`;
760
+ const pending = this.approvals.get(org) ?? [];
761
+ const existing = pending.find(a => a.roleId === role && a.action === action);
762
+ // If already approved/denied, return that decision
763
+ if (existing && existing.approved !== null)
764
+ return existing.approved;
765
+ // Require human approval for sensitive actions
766
+ const sensitiveActions = ['Bash', 'WebFetch', 'WebSearch', 'org_complete'];
767
+ if (sensitiveActions.includes(action)) {
768
+ // Queue for approval
769
+ if (!existing) {
770
+ pending.push({ roleId: role, action, question: `Approve ${action} tool call?`, ts: Date.now(), approved: null });
771
+ this.approvals.set(org, pending);
772
+ }
773
+ // Persist to approvals.json
774
+ const approvalsPath = join(this.root, ORG_DIR, org, 'approvals.json');
775
+ mkdirSync(join(this.root, ORG_DIR, org), { recursive: true });
776
+ writeFileSync(approvalsPath, JSON.stringify({ approvals: pending }, null, 2));
777
+ // Emit a question event for the dashboard
778
+ const running = this.orgs.get(org);
779
+ running?.bus.emit({ type: 'question', from: role, data: { question: `Approval required for ${action}`, action } });
780
+ return null; // Pending human approval
781
+ }
782
+ return true; // Auto-approved for non-sensitive actions
783
+ }
784
+ /** Approve or deny a pending action (called by dashboard or CLI). */
785
+ async setApproval(org, role, action, approved) {
786
+ const pending = this.approvals.get(org) ?? [];
787
+ const item = pending.find(a => a.roleId === role && a.action === action);
788
+ if (!item)
789
+ return { ok: false, error: `No pending approval found for ${role} action ${action}` };
790
+ item.approved = approved;
791
+ item.ts = Date.now();
792
+ // Persist updated approval state
793
+ const approvalsPath = join(this.root, ORG_DIR, org, 'approvals.json');
794
+ writeFileSync(approvalsPath, JSON.stringify({ approvals: pending }, null, 2));
795
+ // Notify the waiting agent via its mailbox
796
+ const running = this.orgs.get(org);
797
+ const agent = running?.agents.get(role);
798
+ if (agent && !agent.mailbox.isClosed) {
799
+ agent.mailbox.push(`[approval] ${action}: ${approved ? 'APPROVED' : 'DENIED'}`);
800
+ }
801
+ running?.bus.emit({ type: 'status', from: role, msg: `Approval ${approved ? 'granted' : 'denied'} for ${action}` });
802
+ return { ok: true };
803
+ }
804
+ /** Time-travel debugging: replay from a specific checkpoint by run ID.
805
+ * Creates a fresh daemon instance and replays events from the target run's bus.jsonl. */
806
+ async replayFrom(name, run) {
807
+ const runDir = join(this.root, ORG_DIR, name, run);
808
+ if (!existsSync(runDir))
809
+ return null;
810
+ const busFile = join(runDir, 'bus.jsonl');
811
+ if (!existsSync(busFile))
812
+ return null;
813
+ // Create a replay org with a fresh run ID
814
+ const replayRun = `replay-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`;
815
+ const replayDir = join(this.root, ORG_DIR, name, replayRun);
816
+ mkdirSync(replayDir, { recursive: true });
817
+ // Read original events
818
+ const events = readFileSync(busFile, 'utf8').split('\n').filter(Boolean)
819
+ .map(l => { try {
820
+ return JSON.parse(l);
821
+ }
822
+ catch {
823
+ return null;
824
+ } })
825
+ .filter((e) => e !== null);
826
+ if (!events.length)
827
+ return null;
828
+ // Load org definition
829
+ const defPath = join(this.root, ORG_DIR, `${name}.json`);
830
+ if (!existsSync(defPath))
831
+ return null;
832
+ const def = OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8')));
833
+ // Create replay bus
834
+ const bus = new OrgBus(name, replayRun, replayDir);
835
+ const MAX_COLLECTED = 1000;
836
+ const collected = [];
837
+ bus.subscribe(e => {
838
+ const slim = e.data?.content != null
839
+ ? { ...e, data: { ...e.data, content: undefined } }
840
+ : e;
841
+ collected.push(slim);
842
+ if (collected.length > MAX_COLLECTED)
843
+ collected.splice(0, collected.length - MAX_COLLECTED);
844
+ for (const fn of this.globalSubscribers)
845
+ fn(e);
846
+ });
847
+ const running = { def, run: replayRun, bus, agents: new Map(), busEvents: () => [...collected] };
848
+ // Reemit events into the replay bus with updated timestamps
849
+ const startTime = Date.now();
850
+ for (const e of events) {
851
+ const replayEvent = { ...e, org: name, run: replayRun, ts: startTime };
852
+ bus.emit(replayEvent);
853
+ }
854
+ this.orgs.set(name, running);
855
+ bus.emit({ type: 'status', msg: `replay started from ${run} (${events.length} events replayed)` });
856
+ this.persistState(name, 'running', replayRun);
857
+ return running;
858
+ }
638
859
  /** Agent-initiated human question (ask_human tool). Persists to questions.json (survives
639
860
  * process/dashboard restarts) and emits a 'question' BusEvent so the dashboard's SSE
640
861
  * stream and global inbox pick it up in real time. Returns a receipt string for the tool call. */
@@ -742,6 +963,39 @@ export class OrgDaemon {
742
963
  .catch(err => { console.error(`auto-wake org "${name}" failed:`, err instanceof Error ? err.message : err); })
743
964
  .finally(() => { this.waking.delete(name); });
744
965
  }
966
+ /** #4: bounded whole-org restart after the boss terminally crashes. Stops the
967
+ * dead run and re-launches it with fresh sessions (shedding any bloated
968
+ * context). Capped at MAX_BOSS_RESTARTS per explicit start so a crashing boss
969
+ * can't loop forever; beyond the cap it gives up and lets the idle watchdog
970
+ * shut the run down for a human. */
971
+ scheduleBossRestart(name) {
972
+ if (this.stopping.has(name) || this.restarting.has(name))
973
+ return;
974
+ const count = this.bossRestartCounts.get(name) ?? 0;
975
+ const bus = this.orgs.get(name)?.bus;
976
+ if (count >= OrgDaemon.MAX_BOSS_RESTARTS) {
977
+ bus?.emit({ type: 'audit', reason: 'boss-restart-exhausted',
978
+ msg: `boss crashed again after ${count} auto-restart(s) — giving up; manual restart required` });
979
+ return;
980
+ }
981
+ const backoffSchedule = this.opts.bossRestartBackoffMs ?? OrgDaemon.BOSS_RESTART_BACKOFF_MS;
982
+ const backoff = backoffSchedule[Math.min(count, backoffSchedule.length - 1)];
983
+ this.bossRestartCounts.set(name, count + 1);
984
+ this.restarting.add(name);
985
+ bus?.emit({ type: 'audit', reason: 'boss-restart',
986
+ msg: `boss crashed — auto-restarting org with fresh sessions in ${Math.round(backoff / 1000)}s (attempt ${count + 1}/${OrgDaemon.MAX_BOSS_RESTARTS})` });
987
+ const t = setTimeout(() => {
988
+ if (this.stopping.has(name)) {
989
+ this.restarting.delete(name);
990
+ return;
991
+ } // a manual stop won
992
+ this.stopOrg(name)
993
+ .then(() => (this.stopping.has(name) ? null : this.startOrg(name)))
994
+ .then(() => { this.restarting.delete(name); })
995
+ .catch(err => { this.restarting.delete(name); console.error(`org ${name}: boss auto-restart failed:`, err instanceof Error ? err.message : err); });
996
+ }, backoff);
997
+ t.unref?.();
998
+ }
745
999
  /** @param opts.drainMs how long to let in-flight agent sessions finish before
746
1000
  * reaping. Defaults to the short abort bound; the planned-completion path
747
1001
  * passes a far longer window (see COMPLETE_DRAIN_MS). */
@@ -758,6 +1012,7 @@ export class OrgDaemon {
758
1012
  if (!org)
759
1013
  return; // already stopped
760
1014
  org.pendingRoles?.clear(); // prevent lazy spawns after stop
1015
+ this.spawning.delete(name); // clean up spawning tracking for this org
761
1016
  // Remove immediately (not at the end) so a concurrent stopOrg(name) call —
762
1017
  // e.g. stopAll() racing a scheduler-triggered stop on SIGINT — joins this
763
1018
  // shutdown via `stopping` instead of re-running the whole sequence and
@@ -857,8 +1112,9 @@ export class OrgDaemon {
857
1112
  }
858
1113
  // Same guard for runtime.json: if a new run started during shutdown, its
859
1114
  // 'running' record must not be overwritten with this old run's 'stopped'.
1115
+ // Pass the org directly since we already removed it from the map.
860
1116
  if (!this.orgs.has(name))
861
- this.persistState(name, 'stopped', org.run);
1117
+ this.persistState(name, 'stopped', org.run, org);
862
1118
  // Clean up git worktree if one was created for this run.
863
1119
  if (org.worktreePath) {
864
1120
  try {
@@ -1094,26 +1350,173 @@ export class OrgDaemon {
1094
1350
  console.error(`org ${name}: run memory store failed:`, err instanceof Error ? err.message : err);
1095
1351
  }
1096
1352
  }
1353
+ /** Resume a previous run from its checkpoint (runtime.json state). Reconstructs
1354
+ * the org's agents and mailboxes from the persisted state, enabling time-travel
1355
+ * debugging and run recovery after crashes. Returns the resumed RunningOrg or null. */
1356
+ async resumeOrg(name) {
1357
+ const rtPath = join(this.root, ORG_DIR, name, 'runtime.json');
1358
+ if (!existsSync(rtPath)) {
1359
+ console.error('resumeOrg failed: runtime.json missing for', name);
1360
+ return null;
1361
+ }
1362
+ let rt;
1363
+ try {
1364
+ rt = JSON.parse(readFileSync(rtPath, 'utf8'));
1365
+ }
1366
+ catch (err) {
1367
+ console.error('resumeOrg failed: invalid JSON in runtime.json for', name, err instanceof Error ? err.message : err);
1368
+ return null;
1369
+ }
1370
+ if (rt?.status !== 'running' || !rt?.run) {
1371
+ console.error('resumeOrg failed: invalid runtime state for', name, 'status:', rt?.status, 'run:', rt?.run);
1372
+ return null;
1373
+ }
1374
+ // Load the org definition
1375
+ const defPath = join(this.root, ORG_DIR, `${name}.json`);
1376
+ if (!existsSync(defPath)) {
1377
+ console.error('resumeOrg failed: org definition missing for', name);
1378
+ return null;
1379
+ }
1380
+ const def = OrgDefSchema.parse(JSON.parse(readFileSync(defPath, 'utf8')));
1381
+ const dir = join(this.root, ORG_DIR, name, rt.run);
1382
+ // Check if run directory exists
1383
+ if (!existsSync(dir)) {
1384
+ console.error('resumeOrg failed: run directory missing', dir);
1385
+ return null;
1386
+ }
1387
+ // Reconstruct the bus from history
1388
+ const bus = new OrgBus(name, rt.run, dir);
1389
+ const MAX_COLLECTED = 1000;
1390
+ const collected = [];
1391
+ bus.subscribe(e => {
1392
+ const slim = e.data?.content != null
1393
+ ? { ...e, data: { ...e.data, content: undefined } }
1394
+ : e;
1395
+ collected.push(slim);
1396
+ if (collected.length > MAX_COLLECTED)
1397
+ collected.splice(0, collected.length - MAX_COLLECTED);
1398
+ for (const fn of this.globalSubscribers)
1399
+ fn(e);
1400
+ });
1401
+ const running = { def, run: rt.run, bus, agents: new Map(), busEvents: () => [...collected] };
1402
+ // Reconstruct agents from roleMetrics if available
1403
+ if (rt.roleMetrics) {
1404
+ for (const [roleId, metrics] of Object.entries(rt.roleMetrics)) {
1405
+ const role = def.roles.find(r => r.id === roleId);
1406
+ if (role) {
1407
+ const mailbox = new Mailbox();
1408
+ const perRoleBudget = Math.floor((def.run_config.budget_tokens ?? 1_000_000) / def.roles.length);
1409
+ const policy = new PolicyEngine(roleId, { maxTokens: perRoleBudget, ...(role.policy ?? {}) }, bus, this.root);
1410
+ const runtime = {
1411
+ mailbox,
1412
+ policy,
1413
+ status: 'running',
1414
+ done: Promise.resolve(),
1415
+ metrics: { tokens: metrics.tokens, costUsd: metrics.costUsd },
1416
+ lastMessageId: metrics.lastMessageId,
1417
+ };
1418
+ running.agents.set(roleId, runtime);
1419
+ // Restore mailbox state if it was closed
1420
+ if (rt.mailboxState?.[roleId]?.closed) {
1421
+ mailbox.close();
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ // Restore abandoned roles tracking
1427
+ if (rt.abandonedRoles) {
1428
+ this.abandoned.set(name, new Set(rt.abandonedRoles));
1429
+ }
1430
+ // Set up pending roles for lazy spawn (roles not yet reconstructed)
1431
+ const reconstructedRoles = new Set(Object.keys(rt.roleMetrics ?? {}));
1432
+ const pendingRoles = new Map();
1433
+ for (const role of def.roles) {
1434
+ if (!reconstructedRoles.has(role.id)) {
1435
+ pendingRoles.set(role.id, role);
1436
+ }
1437
+ }
1438
+ if (pendingRoles.size > 0) {
1439
+ running.pendingRoles = pendingRoles;
1440
+ }
1441
+ this.orgs.set(name, running);
1442
+ bus.emit({ type: 'status', msg: `org resumed from checkpoint (${rt.run})` });
1443
+ this.persistState(name, 'running', rt.run);
1444
+ return running;
1445
+ }
1446
+ /** Create a branch from a checkpoint for "what-if" experiments */
1447
+ branchCheckpoint(name, run, branchName) {
1448
+ const runDir = join(this.root, ORG_DIR, name, run);
1449
+ if (!existsSync(runDir)) {
1450
+ return { ok: false, error: `run ${run} not found for org ${name}` };
1451
+ }
1452
+ const branchRun = `branch-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${Math.random().toString(36).slice(2, 6)}`;
1453
+ const branchDir = join(this.root, ORG_DIR, name, branchRun);
1454
+ try {
1455
+ mkdirSync(branchDir, { recursive: true });
1456
+ // Copy bus.jsonl to branch
1457
+ const busFile = join(runDir, 'bus.jsonl');
1458
+ if (existsSync(busFile)) {
1459
+ const busContent = readFileSync(busFile, 'utf8');
1460
+ writeFileSync(join(branchDir, 'bus.jsonl'), busContent);
1461
+ }
1462
+ // Create branch marker file
1463
+ writeFileSync(join(branchDir, '.branch-source'), JSON.stringify({ from: run, branchedAt: new Date().toISOString() }));
1464
+ return { ok: true, branchRun };
1465
+ }
1466
+ catch (err) {
1467
+ return { ok: false, error: `failed to create branch: ${err instanceof Error ? err.message : String(err)}` };
1468
+ }
1469
+ }
1470
+ /** Record a structured decision trace for Rifft-style debugging */
1471
+ recordDecision(org, role, decision) {
1472
+ const running = this.orgs.get(org);
1473
+ if (!running)
1474
+ return;
1475
+ running.bus.emit({
1476
+ type: 'audit',
1477
+ from: role,
1478
+ reason: 'decision-trace',
1479
+ data: {
1480
+ decisionType: decision.type,
1481
+ context: decision.context,
1482
+ reasoning: decision.reasoning,
1483
+ alternatives: decision.alternatives,
1484
+ outcome: decision.outcome,
1485
+ ts: new Date().toISOString()
1486
+ },
1487
+ });
1488
+ }
1097
1489
  /** Roles that never spawned, per org. An org missing a role is still reported
1098
1490
  * `running` by every status path, so the absence was visible only as one
1099
1491
  * audit line in the log — a run went 40 minutes with no tester and nothing
1100
1492
  * said so. Persisted to runtime.json so `org status` can say it out loud. */
1101
1493
  abandoned = new Map();
1102
- persistState(name, status, run) {
1494
+ persistState(name, status, run, org) {
1103
1495
  const p = join(this.root, ORG_DIR, name, 'runtime.json');
1104
1496
  const missing = [...(this.abandoned.get(name) ?? [])];
1105
- const running = this.orgs.get(name);
1497
+ const running = org ?? this.orgs.get(name);
1106
1498
  // Per-role cost tracking: collect live metrics from active agents
1107
1499
  const roleMetrics = {};
1500
+ // Mailbox queue state for checkpoint/resume
1501
+ const mailboxState = {};
1108
1502
  if (running) {
1109
1503
  for (const [roleId, runtime] of running.agents) {
1110
- roleMetrics[roleId] = { tokens: runtime.policy.usage, costUsd: runtime.metrics.costUsd };
1504
+ roleMetrics[roleId] = {
1505
+ tokens: runtime.policy.usage,
1506
+ costUsd: runtime.metrics.costUsd,
1507
+ lastMessageId: runtime.lastMessageId
1508
+ };
1509
+ mailboxState[roleId] = {
1510
+ queued: runtime.mailbox['queue']?.length ?? 0,
1511
+ closed: runtime.mailbox.isClosed
1512
+ };
1111
1513
  }
1112
1514
  }
1113
1515
  writeFileSync(p, JSON.stringify({
1114
1516
  status, run, pid: process.pid, updated: new Date().toISOString(),
1115
1517
  ...(missing.length ? { abandonedRoles: missing } : {}),
1116
1518
  ...(Object.keys(roleMetrics).length ? { roleMetrics } : {}),
1519
+ ...(Object.keys(mailboxState).length ? { mailboxState } : {}),
1117
1520
  }, null, 2));
1118
1521
  }
1119
1522
  /** Mark every currently-running org as crashed in runtime.json.