@adaptic/maestro 1.7.2 → 1.8.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.
Files changed (36) hide show
  1. package/.claude/commands/init-maestro.md +15 -2
  2. package/.gitignore +7 -0
  3. package/README.md +62 -11
  4. package/bin/maestro.mjs +338 -2
  5. package/bin/maestro.test.mjs +299 -0
  6. package/docs/guides/poller-daemon-setup.md +21 -8
  7. package/docs/runbooks/perpetual-operations.md +19 -15
  8. package/docs/runbooks/recovery-and-failover.md +42 -0
  9. package/lib/cadence-bus.mjs +625 -0
  10. package/lib/cadence-bus.test.mjs +354 -0
  11. package/package.json +6 -1
  12. package/scaffold/CLAUDE.md +11 -7
  13. package/scripts/cadence/cadence-status.mjs +36 -0
  14. package/scripts/cadence/enqueue-cadence-tick.mjs +158 -0
  15. package/scripts/cadence/enqueue-cadence-tick.test.mjs +154 -0
  16. package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
  17. package/scripts/daemon/cadence-consumer.mjs +439 -0
  18. package/scripts/daemon/cadence-consumer.test.mjs +397 -0
  19. package/scripts/daemon/cadence-handlers.mjs +263 -0
  20. package/scripts/daemon/maestro-daemon.mjs +20 -0
  21. package/scripts/local-triggers/generate-plists.sh +33 -12
  22. package/scripts/local-triggers/generate-plists.test.mjs +185 -0
  23. package/scripts/local-triggers/plists/.gitkeep +0 -0
  24. package/scripts/local-triggers/run-trigger.sh +22 -3
  25. package/scripts/local-triggers/plists/ai.adaptic.sophie-backlog-executor.plist +0 -21
  26. package/scripts/local-triggers/plists/ai.adaptic.sophie-daemon.plist +0 -32
  27. package/scripts/local-triggers/plists/ai.adaptic.sophie-inbox-processor.plist +0 -21
  28. package/scripts/local-triggers/plists/ai.adaptic.sophie-meeting-action-capture.plist +0 -21
  29. package/scripts/local-triggers/plists/ai.adaptic.sophie-meeting-prep.plist +0 -21
  30. package/scripts/local-triggers/plists/ai.adaptic.sophie-midday-sweep.plist +0 -26
  31. package/scripts/local-triggers/plists/ai.adaptic.sophie-quarterly-self-assessment.plist +0 -62
  32. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-engineering-health.plist +0 -28
  33. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-execution.plist +0 -28
  34. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-hiring.plist +0 -28
  35. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-priorities.plist +0 -28
  36. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-strategic-memo.plist +0 -28
