@adaptic/maestro 1.9.4 → 1.10.0

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.
@@ -49,6 +49,33 @@ export function resolveClaudeBin() {
49
49
  return _resolved;
50
50
  }
51
51
 
52
+ /**
53
+ * Default args every daemon-spawned `claude --print` should receive on
54
+ * top of the prompt + model selection. Currently:
55
+ *
56
+ * --strict-mcp-config
57
+ * Skip every globally-installed Claude Code MCP plugin (Serena,
58
+ * Playwright, etc). Daemon spawns don't need IDE-grade code
59
+ * intelligence; they need to write a Slack reply / draft an email
60
+ * / classify an item. Loading MCPs wastes 3-5s per spawn AND opens
61
+ * a browser dashboard tab per session (Serena does this).
62
+ *
63
+ * --bare (opt-in via DAEMON_BARE_MODE=1)
64
+ * Skip hooks, LSP, plugin sync, auto-memory, keychain reads. Even
65
+ * more minimal. Use when you specifically want a stateless spawn —
66
+ * but it disables Keychain OAuth, so MAESTRO_PREFER_SUBSCRIPTION_AUTH
67
+ * callers should NOT set this.
68
+ *
69
+ * Operators who want full MCP support on daemon spawns can set
70
+ * DAEMON_LOAD_MCPS=1 in .env; the spawn sites will skip these flags.
71
+ */
72
+ export function daemonClaudeArgs() {
73
+ if (process.env.DAEMON_LOAD_MCPS === "1") return [];
74
+ const args = ["--strict-mcp-config"];
75
+ if (process.env.DAEMON_BARE_MODE === "1") args.unshift("--bare");
76
+ return args;
77
+ }
78
+
52
79
  /**
53
80
  * Build a PATH suitable for child processes spawned from a daemon.
54
81
  * launchd strips PATH down to /usr/bin:/bin; this returns a string that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.9.4",
3
+ "version": "1.10.0",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "always-build-npm": true,
48
48
  "scripts": {
49
- "test": "node --test lib/cadence-bus.test.mjs scripts/cadence/enqueue-cadence-tick.test.mjs scripts/daemon/cadence-consumer.test.mjs scripts/daemon/lib/session-router.test.mjs scripts/local-triggers/generate-plists.test.mjs bin/maestro.test.mjs",
49
+ "test": "node --test lib/cadence-bus.test.mjs scripts/cadence/enqueue-cadence-tick.test.mjs scripts/daemon/cadence-consumer.test.mjs scripts/daemon/dispatcher-cooldown.test.mjs scripts/daemon/lib/session-router.test.mjs scripts/local-triggers/generate-plists.test.mjs scripts/poller/slack-socket-mode.test.mjs bin/maestro.test.mjs",
50
50
  "test:cadence": "node --test lib/cadence-bus.test.mjs scripts/cadence/enqueue-cadence-tick.test.mjs scripts/daemon/cadence-consumer.test.mjs",
51
51
  "test:cli": "node --test bin/maestro.test.mjs",
52
52
  "test:plists": "node --test scripts/local-triggers/generate-plists.test.mjs",
@@ -54,7 +54,11 @@ import { acquireLock, updateLock, scanStaleLocks, acquireThreadLock, claimReques
54
54
  // ---------------------------------------------------------------------------
55
55
 
56
56
  const POLL_INTERVAL = parseInt(process.env.DAEMON_POLL_INTERVAL || "60000", 10); // 60s (up from 30s to avoid Slack rate limits)
57
- const BACKLOG_INTERVAL = parseInt(process.env.DAEMON_BACKLOG_INTERVAL || "120000", 10); // 2 min
57
+ // Backlog sweep cadence bumped 2min → 10min in 1.10. The dispatcher
58
+ // applies a per-item post-completion cooldown (4h success, 30min failure)
59
+ // so we no longer need a tight sweep loop. Operators with high-throughput
60
+ // queues can override via DAEMON_BACKLOG_INTERVAL.
61
+ const BACKLOG_INTERVAL = parseInt(process.env.DAEMON_BACKLOG_INTERVAL || "600000", 10); // 10 min
58
62
  const HEALTH_INTERVAL = 60000; // 1 min
59
63
  // Note: dedup is now handled by file-based locks in session-lock.mjs
60
64
 
@@ -59,7 +59,7 @@ import {
59
59
  logBusEvent,
60
60
  busDepth,
61
61
  } from "../../lib/cadence-bus.mjs";
62
- import { resolveClaudeBin as sharedResolveClaude, augmentedPath } from "../../lib/claude-bin.mjs";
62
+ import { resolveClaudeBin as sharedResolveClaude, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
63
63
  import { getCadenceDef } from "./cadence-handlers.mjs";
64
64
 
65
65
  // ---------------------------------------------------------------------------
@@ -135,7 +135,7 @@ function realSpawnSession({ agentRoot, cadence, promptPath, timeoutMs, log }) {
135
135
  }
136
136
 
137
137
  const bin = resolveClaudeBin();
138
- const args = ["--print", "--dangerously-skip-permissions", body];
138
+ const args = ["--print", "--dangerously-skip-permissions", ...daemonClaudeArgs(), body];
139
139
  // PATH augmented via lib/claude-bin.mjs so subsession can find jq/node.
140
140
  const env = {
141
141
  ...process.env,
@@ -129,16 +129,31 @@ async function guardInboxProcessor({ agentRoot }) {
129
129
 
130
130
  /**
131
131
  * backlog-executor guard:
132
- * - Look at state/queues/*.yaml; any queue with at least one item is work.
133
- * - If none, complete inline.
134
- * - If any, escalate.
132
+ * The reactive daemon (agent-daemon.mjs) already runs an internal
133
+ * backlog sweep every BACKLOG_INTERVAL (default 10min) which dispatches
134
+ * one session per top-priority queue item with proper post-completion
135
+ * cooldowns. Running the cadence-bus backlog-executor on top of that
136
+ * spawns DUPLICATE sessions for the same items — observed as 159
137
+ * redundant spawns / day in ravi-ai's logs.
138
+ *
139
+ * So this guard now always returns inline. The reactive daemon owns
140
+ * the backlog. Operators who prefer the cadence-only mode (no reactive
141
+ * daemon) can set BACKLOG_CADENCE_ESCALATE=1 in .env to flip behaviour.
135
142
  */
