@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.
- package/dist/src/commands/org-observe.d.ts +16 -2
- package/dist/src/commands/org-observe.d.ts.map +1 -1
- package/dist/src/commands/org-observe.js +268 -4
- package/dist/src/commands/org-observe.js.map +1 -1
- package/dist/src/commands/org.d.ts.map +1 -1
- package/dist/src/commands/org.js +83 -1
- package/dist/src/commands/org.js.map +1 -1
- package/dist/src/orgrt/broker.d.ts +2 -0
- package/dist/src/orgrt/broker.d.ts.map +1 -1
- package/dist/src/orgrt/broker.js +16 -2
- package/dist/src/orgrt/broker.js.map +1 -1
- package/dist/src/orgrt/daemon.d.ts +63 -0
- package/dist/src/orgrt/daemon.d.ts.map +1 -1
- package/dist/src/orgrt/daemon.js +421 -18
- package/dist/src/orgrt/daemon.js.map +1 -1
- package/dist/src/orgrt/inbox.d.ts +8 -0
- package/dist/src/orgrt/inbox.d.ts.map +1 -1
- package/dist/src/orgrt/inbox.js.map +1 -1
- package/dist/src/orgrt/mailbox.d.ts +9 -0
- package/dist/src/orgrt/mailbox.d.ts.map +1 -1
- package/dist/src/orgrt/mailbox.js +13 -1
- package/dist/src/orgrt/mailbox.js.map +1 -1
- package/dist/src/orgrt/server.d.ts +7 -0
- package/dist/src/orgrt/server.d.ts.map +1 -1
- package/dist/src/orgrt/server.js +8 -3
- package/dist/src/orgrt/server.js.map +1 -1
- package/dist/src/orgrt/session.d.ts +7 -3
- package/dist/src/orgrt/session.d.ts.map +1 -1
- package/dist/src/orgrt/session.js +72 -20
- package/dist/src/orgrt/session.js.map +1 -1
- package/dist/src/orgrt/test-loop.d.ts +1 -1
- package/dist/src/orgrt/test-loop.d.ts.map +1 -1
- package/dist/src/orgrt/test-loop.js +83 -2
- package/dist/src/orgrt/test-loop.js.map +1 -1
- package/dist/src/orgrt/types.d.ts +14 -15
- package/dist/src/orgrt/types.d.ts.map +1 -1
- package/dist/src/orgrt/types.js +10 -15
- package/dist/src/orgrt/types.js.map +1 -1
- package/dist/src/orgrt/visualization.d.ts +8 -0
- package/dist/src/orgrt/visualization.d.ts.map +1 -0
- package/dist/src/orgrt/visualization.js +138 -0
- package/dist/src/orgrt/visualization.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
package/dist/src/orgrt/daemon.js
CHANGED
|
@@ -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
|
-
|
|
428
|
-
//
|
|
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
|
-
|
|
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
|
|
528
|
-
const
|
|
529
|
-
const parentId =
|
|
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
|
-
|
|
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] = {
|
|
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.
|