@adaptic/maestro 1.10.5 → 1.10.7
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/package.json +2 -2
- package/scripts/daemon/agent-daemon.mjs +18 -4
- package/scripts/daemon/dispatcher.mjs +46 -6
- package/scripts/daemon/inbox-deferral.mjs +171 -0
- package/scripts/daemon/inbox-deferral.test.mjs +154 -0
- package/scripts/poller/slack-poller.mjs +59 -2
- package/scripts/poller/utils.mjs +13 -5
- package/scripts/slack-send.sh +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adaptic/maestro",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.7",
|
|
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/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",
|
|
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/inbox-deferral.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",
|
|
@@ -47,7 +47,8 @@ import { dispatch, getStatus, availableSlots, canDispatchBacklog, resetActiveSes
|
|
|
47
47
|
import { buildPrompt } from "./prompt-builder.mjs";
|
|
48
48
|
import { sendQuickResponse, sendHoldingMessage, isQuickReply } from "./responder.mjs";
|
|
49
49
|
import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
|
|
50
|
-
import { acquireLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims } from "./session-lock.mjs";
|
|
50
|
+
import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims } from "./session-lock.mjs";
|
|
51
|
+
import { markDeferred } from "./inbox-deferral.mjs";
|
|
51
52
|
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Configuration
|
|
@@ -266,15 +267,28 @@ async function processItem(item, service) {
|
|
|
266
267
|
if (threadTs && channel) {
|
|
267
268
|
const threadCheck = acquireThreadLock(channel, threadTs);
|
|
268
269
|
if (!threadCheck.allowed) {
|
|
269
|
-
|
|
270
|
+
// Defer instead of dropping: rename to .deferred so the file
|
|
271
|
+
// is preserved and re-promoted to the live inbox when the
|
|
272
|
+
// active session for this channel releases its lock. The
|
|
273
|
+
// dispatcher calls promoteDeferred() from its session-close
|
|
274
|
+
// path; the next poll cycle then picks the latest deferred
|
|
275
|
+
// item up and dispatches a single session that sees the full
|
|
276
|
+
// thread context (older bursts are bundled).
|
|
277
|
+
//
|
|
278
|
+
// We also release the per-item lock that the poll filter
|
|
279
|
+
// acquired at the top of the cycle — otherwise the next
|
|
280
|
+
// poll's acquireLock would block re-processing of this
|
|
281
|
+
// exact item after we promote it back to live.
|
|
282
|
+
const deferred = markDeferred(item, service, AGENT_REPO_DIR);
|
|
283
|
+
releaseLock(itemId);
|
|
284
|
+
console.log(`[daemon] Thread/channel dedup: deferring item from ${item.sender} — ${threadCheck.reason}${deferred ? "" : " (no inbox file to defer)"}`);
|
|
270
285
|
logEvent("classifications", {
|
|
271
286
|
item_id: itemId,
|
|
272
287
|
sender: item.sender,
|
|
273
288
|
service,
|
|
274
|
-
|
|
289
|
+
deferred: deferred > 0,
|
|
275
290
|
reason: `thread_dedup: ${threadCheck.reason}`,
|
|
276
291
|
});
|
|
277
|
-
markProcessed(item, service);
|
|
278
292
|
return;
|
|
279
293
|
}
|
|
280
294
|
}
|
|
@@ -6,6 +6,7 @@ import { spawn } from "child_process";
|
|
|
6
6
|
import { appendFileSync, mkdirSync, writeFileSync, readFileSync, renameSync } from "fs";
|
|
7
7
|
import { join, dirname } from "path";
|
|
8
8
|
import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
|
|
9
|
+
import { promoteDeferred } from "./inbox-deferral.mjs";
|
|
9
10
|
import { recordSession } from "./health.mjs";
|
|
10
11
|
|
|
11
12
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
@@ -384,10 +385,35 @@ function spawnSession(entry) {
|
|
|
384
385
|
const itemId = item.raw_ref || item.id || item.title;
|
|
385
386
|
if (itemId) releaseLock(itemId);
|
|
386
387
|
|
|
387
|
-
// Release thread lock so new messages in this thread can
|
|
388
|
-
|
|
388
|
+
// Release thread/channel lock so new messages in this thread/DM can
|
|
389
|
+
// be processed. We must release unconditionally when a channel is
|
|
390
|
+
// known — the previous `if (item.thread_id)` gate skipped DMs (which
|
|
391
|
+
// always have empty thread_id), so DM-channel locks never cleared
|
|
392
|
+
// until the 60min TTL fired. acquireThreadLock normalises DM
|
|
393
|
+
// channels to `dm-channel` internally; releaseThreadLock mirrors
|
|
394
|
+
// that normalisation, so calling it with an empty thread_id on a
|
|
395
|
+
// DM channel does the right thing.
|
|
396
|
+
{
|
|
389
397
|
const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
|
|
390
|
-
if (channel)
|
|
398
|
+
if (channel) {
|
|
399
|
+
releaseThreadLock(channel, item.thread_id);
|
|
400
|
+
// Now that the lock is gone, promote any messages that were
|
|
401
|
+
// deferred behind it. Latest-wins: a burst of N messages
|
|
402
|
+
// collapses into ONE re-dispatch carrying the most recent
|
|
403
|
+
// message (its thread_context already includes the earlier
|
|
404
|
+
// ones), so the user gets a single coherent reply rather than
|
|
405
|
+
// N replies serialised over N session-durations.
|
|
406
|
+
const promo = promoteDeferred(channel, AGENT_REPO_DIR);
|
|
407
|
+
if (promo.promoted > 0) {
|
|
408
|
+
logSession({
|
|
409
|
+
event: "deferred_promoted",
|
|
410
|
+
channel,
|
|
411
|
+
promoted: promo.promoted,
|
|
412
|
+
bundled: promo.bundled,
|
|
413
|
+
service: promo.service,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
391
417
|
}
|
|
392
418
|
|
|
393
419
|
// Release request claim so the same type of request can be processed again
|
|
@@ -462,10 +488,24 @@ function spawnSession(entry) {
|
|
|
462
488
|
const itemId = item.raw_ref || item.id || item.title;
|
|
463
489
|
if (itemId) releaseLock(itemId);
|
|
464
490
|
|
|
465
|
-
// Release thread lock so new messages
|
|
466
|
-
|
|
491
|
+
// Release thread/channel lock so new messages can be processed.
|
|
492
|
+
// Mirror of close-handler logic — unconditional when channel is
|
|
493
|
+
// known, since releaseThreadLock handles the DM normalization.
|
|
494
|
+
{
|
|
467
495
|
const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
|
|
468
|
-
if (channel)
|
|
496
|
+
if (channel) {
|
|
497
|
+
releaseThreadLock(channel, item.thread_id);
|
|
498
|
+
const promo = promoteDeferred(channel, AGENT_REPO_DIR);
|
|
499
|
+
if (promo.promoted > 0) {
|
|
500
|
+
logSession({
|
|
501
|
+
event: "deferred_promoted_on_error",
|
|
502
|
+
channel,
|
|
503
|
+
promoted: promo.promoted,
|
|
504
|
+
bundled: promo.bundled,
|
|
505
|
+
service: promo.service,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
469
509
|
}
|
|
470
510
|
|
|
471
511
|
// Release request claim + emit explicit claim_released event so
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Inbox deferral utilities
|
|
3
|
+
*
|
|
4
|
+
* When the dispatcher hits a thread/channel-level dedup lock, the
|
|
5
|
+
* incoming message used to be marked `.processed` and dropped on the
|
|
6
|
+
* floor. That meant rapid-fire DMs (or thread bursts) silently lost
|
|
7
|
+
* every message after the first.
|
|
8
|
+
*
|
|
9
|
+
* This module replaces that with a defer-then-promote pattern:
|
|
10
|
+
*
|
|
11
|
+
* markDeferred(item, service, agentRoot)
|
|
12
|
+
* Renames the inbox file from `<name>` to `<name>.deferred`.
|
|
13
|
+
* The daemon's inbox scanner skips `.deferred` files, so the
|
|
14
|
+
* message is parked but not lost.
|
|
15
|
+
*
|
|
16
|
+
* promoteDeferred(channel, agentRoot)
|
|
17
|
+
* Called from the dispatcher's session-close release path. Finds
|
|
18
|
+
* every `.deferred` file in any service inbox dir whose body
|
|
19
|
+
* references `channel`. If exactly one is found, renames it back
|
|
20
|
+
* to its original name (next poll picks it up). If N>1 are found,
|
|
21
|
+
* keeps only the LATEST (by timestamp), promotes that one, and
|
|
22
|
+
* marks the others `.processed-bundled` for the audit trail —
|
|
23
|
+
* the latest item's `thread_context` already contains the prior
|
|
24
|
+
* messages as conversation history, so Claude sees everything
|
|
25
|
+
* and can compose a single coherent reply.
|
|
26
|
+
*
|
|
27
|
+
* The file format is the YAML emitted by the slack/gmail/calendar
|
|
28
|
+
* pollers (a flat top-level object with quoted scalar fields). We
|
|
29
|
+
* deliberately avoid pulling in a YAML parser — regex extraction of
|
|
30
|
+
* `channel_id:` and `timestamp:` is sufficient for the routing
|
|
31
|
+
* decision and keeps this module dependency-free.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
const SERVICE_DIRS = ["slack", "gmail", "calendar", "internal", "sms", "whatsapp"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Rename a live inbox file to its `.deferred` suffix so the scanner
|
|
41
|
+
* skips it until the channel/thread lock releases.
|
|
42
|
+
*
|
|
43
|
+
* Mirrors markProcessed's file-matching strategy (id or raw_ref
|
|
44
|
+
* substring) so we hit the same file that markProcessed would have.
|
|
45
|
+
*
|
|
46
|
+
* Idempotent: if the file is already `.deferred` or `.processed`, no-op.
|
|
47
|
+
*
|
|
48
|
+
* @returns {number} number of files renamed (usually 1, occasionally 0
|
|
49
|
+
* for backlog items with no inbox file)
|
|
50
|
+
*/
|
|
51
|
+
export function markDeferred(item, service, agentRoot) {
|
|
52
|
+
if (!item || !service || !agentRoot) return 0;
|
|
53
|
+
const inboxDir = join(agentRoot, "state", "inbox", service);
|
|
54
|
+
if (!existsSync(inboxDir)) return 0;
|
|
55
|
+
let renamed = 0;
|
|
56
|
+
try {
|
|
57
|
+
const needle = item.id || item.raw_ref;
|
|
58
|
+
if (!needle) return 0;
|
|
59
|
+
const files = readdirSync(inboxDir).filter(
|
|
60
|
+
(f) => !f.endsWith(".processed") &&
|
|
61
|
+
!f.endsWith(".deferred") &&
|
|
62
|
+
!f.endsWith(".processed-bundled") &&
|
|
63
|
+
(f.includes(item.id || "") || f.includes(item.raw_ref || ""))
|
|
64
|
+
);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
renameSync(join(inboxDir, file), join(inboxDir, `${file}.deferred`));
|
|
67
|
+
renamed++;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Inbox dir may not exist for non-poller-backed items (e.g. backlog).
|
|
71
|
+
}
|
|
72
|
+
return renamed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract a top-level scalar value from a poller-written inbox YAML
|
|
77
|
+
* file. Returns `null` if the field is absent.
|
|
78
|
+
*
|
|
79
|
+
* We only need this for `channel_id` (routing) and `timestamp`
|
|
80
|
+
* (latest-wins selection) — both are written as quoted single-line
|
|
81
|
+
* scalars by every poller, so a one-line regex is enough.
|
|
82
|
+
*/
|
|
83
|
+
function readScalar(body, field) {
|
|
84
|
+
// Matches: ` field: "value"` or `field: value` (leading space ok)
|
|
85
|
+
const re = new RegExp(`^\\s*${field}\\s*:\\s*"?([^"\\n]+?)"?\\s*$`, "m");
|
|
86
|
+
const m = body.match(re);
|
|
87
|
+
return m ? m[1].trim() : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Promote every `.deferred` item targeting `channel` back into the
|
|
92
|
+
* live inbox. Bundles bursts: if multiple deferred items exist for
|
|
93
|
+
* the same channel, only the latest is re-queued (the others are
|
|
94
|
+
* marked `.processed-bundled` since their content is already present
|
|
95
|
+
* in the latest item's thread_context).
|
|
96
|
+
*
|
|
97
|
+
* Called from the dispatcher's session-close path, AFTER the channel
|
|
98
|
+
* lock has been released, so the next poll cycle is free to re-acquire.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{promoted: number, bundled: number, service: string|null}}
|
|
101
|
+
*/
|
|
102
|
+
export function promoteDeferred(channel, agentRoot) {
|
|
103
|
+
const result = { promoted: 0, bundled: 0, service: null };
|
|
104
|
+
if (!channel || !agentRoot) return result;
|
|
105
|
+
|
|
106
|
+
for (const service of SERVICE_DIRS) {
|
|
107
|
+
const inboxDir = join(agentRoot, "state", "inbox", service);
|
|
108
|
+
if (!existsSync(inboxDir)) continue;
|
|
109
|
+
|
|
110
|
+
let deferredFiles;
|
|
111
|
+
try {
|
|
112
|
+
deferredFiles = readdirSync(inboxDir).filter((f) => f.endsWith(".deferred"));
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (deferredFiles.length === 0) continue;
|
|
117
|
+
|
|
118
|
+
// Filter to items whose body references this channel. Reading
|
|
119
|
+
// every .deferred file is cheap (a handful at most) — we stay
|
|
120
|
+
// dependency-free by regex-scanning rather than parsing YAML.
|
|
121
|
+
const matches = [];
|
|
122
|
+
for (const file of deferredFiles) {
|
|
123
|
+
let body;
|
|
124
|
+
try {
|
|
125
|
+
body = readFileSync(join(inboxDir, file), "utf-8");
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const fileChannel = readScalar(body, "channel_id");
|
|
130
|
+
if (fileChannel !== channel) continue;
|
|
131
|
+
matches.push({
|
|
132
|
+
file,
|
|
133
|
+
timestamp: readScalar(body, "timestamp") || "",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (matches.length === 0) continue;
|
|
137
|
+
|
|
138
|
+
// Latest-wins: lex-sort ISO timestamps, take the most recent.
|
|
139
|
+
matches.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
140
|
+
const latest = matches[matches.length - 1];
|
|
141
|
+
const older = matches.slice(0, -1);
|
|
142
|
+
|
|
143
|
+
// Promote the latest back to live inbox.
|
|
144
|
+
try {
|
|
145
|
+
const live = latest.file.replace(/\.deferred$/, "");
|
|
146
|
+
renameSync(join(inboxDir, latest.file), join(inboxDir, live));
|
|
147
|
+
result.promoted++;
|
|
148
|
+
result.service = service;
|
|
149
|
+
} catch {
|
|
150
|
+
// If the rename failed (race with another process), leave it
|
|
151
|
+
// as .deferred — the next session-close will retry.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Mark older bursts as bundled — their content is already part of
|
|
155
|
+
// the latest item's thread_context, so they don't need their own
|
|
156
|
+
// session, but we preserve the file for audit.
|
|
157
|
+
for (const m of older) {
|
|
158
|
+
try {
|
|
159
|
+
renameSync(
|
|
160
|
+
join(inboxDir, m.file),
|
|
161
|
+
join(inboxDir, m.file.replace(/\.deferred$/, ".processed-bundled"))
|
|
162
|
+
);
|
|
163
|
+
result.bundled++;
|
|
164
|
+
} catch {
|
|
165
|
+
// Best-effort; missing file is fine.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
import { markDeferred, promoteDeferred } from "./inbox-deferral.mjs";
|
|
8
|
+
|
|
9
|
+
function makeAgentRoot() {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-deferral-"));
|
|
11
|
+
mkdirSync(join(root, "state", "inbox", "slack"), { recursive: true });
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeInboxItem(root, name, { channel_id, timestamp, id = "test-id" }) {
|
|
16
|
+
const body = [
|
|
17
|
+
`id: "${id}"`,
|
|
18
|
+
`service: "slack"`,
|
|
19
|
+
`channel_id: "${channel_id}"`,
|
|
20
|
+
`timestamp: "${timestamp}"`,
|
|
21
|
+
`content: |`,
|
|
22
|
+
` body`,
|
|
23
|
+
"",
|
|
24
|
+
].join("\n");
|
|
25
|
+
writeFileSync(join(root, "state", "inbox", "slack", name), body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("markDeferred renames live file to .deferred", () => {
|
|
29
|
+
const root = makeAgentRoot();
|
|
30
|
+
try {
|
|
31
|
+
writeInboxItem(root, "msg-A.yaml", {
|
|
32
|
+
channel_id: "D001",
|
|
33
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
34
|
+
id: "msg-A",
|
|
35
|
+
});
|
|
36
|
+
const n = markDeferred({ id: "msg-A" }, "slack", root);
|
|
37
|
+
assert.equal(n, 1);
|
|
38
|
+
const files = readdirSync(join(root, "state", "inbox", "slack"));
|
|
39
|
+
assert.deepEqual(files, ["msg-A.yaml.deferred"]);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(root, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("markDeferred is idempotent — already-deferred files are skipped", () => {
|
|
46
|
+
const root = makeAgentRoot();
|
|
47
|
+
try {
|
|
48
|
+
writeInboxItem(root, "msg-B.yaml.deferred", {
|
|
49
|
+
channel_id: "D001",
|
|
50
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
51
|
+
id: "msg-B",
|
|
52
|
+
});
|
|
53
|
+
const n = markDeferred({ id: "msg-B" }, "slack", root);
|
|
54
|
+
assert.equal(n, 0);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("markDeferred is no-op when item has no matching file (e.g. backlog)", () => {
|
|
61
|
+
const root = makeAgentRoot();
|
|
62
|
+
try {
|
|
63
|
+
const n = markDeferred({ id: "no-such-id" }, "slack", root);
|
|
64
|
+
assert.equal(n, 0);
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("promoteDeferred with single item: renames back to live", () => {
|
|
71
|
+
const root = makeAgentRoot();
|
|
72
|
+
try {
|
|
73
|
+
writeInboxItem(root, "msg-C.yaml.deferred", {
|
|
74
|
+
channel_id: "D099N1JEA10",
|
|
75
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
76
|
+
id: "msg-C",
|
|
77
|
+
});
|
|
78
|
+
const r = promoteDeferred("D099N1JEA10", root);
|
|
79
|
+
assert.equal(r.promoted, 1);
|
|
80
|
+
assert.equal(r.bundled, 0);
|
|
81
|
+
assert.equal(r.service, "slack");
|
|
82
|
+
const files = readdirSync(join(root, "state", "inbox", "slack"));
|
|
83
|
+
assert.deepEqual(files, ["msg-C.yaml"]);
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(root, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("promoteDeferred with multiple items: keeps latest, bundles rest", () => {
|
|
90
|
+
const root = makeAgentRoot();
|
|
91
|
+
try {
|
|
92
|
+
writeInboxItem(root, "msg-1.yaml.deferred", {
|
|
93
|
+
channel_id: "D001",
|
|
94
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
95
|
+
id: "msg-1",
|
|
96
|
+
});
|
|
97
|
+
writeInboxItem(root, "msg-2.yaml.deferred", {
|
|
98
|
+
channel_id: "D001",
|
|
99
|
+
timestamp: "2026-05-13T00:01:00Z",
|
|
100
|
+
id: "msg-2",
|
|
101
|
+
});
|
|
102
|
+
writeInboxItem(root, "msg-3.yaml.deferred", {
|
|
103
|
+
channel_id: "D001",
|
|
104
|
+
timestamp: "2026-05-13T00:02:00Z",
|
|
105
|
+
id: "msg-3",
|
|
106
|
+
});
|
|
107
|
+
const r = promoteDeferred("D001", root);
|
|
108
|
+
assert.equal(r.promoted, 1);
|
|
109
|
+
assert.equal(r.bundled, 2);
|
|
110
|
+
const files = readdirSync(join(root, "state", "inbox", "slack")).sort();
|
|
111
|
+
assert.deepEqual(files, [
|
|
112
|
+
"msg-1.yaml.processed-bundled",
|
|
113
|
+
"msg-2.yaml.processed-bundled",
|
|
114
|
+
"msg-3.yaml", // latest survives as live
|
|
115
|
+
]);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(root, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("promoteDeferred ignores items in other channels", () => {
|
|
122
|
+
const root = makeAgentRoot();
|
|
123
|
+
try {
|
|
124
|
+
writeInboxItem(root, "for-A.yaml.deferred", {
|
|
125
|
+
channel_id: "D-A",
|
|
126
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
127
|
+
id: "for-A",
|
|
128
|
+
});
|
|
129
|
+
writeInboxItem(root, "for-B.yaml.deferred", {
|
|
130
|
+
channel_id: "D-B",
|
|
131
|
+
timestamp: "2026-05-13T00:00:00Z",
|
|
132
|
+
id: "for-B",
|
|
133
|
+
});
|
|
134
|
+
const r = promoteDeferred("D-A", root);
|
|
135
|
+
assert.equal(r.promoted, 1);
|
|
136
|
+
assert.equal(r.bundled, 0);
|
|
137
|
+
const files = readdirSync(join(root, "state", "inbox", "slack")).sort();
|
|
138
|
+
assert.deepEqual(files, ["for-A.yaml", "for-B.yaml.deferred"]);
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(root, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("promoteDeferred no-op when nothing to promote", () => {
|
|
145
|
+
const root = makeAgentRoot();
|
|
146
|
+
try {
|
|
147
|
+
const r = promoteDeferred("D999", root);
|
|
148
|
+
assert.equal(r.promoted, 0);
|
|
149
|
+
assert.equal(r.bundled, 0);
|
|
150
|
+
assert.equal(r.service, null);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(root, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -273,6 +273,59 @@ export async function pollSlack() {
|
|
|
273
273
|
activeThreads = {};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// ── CEO DM pre-pass ──────────────────────────────────────────────────
|
|
277
|
+
// Mehran's DMs are the single most time-sensitive signal. The channel
|
|
278
|
+
// loop below routinely eats the entire 55s cycle budget on a busy
|
|
279
|
+
// workspace and skips DMs entirely. Poll the CEO DMs FIRST (with a
|
|
280
|
+
// small dedicated budget) so they always get fresh data regardless of
|
|
281
|
+
// how the broader cycle plays out. The downstream DM section will
|
|
282
|
+
// re-iterate but the items dedup on raw_ref so duplicates are dropped.
|
|
283
|
+
const ceoDmItemsSeen = new Set(); // raw_refs we already pushed in the pre-pass
|
|
284
|
+
try {
|
|
285
|
+
const convos = await slackApi("conversations.list", { types: "im", limit: 50 });
|
|
286
|
+
if (convos.ok) {
|
|
287
|
+
const ceoDMs = (convos.channels || []).filter((im) => im.user === CEO_USER_ID);
|
|
288
|
+
for (const im of ceoDMs) {
|
|
289
|
+
const history = await slackApi("conversations.history", {
|
|
290
|
+
channel: im.id,
|
|
291
|
+
oldest,
|
|
292
|
+
limit: 10,
|
|
293
|
+
});
|
|
294
|
+
if (!history.ok) continue;
|
|
295
|
+
for (const msg of history.messages || []) {
|
|
296
|
+
if (msg.bot_id || msg.user === AGENT_USER_ID) continue;
|
|
297
|
+
if (isVoiceTranscript(msg.text || "")) continue; // handled in main DM loop
|
|
298
|
+
const rawRef = `slack:${im.id}:${msg.ts}`;
|
|
299
|
+
ceoDmItemsSeen.add(rawRef);
|
|
300
|
+
items.push({
|
|
301
|
+
id: msg.ts.replace(".", "-"),
|
|
302
|
+
service: "slack",
|
|
303
|
+
channel: `dm/${resolveName(msg.user)}`,
|
|
304
|
+
sender: resolveName(msg.user),
|
|
305
|
+
sender_privilege: resolvePrivilege(msg.user),
|
|
306
|
+
timestamp: new Date(parseFloat(msg.ts) * 1000).toISOString(),
|
|
307
|
+
subject: `DM from ${resolveName(msg.user)}`,
|
|
308
|
+
content: msg.text || "",
|
|
309
|
+
thread_id: msg.thread_ts && msg.thread_ts !== msg.ts ? msg.thread_ts : "",
|
|
310
|
+
is_reply: false,
|
|
311
|
+
is_voice_transcript: false,
|
|
312
|
+
priority_signals: {
|
|
313
|
+
from_ceo: true,
|
|
314
|
+
tagged_urgent:
|
|
315
|
+
/\b(urgent|emergency|asap|blocker|critical)\b/i.test(msg.text || ""),
|
|
316
|
+
contains_deadline: false,
|
|
317
|
+
mentions_agent: true,
|
|
318
|
+
},
|
|
319
|
+
raw_ref: rawRef,
|
|
320
|
+
channel_id: im.id,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
errors.push(`CEO DM pre-pass: ${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
276
329
|
for (const channel of channelsThisCycle) {
|
|
277
330
|
// Check cycle time budget — skip remaining channels if running long
|
|
278
331
|
if (isCycleBudgetExceeded()) {
|
|
@@ -391,12 +444,16 @@ export async function pollSlack() {
|
|
|
391
444
|
});
|
|
392
445
|
if (convos.ok) {
|
|
393
446
|
const allDMs = convos.channels || [];
|
|
394
|
-
// Separate CEO DMs (always poll) from other DMs (rotate in halves)
|
|
447
|
+
// Separate CEO DMs (always poll) from other DMs (rotate in halves).
|
|
448
|
+
// We still track ceoDMChannelIds for the downstream thread-scan
|
|
449
|
+
// priority pass, but skip CEO DMs in the per-channel polling loop
|
|
450
|
+
// below because the CEO DM pre-pass at the top of the cycle has
|
|
451
|
+
// already fetched + pushed their messages with fresh budget.
|
|
395
452
|
const ceoDMs = allDMs.filter((im) => im.user === CEO_USER_ID);
|
|
396
453
|
for (const dm of ceoDMs) ceoDMChannelIds.add(dm.id);
|
|
397
454
|
const otherDMs = allDMs.filter((im) => im.user !== CEO_USER_ID);
|
|
398
455
|
const rotatedOtherDMs = otherDMs.filter((_, i) => i % 2 === pollCycleCount % 2);
|
|
399
|
-
const dmsThisCycle =
|
|
456
|
+
const dmsThisCycle = rotatedOtherDMs;
|
|
400
457
|
|
|
401
458
|
for (const im of dmsThisCycle) {
|
|
402
459
|
// Check cycle time budget — skip remaining DMs if running long
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -17,11 +17,19 @@ export function writeInboxItem(service, item) {
|
|
|
17
17
|
const filename = `${ts}-${item.id}.yaml`;
|
|
18
18
|
const filePath = join(dir, filename);
|
|
19
19
|
|
|
20
|
-
// Skip if this item was already written or already processed.
|
|
21
|
-
// The daemon renames files to .processed after handling
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
20
|
+
// Skip if this item was already written or already processed/deferred.
|
|
21
|
+
// The daemon renames files to .processed after handling, to .deferred
|
|
22
|
+
// when parked behind an active channel lock, and to .processed-bundled
|
|
23
|
+
// when older entries are folded into a newer message during promotion.
|
|
24
|
+
// Without all four checks the 30-min thread lookback re-creates the
|
|
25
|
+
// file every poll cycle, causing duplicate responses or surprise
|
|
26
|
+
// re-promotion of work that was already bundled.
|
|
27
|
+
if (
|
|
28
|
+
existsSync(filePath) ||
|
|
29
|
+
existsSync(filePath + ".processed") ||
|
|
30
|
+
existsSync(filePath + ".deferred") ||
|
|
31
|
+
existsSync(filePath + ".processed-bundled")
|
|
32
|
+
) {
|
|
25
33
|
return;
|
|
26
34
|
}
|
|
27
35
|
|
package/scripts/slack-send.sh
CHANGED
|
@@ -72,6 +72,36 @@ if [ -z "$CHANNEL" ] || [ -z "$MESSAGE" ]; then
|
|
|
72
72
|
exit 1
|
|
73
73
|
fi
|
|
74
74
|
|
|
75
|
+
# Auto-detect --responding_to from the inbox if the caller didn't pass it.
|
|
76
|
+
# Protects against operator-vs-daemon duplicate replies: when a human-prompted
|
|
77
|
+
# session replies via this script, it should claim the dedup lock so the
|
|
78
|
+
# daemon's session (which always passes --responding_to) finds the lock
|
|
79
|
+
# already taken and skips its own send. Without auto-detect, two reply
|
|
80
|
+
# sources race and the user gets duplicate substantive responses (observed
|
|
81
|
+
# on 2026-05-12 when operator + daemon both replied to a CEO DM 3 min apart).
|
|
82
|
+
#
|
|
83
|
+
# Strategy: scan state/inbox/slack/ for the most recent unprocessed YAML
|
|
84
|
+
# whose body references this channel; extract its raw_ref ts as RESPONDING_TO.
|
|
85
|
+
# Operator can override by passing --responding_to explicitly, or skip auto-
|
|
86
|
+
# detect with --responding_to "" (proactive send not tied to an inbox item).
|
|
87
|
+
if [ -z "$RESPONDING_TO" ] && [[ "$CHANNEL" =~ ^[CD][A-Z0-9]+$ ]]; then
|
|
88
|
+
INBOX_DIR="$AGENT_REPO_DIR/state/inbox/slack"
|
|
89
|
+
if [ -d "$INBOX_DIR" ]; then
|
|
90
|
+
LATEST_INBOX=$(
|
|
91
|
+
ls -t "$INBOX_DIR"/*.yaml 2>/dev/null |
|
|
92
|
+
xargs -I{} grep -l "channel_id: \"$CHANNEL\"" {} 2>/dev/null |
|
|
93
|
+
head -1
|
|
94
|
+
)
|
|
95
|
+
if [ -n "$LATEST_INBOX" ]; then
|
|
96
|
+
AUTO_TS=$(grep "^raw_ref:" "$LATEST_INBOX" 2>/dev/null | sed -E 's/.*:([0-9]+\.[0-9]+)".*/\1/')
|
|
97
|
+
if [ -n "$AUTO_TS" ]; then
|
|
98
|
+
RESPONDING_TO="$AUTO_TS"
|
|
99
|
+
echo "[slack-send] auto-detected --responding_to=$RESPONDING_TO from $(basename "$LATEST_INBOX")" >&2
|
|
100
|
+
fi
|
|
101
|
+
fi
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
|
|
75
105
|
# Dedup check: if --responding_to is set, use atomic locking to prevent concurrent sends
|
|
76
106
|
DEDUP_ACQUIRED=""
|
|
77
107
|
if [ -n "$RESPONDING_TO" ]; then
|