136
143
  async function guardBacklogExecutor({ agentRoot }) {
137
- const withWork = queuesWithWork(agentRoot);
138
- if (withWork.length === 0) {
139
- return { ok: true, decision: "inline", reason: "all queues empty" };
144
+ if (process.env.BACKLOG_CADENCE_ESCALATE === "1") {
145
+ const withWork = queuesWithWork(agentRoot);
146
+ if (withWork.length === 0) {
147
+ return { ok: true, decision: "inline", reason: "all queues empty" };
148
+ }
149
+ return { ok: true, decision: "escalate", queues_with_work: withWork };
140
150
  }
141
- return { ok: true, decision: "escalate", queues_with_work: withWork };
151
+ return {
152
+ ok: true,
153
+ decision: "inline",
154
+ reason: "reactive-daemon-owns-backlog",
155
+ note: "Set BACKLOG_CADENCE_ESCALATE=1 to override.",
156
+ };
142
157
  }
143
158
 
144
159
  /**
@@ -103,7 +103,7 @@ function loadAgentRegistry() {
103
103
  const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
104
104
  const OPENAI_MODEL = "gpt-4o-mini";
105
105
  // Resolve claude against the agent's PATH (not launchd's bare env).
106
- import { resolveClaudeBin, augmentedPath } from "../../lib/claude-bin.mjs";
106
+ import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
107
107
  const CLAUDE_BIN = resolveClaudeBin();
108
108
  const CLAUDE_CLI_TIMEOUT_MS = 30_000;
109
109
 
@@ -314,6 +314,7 @@ async function runClaudeCLI(systemPrompt, userPrompt) {
314
314
  const args = [
315
315
  "--print",
316
316
  "--dangerously-skip-permissions",
317
+ ...daemonClaudeArgs(),
317
318
  "--model", ANTHROPIC_MODEL,
318
319
  "--append-system-prompt", systemPrompt,
319
320
  ];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * dispatcher-cooldown.test.mjs — Coverage for the backlog post-completion
3
+ * cooldown that stopped the 159-redundant-spawns-per-day loop.
4
+ *
5
+ * The dispatcher module is stateful (in-memory Sets/Maps for active
6
+ * sessions, retry counts, cooldowns) and reads from AGENT_DIR at import
7
+ * time, so each test isolates by setting AGENT_DIR before dynamic-importing
8
+ * a fresh module instance.
9
+ */
10
+
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { promises as fsp } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+
17
+ async function freshDispatcher() {
18
+ // Isolated tmpdir per test so cooldown state file doesn't bleed.
19
+ const dir = join(
20
+ tmpdir(),
21
+ `dispatcher-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
22
+ );
23
+ await fsp.mkdir(join(dir, "state/sessions"), { recursive: true });
24
+ await fsp.mkdir(join(dir, "logs/daemon"), { recursive: true });
25
+ process.env.AGENT_DIR = dir;
26
+ // Bust the module cache so state resets cleanly.
27
+ const url = new URL("./dispatcher.mjs", import.meta.url);
28
+ const mod = await import(`${url.href}?test=${Math.random()}`);
29
+ return { mod, dir };
30
+ }
31
+
32
+ async function cleanup(dir) {
33
+ try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* */ }
34
+ }
35
+
36
+ test("canDispatchBacklog: allowed for a fresh item", async () => {
37
+ const { mod, dir } = await freshDispatcher();
38
+ try {
39
+ const r = mod.canDispatchBacklog({ id: "TEST-1", title: "Fresh item" });
40
+ assert.equal(r.allowed, true);
41
+ } finally { await cleanup(dir); }
42
+ });
43
+
44
+ test("canDispatchBacklog: blocked while cooldown is active", async () => {
45
+ const { mod, dir } = await freshDispatcher();
46
+ try {
47
+ // Simulate a session completion by directly writing the cooldown state
48
+ // file the dispatcher reads on init. We can't easily call internal
49
+ // setters, but the cooldown file IS public API for state persistence.
50
+ const cooldownPath = join(dir, "state/sessions/backlog-cooldowns.json");
51
+ const tomorrow = Date.now() + 60 * 60 * 1000; // +1h
52
+ await fsp.writeFile(cooldownPath, JSON.stringify({
53
+ "TEST-2": tomorrow,
54
+ }) + "\n");
55
+ // Re-import to load the cooldown file.
56
+ const { mod: mod2, dir: dir2 } = await freshDispatcher();
57
+ try {
58
+ // Plant the same cooldown in this fresh dispatcher's dir.
59
+ await fsp.writeFile(
60
+ join(dir2, "state/sessions/backlog-cooldowns.json"),
61
+ JSON.stringify({ "TEST-2": tomorrow }) + "\n"
62
+ );
63
+ // Reload yet again with the cooldown file present.
64
+ const { mod: mod3, dir: dir3 } = await freshDispatcher();
65
+ try {
66
+ await fsp.writeFile(
67
+ join(dir3, "state/sessions/backlog-cooldowns.json"),
68
+ JSON.stringify({ "TEST-2": tomorrow }) + "\n"
69
+ );
70
+ // Final import — this one reads the cooldown file at module load.
71
+ process.env.AGENT_DIR = dir3;
72
+ const url = new URL("./dispatcher.mjs", import.meta.url);
73
+ const final = await import(`${url.href}?cooldown=${Math.random()}`);
74
+ const r = final.canDispatchBacklog({ id: "TEST-2", title: "Cooldown item" });
75
+ assert.equal(r.allowed, false);
76
+ assert.equal(r.reason, "post_completion_cooldown");
77
+ assert.ok(r.remaining_min >= 1 && r.remaining_min <= 60);
78
+ } finally { await cleanup(dir3); }
79
+ } finally { await cleanup(dir2); }
80
+ } finally { await cleanup(dir); }
81
+ });
82
+
83
+ test("canDispatchBacklog: allowed after cooldown expires", async () => {
84
+ const dir = join(
85
+ tmpdir(),
86
+ `dispatcher-expire-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
87
+ );
88
+ try {
89
+ await fsp.mkdir(join(dir, "state/sessions"), { recursive: true });
90
+ await fsp.mkdir(join(dir, "logs/daemon"), { recursive: true });
91
+ // Cooldown already expired (set to 1h ago).
92
+ await fsp.writeFile(
93
+ join(dir, "state/sessions/backlog-cooldowns.json"),
94
+ JSON.stringify({ "TEST-3": Date.now() - 3_600_000 }) + "\n"
95
+ );
96
+ process.env.AGENT_DIR = dir;
97
+ const url = new URL("./dispatcher.mjs", import.meta.url);
98
+ const mod = await import(`${url.href}?expired=${Math.random()}`);
99
+ const r = mod.canDispatchBacklog({ id: "TEST-3", title: "Expired cooldown item" });
100
+ assert.equal(r.allowed, true);
101
+ } finally { await cleanup(dir); }
102
+ });
103
+
104
+ test("backlog-cooldowns.json persists across simulated daemon restarts", async () => {
105
+ const dir = join(
106
+ tmpdir(),
107
+ `dispatcher-persist-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
108
+ );
109
+ try {
110
+ await fsp.mkdir(join(dir, "state/sessions"), { recursive: true });
111
+ await fsp.mkdir(join(dir, "logs/daemon"), { recursive: true });
112
+ const cooldownPath = join(dir, "state/sessions/backlog-cooldowns.json");
113
+ const future = Date.now() + 30 * 60_000;
114
+ await fsp.writeFile(cooldownPath, JSON.stringify({ "RESTART-1": future }) + "\n");
115
+ process.env.AGENT_DIR = dir;
116
+ const url = new URL("./dispatcher.mjs", import.meta.url);
117
+ const mod = await import(`${url.href}?persist=${Math.random()}`);
118
+ const r = mod.canDispatchBacklog({ id: "RESTART-1", title: "After restart" });
119
+ assert.equal(r.allowed, false, "loaded cooldown should block dispatch");
120
+ assert.equal(r.reason, "post_completion_cooldown");
121
+ } finally { await cleanup(dir); }
122
+ });
@@ -4,14 +4,14 @@
4
4
 
5
5
  import { spawn } from "child_process";
6
6
  import { appendFileSync, mkdirSync, writeFileSync, readFileSync, renameSync } from "fs";
7
- import { join } from "path";
7
+ import { join, dirname } from "path";
8
8
  import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
9
9
  import { recordSession } from "./health.mjs";
10
10
 
11
11
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
12
12
  // Resolve the claude binary against the agent's PATH (not launchd's bare
13
13
  // env). Without this, every daemon-spawned `claude --print` exits ENOENT.
14
- import { resolveClaudeBin, augmentedPath } from "../../lib/claude-bin.mjs";
14
+ import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
15
15
  const CLAUDE_BIN = resolveClaudeBin();
16
16
  const MAX_CONCURRENT = parseInt(process.env.DAEMON_MAX_CONCURRENT || "10", 10);
17
17
  const RESERVED_INBOX_SLOTS = 3; // Always keep 3 slots free for real-time inbox items
@@ -42,6 +42,41 @@ const activeBacklogKeys = new Set(); // backlog item key -> true (while session
42
42
  const backlogRetryCount = new Map(); // backlog item key -> number of times dispatched
43
43
  const MAX_BACKLOG_RETRIES = 6; // Max retries before skipping (was 3 — too aggressive)
44
44
 
45
+ // Post-completion cooldown — once a session has run on a backlog item, don't
46
+ // re-dispatch it until N hours later. Without this, every 2-min backlog
47
+ // sweep re-dispatches the same items because the daemon has no signal
48
+ // that the underlying work was actually completed (sessions exit 0 even
49
+ // when they only "looked at" the item). 53 redundant spawns/day per item
50
+ // was the observed rate before this fix.
51
+ const SUCCESS_COOLDOWN_MS = 4 * 60 * 60 * 1000; // 4h after exit 0
52
+ const FAILURE_COOLDOWN_MS = 30 * 60 * 1000; // 30m after non-zero exit
53
+ const backlogCooldownUntil = new Map(); // key -> epoch ms
54
+ const COOLDOWN_STATE_PATH = join(AGENT_REPO_DIR, "state/sessions/backlog-cooldowns.json");
55
+
56
+ // Persist cooldown state across daemon restarts so a freshly-started
57
+ // daemon doesn't immediately re-dispatch items it just completed.
58
+ function loadCooldowns() {
59
+ try {
60
+ const body = readFileSync(COOLDOWN_STATE_PATH, "utf-8");
61
+ const data = JSON.parse(body);
62
+ const now = Date.now();
63
+ for (const [key, until] of Object.entries(data || {})) {
64
+ if (typeof until === "number" && until > now) {
65
+ backlogCooldownUntil.set(key, until);
66
+ }
67
+ }
68
+ } catch { /* file missing or malformed — start fresh */ }
69
+ }
70
+ function saveCooldowns() {
71
+ try {
72
+ const obj = {};
73
+ for (const [k, v] of backlogCooldownUntil) obj[k] = v;
74
+ mkdirSync(dirname(COOLDOWN_STATE_PATH), { recursive: true });
75
+ writeFileSync(COOLDOWN_STATE_PATH, JSON.stringify(obj, null, 2) + "\n");
76
+ } catch { /* best-effort */ }
77
+ }
78
+ loadCooldowns();
79
+
45
80
  function logDir() {
46
81
  const dir = join(AGENT_REPO_DIR, "logs", "daemon");
47
82
  mkdirSync(dir, { recursive: true });
@@ -183,6 +218,13 @@ export function canDispatchBacklog(item) {
183
218
  if (retries >= MAX_BACKLOG_RETRIES) {
184
219
  return { allowed: false, reason: "max_retries_exceeded", retries };
185
220
  }
221
+ // Post-completion cooldown — prevent every-2-min re-dispatch of items
222
+ // that completed (success or failure) within the recent window.
223
+ const cooldownUntil = backlogCooldownUntil.get(key) || 0;
224
+ if (cooldownUntil > Date.now()) {
225
+ const remaining_min = Math.ceil((cooldownUntil - Date.now()) / 60000);
226
+ return { allowed: false, reason: "post_completion_cooldown", remaining_min };
227
+ }
186
228
  return { allowed: true };
187
229
  }
188
230
 
@@ -276,6 +318,7 @@ function spawnSession(entry) {
276
318
  const args = [
277
319
  "--print",
278
320
  "--dangerously-skip-permissions",
321
+ ...daemonClaudeArgs(),
279
322
  "--model", model,
280
323
  prompt,
281
324
  ];
@@ -362,6 +405,19 @@ function spawnSession(entry) {
362
405
  activeBacklogKeys.delete(key);
363
406
  const retries = backlogRetryCount.get(key) || 0;
364
407
 
408
+ // Apply post-completion cooldown — different for success vs failure.
409
+ // This is the fix for the every-2-min re-dispatch loop: once a
410
+ // session has touched an item, we wait before touching it again.
411
+ const cooldownMs = code === 0 ? SUCCESS_COOLDOWN_MS : FAILURE_COOLDOWN_MS;
412
+ backlogCooldownUntil.set(key, Date.now() + cooldownMs);
413
+ saveCooldowns();
414
+ logSession({
415
+ event: "cooldown_set",
416
+ summary: classResult.summary,
417
+ exit_code: code,
418
+ cooldown_minutes: Math.round(cooldownMs / 60000),
419
+ });
420
+
365
421
  // Release file-based item claim (ib-20260407-001b)
366
422
  if (item.id) releaseItemClaim(item.id);
367
423
 
@@ -426,10 +482,13 @@ function spawnSession(entry) {
426
482
  claimReleased = true;
427
483
  }
428
484
 
429
- // Release backlog tracking + item claim.
485
+ // Release backlog tracking + item claim. Apply failure cooldown so the
486
+ // same item isn't re-spawned on the next backlog sweep.
430
487
  if (source === "backlog") {
431
488
  const key = backlogKey(item);
432
489
  activeBacklogKeys.delete(key);
490
+ backlogCooldownUntil.set(key, Date.now() + FAILURE_COOLDOWN_MS);
491
+ saveCooldowns();
433
492
  // Release file-based item claim (ib-20260407-001b)
434
493
  if (item.id) releaseItemClaim(item.id);
435
494
  }
@@ -26,7 +26,7 @@ import { routingKey as deriveRoutingKey, createRouter } from "./lib/session-rout
26
26
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
27
27
  const SONNET_MODEL = "claude-sonnet-4-6";
28
28
  // Resolve claude against the agent's PATH (not launchd's bare env).
29
- import { resolveClaudeBin, augmentedPath } from "../../lib/claude-bin.mjs";
29
+ import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
30
30
  const CLAUDE_BIN = resolveClaudeBin();
31
31
  const CLAUDE_CLI_TIMEOUT_MS = 60_000;
32
32
  const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
@@ -129,6 +129,7 @@ function runClaudeCLI(systemPrompt, userPrompt, model, opts = {}) {
129
129
  const args = [
130
130
  "--print",
131
131
  "--dangerously-skip-permissions",
132
+ ...daemonClaudeArgs(),
132
133
  "--model", model,
133
134
  "--append-system-prompt", systemPrompt,
134
135
  ];