macro-agent 0.1.12 → 0.2.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.
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +240 -7
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/types.d.ts +47 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +33 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +142 -11
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
- package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
- package/dist/cli/inbox-mcp-proxy.js +51 -0
- package/dist/cli/inbox-mcp-proxy.js.map +1 -0
- package/dist/dispatch/loadout-translation.d.ts +100 -0
- package/dist/dispatch/loadout-translation.d.ts.map +1 -0
- package/dist/dispatch/loadout-translation.js +90 -0
- package/dist/dispatch/loadout-translation.js.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.js +261 -0
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
- package/dist/dispatch/permission-evaluator.d.ts +68 -0
- package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
- package/dist/dispatch/permission-evaluator.js +159 -0
- package/dist/dispatch/permission-evaluator.js.map +1 -0
- package/dist/dispatch/permission-overlay.d.ts +64 -0
- package/dist/dispatch/permission-overlay.d.ts.map +1 -0
- package/dist/dispatch/permission-overlay.js +72 -0
- package/dist/dispatch/permission-overlay.js.map +1 -0
- package/dist/dispatch/permissions-handler.d.ts +71 -0
- package/dist/dispatch/permissions-handler.d.ts.map +1 -0
- package/dist/dispatch/permissions-handler.js +83 -0
- package/dist/dispatch/permissions-handler.js.map +1 -0
- package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
- package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
- package/dist/dispatch/spawn-agent-handler.js +85 -0
- package/dist/dispatch/spawn-agent-handler.js.map +1 -0
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +27 -0
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/map/lifecycle-bridge.d.ts +18 -0
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +23 -1
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +55 -0
- package/dist/map/mail-bridge.d.ts.map +1 -0
- package/dist/map/mail-bridge.js +115 -0
- package/dist/map/mail-bridge.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +245 -1
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +15 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +1 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +2 -0
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +6 -5
- package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
- package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
- package/src/agent/agent-manager-v2.ts +268 -8
- package/src/agent/types.ts +51 -0
- package/src/boot-v2.ts +190 -12
- package/src/cli/inbox-mcp-proxy.ts +56 -0
- package/src/dispatch/CLAUDE.md +129 -0
- package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
- package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
- package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
- package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
- package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
- package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
- package/src/dispatch/loadout-translation.ts +138 -0
- package/src/dispatch/mail-inbound-consumer.ts +397 -0
- package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
- package/src/dispatch/permission-evaluator.ts +191 -0
- package/src/dispatch/permission-overlay.ts +89 -0
- package/src/dispatch/permissions-handler.ts +112 -0
- package/src/dispatch/spawn-agent-handler.ts +160 -0
- package/src/lifecycle/handlers-v2.ts +34 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
- package/src/map/__tests__/mail-bridge.test.ts +196 -0
- package/src/map/lifecycle-bridge.ts +48 -2
- package/src/map/mail-bridge.ts +203 -0
- package/src/map/sidecar.ts +346 -1
- package/src/map/types.ts +21 -0
- package/src/mcp/tools/done-v2.ts +1 -0
- package/src/teams/team-loader.ts +3 -1
- package/src/teams/team-runtime-v2.ts +2 -0
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- package/dist/workspace/dataplane-adapter.js.map +0 -1
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail-Inbound Consumer
|
|
3
|
+
*
|
|
4
|
+
* Receives hub-driven `x-dispatch/work` envelopes from the local agent-inbox
|
|
5
|
+
* and spawns a worker agent to handle them — without requiring the optional
|
|
6
|
+
* outbound swarm-dispatch orchestrator (`config.dispatch.enabled`).
|
|
7
|
+
*
|
|
8
|
+
* This makes mail-inbound dispatch a **default capability** of macro-agent: as
|
|
9
|
+
* long as the MAP sidecar is connected and the inbox is running, any hub that
|
|
10
|
+
* delivers work via `mail/turn.received` will be served.
|
|
11
|
+
*
|
|
12
|
+
* ## Data flow
|
|
13
|
+
*
|
|
14
|
+
* hub sends `mail/turn.received`
|
|
15
|
+
* → mail-bridge translates {type,body} → {schema,data} + _conversationId
|
|
16
|
+
* → inboxAdapter delivers to local inbox (recipient = dispatcherAgentId)
|
|
17
|
+
* → inbox.events fires "inbox.message"
|
|
18
|
+
* → consumer classifies: schema === 'x-dispatch/work'?
|
|
19
|
+
* yes → spawn worker via agentManager.spawn()
|
|
20
|
+
* → record agentId → conversationId in side map
|
|
21
|
+
* → worker calls done(summary="…SENTINEL…")
|
|
22
|
+
* → handlers-v2 stores _lastSummary in agentStore metadata (parentId null branch)
|
|
23
|
+
* → agentManager.onLifecycleEvent fires "stopped"
|
|
24
|
+
* → consumer reads _lastSummary + conversationId
|
|
25
|
+
* → mapSidecar.postMailTurn(conversationId, agentId, summary) [fire-and-forget]
|
|
26
|
+
*
|
|
27
|
+
* @module dispatch/mail-inbound-consumer
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { AgentManager } from "../agent/agent-manager.js";
|
|
31
|
+
import type { AgentStore } from "../agent/agent-store.js";
|
|
32
|
+
import { loadoutToSpawnOptions, type WireLoadout } from "./loadout-translation.js";
|
|
33
|
+
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────
|
|
35
|
+
// Dependency interfaces (narrow — keeps the module testable without
|
|
36
|
+
// dragging in the full InboxAdapter / MAPSidecar concrete types)
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface InboxEvents {
|
|
40
|
+
on(event: "inbox.message", listener: (event: InboxMessageEvent) => void): void;
|
|
41
|
+
off?(event: "inbox.message", listener: (event: InboxMessageEvent) => void): void;
|
|
42
|
+
removeListener?(event: "inbox.message", listener: (event: InboxMessageEvent) => void): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InboxMessageEvent {
|
|
46
|
+
/** The inbox recipient agent ID. */
|
|
47
|
+
agentId: string;
|
|
48
|
+
message: {
|
|
49
|
+
id?: string;
|
|
50
|
+
content?: unknown;
|
|
51
|
+
sender_id?: string;
|
|
52
|
+
thread_tag?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MailInboundSidecar {
|
|
57
|
+
postMailTurn?(
|
|
58
|
+
conversationId: string,
|
|
59
|
+
participantId: string,
|
|
60
|
+
content: string,
|
|
61
|
+
): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MailInboundConsumerOptions {
|
|
65
|
+
/**
|
|
66
|
+
* The inbox agent ID that mail-bridge delivers envelopes to.
|
|
67
|
+
* Typically `dispatcher:<claimantId>` when the outbound orchestrator
|
|
68
|
+
* is also running, or a dedicated ID when it is not.
|
|
69
|
+
*/
|
|
70
|
+
dispatcherAgentId: string;
|
|
71
|
+
|
|
72
|
+
/** Raw inbox event emitter (from inboxAdapter.getInbox().events). */
|
|
73
|
+
inboxEvents: InboxEvents;
|
|
74
|
+
|
|
75
|
+
/** Agent lifecycle manager — used to spawn workers. */
|
|
76
|
+
agentManager: AgentManager;
|
|
77
|
+
|
|
78
|
+
/** Agent store — used to read _lastSummary after the agent stops. */
|
|
79
|
+
agentStore: AgentStore;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Optional sidecar reference. Populated after step 13 in boot-v2 via
|
|
83
|
+
* the shared systemRef — the consumer accesses it lazily at reply time
|
|
84
|
+
* so it works even though the sidecar is created after the consumer.
|
|
85
|
+
*/
|
|
86
|
+
getSidecar: () => MailInboundSidecar | null | undefined;
|
|
87
|
+
|
|
88
|
+
/** Optional logger (default: console.log). */
|
|
89
|
+
log?: (msg: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MailInboundConsumerStats {
|
|
93
|
+
/** Count of envelopes dropped because they lacked a taskId. */
|
|
94
|
+
droppedMalformed: number;
|
|
95
|
+
/** Number of distinct taskIds currently tracked for dedup. */
|
|
96
|
+
seenTaskIds: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface MailInboundConsumer {
|
|
100
|
+
stop(): void;
|
|
101
|
+
/** Snapshot of consumer-level counters for observability. */
|
|
102
|
+
stats(): MailInboundConsumerStats;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─────────────────────────────────────────────────────────────────
|
|
106
|
+
// Implementation
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Wire the mail-inbound consumer.
|
|
111
|
+
*
|
|
112
|
+
* Returns a `stop()` handle that detaches all listeners.
|
|
113
|
+
* Safe to call multiple times (idempotent cleanup).
|
|
114
|
+
*/
|
|
115
|
+
export function createMailInboundConsumer(
|
|
116
|
+
opts: MailInboundConsumerOptions,
|
|
117
|
+
): MailInboundConsumer {
|
|
118
|
+
const {
|
|
119
|
+
dispatcherAgentId,
|
|
120
|
+
inboxEvents,
|
|
121
|
+
agentManager,
|
|
122
|
+
agentStore,
|
|
123
|
+
getSidecar,
|
|
124
|
+
log = (msg: string) => console.log(msg),
|
|
125
|
+
} = opts;
|
|
126
|
+
|
|
127
|
+
// ── Side-channel maps ────────────────────────────────────────
|
|
128
|
+
// agentId → conversationId: populated when a worker is spawned for a
|
|
129
|
+
// mail-inbound envelope; read when the agent's stopped event fires.
|
|
130
|
+
const agentConversationMap = new Map<string, string>();
|
|
131
|
+
|
|
132
|
+
// taskId → expiresAt: idempotency guard keyed on the dispatch envelope's
|
|
133
|
+
// task identifier. The local inbox can re-fire `inbox.message` for the
|
|
134
|
+
// same logical delivery — without this guard, a single bridged turn would
|
|
135
|
+
// trigger N concurrent spawn() calls, each producing a long-lived ACP
|
|
136
|
+
// subprocess.
|
|
137
|
+
//
|
|
138
|
+
// Bounded by TTL so the map cannot grow unbounded over a long-running
|
|
139
|
+
// deployment. SEEN_TASK_TTL_MS is generous (1 hour) — re-deliveries within
|
|
140
|
+
// that window are dropped, beyond it the dedup expires and a stale retry
|
|
141
|
+
// could legitimately re-spawn (preferable to permanent memory growth).
|
|
142
|
+
const SEEN_TASK_TTL_MS = 60 * 60 * 1000;
|
|
143
|
+
const seenTaskIds = new Map<string, number>();
|
|
144
|
+
function pruneSeenTaskIds(): void {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
for (const [id, expiresAt] of seenTaskIds) {
|
|
147
|
+
if (expiresAt <= now) seenTaskIds.delete(id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Counter for envelopes dropped because they are malformed (no taskId).
|
|
152
|
+
// Surfaced via the consumer handle's stats() method so operators can
|
|
153
|
+
// distinguish "no work" from "work is broken".
|
|
154
|
+
let droppedMalformedCount = 0;
|
|
155
|
+
|
|
156
|
+
log(
|
|
157
|
+
`[mail-inbound] Consumer ready — listening for x-dispatch/work envelopes ` +
|
|
158
|
+
`(recipient=${dispatcherAgentId})`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ── Inbox message listener ───────────────────────────────────
|
|
162
|
+
const onMessage = (event: InboxMessageEvent): void => {
|
|
163
|
+
// Only handle messages delivered to our dispatcher recipient.
|
|
164
|
+
if (event.agentId !== dispatcherAgentId) return;
|
|
165
|
+
|
|
166
|
+
const content = event.message?.content as {
|
|
167
|
+
schema?: string;
|
|
168
|
+
data?: {
|
|
169
|
+
taskId?: string;
|
|
170
|
+
prompt?: string;
|
|
171
|
+
content?: string;
|
|
172
|
+
title?: string;
|
|
173
|
+
role?: string;
|
|
174
|
+
tags?: string[];
|
|
175
|
+
loadout?: WireLoadout;
|
|
176
|
+
metadata?: Record<string, unknown>;
|
|
177
|
+
};
|
|
178
|
+
_conversationId?: string;
|
|
179
|
+
} | undefined;
|
|
180
|
+
|
|
181
|
+
if (content?.schema !== "x-dispatch/work") return;
|
|
182
|
+
|
|
183
|
+
const data = content.data;
|
|
184
|
+
if (!data?.taskId) {
|
|
185
|
+
droppedMalformedCount++;
|
|
186
|
+
log(
|
|
187
|
+
`[mail-inbound] Dropping malformed envelope (no taskId, total=${droppedMalformedCount}) — ` +
|
|
188
|
+
`keys=${Object.keys(data ?? {}).join(',')} from=${event.message?.sender_id ?? '?'}`,
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const taskId = data.taskId;
|
|
194
|
+
pruneSeenTaskIds();
|
|
195
|
+
const seenExpiresAt = seenTaskIds.get(taskId);
|
|
196
|
+
if (seenExpiresAt !== undefined && seenExpiresAt > Date.now()) {
|
|
197
|
+
// Already spawned a worker for this dispatch within the dedup window
|
|
198
|
+
// — silently ignore the re-delivery. The hub treats dispatch as
|
|
199
|
+
// exactly-once on the worker side, so dropping is correct.
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
seenTaskIds.set(taskId, Date.now() + SEEN_TASK_TTL_MS);
|
|
203
|
+
|
|
204
|
+
const conversationId = content._conversationId;
|
|
205
|
+
const prompt = data.prompt ?? data.content ?? "";
|
|
206
|
+
|
|
207
|
+
// Validate the envelope's role against the local role registry. Unknown
|
|
208
|
+
// role names (e.g. team-role-ref roles like 'executor' surfaced by hubs
|
|
209
|
+
// that don't share macro-agent's role taxonomy) silently fall back to
|
|
210
|
+
// GenericRole inside `resolveRole`, which has `lifecycle.type='persistent'`
|
|
211
|
+
// and no system-prompt instruction to call `done()`. That breaks the
|
|
212
|
+
// mail-reply path because the worker stops without writing
|
|
213
|
+
// `_lastSummary`, so we end up logging "Worker stopped but _lastSummary
|
|
214
|
+
// is empty — no reply turn posted" and the hub never sees the answer.
|
|
215
|
+
//
|
|
216
|
+
// Use 'worker' as the fallback (ephemeral lifecycle + LIFECYCLE.DONE
|
|
217
|
+
// capability + system prompt that mandates `done()`) so unknown roles
|
|
218
|
+
// get a sensible default that completes the reply round-trip.
|
|
219
|
+
const requestedRole = data.role;
|
|
220
|
+
const roleRegistry = agentManager.getRoleRegistry?.();
|
|
221
|
+
const knownRole =
|
|
222
|
+
requestedRole && roleRegistry?.getRole(requestedRole) !== undefined;
|
|
223
|
+
const role = knownRole ? requestedRole! : "worker";
|
|
224
|
+
if (requestedRole && !knownRole) {
|
|
225
|
+
log(
|
|
226
|
+
`[mail-inbound] Unknown role '${requestedRole}' for taskId=${taskId} — falling back to 'worker'`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Loadout-derived structured fields ride in the envelope. We prefer the
|
|
231
|
+
// canonical top-level `data.loadout` slot (Step 3 of the ACP+lifecycle
|
|
232
|
+
// plan) but fall back to the legacy `data.metadata.permissions` shape
|
|
233
|
+
// for one deprecation cycle so older hubs that haven't rolled the new
|
|
234
|
+
// wire shape continue to work.
|
|
235
|
+
//
|
|
236
|
+
// `loadoutToSpawnOptions` is shared with the new `dispatch/spawn-agent`
|
|
237
|
+
// MAP handler so both wire paths produce identical spawn options.
|
|
238
|
+
//
|
|
239
|
+
// `fullAutonomous: true` because mail-inbound workers have no human in
|
|
240
|
+
// the loop to answer `ask` rules — collapse them to `allow` (vs. the
|
|
241
|
+
// safer `deny` default for spawns where a human might still be reached).
|
|
242
|
+
let wireLoadout: WireLoadout | undefined = data.loadout;
|
|
243
|
+
if (!wireLoadout) {
|
|
244
|
+
const legacyPermissions = data.metadata?.permissions as
|
|
245
|
+
| { allow?: string[]; deny?: string[]; ask?: string[] }
|
|
246
|
+
| undefined;
|
|
247
|
+
const legacyMcpProviders = data.metadata?.mcpProviders as
|
|
248
|
+
| WireLoadout["mcpProviders"]
|
|
249
|
+
| undefined;
|
|
250
|
+
if (legacyPermissions || legacyMcpProviders) {
|
|
251
|
+
wireLoadout = {
|
|
252
|
+
...(legacyPermissions ? { permissions: legacyPermissions } : {}),
|
|
253
|
+
...(legacyMcpProviders ? { mcpProviders: legacyMcpProviders } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const spawnLoadoutOpts = loadoutToSpawnOptions(wireLoadout, {
|
|
258
|
+
fullAutonomous: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
log(
|
|
262
|
+
`[mail-inbound] Received x-dispatch/work taskId=${taskId} ` +
|
|
263
|
+
`conv=${conversationId ?? "(none)"} role=${role}` +
|
|
264
|
+
(spawnLoadoutOpts.permissions
|
|
265
|
+
? ` permissions=${JSON.stringify(spawnLoadoutOpts.permissions)}`
|
|
266
|
+
: ""),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Spawn is async — fire and forget. Errors are logged, not thrown.
|
|
270
|
+
agentManager
|
|
271
|
+
.spawn({
|
|
272
|
+
task: prompt,
|
|
273
|
+
task_id: taskId,
|
|
274
|
+
role,
|
|
275
|
+
parent: null,
|
|
276
|
+
// Mail-inbound dispatch workers run sandboxed — strip the host's
|
|
277
|
+
// user-level Claude setting sources so installed plugin MCP servers
|
|
278
|
+
// (claude-code-swarm, oh-my-claudecode, …) don't auto-load and hang
|
|
279
|
+
// session/new on environments where the host services aren't reachable.
|
|
280
|
+
isolatedSettings: true,
|
|
281
|
+
...spawnLoadoutOpts,
|
|
282
|
+
})
|
|
283
|
+
.then(async (spawned) => {
|
|
284
|
+
log(
|
|
285
|
+
`[mail-inbound] Spawned worker agentId=${spawned.id} for taskId=${taskId}`,
|
|
286
|
+
);
|
|
287
|
+
if (conversationId) {
|
|
288
|
+
agentConversationMap.set(spawned.id, conversationId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Spawn only creates an idle ACP session — the task lives in the
|
|
292
|
+
// system prompt as instructions. To get the model to actually do
|
|
293
|
+
// the work, send the prompt as a user message via promptUntilDone.
|
|
294
|
+
// This drives the worker to completion (done() called) so the
|
|
295
|
+
// lifecycle stopped listener below fires and posts the reply
|
|
296
|
+
// back to the hub. Fire-and-forget; errors are logged.
|
|
297
|
+
try {
|
|
298
|
+
await agentManager.promptUntilDone(spawned.id, prompt, {
|
|
299
|
+
maxFollowUps: 0,
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
log(
|
|
303
|
+
`[mail-inbound] promptUntilDone failed for agentId=${spawned.id}: ` +
|
|
304
|
+
`${(err as Error).message ?? String(err)}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
.catch((err: unknown) => {
|
|
309
|
+
log(
|
|
310
|
+
`[mail-inbound] Spawn failed for taskId=${taskId}: ${
|
|
311
|
+
(err as Error).message ?? String(err)
|
|
312
|
+
}`,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
inboxEvents.on("inbox.message", onMessage);
|
|
318
|
+
|
|
319
|
+
// ── Lifecycle stopped listener ───────────────────────────────
|
|
320
|
+
const unsubscribeLifecycle = agentManager.onLifecycleEvent((event) => {
|
|
321
|
+
if (event.type !== "stopped") return;
|
|
322
|
+
|
|
323
|
+
const agentId = event.agent.id;
|
|
324
|
+
const conversationId = agentConversationMap.get(agentId);
|
|
325
|
+
if (!conversationId) return; // not a mail-inbound worker we spawned
|
|
326
|
+
|
|
327
|
+
agentConversationMap.delete(agentId);
|
|
328
|
+
|
|
329
|
+
// Read the summary stored by handlers-v2 for parentless workers.
|
|
330
|
+
const record = agentStore.getAgent(agentId);
|
|
331
|
+
const summary = record?.metadata?._lastSummary as string | undefined;
|
|
332
|
+
if (!summary) {
|
|
333
|
+
log(
|
|
334
|
+
`[mail-inbound] Worker agentId=${agentId} stopped but _lastSummary is empty — ` +
|
|
335
|
+
`no reply turn posted`,
|
|
336
|
+
);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log(
|
|
341
|
+
`[mail-inbound] Worker agentId=${agentId} stopped — posting reply to ` +
|
|
342
|
+
`conv=${conversationId}`,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const sidecar = getSidecar();
|
|
346
|
+
if (!sidecar?.postMailTurn) {
|
|
347
|
+
log(`[mail-inbound] No sidecar/postMailTurn — reply turn dropped`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
sidecar.postMailTurn(conversationId, agentId, summary)
|
|
352
|
+
.then(() => {
|
|
353
|
+
// Clear the stored summary so it can't replay if the same agentId
|
|
354
|
+
// is ever reused for another dispatch (the AgentManager generally
|
|
355
|
+
// mints fresh ids, but this is cheap insurance against a future
|
|
356
|
+
// change).
|
|
357
|
+
try {
|
|
358
|
+
const existingMeta = agentStore.getAgent(agentId)?.metadata ?? {};
|
|
359
|
+
const { _lastSummary: _drop, ...rest } = existingMeta as Record<string, unknown>;
|
|
360
|
+
void _drop;
|
|
361
|
+
agentStore.updateAgent(agentId, { metadata: rest });
|
|
362
|
+
} catch {
|
|
363
|
+
// best-effort — store may be closing during shutdown
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
.catch(() => {
|
|
367
|
+
// best-effort — hub may be temporarily unreachable
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ── Cleanup ──────────────────────────────────────────────────
|
|
372
|
+
let stopped = false;
|
|
373
|
+
return {
|
|
374
|
+
stop() {
|
|
375
|
+
if (stopped) return;
|
|
376
|
+
stopped = true;
|
|
377
|
+
try {
|
|
378
|
+
if (inboxEvents.off) {
|
|
379
|
+
inboxEvents.off("inbox.message", onMessage);
|
|
380
|
+
} else if (inboxEvents.removeListener) {
|
|
381
|
+
inboxEvents.removeListener("inbox.message", onMessage);
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
// best effort
|
|
385
|
+
}
|
|
386
|
+
unsubscribeLifecycle();
|
|
387
|
+
log(`[mail-inbound] Consumer stopped`);
|
|
388
|
+
},
|
|
389
|
+
stats() {
|
|
390
|
+
pruneSeenTaskIds();
|
|
391
|
+
return {
|
|
392
|
+
droppedMalformed: droppedMalformedCount,
|
|
393
|
+
seenTaskIds: seenTaskIds.size,
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|