@@ -0,0 +1,299 @@
1
+ /**
2
+ * maestro.test.mjs — CLI tests for the bin/maestro.mjs commands.
3
+ *
4
+ * Covers:
5
+ * • `create` scaffolds the cadence-bus directory tree, the enqueue
6
+ * script, the consumer, and the cadence handlers under the new agent.
7
+ * • `upgrade` migrates a legacy-style agent repo: backs up old plists,
8
+ * bootstraps state/cadence-bus/, regenerates plists.
9
+ * • `upgrade` preserves agent-specific files (CLAUDE.md, config/,
10
+ * knowledge/, memory/, state/, logs/, outputs/, .env) untouched.
11
+ * • `upgrade` is idempotent when run twice on the same repo.
12
+ * • `doctor` flags legacy plists with actionable remediation.
13
+ *
14
+ * Note: `create` runs `npm install` at the end, which is heavyweight. To
15
+ * keep test runtime reasonable we skip the npm install step by passing
16
+ * an alternate npm shim on PATH that is a no-op. Same trick for git.
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { promises as fsp } from "fs";
22
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
23
+ import { tmpdir } from "os";
24
+ import { join, resolve, dirname } from "path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { spawnSync, execFileSync } from "node:child_process";
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const MAESTRO_ROOT = resolve(__dirname, "..");
30
+ const MAESTRO_CLI = resolve(__dirname, "maestro.mjs");
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ async function tmpRoot(prefix = "maestro-cli-test") {
37
+ const path = join(
38
+ tmpdir(),
39
+ `${prefix}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
40
+ );
41
+ await fsp.mkdir(path, { recursive: true });
42
+ return path;
43
+ }
44
+
45
+ async function rmRoot(path) {
46
+ try { await fsp.rm(path, { recursive: true, force: true }); } catch { /* */ }
47
+ }
48
+
49
+ /**
50
+ * Build a PATH that shadows `npm` (and optionally `git`) with no-op shims
51
+ * so `create` doesn't actually try to install dependencies. Returns the new
52
+ * env block plus the shim dir (for cleanup).
53
+ */
54
+ async function withShim() {
55
+ const dir = await tmpRoot("maestro-shim");
56
+ const npm = join(dir, "npm");
57
+ writeFileSync(npm, "#!/bin/bash\necho '[shim npm]' \"$@\" >&2\nexit 0\n");
58
+ await fsp.chmod(npm, 0o755);
59
+ // Keep real git (we want git init to actually init).
60
+ const env = { ...process.env, PATH: `${dir}:${process.env.PATH || ""}` };
61
+ return { env, dir };
62
+ }
63
+
64
+ function runCli(args, cwd, env) {
65
+ return spawnSync(process.execPath, [MAESTRO_CLI, ...args], {
66
+ cwd: cwd || process.cwd(),
67
+ env: env || process.env,
68
+ encoding: "utf-8",
69
+ });
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // create
74
+ // ---------------------------------------------------------------------------
75
+
76
+ test("create scaffolds cadence-bus dirs and enqueue/consumer/handlers", async () => {
77
+ const parent = await tmpRoot("maestro-create");
78
+ const { env, dir: shim } = await withShim();
79
+ try {
80
+ const r = runCli(["create", "test-agent"], parent, env);
81
+ assert.equal(r.status, 0, r.stderr || r.stdout);
82
+ const agent = join(parent, "test-agent");
83
+
84
+ // Cadence bus directory tree
85
+ for (const d of [
86
+ "state/cadence-bus",
87
+ "state/cadence-bus/inbox",
88
+ "state/cadence-bus/claimed",
89
+ "state/cadence-bus/processed",
90
+ "state/cadence-bus/failed",
91
+ "state/cadence-bus/dlq",
92
+ "logs/cadence-bus",
93
+ ]) {
94
+ assert.ok(existsSync(join(agent, d)), `missing ${d}`);
95
+ }
96
+
97
+ // New framework files copied into the agent repo.
98
+ for (const f of [
99
+ "lib/cadence-bus.mjs",
100
+ "scripts/cadence/enqueue-cadence-tick.mjs",
101
+ "scripts/cadence/launchd-cadence-wrapper.sh",
102
+ "scripts/cadence/cadence-status.mjs",
103
+ "scripts/daemon/cadence-consumer.mjs",
104
+ "scripts/daemon/cadence-handlers.mjs",
105
+ ]) {
106
+ assert.ok(existsSync(join(agent, f)), `missing ${f}`);
107
+ }
108
+
109
+ // Agent package.json has the cadence:* scripts.
110
+ const pkg = JSON.parse(readFileSync(join(agent, "package.json"), "utf-8"));
111
+ assert.ok(pkg.scripts["cadence:enqueue"]);
112
+ assert.ok(pkg.scripts["cadence:consume"]);
113
+ assert.ok(pkg.scripts["cadence:status"]);
114
+ } finally {
115
+ await rmRoot(parent);
116
+ await rmRoot(shim);
117
+ }
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // upgrade — migration
122
+ // ---------------------------------------------------------------------------
123
+
124
+ async function makeLegacyAgent() {
125
+ const root = await tmpRoot("maestro-legacy");
126
+ // Minimal identity so `upgrade` recognises this as a Maestro repo.
127
+ mkdirSync(join(root, "config"), { recursive: true });
128
+ writeFileSync(join(root, "CLAUDE.md"), "# legacy agent\n");
129
+ writeFileSync(join(root, "config/agent.ts"), "export const AGENT = { firstName: 'legacy' };\n");
130
+ writeFileSync(join(root, "package.json"), '{"name":"legacy-agent","version":"1.0.0"}\n');
131
+ // Personal/local agent state that must NOT be touched.
132
+ mkdirSync(join(root, "knowledge/decisions"), { recursive: true });
133
+ mkdirSync(join(root, "memory/interactions"), { recursive: true });
134
+ mkdirSync(join(root, "state/queues"), { recursive: true });
135
+ mkdirSync(join(root, "outputs"), { recursive: true });
136
+ mkdirSync(join(root, "logs"), { recursive: true });
137
+ writeFileSync(join(root, "knowledge/decisions/local-decision.md"), "# do not touch");
138
+ writeFileSync(join(root, "memory/interactions/local-memory.yaml"), "# do not touch");
139
+ writeFileSync(join(root, "state/queues/action-stack.yaml"), "items:\n - id: P1\n title: local item\n");
140
+ writeFileSync(join(root, "outputs/local-output.md"), "# my draft");
141
+ writeFileSync(join(root, "logs/local.log"), "audit data");
142
+ writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk-x\n");
143
+
144
+ // Legacy plist that calls run-trigger.sh directly.
145
+ mkdirSync(join(root, "scripts/local-triggers/plists"), { recursive: true });
146
+ writeFileSync(
147
+ join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"),
148
+ `<?xml version="1.0"?><plist><dict>
149
+ <key>ProgramArguments</key><array>
150
+ <string>/foo/scripts/local-triggers/run-trigger.sh</string>
151
+ <string>inbox-processor</string>
152
+ </array></dict></plist>`
153
+ );
154
+
155
+ // Init git so smart-merge sees the working tree as clean.
156
+ execFileSync("git", ["init", "-q"], { cwd: root });
157
+ execFileSync("git", ["add", "-A"], { cwd: root });
158
+ execFileSync(
159
+ "git",
160
+ ["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
161
+ { cwd: root },
162
+ );
163
+ return root;
164
+ }
165
+
166
+ test("upgrade backs up legacy plists and regenerates with cadence-bus marker", async () => {
167
+ const root = await makeLegacyAgent();
168
+ try {
169
+ const r = runCli(["upgrade"], root);
170
+ assert.equal(r.status, 0, r.stderr);
171
+ // Backup directory exists.
172
+ const backupRoot = join(root, ".maestro/backup/plists");
173
+ assert.ok(existsSync(backupRoot), "backup root must exist");
174
+ const stamps = readdirSync(backupRoot);
175
+ assert.ok(stamps.length >= 1);
176
+ const backedUp = readdirSync(join(backupRoot, stamps[0]));
177
+ assert.ok(backedUp.some((n) => /legacy-inbox-processor\.plist$/.test(n)));
178
+
179
+ // After regeneration, plists carry the cadence-bus marker and none
180
+ // reference run-trigger.sh.
181
+ const plistDir = join(root, "scripts/local-triggers/plists");
182
+ const plists = readdirSync(plistDir).filter((n) => n.endsWith(".plist"));
183
+ assert.ok(plists.length >= 1);
184
+ let legacy = 0;
185
+ let modern = 0;
186
+ for (const name of plists) {
187
+ const body = readFileSync(join(plistDir, name), "utf-8");
188
+ if (body.includes("run-trigger.sh")) legacy++;
189
+ if (body.includes("maestro-plist-arch: cadence-bus")) modern++;
190
+ }
191
+ assert.equal(legacy, 0, "no legacy plists should remain post-upgrade");
192
+ assert.equal(modern, plists.length, "every plist must carry the cadence-bus marker");
193
+ } finally { await rmRoot(root); }
194
+ });
195
+
196
+ test("upgrade preserves agent-specific files untouched", async () => {
197
+ const root = await makeLegacyAgent();
198
+ try {
199
+ const before = {
200
+ claudemd: readFileSync(join(root, "CLAUDE.md"), "utf-8"),
201
+ agentTs: readFileSync(join(root, "config/agent.ts"), "utf-8"),
202
+ decision: readFileSync(join(root, "knowledge/decisions/local-decision.md"), "utf-8"),
203
+ memory: readFileSync(join(root, "memory/interactions/local-memory.yaml"), "utf-8"),
204
+ queue: readFileSync(join(root, "state/queues/action-stack.yaml"), "utf-8"),
205
+ output: readFileSync(join(root, "outputs/local-output.md"), "utf-8"),
206
+ log: readFileSync(join(root, "logs/local.log"), "utf-8"),
207
+ env: readFileSync(join(root, ".env"), "utf-8"),
208
+ };
209
+ const r = runCli(["upgrade"], root);
210
+ assert.equal(r.status, 0, r.stderr);
211
+ for (const [k, v] of Object.entries(before)) {
212
+ const path = ({
213
+ claudemd: "CLAUDE.md",
214
+ agentTs: "config/agent.ts",
215
+ decision: "knowledge/decisions/local-decision.md",
216
+ memory: "memory/interactions/local-memory.yaml",
217
+ queue: "state/queues/action-stack.yaml",
218
+ output: "outputs/local-output.md",
219
+ log: "logs/local.log",
220
+ env: ".env",
221
+ })[k];
222
+ assert.equal(
223
+ readFileSync(join(root, path), "utf-8"),
224
+ v,
225
+ `${path} was modified by upgrade`,
226
+ );
227
+ }
228
+ } finally { await rmRoot(root); }
229
+ });
230
+
231
+ test("upgrade is idempotent (second run is a clean no-op for plists)", async () => {
232
+ const root = await makeLegacyAgent();
233
+ try {
234
+ const first = runCli(["upgrade"], root);
235
+ assert.equal(first.status, 0);
236
+ // Snapshot the regenerated plist contents.
237
+ const plistDir = join(root, "scripts/local-triggers/plists");
238
+ const before = {};
239
+ for (const name of readdirSync(plistDir)) {
240
+ before[name] = readFileSync(join(plistDir, name), "utf-8");
241
+ }
242
+ const second = runCli(["upgrade"], root);
243
+ assert.equal(second.status, 0);
244
+ // The second run should NOT introduce any new backup directory beyond
245
+ // what the first run created, because there are no legacy plists left.
246
+ const backups = readdirSync(join(root, ".maestro/backup/plists"));
247
+ assert.equal(backups.length, 1, "no new backup directory on idempotent run");
248
+ // Plist contents byte-identical.
249
+ for (const [name, body] of Object.entries(before)) {
250
+ assert.equal(readFileSync(join(plistDir, name), "utf-8"), body, `${name} changed`);
251
+ }
252
+ } finally { await rmRoot(root); }
253
+ });
254
+
255
+ test("upgrade --dry-run reports legacy without modifying anything", async () => {
256
+ const root = await makeLegacyAgent();
257
+ try {
258
+ // Snapshot a known sentinel file.
259
+ const sentinel = readFileSync(join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"), "utf-8");
260
+ const r = runCli(["upgrade", "--dry-run"], root);
261
+ assert.equal(r.status, 0);
262
+ assert.match(r.stdout, /still call run-trigger\.sh/);
263
+ // Sentinel must not have changed.
264
+ assert.equal(
265
+ readFileSync(join(root, "scripts/local-triggers/plists/ai.adaptic.legacy-inbox-processor.plist"), "utf-8"),
266
+ sentinel,
267
+ );
268
+ // No backup directory created.
269
+ assert.ok(!existsSync(join(root, ".maestro/backup")), "dry-run must not create backups");
270
+ } finally { await rmRoot(root); }
271
+ });
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // doctor
275
+ // ---------------------------------------------------------------------------
276
+
277
+ test("doctor flags legacy plists with actionable remediation", async () => {
278
+ const root = await makeLegacyAgent();
279
+ try {
280
+ const r = runCli(["doctor"], root);
281
+ // doctor exits 1 when issues are found; that's expected here.
282
+ assert.ok(r.status >= 0);
283
+ const combined = (r.stdout || "") + (r.stderr || "");
284
+ assert.match(combined, /run-trigger\.sh/);
285
+ assert.match(combined, /maestro upgrade/);
286
+ } finally { await rmRoot(root); }
287
+ });
288
+
289
+ test("doctor recognises new cadence-bus architecture after upgrade", async () => {
290
+ const root = await makeLegacyAgent();
291
+ try {
292
+ runCli(["upgrade"], root);
293
+ const r = runCli(["doctor"], root);
294
+ const combined = (r.stdout || "") + (r.stderr || "");
295
+ assert.match(combined, /cadence-bus architecture/);
296
+ // 'plist(s) still call run-trigger.sh' must NOT appear.
297
+ assert.ok(!combined.includes("plist(s) still call run-trigger.sh"));
298
+ } finally { await rmRoot(root); }
299
+ });
@@ -43,16 +43,29 @@ The agent has three concurrent execution modes, powered by different subsystems:
43
43
  │ │ Up to 10 parallel claude --print sessions │ │
44
44
  │ └────────────────────────────────────────────────────────────┘ │
45
45
  ├─────────────────────────────────────────────────────────────────────┤
46
- │ MODE 2: SCHEDULED — Run Cadence Workflows
46
+ │ MODE 2: SCHEDULED — Run Cadence Workflows via the Cadence Bus
47
47
  │ │
48
- │ ┌──────────┐ ┌──────────────┐ ┌──────────────────────────┐
49
- │ │ launchd │─▶│ run-trigger │─▶│ claude --print │
50
- │ │ (schedule)│ │ .sh │ │ (non-interactive session)
51
- │ └──────────┘ └──────────────┘ └──────────────────────────┘
48
+ │ ┌──────────┐ ┌────────────────────┐ ┌─────────────────────────┐
49
+ │ │ launchd │─▶│ enqueue-cadence- │─▶│ state/cadence-bus/
50
+ │ │ (schedule)│ │ tick.mjs (~10ms) │ │ inbox/<event>.json │
51
+ │ └──────────┘ └────────────────────┘ └────────────┬────────────┘
52
+ │ │ │
53
+ │ ┌──────────▼───────────┐ │
54
+ │ │ cadence-consumer.mjs │ │
55
+ │ │ (inside daemon) │ │
56
+ │ │ │ │
57
+ │ │ ┌─────┐ ┌──────────┐ │ │
58
+ │ │ │inline│ │sub-session│ │ │
59
+ │ │ └─────┘ └──────────┘ │ │
60
+ │ └──────────────────────┘ │
52
61
  │ │
53
- Triggers: morning-brief, midday-sweep, evening-wrap,
54
- backlog-executor, inbox-processor, meeting-prep,
55
- meeting-action-capture, weekly-*, quarterly-*
62
+ Per-cadence policy in scripts/daemon/cadence-handlers.mjs:
63
+ inline → housekeeping (heartbeat, recovery)
64
+ guarded → cheap pre-check; escalate only if work pending
65
+ │ escalate → spawn claude --print with schedules/triggers/*.md │
66
+ │ │
67
+ │ Cadences: inbox-processor, backlog-executor, meeting-prep, │
68
+ │ meeting-action-capture, daily-*, weekly-*, quarterly-* │
56
69
  ├─────────────────────────────────────────────────────────────────────┤
57
70
  │ MODE 3: PROACTIVE — Execute the Backlog │
58
71
  │ │
@@ -47,39 +47,43 @@ Key configuration:
47
47
  - **KeepAlive.SuccessfulExit**: false -- restarts if the process exits with a non-zero code
48
48
  - **ThrottleInterval**: 30 seconds -- prevents rapid restart loops
49
49
 
50
- ### Per-Workflow Scheduling
50
+ ### Per-Workflow Scheduling (Cadence Bus, 1.8+)
51
51
 
52
- Each workflow also has a corresponding `.plist` file in `schedules/` for direct launchd scheduling. Example for the morning brief:
52
+ As of maestro 1.8, scheduled cadence ticks no longer spawn `claude --print` per tick. Each scheduled workflow has a launchd `.plist` at `scripts/local-triggers/plists/` that calls the lightweight Node cadence enqueue script. That script drops a JSON event onto `state/cadence-bus/inbox/`. The persistent daemon (`scripts/daemon/maestro-daemon.mjs`) hosts the consumer that drains the bus and decides whether to handle each tick inline or escalate to a managed sub-session.
53
+
54
+ Example trigger plist (generated by `scripts/local-triggers/generate-plists.sh`):
53
55
 
54
56
  ```xml
55
57
  <?xml version="1.0" encoding="UTF-8"?>
58
+ <!-- maestro-plist-arch: cadence-bus v1 -->
56
59
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
57
60
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
61
  <plist version="1.0">
59
62
  <dict>
60
63
  <key>Label</key>
61
- <string>com.adaptic.ceobrain.morning-brief</string>
64
+ <string>ai.adaptic.<agent>-inbox-processor</string>
62
65
  <key>ProgramArguments</key>
63
66
  <array>
64
- <string>/usr/local/bin/claude</string>
65
- <string>--workflow</string>
66
- <string>/Users/sophie/sophie-ai/workflows/daily/morning-brief.yaml</string>
67
+ <string>/Users/<agent>/<agent>-ai/scripts/cadence/launchd-cadence-wrapper.sh</string>
68
+ <string>/Users/<agent>/<agent>-ai/scripts/cadence/enqueue-cadence-tick.mjs</string>
69
+ <string>inbox-processor</string>
70
+ <string>--source=launchd</string>
71
+ <string>--quiet</string>
67
72
  </array>
68
- <key>StartCalendarInterval</key>
69
- <dict>
70
- <key>Hour</key>
71
- <integer>6</integer>
72
- <key>Minute</key>
73
- <integer>0</integer>
74
- </dict>
73
+ <key>StartInterval</key>
74
+ <integer>300</integer>
75
75
  <key>StandardOutPath</key>
76
- <string>/Users/sophie/sophie-ai/logs/morning-brief.log</string>
76
+ <string>/Users/<agent>/<agent>-ai/logs/daemon/launchd-stdout.log</string>
77
77
  <key>StandardErrorPath</key>
78
- <string>/Users/sophie/sophie-ai/logs/morning-brief-error.log</string>
78
+ <string>/Users/<agent>/<agent>-ai/logs/daemon/launchd-stderr.log</string>
79
79
  </dict>
80
80
  </plist>
81
81
  ```
82
82
 
83
+ The bus consumer's per-tick decisions are logged to `logs/cadence-bus/<date>.jsonl`. Bus depth and consumer heartbeat are available via `npm run cadence:status`. The consumer writes a heartbeat to `state/cadence-bus/health.json` every ~15 seconds; doctor flags it as stale if it's older than 5 minutes.
84
+
85
+ **Legacy plists (run-trigger.sh):** Plists that still call `scripts/local-triggers/run-trigger.sh` directly are detected by `maestro doctor` and auto-migrated by `maestro upgrade` (backed up under `.maestro/backup/plists/<utc-timestamp>/`). `run-trigger.sh` itself is preserved for manual one-shot operator use only.
86
+
83
87
  ### Managing Jobs
84
88
 
85
89
  ```bash
@@ -162,6 +162,48 @@ launchctl load ~/Library/LaunchAgents/com.adaptic.maestro.plist
162
162
  launchctl list | grep com.adaptic.maestro
163
163
  ```
164
164
 
165
+ ### Cadence Bus Recovery (1.8+)
166
+
167
+ The cadence bus at `state/cadence-bus/` is the work queue for scheduled cadence ticks. The daemon's consumer drains it; if the daemon crashes mid-tick, the in-flight event remains in `state/cadence-bus/claimed/` until recovered.
168
+
169
+ **Symptoms of a stuck bus**:
170
+ - `npm run cadence:status` shows `claimed > 0` and `heartbeat_age_ms > 5 minutes`.
171
+ - `logs/cadence-bus/<date>.jsonl` has `stage:claimed` events with no matching `stage:processed` or `stage:dlq`.
172
+ - Bus inbox depth grows over time (`state/cadence-bus/inbox/` has many events).
173
+
174
+ **Manual recovery**:
175
+
176
+ ```bash
177
+ # 1. Inspect bus state
178
+ npm run cadence:status
179
+
180
+ # 2. Recover stale claims (returns claims older than 30 min to inbox/, or
181
+ # moves them to dlq if past retry budget). The consumer also runs this
182
+ # automatically every 5 minutes — manual run is for impatient operators.
183
+ node -e "import('./lib/cadence-bus.mjs').then(m => console.log(m.recoverStaleClaims(process.cwd())))"
184
+
185
+ # 3. If the bus is genuinely stuck, restart the daemon
186
+ launchctl unload ~/Library/LaunchAgents/ai.adaptic.<agent>-daemon.plist
187
+ launchctl load ~/Library/LaunchAgents/ai.adaptic.<agent>-daemon.plist
188
+ ```
189
+
190
+ **Resetting the bus** (last resort — loses pending ticks, but they re-arrive at next cadence interval):
191
+
192
+ ```bash
193
+ # Move all in-flight events to a dated archive, then wipe the bus
194
+ ts=$(date -u +%Y-%m-%dT%H-%M-%SZ)
195
+ mkdir -p .maestro/archive/cadence-bus/$ts
196
+ mv state/cadence-bus/inbox state/cadence-bus/claimed state/cadence-bus/dlq .maestro/archive/cadence-bus/$ts/
197
+ mkdir -p state/cadence-bus/{inbox,claimed,processed,failed,dlq}
198
+ ```
199
+
200
+ **Dead-letter queue** (`state/cadence-bus/dlq/`): events that exceeded their retry budget (default 5) land here. Each file is a full event payload with `last_error` and `failed_at`. Review periodically; re-enqueue manually if appropriate:
201
+
202
+ ```bash
203
+ ls state/cadence-bus/dlq/
204
+ node scripts/cadence/enqueue-cadence-tick.mjs <cadence> --source=manual --metadata=note=dlq-replay
205
+ ```
206
+
165
207
  ### Desktop App Crashes (Slack, Safari, WhatsApp)
166
208
 
167
209
  **Symptoms**: Follow-up messages not sent, comms triage returning empty results, desktop control errors in logs.