evolcore 0.0.18 → 0.0.20

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 (51) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/claude-runner.js +4 -3
  5. package/dist/agents/codex-runner.js +25 -20
  6. package/dist/agents/ecagent-runner.js +3 -2
  7. package/dist/aun/aid/agentmd.js +7 -0
  8. package/dist/aun/msg/group.js +14 -3
  9. package/dist/aun/msg/p2p.js +21 -11
  10. package/dist/aun/outbox.js +144 -19
  11. package/dist/channels/aun.js +621 -211
  12. package/dist/cli/daemon-commands.js +41 -8
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/init.js +55 -15
  15. package/dist/cli/restart-monitor.js +3 -3
  16. package/dist/config/aun-gateway-config.js +2 -0
  17. package/dist/config/config-manager.js +92 -8
  18. package/dist/config/config-operation-service.js +1 -2
  19. package/dist/config/gateway-config.js +9 -7
  20. package/dist/config/lifecycle.js +16 -5
  21. package/dist/config-store.js +13 -6
  22. package/dist/core/auth/authorization-audit.js +5 -2
  23. package/dist/core/bootstrap-messages.js +2 -2
  24. package/dist/core/bootstrap-service.js +21 -36
  25. package/dist/core/channel-loader.js +0 -2
  26. package/dist/core/data-migration.js +10 -4
  27. package/dist/core/evolagent.js +5 -4
  28. package/dist/core/message/message-bridge.js +6 -11
  29. package/dist/core/message/response-engine.js +62 -6
  30. package/dist/core/permission/ec-command-parser.js +203 -24
  31. package/dist/core/permission/sandbox-runtime.js +46 -12
  32. package/dist/core/permission/tool-policy.js +116 -47
  33. package/dist/core/relation/peer-identity.js +18 -0
  34. package/dist/eck/kit-renderer.js +17 -8
  35. package/dist/index.js +30 -19
  36. package/dist/ipc.js +6 -1
  37. package/dist/paths.js +0 -3
  38. package/dist/utils/stats.js +52 -18
  39. package/dist/utils/welcome.js +2 -2
  40. package/kits/docs/path-registry.md +1 -1
  41. package/kits/rules/01-overview.md +1 -1
  42. package/kits/rules/02-navigation.md +2 -2
  43. package/kits/rules/03-identity.md +1 -1
  44. package/kits/rules/05-venue.md +1 -1
  45. package/kits/schemas/_meta.json +7 -4
  46. package/kits/schemas/agent-config.schema.10.json +2 -1
  47. package/kits/schemas/agent-config.schema.11.json +408 -0
  48. package/kits/schemas/daemon.schema.5.json +136 -0
  49. package/kits/schemas/defaults.schema.5.json +107 -0
  50. package/package.json +2 -1
  51. package/dist/core/message/pause-controller.js +0 -53
@@ -19,7 +19,7 @@ import { appendAidLifecycle } from '../aun/aid/identity.js';
19
19
  import { enableFullGroupPullPagination, getAidStore, loadClient, SLOT } from '../aun/aid/store.js';
20
20
  import { MAX_AUN_ATTACHMENT_SIZE, uploadBufferAndBuildPayload, uploadFileAndBuildPayload } from '../aun/msg/upload.js';
21
21
  import { loadAgent } from '../config-store.js';
22
- import { normalizeAgentLifecycle } from '../config/lifecycle.js';
22
+ import { resolveAgentLifecycle } from '../config/lifecycle.js';
23
23
  import { resolveEffective } from '../config/config-manager.js';
24
24
  import { isManagementRole } from '../config/builtin-roles.js';
25
25
  import { normalizeMentionMode } from '../config/mention-mode.js';
@@ -195,6 +195,211 @@ export function buildAunFilePayload(params) {
195
195
  }
196
196
  return payload;
197
197
  }
198
+ const AUN_PERMANENT_ERROR_CODES = new Set([
199
+ 400, 401, 403, 404,
200
+ 4000, 4001, 4010, 4030, 4040, 4090,
201
+ -32700, -32600, -32601, -32602,
202
+ -32001, -32002, -32003, -32004, -32005, -32008, -32009, -32011, -32013,
203
+ -32040, -32041, -32042, -32043, -32044, -32050, -32051,
204
+ -32100, -32101, -32102, -32103, -32104, -32105, -32150, -32152, -32153,
205
+ -32160, -32161, -32162, -32164, -32170, -32171, -32172, -32173, -32174, -32175,
206
+ -32176, -32177, -32178, -32179, -32185, -32186,
207
+ -32180, -32181, -32182, -32183, -32184,
208
+ -33001, -33004, -33005, -33006, -33007, -33008, -33009,
209
+ -33401, -33403, -33404, -33405,
210
+ ]);
211
+ const AUN_RETRYABLE_ERROR_CODES = new Set([
212
+ 429, 4290,
213
+ -32603,
214
+ -32010, -32029, -32429,
215
+ -32151, -32154, -32163,
216
+ -33402, -33406, -33407,
217
+ ]);
218
+ const AUN_ACCEPTED_DISPATCH_STATUSES = new Set([
219
+ 'debounced', 'dispatched', 'accepted', 'queued', 'queued_batch', 'broadcast', 'sent',
220
+ ]);
221
+ const AUN_PERMANENT_STRING_CODES = new Set([
222
+ 'invalid_params', 'invalid_param', 'invalid_request', 'permission_denied',
223
+ 'unauthorized', 'authentication_failed', 'not_found', 'group_not_found',
224
+ 'group_not_member', 'member_not_found', 'target_not_found', 'agent_not_found',
225
+ 'recipient_not_found', 'invalid_argument', 'invalid_payload', 'forbidden',
226
+ ]);
227
+ const AUN_RETRYABLE_STRING_CODES = new Set([
228
+ 'network_error', 'connection_error', 'timeout', 'timed_out', 'rate_limited',
229
+ 'too_many_requests', 'temporarily_unavailable', 'server_error', 'aun_not_connected',
230
+ ]);
231
+ function errorRecord(value) {
232
+ return value && typeof value === 'object' && !Array.isArray(value)
233
+ ? value
234
+ : undefined;
235
+ }
236
+ function firstErrorField(candidates, keys, accept) {
237
+ for (const candidate of candidates) {
238
+ for (const key of keys) {
239
+ const value = candidate[key];
240
+ if (value !== undefined && value !== null && value !== '' && (!accept || accept(value)))
241
+ return value;
242
+ }
243
+ }
244
+ return undefined;
245
+ }
246
+ function aunErrorDetails(value, fallback = 'AUN send failed') {
247
+ const root = errorRecord(value);
248
+ const nested = root
249
+ ? [root.error, root.data, root.details, root.cause]
250
+ .map(errorRecord)
251
+ .filter((candidate) => !!candidate)
252
+ : [];
253
+ const candidates = root ? [root, ...nested] : [];
254
+ const rawMessage = firstErrorField(candidates, [
255
+ 'message', 'error_message', 'errorMessage', 'error', 'reason', 'detail',
256
+ ], value => typeof value === 'string');
257
+ const message = typeof value === 'string'
258
+ ? value
259
+ : typeof rawMessage === 'string'
260
+ ? rawMessage
261
+ : value instanceof Error
262
+ ? value.message
263
+ : fallback;
264
+ const rawCode = firstErrorField(candidates, [
265
+ 'code', 'error_code', 'errorCode', 'stringCode', 'string_code',
266
+ ], value => typeof value === 'string' || typeof value === 'number');
267
+ const rawStatus = firstErrorField(candidates, ['status', 'statusCode', 'http_status', 'httpStatus'], value => (typeof value === 'number' && Number.isFinite(value))
268
+ || (typeof value === 'string' && /^\d+$/.test(value)));
269
+ const status = typeof rawStatus === 'number'
270
+ ? rawStatus
271
+ : typeof rawStatus === 'string' && /^\d+$/.test(rawStatus)
272
+ ? Number(rawStatus)
273
+ : undefined;
274
+ const retryableCandidate = candidates.find(candidate => Object.prototype.hasOwnProperty.call(candidate, 'retryable'));
275
+ const retryable = retryableCandidate && typeof retryableCandidate.retryable === 'boolean'
276
+ ? retryableCandidate.retryable
277
+ : undefined;
278
+ const name = firstErrorField(candidates, ['name', 'type'], value => typeof value === 'string');
279
+ const messageStatus = message.match(/\b(?:HTTP\b[^\d]{0,40}|status(?:\s*code)?\s*[:=]?\s*)(\d{3})\b/i);
280
+ const dispatchStatus = root?.message_dispatch && typeof root.message_dispatch === 'object'
281
+ ? root.message_dispatch.status
282
+ : undefined;
283
+ return {
284
+ code: typeof rawCode === 'string' || typeof rawCode === 'number' ? rawCode : undefined,
285
+ message: message || fallback,
286
+ name: typeof name === 'string' ? name : undefined,
287
+ retryable,
288
+ status: status ?? (messageStatus ? Number(messageStatus[1]) : undefined),
289
+ dispatchStatus: typeof dispatchStatus === 'string' ? dispatchStatus.toLowerCase() : undefined,
290
+ };
291
+ }
292
+ function numericErrorCode(code) {
293
+ if (typeof code === 'number' && Number.isFinite(code))
294
+ return code;
295
+ if (typeof code === 'string' && /^-?\d+$/.test(code.trim()))
296
+ return Number(code);
297
+ return undefined;
298
+ }
299
+ function normalizedErrorCode(code) {
300
+ return code === undefined ? '' : String(code).trim().toLowerCase().replace(/[\s-]+/g, '_');
301
+ }
302
+ function shortErrorMessage(message) {
303
+ const compact = message.replace(/\s+/g, ' ').trim();
304
+ return compact.length > 500 ? `${compact.slice(0, 497)}...` : compact;
305
+ }
306
+ /**
307
+ * Classify a gateway response before it reaches the durable queue. Protocol
308
+ * and HTTP semantics take precedence; the SDK's `retryable` bit is used when
309
+ * no known code or explicit rejection already determines the outcome.
310
+ */
311
+ export function classifyAunSendFailure(value, fallback = 'AUN send failed') {
312
+ const details = aunErrorDetails(value, fallback);
313
+ const numericCode = numericErrorCode(details.code);
314
+ const stringCode = normalizedErrorCode(details.code);
315
+ const text = `${details.name ?? ''} ${stringCode} ${details.message}`.toLowerCase();
316
+ const httpStatus = details.status
317
+ ?? (numericCode !== undefined && numericCode >= 100 && numericCode <= 599 ? numericCode : undefined);
318
+ const retryByName = /(?:timeout|connection|rate.?limit|temporar(?:y|ily)|unavailable|network|socket)/i.test(details.name ?? '');
319
+ const relayTargetMissing = text.includes('relay_target_not_found') || text.includes('relay target not found');
320
+ const rpcHandlerTimeout = numericCode === -32004 && /rpc handler timeout/i.test(text);
321
+ const groupStateCode = numericCode === -33002 || numericCode === -33003;
322
+ const groupClosed = groupStateCode && /\b(?:closed|dissolved|disbanded)\b|解散|已关闭/i.test(text);
323
+ const groupSuspended = groupStateCode && /\bsuspend(?:ed)?\b|暂停/i.test(text);
324
+ const transientHttpStatus = httpStatus === 408 || httpStatus === 425 || httpStatus === 429
325
+ || (httpStatus !== undefined && httpStatus >= 500 && httpStatus < 600);
326
+ const permanentHttpStatus = httpStatus !== undefined
327
+ && httpStatus >= 400 && httpStatus < 500
328
+ && !transientHttpStatus;
329
+ const permanentByText = !relayTargetMissing && /(?:group|peer|agent|recipient|target|member|object|task|stream)\s*(?:id\s*)?(?:not found|does not exist|不存在)|group[_ -]?not[_ -]?found|not[_ -]?a[_ -]?member|(?:permission|access|role).*(?:denied|forbidden|拒绝|无权限)|(?:invalid|malformed|bad)\s*(?:argument|param|request)|unauthori[sz]ed|authentication failed|signature invalid/i.test(text);
330
+ const retryByText = /timeout|timed out|temporar|unavailable|overload|rate.?limit|too many requests|try again|not connected|connection|network|socket|econn|eai_again|etimedout|epipe|broken pipe|connection refused|connect timeout|fetch failed|dns|reset by peer|gateway service degraded|upstream/i.test(text);
331
+ const acceptedDispatch = details.dispatchStatus !== undefined
332
+ && AUN_ACCEPTED_DISPATCH_STATUSES.has(details.dispatchStatus);
333
+ const rejectedDispatch = details.dispatchStatus === 'failed'
334
+ || details.dispatchStatus === 'skipped';
335
+ const successfulHttpWithoutReceipt = httpStatus !== undefined
336
+ && httpStatus >= 200 && httpStatus < 300
337
+ && numericCode === undefined && !permanentByText && !retryByText;
338
+ const ambiguousAccepted = errorRecord(value)?.ok === true
339
+ && numericCode === undefined
340
+ && httpStatus === undefined
341
+ && !permanentByText
342
+ && !retryByText;
343
+ const ambiguousNoReceipt = (value === null || value === undefined)
344
+ && /no message[_ -]?id|no receipt|empty response|returned no/i.test(fallback);
345
+ const missingReceipt = /no message[_ -]?id|missing message[_ -]?id|no receipt|empty response|returned no/i.test(fallback);
346
+ const unconfirmedDelivery = missingReceipt
347
+ && (acceptedDispatch || ambiguousAccepted || ambiguousNoReceipt || successfulHttpWithoutReceipt);
348
+ let status;
349
+ if ((AUN_PERMANENT_ERROR_CODES.has(numericCode ?? Number.NaN) && !rpcHandlerTimeout)
350
+ || permanentByText
351
+ || AUN_PERMANENT_STRING_CODES.has(stringCode)
352
+ || rejectedDispatch
353
+ || groupClosed
354
+ || (groupStateCode && !groupSuspended)
355
+ || permanentHttpStatus) {
356
+ status = 'permanent';
357
+ }
358
+ else if (unconfirmedDelivery) {
359
+ // The RPC completed with a success-looking result but without the protocol
360
+ // receipt. Reissuing it could duplicate a message because AUN send calls
361
+ // do not carry our local outbox operation ID as an idempotency key.
362
+ status = 'permanent';
363
+ }
364
+ else if (AUN_RETRYABLE_ERROR_CODES.has(numericCode ?? Number.NaN)
365
+ || transientHttpStatus
366
+ || rpcHandlerTimeout
367
+ || AUN_RETRYABLE_STRING_CODES.has(stringCode)
368
+ || retryByName
369
+ || relayTargetMissing
370
+ || groupSuspended
371
+ || retryByText) {
372
+ // Known transient protocol, HTTP, SDK-class and transport signals take
373
+ // precedence over the SDK's generic `retryable` default. Several SDK
374
+ // error classes default that flag to false even for connection failures.
375
+ status = 'retry';
376
+ }
377
+ else if (details.retryable === false) {
378
+ status = 'permanent';
379
+ }
380
+ else if (details.retryable === true) {
381
+ status = 'retry';
382
+ }
383
+ else {
384
+ // A response with no recognized transient signal is deterministic enough
385
+ // to stop retrying. This is deliberately fail-closed for durable sends.
386
+ status = 'permanent';
387
+ }
388
+ return {
389
+ status,
390
+ error: shortErrorMessage(details.message),
391
+ ...(details.code !== undefined
392
+ ? { code: details.code }
393
+ : details.status !== undefined
394
+ ? { code: details.status }
395
+ : missingReceipt
396
+ ? { code: 'MISSING_MESSAGE_ID' }
397
+ : {}),
398
+ };
399
+ }
400
+ function sentOutboxResult() {
401
+ return { status: 'sent' };
402
+ }
198
403
  function setIfDefined(target, key, value) {
199
404
  if (value !== undefined)
200
405
  target[key] = value;
@@ -1017,6 +1222,7 @@ export class AUNChannel {
1017
1222
  * the event handler boundary, before payload/slash/mention parsing.
1018
1223
  */
1019
1224
  inboundSeenMessages = new Map();
1225
+ invalidInboundEnvelopeLastLogAt = 0;
1020
1226
  groupNameCache = new Map(); // groupId → 群显示名(进程内缓存,群名极少变)
1021
1227
  peerInfoCache = new Map();
1022
1228
  messageSeqMap = new Map(); // messageId → seq (for ack/diagnostics)
@@ -1174,11 +1380,9 @@ export class AUNChannel {
1174
1380
  const aidName = this.config.aid;
1175
1381
  // encryptionSeed 由 getAidStore 内部解析(config / env / 'evol')
1176
1382
  // Migration from ~/.aun is handled by ensureDataDirs() at startup with a marker file.
1177
- // Gateway discovery/cache is owned by the SDK. Do not do a blocking
1178
- // well-known probe here: it prevents the SDK from using its token-store
1179
- // gateway metadata cache when well-known is temporarily unavailable.
1180
- const configuredGateway = this.config.gatewayUrl || '';
1181
- logger.info(`${this.logPrefix()} Initializing: aid=${aidName}, gateway=${configuredGateway || '<sdk-discovery>'}, aun_path=${aunPath}`);
1383
+ // Gateway discovery/cache is owned entirely by the SDK.
1384
+ this.gatewayUrl = '';
1385
+ logger.info(`${this.logPrefix()} Initializing: aid=${aidName}, gateway=<sdk-discovery>, aun_path=${aunPath}`);
1182
1386
  // 构造 AIDStore。daemon 使用独立 slot,CLI/netcheck 不能触碰业务入站游标。
1183
1387
  // encryptionSeed / rootCaPath 由 getAidStore 内部注入
1184
1388
  const store = await getAidStore({
@@ -1193,13 +1397,6 @@ export class AUNChannel {
1193
1397
  // A reconnect replaces the SDK client. Late events from the retired
1194
1398
  // instance must not mutate the new instance's health or message state.
1195
1399
  const isCurrentClient = () => this.client === client;
1196
- // fastaun gives a preset in-memory gateway precedence over its metadata
1197
- // cache and AID discovery. authenticate({ gateway }) is deliberately
1198
- // rejected by its public API, so set the documented client preset before
1199
- // authentication instead.
1200
- if (configuredGateway)
1201
- client._gatewayUrl = configuredGateway;
1202
- this.gatewayUrl = configuredGateway;
1203
1400
  // Register event handlers before connecting
1204
1401
  client.on('message.received', (data) => {
1205
1402
  if (!isCurrentClient())
@@ -1306,7 +1503,7 @@ export class AUNChannel {
1306
1503
  const auth = await client.authenticate();
1307
1504
  this.trace('OUT', 'auth.authenticate.ok', { aid: client.aid, gateway: auth?.gateway, hasToken: !!auth?.access_token });
1308
1505
  this.trace('IN', 'auth.result', { aid: client.aid, gateway: auth?.gateway, hasToken: !!auth?.access_token });
1309
- const resolvedGateway = String(auth?.gateway ?? this.gatewayUrl);
1506
+ const resolvedGateway = typeof auth?.gateway === 'string' ? auth.gateway : '';
1310
1507
  this.gatewayUrl = resolvedGateway;
1311
1508
  logger.info(`${this.logPrefix()} Authenticated as ${client.aid ?? '?'}, gateway=${resolvedGateway}`);
1312
1509
  }
@@ -1393,22 +1590,11 @@ export class AUNChannel {
1393
1590
  if (hasMessageLogOperation(chatDir, operationId))
1394
1591
  return true;
1395
1592
  const prepared = outbox.findByDedupeKey(aidName, operationId);
1396
- const initial = outbox.findByDedupeKey(aidName, bootstrapInitialMessageOperationId(aidName));
1397
- const initialDelivery = initial && outbox.isDeliveryForChannel(initial.delivery, owner)
1398
- ? initial.delivery
1399
- : undefined;
1400
- // During bootstrap completion the lifecycle is intentionally still
1401
- // non-active. Persisting the welcome must stay local and must not block
1402
- // on a best-effort group RPC before the lifecycle commit. If the initial
1403
- // bootstrap route is already known, preserve it; otherwise migration
1404
- // will resolve a group route after the channel is active.
1405
- const lifecycle = normalizeAgentLifecycle(agentConfig).lifecycle;
1406
- const ownerIsGroup = initialDelivery
1407
- ? initialDelivery.chatType === 'group'
1408
- : lifecycle === 'active' ? await this.isGroup(owner) : undefined;
1409
- const delivery = initialDelivery ?? (ownerIsGroup === true
1410
- ? { chatType: 'group', groupId: owner }
1411
- : { chatType: 'private' });
1593
+ // Bootstrap welcomes target the configured personal Owner AID. They are
1594
+ // not replies to an inbound group message, so their route is always
1595
+ // private and must not be inferred with group.get_info.
1596
+ const lifecycle = resolveAgentLifecycle(agentConfig);
1597
+ const delivery = { chatType: 'private' };
1412
1598
  if (prepared
1413
1599
  && prepared.channelId === owner
1414
1600
  && isDeliveryTarget(prepared.delivery)
@@ -1452,6 +1638,7 @@ export class AUNChannel {
1452
1638
  return this.reconcilePostBootstrapWelcome();
1453
1639
  }
1454
1640
  async hasPendingPostBootstrapWelcome() {
1641
+ this.repairBootstrapRoutes();
1455
1642
  const aid = this.config.aid.replace(/^@/, '');
1456
1643
  return hasPendingPostBootstrapWelcomeOutbox(aid);
1457
1644
  }
@@ -1467,6 +1654,7 @@ export class AUNChannel {
1467
1654
  * already-active agent from manufacturing another welcome message.
1468
1655
  */
1469
1656
  async reconcilePostBootstrapWelcome() {
1657
+ this.repairBootstrapRoutes();
1470
1658
  const configuredAid = this.config.aid;
1471
1659
  const aid = configuredAid.startsWith('@') ? configuredAid.slice(1) : configuredAid;
1472
1660
  const operationId = postBootstrapWelcomeOperationId(aid);
@@ -1484,15 +1672,17 @@ export class AUNChannel {
1484
1672
  return false;
1485
1673
  }
1486
1674
  const agentConfig = loadAgent(aid);
1487
- if (!agentConfig || normalizeAgentLifecycle(agentConfig).lifecycle !== 'active') {
1675
+ if (!agentConfig || resolveAgentLifecycle(agentConfig) !== 'active') {
1488
1676
  logger.info(`${this.logPrefix()} Post-bootstrap welcome prepared; waiting for lifecycle=active`);
1489
1677
  return true;
1490
1678
  }
1491
1679
  if (!this.connected || !this.client)
1492
1680
  return true;
1493
- const sent = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
1494
- if (sent)
1495
- outbox.remove(aid, entry.id);
1681
+ const sent = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
1682
+ if (sent.status === 'sent')
1683
+ this.removeDeliveredOutboxEntry(entry);
1684
+ else if (sent.status === 'permanent')
1685
+ this.markPermanentOutboxFailure(entry, sent);
1496
1686
  return true;
1497
1687
  }
1498
1688
  // ── Event handlers ──────────────────────────────────────────
@@ -1743,17 +1933,30 @@ export class AUNChannel {
1743
1933
  const msg = data;
1744
1934
  const receivedAt = Date.now();
1745
1935
  const receivedAtMono = performance.now();
1746
- // Claim before any payload extraction or command parsing. Retransmits may
1747
- // carry a new seq, but the application identity remains message_id.
1936
+ // Validate the authenticated envelope before any payload extraction or
1937
+ // command parsing. Retransmits may carry a new seq, but the application
1938
+ // identity remains message_id.
1748
1939
  const messageId = typeof msg.message_id === 'string' ? msg.message_id : '';
1749
1940
  const seq = typeof msg.seq === 'number' ? msg.seq : undefined;
1750
- if (!this.claimInboundMessage('private', messageId, seq))
1751
- return;
1752
1941
  // SDK 0.5.* 移除了顶层 from/to/group_id/encrypted 等别名,统一从 msg.envelope.* 读取。
1753
1942
  // message_id / seq / payload / same_* 等仍是顶层独立字段,不在 envelope 内。
1754
1943
  const env = (msg.envelope && typeof msg.envelope === 'object') ? msg.envelope : {};
1944
+ const fromAid = typeof env.from === 'string' ? env.from.trim() : '';
1945
+ if (!messageId || !fromAid) {
1946
+ this.acknowledgeImmediately(messageId, seq);
1947
+ const now = Date.now();
1948
+ if (now - this.invalidInboundEnvelopeLastLogAt >= 60_000) {
1949
+ this.invalidInboundEnvelopeLastLogAt = now;
1950
+ logger.warn(`${this.logPrefix()} Dropped private inbound: invalid envelope code=INVALID_INBOUND_ENVELOPE mid=${messageId || 'unknown'} from=${fromAid || 'unknown'}`);
1951
+ }
1952
+ else {
1953
+ logger.debug(`${this.logPrefix()} Dropped private inbound: invalid envelope (suppressed duplicate) mid=${messageId || 'unknown'}`);
1954
+ }
1955
+ return;
1956
+ }
1957
+ if (!this.claimInboundMessage('private', messageId, seq))
1958
+ return;
1755
1959
  const protectedHeaders = verifiedProtectedHeaders(msg, env);
1756
- const fromAid = env.from ?? '';
1757
1960
  const payload = msg.payload ?? '';
1758
1961
  const mentions = this.parsePayloadMentionsOrReject(payload, 'p2p.inbound', messageId, seq);
1759
1962
  if (!mentions)
@@ -2319,7 +2522,7 @@ export class AUNChannel {
2319
2522
  setObserverConfigResolver(fn) {
2320
2523
  this.observerConfigResolver = fn;
2321
2524
  }
2322
- /** 读取 observable 开关 + owners;无 resolver(如未接入 daemon)时视为关闭。 */
2525
+ /** 读取 observable 开关 + owners;未接入 daemon 时没有 owning Agent,保持关闭。 */
2323
2526
  getObserverConfig() {
2324
2527
  return this.observerConfigResolver?.() ?? { observable: false, owners: [] };
2325
2528
  }
@@ -2869,8 +3072,27 @@ export class AUNChannel {
2869
3072
  this.outboxInFlight.delete(entry.id);
2870
3073
  }
2871
3074
  }
3075
+ markPermanentOutboxFailure(entry, result) {
3076
+ const error = result.error ?? 'permanent send failure';
3077
+ const terminated = outbox.markTerminal(this.config.aid, entry.id, { error, code: result.code }, entry);
3078
+ if (terminated) {
3079
+ logger.error(`${this.logPrefix()} Permanent AUN delivery failure; outbox entry terminated: id=${entry.id} channel=${entry.channelId} code=${result.code ?? 'unknown'} error=${error}`);
3080
+ }
3081
+ else {
3082
+ logger.warn(`${this.logPrefix()} Ignored stale permanent delivery result after outbox route changed: id=${entry.id} submittedChannel=${entry.channelId}`);
3083
+ }
3084
+ }
3085
+ removeDeliveredOutboxEntry(entry) {
3086
+ if (!outbox.removeIfRouteMatches(this.config.aid, entry)) {
3087
+ logger.warn(`${this.logPrefix()} Preserved outbox entry whose route changed while delivery was in flight: id=${entry.id} submittedChannel=${entry.channelId}`);
3088
+ }
3089
+ }
2872
3090
  messageIdFromSendResult(result) {
2873
- return result?.message?.message_id ?? result?.message_id ?? null;
3091
+ const nested = result?.message?.message_id;
3092
+ if (typeof nested === 'string' && nested.trim())
3093
+ return nested;
3094
+ const direct = result?.message_id;
3095
+ return typeof direct === 'string' && direct.trim() ? direct : null;
2874
3096
  }
2875
3097
  /**
2876
3098
  * An AUN send result is only a gateway transport result. It is not a
@@ -3140,10 +3362,11 @@ export class AUNChannel {
3140
3362
  const detail = error instanceof Error ? error.message : String(error);
3141
3363
  const code = error?.code ?? 'INVALID_MENTION_SCHEMA';
3142
3364
  logger.error(`${this.logPrefix()} Dropped durable AUN payload: invalid payload.mentions (${detail}) code=${code} channel=${channelId}`);
3143
- return { ok: false, discarded: true };
3365
+ return { ok: false, status: 'permanent', error: detail, code };
3366
+ }
3367
+ if (!this.client || !this.connected) {
3368
+ return { ok: false, status: 'retry', error: 'AUN channel is not connected', code: 'AUN_NOT_CONNECTED' };
3144
3369
  }
3145
- if (!this.client || !this.connected)
3146
- return { ok: false };
3147
3370
  const isGroup = delivery.chatType === 'group';
3148
3371
  const targetAid = channelId;
3149
3372
  const encryptTarget = isGroup ? channelId : targetAid;
@@ -3171,10 +3394,11 @@ export class AUNChannel {
3171
3394
  : await this.callAndTrace(method, sendParams);
3172
3395
  const mid = this.messageIdFromSendResult(result);
3173
3396
  if (!mid) {
3174
- logger.warn(`${this.logPrefix()} ${method}${fallback ? ' fallback' : ''} (${label}) returned no message_id: ${JSON.stringify(result)}`);
3175
- return { ok: false, result, encrypt: !!sendParams.encrypt };
3397
+ const failure = classifyAunSendFailure(result, `${method} (${label}) returned no message_id`);
3398
+ logger.warn(`${this.logPrefix()} ${method}${fallback ? ' fallback' : ''} (${label}) returned no message_id: ${JSON.stringify(result)}; disposition=${failure.status}`);
3399
+ return { ok: false, status: failure.status, error: failure.error, code: failure.code, result, encrypt: !!sendParams.encrypt };
3176
3400
  }
3177
- return { ok: true, messageId: mid, result, encrypt: !!sendParams.encrypt };
3401
+ return { ok: true, status: 'sent', messageId: mid, result, encrypt: !!sendParams.encrypt };
3178
3402
  };
3179
3403
  try {
3180
3404
  return await callOnce(params, false);
@@ -3187,18 +3411,22 @@ export class AUNChannel {
3187
3411
  try {
3188
3412
  this.trace('OUT', `${method}.${label}.fallback`, fallbackParams);
3189
3413
  const sent = await callOnce(fallbackParams, true);
3190
- this.trace('OUT', `${method}.${label}.fallback.${sent.ok ? 'ok' : 'missing_id'}`, { message_id: sent.messageId });
3414
+ this.trace('OUT', `${method}.${label}.fallback.${sent.ok ? 'ok' : 'missing_id'}`, sent.ok
3415
+ ? { message_id: sent.messageId }
3416
+ : { disposition: sent.status, code: sent.code });
3191
3417
  return sent;
3192
3418
  }
3193
3419
  catch (e2) {
3194
3420
  this.trace('OUT', `${method}.${label}.fallback.error`, { channelId, error: String(e2) });
3195
- logger.error(`${this.logPrefix()} Plaintext ${label} fallback also failed to ${channelId}: ${e2}`);
3196
- return { ok: false };
3421
+ const failure = classifyAunSendFailure(e2, `${method} plaintext fallback failed`);
3422
+ logger.error(`${this.logPrefix()} Plaintext ${label} fallback also failed to ${channelId}: ${failure.error}; disposition=${failure.status}`);
3423
+ return { ok: false, status: failure.status, error: failure.error, code: failure.code };
3197
3424
  }
3198
3425
  }
3199
3426
  this.trace('OUT', `${method}.${label}.error`, { channelId, error: String(e) });
3200
- logger.error(`${this.logPrefix()} ${label} send failed to ${channelId}: ${e}`);
3201
- return { ok: false };
3427
+ const failure = classifyAunSendFailure(e, `${method} failed`);
3428
+ logger.error(`${this.logPrefix()} ${label} send failed to ${channelId}: ${failure.error}; disposition=${failure.status}${failure.code === undefined ? '' : ` code=${failure.code}`}`);
3429
+ return { ok: false, status: failure.status, error: failure.error, code: failure.code };
3202
3430
  }
3203
3431
  }
3204
3432
  recordDurableOutbound(channelId, payload, messageId, encrypt, context, isGroup, contentKind, logText, result, statsContext) {
@@ -3270,11 +3498,19 @@ export class AUNChannel {
3270
3498
  }
3271
3499
  return { queued: true };
3272
3500
  }
3273
- const result = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
3501
+ const result = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false, status: 'retry', error: 'outbox send is already in flight' });
3274
3502
  if (result.ok) {
3275
- outbox.remove(this.config.aid, entry.id);
3503
+ this.removeDeliveredOutboxEntry(entry);
3276
3504
  return { messageId: result.messageId };
3277
3505
  }
3506
+ if (result.status === 'permanent') {
3507
+ this.markPermanentOutboxFailure(entry, result);
3508
+ return {
3509
+ status: 'permanent',
3510
+ ...(result.error !== undefined ? { error: result.error } : {}),
3511
+ ...(result.code !== undefined ? { code: result.code } : {}),
3512
+ };
3513
+ }
3278
3514
  return { queued: true };
3279
3515
  }
3280
3516
  buildTaskPayloadBase(envelope, context) {
@@ -3462,6 +3698,10 @@ export class AUNChannel {
3462
3698
  ? routedContext.metadata.outboxTtl
3463
3699
  : undefined,
3464
3700
  });
3701
+ if (entry.terminal) {
3702
+ logger.warn(`${this.logPrefix()} Skipping previously terminated durable operation: operation=${operationId ?? '<none>'} entry=${entry.id} code=${entry.lastErrorCode ?? 'unknown'}`);
3703
+ return;
3704
+ }
3465
3705
  logger.debug(`${this.logPrefix()} Outbox enqueued: id=${entry.id} channel=${channelId} text=${finalText.slice(0, 40)}`);
3466
3706
  // 积压深度告警:outbox 待发条目累积说明发送速度跟不上,回复将出现明显延迟。
3467
3707
  const backlog = outbox.pendingCount(this.config.aid);
@@ -3476,9 +3716,12 @@ export class AUNChannel {
3476
3716
  return;
3477
3717
  }
3478
3718
  // Attempt immediate delivery
3479
- const ok = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
3480
- if (ok) {
3481
- outbox.remove(this.config.aid, entry.id);
3719
+ const result = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
3720
+ if (result.status === 'sent') {
3721
+ this.removeDeliveredOutboxEntry(entry);
3722
+ }
3723
+ else if (result.status === 'permanent') {
3724
+ this.markPermanentOutboxFailure(entry, result);
3482
3725
  }
3483
3726
  }
3484
3727
  /** Daemon-side transport for `ec msg send` running inside an agent task. */
@@ -3724,7 +3967,7 @@ export class AUNChannel {
3724
3967
  ttl: 300_000,
3725
3968
  };
3726
3969
  const ok = await this.deliverTextEntry(echoEntry);
3727
- if (!ok) {
3970
+ if (ok.status !== 'sent' && ok.status !== 'permanent') {
3728
3971
  outbox.enqueue(this.config.aid, {
3729
3972
  channelId,
3730
3973
  delivery,
@@ -3733,7 +3976,7 @@ export class AUNChannel {
3733
3976
  context: this.withDelivery(echo.context, delivery),
3734
3977
  });
3735
3978
  }
3736
- logger.info(`${this.logPrefix()} [Echo] long echo trace delivered=${ok} to ${channelId} (agent ${agentDuration}ms)`);
3979
+ logger.info(`${this.logPrefix()} [Echo] long echo trace status=${ok.status} code=${ok.code ?? 'none'} to=${channelId} agentDurationMs=${agentDuration}`);
3737
3980
  }
3738
3981
  else {
3739
3982
  outbox.enqueue(this.config.aid, {
@@ -3751,6 +3994,10 @@ export class AUNChannel {
3751
3994
  }
3752
3995
  async deliverTextEntry(entry) {
3753
3996
  const channelId = entry.channelId;
3997
+ if (typeof entry.text !== 'string' || !entry.text.trim()) {
3998
+ logger.warn(`${this.logPrefix()} deliverTextEntry: missing or empty text (outbox id=${entry.id})`);
3999
+ return { status: 'permanent', error: 'durable text is missing or empty', code: 'MISSING_TEXT' };
4000
+ }
3754
4001
  const finalText = entry.text;
3755
4002
  const delivery = this.requirePersistedDelivery(channelId, entry.delivery);
3756
4003
  const context = this.withDelivery(entry.context, delivery);
@@ -3760,12 +4007,13 @@ export class AUNChannel {
3760
4007
  const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', channelId, this.config.aid);
3761
4008
  if (hasMessageLogOperation(chatDir, operationId)) {
3762
4009
  logger.info(`${this.logPrefix()} Durable operation already logged; skipping duplicate send: ${operationId}`);
3763
- return true;
4010
+ return sentOutboxResult();
3764
4011
  }
3765
4012
  if (operationId === postBootstrapWelcomeOperationId(this.config.aid)) {
3766
4013
  const agentConfig = loadAgent(this.config.aid);
3767
- if (!agentConfig || normalizeAgentLifecycle(agentConfig).lifecycle !== 'active')
3768
- return false;
4014
+ if (!agentConfig || resolveAgentLifecycle(agentConfig) !== 'active') {
4015
+ return { status: 'retry', error: 'post-bootstrap welcome is waiting for active lifecycle', code: 'BOOTSTRAP_NOT_ACTIVE' };
4016
+ }
3769
4017
  }
3770
4018
  }
3771
4019
  // 从 context.metadata.source 读取 source,默认为 'daemon'
@@ -3796,7 +4044,7 @@ export class AUNChannel {
3796
4044
  logger.info(`${this.logPrefix()} deliverTextEntry: channelId=${channelId} thread_id=${payload.thread_id ?? 'none'} task_id=${payload.task_id ?? 'none'} chatmode=${payload.chatmode ?? 'none'} source=${source} textLen=${finalText.length}`);
3797
4045
  const isGroup = delivery.chatType === 'group';
3798
4046
  const targetAid = channelId;
3799
- if (operationId && entry.deliveryReceipt) {
4047
+ if (entry.deliveryReceipt) {
3800
4048
  this.appendOutboundJsonl(channelId, {
3801
4049
  ...classifyAunPayloadForLog(payload),
3802
4050
  msgId: entry.deliveryReceipt.messageId,
@@ -3806,7 +4054,7 @@ export class AUNChannel {
3806
4054
  source,
3807
4055
  transport: entry.deliveryReceipt.transport,
3808
4056
  });
3809
- return true;
4057
+ return sentOutboxResult();
3810
4058
  }
3811
4059
  const encryptTarget = isGroup ? channelId : targetAid;
3812
4060
  const encrypt = context?.metadata?.encrypted != null
@@ -3819,16 +4067,19 @@ export class AUNChannel {
3819
4067
  if (isGroup) {
3820
4068
  params.group_id = channelId;
3821
4069
  const result = await this.callAndTrace('group.send', params);
3822
- const mid = result?.message?.message_id ?? result?.message_id ?? null;
4070
+ const mid = this.messageIdFromSendResult(result);
3823
4071
  if (!mid) {
3824
- const dispatchStatus = result?.message_dispatch?.status;
3825
- if (dispatchStatus === 'debounced' || dispatchStatus === 'dispatched') {
3826
- logger.warn(`${this.logPrefix()} group.send returned ${dispatchStatus} without message_id; keeping outbox entry: group=${channelId} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
4072
+ const failure = classifyAunSendFailure(result, 'group.send returned no message_id');
4073
+ const dispatchStatus = typeof result?.message_dispatch?.status === 'string'
4074
+ ? result.message_dispatch.status.toLowerCase()
4075
+ : undefined;
4076
+ if (dispatchStatus && AUN_ACCEPTED_DISPATCH_STATUSES.has(dispatchStatus)) {
4077
+ logger.warn(`${this.logPrefix()} group.send returned ${dispatchStatus} without message_id: disposition=${failure.status} code=${failure.code ?? 'unknown'} group=${channelId}`);
3827
4078
  }
3828
4079
  else {
3829
- logger.warn(`${this.logPrefix()} group.send returned no message_id: ${JSON.stringify(result)}`);
4080
+ logger.warn(`${this.logPrefix()} group.send returned no message_id: disposition=${failure.status} code=${failure.code ?? 'unknown'} group=${channelId} dispatch=${dispatchStatus ?? 'unknown'}`);
3830
4081
  }
3831
- return false;
4082
+ return failure;
3832
4083
  }
3833
4084
  else {
3834
4085
  this.logAunSendAccepted('group.send', channelId, mid, encrypt, result, finalText);
@@ -3847,34 +4098,40 @@ export class AUNChannel {
3847
4098
  else {
3848
4099
  params.to = targetAid;
3849
4100
  const result = await this.callAndTrace('message.send', params);
3850
- if (!result || !result.message_id) {
4101
+ const mid = this.messageIdFromSendResult(result);
4102
+ if (!mid) {
3851
4103
  logger.warn(`${this.logPrefix()} message.send returned no message_id: ${JSON.stringify(result)}`);
3852
- return false;
4104
+ return classifyAunSendFailure(result, 'message.send returned no message_id');
3853
4105
  }
3854
4106
  else {
3855
- this.logAunSendAccepted('message.send', this.peerLabel(targetAid), result.message_id, encrypt, result, finalText);
3856
- this.checkpointTextDelivery(entry, result.message_id, encrypt, result);
4107
+ this.logAunSendAccepted('message.send', this.peerLabel(targetAid), mid, encrypt, result, finalText);
4108
+ this.checkpointTextDelivery(entry, mid, encrypt, result);
3857
4109
  const causation = normalizeCausation(context?.metadata?.causation);
3858
4110
  if (causation) {
3859
- registerAunCausation(result.message_id, this.config.aid, targetAid, causation);
4111
+ registerAunCausation(mid, this.config.aid, targetAid, causation);
3860
4112
  recordCausationSpan(causation, 'message.outbound', {
3861
4113
  status: 'completed',
3862
- refs: { messageId: result.message_id, taskId: context?.metadata?.taskId },
4114
+ refs: { messageId: mid, taskId: context?.metadata?.taskId },
3863
4115
  });
3864
4116
  }
3865
- appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: result.message_id, kind: 'text', len: finalText.length });
4117
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: mid, kind: 'text', len: finalText.length });
3866
4118
  this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode, 'send');
3867
4119
  this.appendOutboundJsonl(targetAid, {
3868
- ...classifyAunPayloadForLog(payload), msgId: result.message_id, encrypt, context, isGroup: false, source,
4120
+ ...classifyAunPayloadForLog(payload), msgId: mid, encrypt, context, isGroup: false, source,
3869
4121
  transport: this.sendReceiptFromResult(result),
3870
4122
  });
3871
4123
  // Observer forward: outbound (private) — 原样转发 SDK SendResult(含 envelope + payload)
3872
4124
  this.forwardOutbound(result);
3873
4125
  }
3874
4126
  }
3875
- return true;
4127
+ return sentOutboxResult();
3876
4128
  }
3877
4129
  catch (e) {
4130
+ if (entry.deliveryReceipt) {
4131
+ const error = e instanceof Error ? e.message : String(e);
4132
+ logger.error(`${this.logPrefix()} AUN accepted the durable text but local post-send processing failed; retaining receipt for recovery: id=${entry.id} error=${error}`);
4133
+ return { status: 'retry', error, code: 'OUTBOX_POST_SEND_FAILED' };
4134
+ }
3878
4135
  if (encrypt && e instanceof E2EEError) {
3879
4136
  this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
3880
4137
  logger.warn(`${this.logPrefix()} E2EE send failed to ${channelId}, retrying plaintext: ${e}`);
@@ -3884,11 +4141,14 @@ export class AUNChannel {
3884
4141
  this.trace('OUT', 'group.send.fallback', params);
3885
4142
  const result = await this.client.call('group.send', params);
3886
4143
  const mid = this.messageIdFromSendResult(result);
3887
- this.trace('OUT', 'group.send.fallback.ok', { message_id: mid });
3888
4144
  if (!mid) {
4145
+ const resultRecord = errorRecord(result);
4146
+ const dispatch = errorRecord(resultRecord?.message_dispatch)?.status;
4147
+ this.trace('OUT', 'group.send.fallback.missing_id', { dispatch });
3889
4148
  logger.warn(`${this.logPrefix()} group.send fallback returned no message_id: ${JSON.stringify(result)}`);
3890
- return false;
4149
+ return classifyAunSendFailure(result, 'group.send plaintext fallback returned no message_id');
3891
4150
  }
4151
+ this.trace('OUT', 'group.send.fallback.ok', { message_id: mid });
3892
4152
  this.checkpointTextDelivery(entry, mid, false, result);
3893
4153
  appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
3894
4154
  const statsContext = await this.groupStatsContext(channelId);
@@ -3902,12 +4162,13 @@ export class AUNChannel {
3902
4162
  else {
3903
4163
  this.trace('OUT', 'message.send.fallback', params);
3904
4164
  const result = await this.client.call('message.send', params);
3905
- const mid = result?.message_id;
3906
- this.trace('OUT', 'message.send.fallback.ok', { message_id: mid });
3907
- if (!result || !mid) {
4165
+ const mid = this.messageIdFromSendResult(result);
4166
+ if (!mid) {
4167
+ this.trace('OUT', 'message.send.fallback.missing_id', {});
3908
4168
  logger.warn(`${this.logPrefix()} message.send fallback returned no message_id: ${JSON.stringify(result)}`);
3909
- return false;
4169
+ return classifyAunSendFailure(result, 'message.send plaintext fallback returned no message_id');
3910
4170
  }
4171
+ this.trace('OUT', 'message.send.fallback.ok', { message_id: mid });
3911
4172
  this.checkpointTextDelivery(entry, mid, false, result);
3912
4173
  const causation = normalizeCausation(context?.metadata?.causation);
3913
4174
  if (causation) {
@@ -3925,31 +4186,48 @@ export class AUNChannel {
3925
4186
  });
3926
4187
  this.forwardOutbound(result);
3927
4188
  }
3928
- return true;
4189
+ return sentOutboxResult();
3929
4190
  }
3930
4191
  catch (e2) {
4192
+ if (entry.deliveryReceipt) {
4193
+ const error = e2 instanceof Error ? e2.message : String(e2);
4194
+ logger.error(`${this.logPrefix()} AUN accepted the plaintext fallback but local post-send processing failed; retaining receipt for recovery: id=${entry.id} error=${error}`);
4195
+ return { status: 'retry', error, code: 'OUTBOX_POST_SEND_FAILED' };
4196
+ }
3931
4197
  this.trace('OUT', 'send.fallback.error', { channelId, error: String(e2) });
3932
4198
  logger.error(`${this.logPrefix()} Plaintext fallback also failed to ${channelId}: ${e2}`);
3933
- return false;
4199
+ return classifyAunSendFailure(e2, `plaintext send fallback failed to ${channelId}`);
3934
4200
  }
3935
4201
  }
3936
4202
  else {
3937
4203
  this.trace('OUT', 'send.error', { channelId, error: String(e) });
3938
4204
  logger.error(`${this.logPrefix()} Send failed to ${channelId} (outbox id=${entry.id}): ${e}`);
3939
- return false;
4205
+ return classifyAunSendFailure(e, `send failed to ${channelId}`);
3940
4206
  }
3941
4207
  }
3942
4208
  }
3943
4209
  checkpointTextDelivery(entry, messageId, encrypt, result) {
3944
- if (!entry.dedupeKey)
3945
- return;
3946
- entry.deliveryReceipt = {
4210
+ this.checkpointDurableDelivery(entry, messageId, encrypt, result);
4211
+ }
4212
+ checkpointDurableDelivery(entry, messageId, encrypt, result) {
4213
+ const submittedRoute = {
4214
+ id: entry.id,
4215
+ channelId: entry.channelId,
4216
+ delivery: entry.delivery,
4217
+ };
4218
+ const receipt = {
3947
4219
  messageId,
3948
4220
  encrypt,
3949
4221
  transport: this.sendReceiptFromResult(result),
3950
4222
  };
3951
- if (!outbox.replace(this.config.aid, entry)) {
3952
- logger.warn(`${this.logPrefix()} Failed to checkpoint durable operation receipt: ${entry.dedupeKey}`);
4223
+ const replaced = outbox.updateDeliveryReceiptIfRouteMatches(this.config.aid, submittedRoute, receipt);
4224
+ if (replaced === 'replaced') {
4225
+ entry.deliveryReceipt = receipt;
4226
+ return true;
4227
+ }
4228
+ else {
4229
+ logger.warn(`${this.logPrefix()} Did not checkpoint durable delivery receipt: entry=${entry.id} operation=${entry.dedupeKey ?? 'none'} reason=${replaced}`);
4230
+ return false;
3953
4231
  }
3954
4232
  }
3955
4233
  async deliverPayloadEntry(entry) {
@@ -3958,32 +4236,71 @@ export class AUNChannel {
3958
4236
  : undefined;
3959
4237
  if (interactionId && this.invalidatedInteractions.has(interactionId)) {
3960
4238
  logger.info(`${this.logPrefix()} Discarded invalidated interaction from durable outbox: request=${interactionId} entry=${entry.id}`);
3961
- return { ok: true };
4239
+ return { ok: true, status: 'sent' };
3962
4240
  }
3963
4241
  const channelId = entry.channelId;
3964
4242
  const payload = entry.payload;
3965
4243
  if (!payload) {
3966
4244
  logger.warn(`${this.logPrefix()} deliverPayloadEntry: missing payload (outbox id=${entry.id})`);
3967
- return { ok: true };
4245
+ return { ok: false, status: 'permanent', error: 'durable payload is missing', code: 'MISSING_PAYLOAD' };
3968
4246
  }
3969
4247
  const contentKind = entry.contentKind;
3970
4248
  const logText = entry.logText ?? this.payloadLogText(payload, contentKind);
3971
4249
  const delivery = this.requirePersistedDelivery(channelId, entry.delivery);
3972
4250
  const context = this.withDelivery(entry.context, delivery);
3973
4251
  logger.info(`${this.logPrefix()} deliverPayloadEntry: id=${entry.id} kind=${contentKind ?? payload.type ?? 'payload'} channelId=${channelId} thread_id=${payload.thread_id ?? 'none'} task_id=${payload.task_id ?? 'none'} textLen=${logText.length}`);
3974
- const sent = await this.sendAunPayload(channelId, payload, context, `${contentKind ?? payload.type ?? 'payload'}`);
3975
- if (sent.discarded) {
3976
- logger.warn(`${this.logPrefix()} Removed malformed durable payload from outbox: id=${entry.id} channel=${channelId}`);
3977
- return { ok: true, discarded: true };
4252
+ const isGroup = delivery.chatType === 'group';
4253
+ const source = context?.metadata?.source ?? 'daemon';
4254
+ if (entry.deliveryReceipt) {
4255
+ try {
4256
+ this.appendOutboundJsonl(channelId, {
4257
+ ...classifyAunPayloadForLog(payload),
4258
+ msgId: entry.deliveryReceipt.messageId,
4259
+ encrypt: entry.deliveryReceipt.encrypt,
4260
+ context,
4261
+ isGroup,
4262
+ source,
4263
+ transport: entry.deliveryReceipt.transport,
4264
+ });
4265
+ this.runPostSend(entry, entry.deliveryReceipt.messageId);
4266
+ return {
4267
+ ok: true,
4268
+ status: 'sent',
4269
+ messageId: entry.deliveryReceipt.messageId,
4270
+ encrypt: entry.deliveryReceipt.encrypt,
4271
+ };
4272
+ }
4273
+ catch (error) {
4274
+ const detail = error instanceof Error ? error.message : String(error);
4275
+ logger.error(`${this.logPrefix()} Failed to recover local post-send state from durable payload receipt: id=${entry.id} error=${detail}`);
4276
+ return { ok: false, status: 'retry', error: detail, code: 'OUTBOX_POST_SEND_FAILED' };
4277
+ }
3978
4278
  }
4279
+ const sent = await this.sendAunPayload(channelId, payload, context, `${contentKind ?? payload.type ?? 'payload'}`);
3979
4280
  if (!sent.ok || !sent.messageId)
3980
4281
  return sent;
3981
- const isGroup = delivery.chatType === 'group';
3982
- const statsContext = isGroup ? await this.groupStatsContext(channelId) : undefined;
3983
- this.logAunSendAccepted(isGroup ? 'group.send' : 'message.send', isGroup ? channelId : this.peerLabel(channelId), sent.messageId, !!sent.encrypt, sent.result, logText);
3984
- this.recordDurableOutbound(channelId, payload, sent.messageId, !!sent.encrypt, context, isGroup, contentKind, logText, sent.result, statsContext);
3985
- this.runPostSend(entry, sent.messageId);
3986
- return sent;
4282
+ try {
4283
+ this.checkpointDurableDelivery(entry, sent.messageId, !!sent.encrypt, sent.result);
4284
+ }
4285
+ catch (error) {
4286
+ const detail = error instanceof Error ? error.message : String(error);
4287
+ logger.error(`${this.logPrefix()} AUN accepted the durable payload but its receipt could not be persisted: id=${entry.id} mid=${sent.messageId} error=${detail}`);
4288
+ return { ok: false, status: 'permanent', error: detail, code: 'OUTBOX_RECEIPT_CHECKPOINT_FAILED' };
4289
+ }
4290
+ try {
4291
+ const statsContext = isGroup ? await this.groupStatsContext(channelId) : undefined;
4292
+ this.logAunSendAccepted(isGroup ? 'group.send' : 'message.send', isGroup ? channelId : this.peerLabel(channelId), sent.messageId, !!sent.encrypt, sent.result, logText);
4293
+ this.recordDurableOutbound(channelId, payload, sent.messageId, !!sent.encrypt, context, isGroup, contentKind, logText, sent.result, statsContext);
4294
+ this.runPostSend(entry, sent.messageId);
4295
+ return sent;
4296
+ }
4297
+ catch (error) {
4298
+ const detail = error instanceof Error ? error.message : String(error);
4299
+ logger.error(`${this.logPrefix()} AUN accepted the durable payload but local post-send processing failed: id=${entry.id} mid=${sent.messageId} error=${detail}`);
4300
+ return entry.deliveryReceipt
4301
+ ? { ok: false, status: 'retry', error: detail, code: 'OUTBOX_POST_SEND_FAILED' }
4302
+ : { ok: false, status: 'permanent', error: detail, code: 'OUTBOX_POST_SEND_UNCHECKPOINTED' };
4303
+ }
3987
4304
  }
3988
4305
  /** 有效会话正文写入 messages.jsonl(message.send/group.send 成功后调用)。 */
3989
4306
  appendOutboundJsonl(channelId, descriptor) {
@@ -4127,16 +4444,22 @@ export class AUNChannel {
4127
4444
  logger.debug(`${this.logPrefix()} thought.put failed to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
4128
4445
  }
4129
4446
  }
4130
- /** 发送结构化 AUN payload;transient 协议载荷不写本地 messages.jsonl。 */
4131
- async sendStructured(channelId, payload, context) {
4447
+ /** Send a transient structured payload and preserve the gateway disposition. */
4448
+ async sendStructuredResult(channelId, payload, context) {
4132
4449
  const delivery = this.requireDelivery(channelId, context);
4133
4450
  context = this.withDelivery(context, delivery);
4134
4451
  // Validate before checking connection state so malformed producer payloads
4135
4452
  // are reported deterministically instead of being hidden by a disconnect.
4136
4453
  const validatedPayload = this.normalizeAunPayloadMentions(payload);
4137
4454
  const finalPayload = this.stripUndefinedDeep(validatedPayload);
4138
- if (!this.connected || !this.client)
4139
- return null;
4455
+ if (!this.connected || !this.client) {
4456
+ return {
4457
+ ok: false,
4458
+ status: 'retry',
4459
+ error: 'AUN channel is not connected',
4460
+ code: 'AUN_NOT_CONNECTED',
4461
+ };
4462
+ }
4140
4463
  const isGroup = delivery.chatType === 'group';
4141
4464
  const targetAid = channelId;
4142
4465
  const encryptTarget = isGroup ? channelId : targetAid;
@@ -4165,27 +4488,54 @@ export class AUNChannel {
4165
4488
  if (isGroup) {
4166
4489
  params.group_id = delivery.groupId;
4167
4490
  const result = await this.callAndTrace('group.send', params);
4168
- const mid = result?.message?.message_id ?? result?.message_id ?? null;
4491
+ const mid = this.messageIdFromSendResult(result);
4492
+ if (!mid) {
4493
+ const failure = classifyAunSendFailure(result, 'group.send returned no message_id');
4494
+ logger.warn(`${this.logPrefix()} group.send (${payload.type}) returned no message_id; disposition=${failure.status}`);
4495
+ return { ok: false, ...failure };
4496
+ }
4169
4497
  logger.info(`${this.logPrefix()} group.send (${payload.type}) ok: group=${channelId} mid=${mid} encrypt=${encrypt}`);
4170
4498
  if (!isMenuPayload)
4171
4499
  this.forwardOutbound(result);
4172
- return mid;
4500
+ return { ok: true, messageId: mid };
4173
4501
  }
4174
4502
  else {
4175
4503
  params.to = targetAid;
4176
4504
  const result = await this.callAndTrace('message.send', params);
4177
- logger.info(`${this.logPrefix()} message.send (${payload.type}) ok: to=${this.peerLabel(targetAid)} mid=${result?.message_id} encrypt=${encrypt}`);
4505
+ const mid = this.messageIdFromSendResult(result);
4506
+ if (!mid) {
4507
+ const failure = classifyAunSendFailure(result, 'message.send returned no message_id');
4508
+ logger.warn(`${this.logPrefix()} message.send (${payload.type}) returned no message_id; disposition=${failure.status}`);
4509
+ return { ok: false, ...failure };
4510
+ }
4511
+ logger.info(`${this.logPrefix()} message.send (${payload.type}) ok: to=${this.peerLabel(targetAid)} mid=${mid} encrypt=${encrypt}`);
4178
4512
  if (!isMenuPayload)
4179
4513
  this.forwardOutbound(result);
4180
- return result?.message_id ?? null;
4514
+ return { ok: true, messageId: mid };
4181
4515
  }
4182
4516
  }
4183
4517
  catch (e) {
4184
- const err = e;
4185
- logger.warn(`${this.logPrefix()} sendStructured failed (${payload.type}) to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
4186
- return null;
4518
+ const failure = classifyAunSendFailure(e, `structured send failed to ${channelId}`);
4519
+ logger.warn(`${this.logPrefix()} sendStructured failed (${payload.type}) to ${channelId}: disposition=${failure.status} code=${failure.code ?? 'unknown'} error=${failure.error}`);
4520
+ return { ok: false, ...failure };
4187
4521
  }
4188
4522
  }
4523
+ /** Compatibility API for optional transient payloads. */
4524
+ async sendStructured(channelId, payload, context) {
4525
+ const result = await this.sendStructuredResult(channelId, payload, context);
4526
+ return result.ok ? result.messageId : null;
4527
+ }
4528
+ /** Strict API for callers that must not report a failed transport as sent. */
4529
+ async sendStructuredOrThrow(channelId, payload, context) {
4530
+ const result = await this.sendStructuredResult(channelId, payload, context);
4531
+ if (result.ok)
4532
+ return result.messageId;
4533
+ throw Object.assign(new Error(result.error), {
4534
+ name: 'AUNSendError',
4535
+ code: result.code ?? 'AUN_SEND_FAILED',
4536
+ retryable: result.status === 'retry',
4537
+ });
4538
+ }
4189
4539
  async sendFile(channelId, filePath, context) {
4190
4540
  const delivery = this.requireDelivery(channelId, context);
4191
4541
  context = this.withDelivery(context, delivery);
@@ -4208,7 +4558,9 @@ export class AUNChannel {
4208
4558
  channelId,
4209
4559
  delivery,
4210
4560
  type: 'file',
4561
+ contentKind: 'file',
4211
4562
  filePath: absPath,
4563
+ logText: `📎 ${path.basename(absPath)} (${formatSize(stat.size)})`,
4212
4564
  context,
4213
4565
  });
4214
4566
  logger.debug(`${this.logPrefix()} Outbox enqueued file: id=${entry.id} channel=${channelId} file=${absPath}`);
@@ -4219,9 +4571,12 @@ export class AUNChannel {
4219
4571
  }
4220
4572
  return;
4221
4573
  }
4222
- const ok = await this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
4223
- if (ok) {
4224
- outbox.remove(this.config.aid, entry.id);
4574
+ const result = await this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
4575
+ if (result.status === 'sent') {
4576
+ this.removeDeliveredOutboxEntry(entry);
4577
+ }
4578
+ else if (result.status === 'permanent') {
4579
+ this.markPermanentOutboxFailure(entry, result);
4225
4580
  }
4226
4581
  }
4227
4582
  async sendImage(channelId, data, mimeType, alt, context) {
@@ -4263,9 +4618,11 @@ export class AUNChannel {
4263
4618
  }
4264
4619
  return;
4265
4620
  }
4266
- const ok = await this.withOutboxInFlight(entry, () => this.deliverImageEntry(entry), false);
4267
- if (ok)
4268
- outbox.remove(this.config.aid, entry.id);
4621
+ const result = await this.withOutboxInFlight(entry, () => this.deliverImageEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
4622
+ if (result.status === 'sent')
4623
+ this.removeDeliveredOutboxEntry(entry);
4624
+ else if (result.status === 'permanent')
4625
+ this.markPermanentOutboxFailure(entry, result);
4269
4626
  }
4270
4627
  async buildUploadedImagePayload(data, mimeType, alt) {
4271
4628
  if (!this.connected || !this.client) {
@@ -4305,7 +4662,7 @@ export class AUNChannel {
4305
4662
  const image = entry.image;
4306
4663
  if (!image || typeof image.dataBase64 !== 'string') {
4307
4664
  logger.warn(`${this.logPrefix()} deliverImageEntry: missing image data (outbox id=${entry.id})`);
4308
- return true;
4665
+ return { status: 'permanent', error: 'durable image data is missing', code: 'MISSING_IMAGE_DATA' };
4309
4666
  }
4310
4667
  const delivery = this.requirePersistedDelivery(entry.channelId, entry.delivery);
4311
4668
  entry.delivery = delivery;
@@ -4313,43 +4670,75 @@ export class AUNChannel {
4313
4670
  const data = Buffer.from(image.dataBase64, 'base64');
4314
4671
  if (data.length === 0) {
4315
4672
  logger.warn(`${this.logPrefix()} deliverImageEntry: invalid image data (outbox id=${entry.id})`);
4316
- return true;
4673
+ return { status: 'permanent', error: 'durable image data is invalid', code: 'INVALID_IMAGE_DATA' };
4317
4674
  }
4318
- const imagePayload = await this.buildUploadedImagePayload(data, image.mimeType, image.alt);
4319
- entry.type = 'payload';
4320
- entry.contentKind = 'image';
4321
- entry.payload = this.applyReplyContextToPayload(imagePayload, entry.context);
4322
- entry.logText ??= image.alt ? `[image] ${image.alt}` : '[image]';
4323
- delete entry.image;
4324
- // Persist the attachment reference before message.send. A failed send then
4325
- // retries the compact wire payload without re-uploading the same image.
4326
- let payloadEntry = entry;
4327
- if (!outbox.replace(this.config.aid, entry)) {
4328
- payloadEntry = outbox.enqueue(this.config.aid, {
4675
+ try {
4676
+ const imagePayload = await this.buildUploadedImagePayload(data, image.mimeType, image.alt);
4677
+ entry.type = 'payload';
4678
+ entry.contentKind = 'image';
4679
+ entry.payload = this.applyReplyContextToPayload(imagePayload, entry.context);
4680
+ entry.logText ??= image.alt ? `[image] ${image.alt}` : '[image]';
4681
+ delete entry.image;
4682
+ // Persist the attachment reference before message.send. A failed send then
4683
+ // retries the compact wire payload without re-uploading the same image.
4684
+ const submittedRoute = {
4685
+ id: entry.id,
4329
4686
  channelId: entry.channelId,
4330
4687
  delivery,
4331
- type: 'payload',
4332
- contentKind: 'image',
4333
- payload: entry.payload,
4334
- context: entry.context,
4335
- logText: entry.logText,
4336
- ttl: entry.ttl,
4337
- });
4688
+ };
4689
+ const replaced = outbox.replaceIfRouteMatches(this.config.aid, submittedRoute, entry);
4690
+ if (replaced === 'route-changed') {
4691
+ logger.warn(`${this.logPrefix()} Image route changed during upload; preserving original outbox entry for the corrected route: id=${entry.id}`);
4692
+ return { status: 'retry', error: 'image delivery route changed during upload', code: 'OUTBOX_ROUTE_CHANGED' };
4693
+ }
4694
+ if (replaced === 'missing') {
4695
+ logger.warn(`${this.logPrefix()} Image outbox entry disappeared during upload; skipping send: id=${entry.id}`);
4696
+ return { status: 'permanent', error: 'image outbox entry disappeared during upload', code: 'OUTBOX_ENTRY_MISSING' };
4697
+ }
4698
+ const sent = await this.deliverPayloadEntry(entry);
4699
+ return sent.ok ? sentOutboxResult() : sent;
4338
4700
  }
4339
- const sent = await this.deliverPayloadEntry(payloadEntry);
4340
- if (sent.ok && payloadEntry.id !== entry.id) {
4341
- outbox.remove(this.config.aid, payloadEntry.id);
4701
+ catch (error) {
4702
+ const failure = classifyAunSendFailure(error, 'image upload or send failed');
4703
+ logger.error(`${this.logPrefix()} Image delivery failed (outbox id=${entry.id}): ${failure.error}; disposition=${failure.status}`);
4704
+ return failure;
4342
4705
  }
4343
- return sent.ok;
4344
4706
  }
4345
4707
  async deliverFileEntry(entry) {
4346
4708
  const channelId = entry.channelId;
4709
+ if (typeof entry.filePath !== 'string' || !entry.filePath.trim()) {
4710
+ logger.warn(`${this.logPrefix()} deliverFileEntry: missing file path (outbox id=${entry.id})`);
4711
+ return { status: 'permanent', error: 'durable file path is missing', code: 'MISSING_FILE_PATH' };
4712
+ }
4347
4713
  const absPath = entry.filePath;
4348
4714
  const delivery = this.requirePersistedDelivery(channelId, entry.delivery);
4349
4715
  const context = this.withDelivery(entry.context, delivery);
4716
+ const isGroup = delivery.chatType === 'group';
4717
+ const source = context?.metadata?.source ?? 'daemon';
4718
+ if (entry.deliveryReceipt) {
4719
+ const filename = path.basename(absPath);
4720
+ const logText = entry.logText ?? `[file] ${filename}`;
4721
+ try {
4722
+ this.appendOutboundJsonl(channelId, {
4723
+ ...classifyAunPayloadForLog({ type: 'file', text: logText }),
4724
+ msgId: entry.deliveryReceipt.messageId,
4725
+ encrypt: entry.deliveryReceipt.encrypt,
4726
+ context,
4727
+ isGroup,
4728
+ source,
4729
+ transport: entry.deliveryReceipt.transport,
4730
+ });
4731
+ return sentOutboxResult();
4732
+ }
4733
+ catch (error) {
4734
+ const detail = error instanceof Error ? error.message : String(error);
4735
+ logger.error(`${this.logPrefix()} Failed to recover local file post-send state from durable receipt: id=${entry.id} error=${detail}`);
4736
+ return { status: 'retry', error: detail, code: 'OUTBOX_POST_SEND_FAILED' };
4737
+ }
4738
+ }
4350
4739
  if (!fs.existsSync(absPath)) {
4351
4740
  logger.warn(`${this.logPrefix()} deliverFileEntry: file gone: ${absPath}`);
4352
- return true; // remove from outbox, file no longer exists
4741
+ return { status: 'permanent', error: 'file no longer exists', code: 'FILE_NOT_FOUND' };
4353
4742
  }
4354
4743
  const filename = path.basename(absPath);
4355
4744
  const fileData = fs.readFileSync(absPath);
@@ -4404,7 +4793,6 @@ export class AUNChannel {
4404
4793
  attachment,
4405
4794
  context,
4406
4795
  });
4407
- const isGroup = delivery.chatType === 'group';
4408
4796
  const fileTargetAid = channelId;
4409
4797
  const encryptTarget = isGroup ? channelId : fileTargetAid;
4410
4798
  const encrypt = context?.metadata?.encrypted != null
@@ -4419,12 +4807,12 @@ export class AUNChannel {
4419
4807
  this.trace('OUT', 'group.send.file', params);
4420
4808
  const result = await this.client.call('group.send', params);
4421
4809
  sendResult = result;
4422
- const fileMid = result?.message?.message_id ?? result?.message_id;
4810
+ const fileMid = this.messageIdFromSendResult(result);
4423
4811
  sentMid = fileMid ?? null;
4424
4812
  this.trace('OUT', 'group.send.file.ok', { message_id: fileMid });
4425
4813
  if (!fileMid) {
4426
4814
  logger.warn(`${this.logPrefix()} group.send.file returned no message_id: ${JSON.stringify(result)}`);
4427
- return false;
4815
+ return classifyAunSendFailure(result, 'group.send.file returned no message_id');
4428
4816
  }
4429
4817
  }
4430
4818
  else {
@@ -4432,11 +4820,11 @@ export class AUNChannel {
4432
4820
  this.trace('OUT', 'message.send.file', params);
4433
4821
  const result = await this.client.call('message.send', params);
4434
4822
  sendResult = result;
4435
- sentMid = result?.message_id ?? null;
4823
+ sentMid = this.messageIdFromSendResult(result);
4436
4824
  this.trace('OUT', 'message.send.file.ok', { message_id: sentMid });
4437
- if (!result || !sentMid) {
4825
+ if (!sentMid) {
4438
4826
  logger.warn(`${this.logPrefix()} message.send.file returned no message_id: ${JSON.stringify(result)}`);
4439
- return false;
4827
+ return classifyAunSendFailure(result, 'message.send.file returned no message_id');
4440
4828
  }
4441
4829
  }
4442
4830
  }
@@ -4453,23 +4841,23 @@ export class AUNChannel {
4453
4841
  this.trace('OUT', 'group.send.file.fallback', params);
4454
4842
  const result = await this.client.call('group.send', params);
4455
4843
  sendResult = result;
4456
- const fbMid = result?.message?.message_id ?? result?.message_id;
4844
+ const fbMid = this.messageIdFromSendResult(result);
4457
4845
  sentMid = fbMid ?? null;
4458
4846
  this.trace('OUT', 'group.send.file.fallback.ok', { message_id: fbMid });
4459
4847
  if (!fbMid) {
4460
4848
  logger.warn(`${this.logPrefix()} group.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
4461
- return false;
4849
+ return classifyAunSendFailure(result, 'group.send.file plaintext fallback returned no message_id');
4462
4850
  }
4463
4851
  }
4464
4852
  else {
4465
4853
  this.trace('OUT', 'message.send.file.fallback', params);
4466
4854
  const result = await this.client.call('message.send', params);
4467
4855
  sendResult = result;
4468
- sentMid = result?.message_id ?? null;
4856
+ sentMid = this.messageIdFromSendResult(result);
4469
4857
  this.trace('OUT', 'message.send.file.fallback.ok', { message_id: sentMid });
4470
- if (!result || !sentMid) {
4858
+ if (!sentMid) {
4471
4859
  logger.warn(`${this.logPrefix()} message.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
4472
- return false;
4860
+ return classifyAunSendFailure(result, 'message.send.file plaintext fallback returned no message_id');
4473
4861
  }
4474
4862
  }
4475
4863
  }
@@ -4477,26 +4865,40 @@ export class AUNChannel {
4477
4865
  throw sendErr;
4478
4866
  }
4479
4867
  }
4480
- logger.info(`${this.logPrefix()} File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
4481
- if (sentMid) {
4482
- const fileText = filePayload.text;
4483
- appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: sentMid, kind: 'file', len: fileText.length, ...(isGroup && { groupId: channelId }) });
4484
- const statsContext = isGroup ? await this.groupStatsContext(channelId) : undefined;
4485
- this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(fileText, 'utf-8'), fileText, false, !!params.encrypt, context?.metadata?.chatmode, 'send', statsContext);
4486
- const source = context?.metadata?.source ?? 'daemon';
4487
- this.appendOutboundJsonl(channelId, {
4488
- ...classifyAunPayloadForLog(filePayload), msgId: sentMid, encrypt: !!params.encrypt,
4489
- context, isGroup, source,
4490
- });
4868
+ if (!sentMid) {
4869
+ return { status: 'permanent', error: 'file send returned no message_id', code: 'MISSING_MESSAGE_ID' };
4870
+ }
4871
+ try {
4872
+ this.checkpointDurableDelivery(entry, sentMid, !!params.encrypt, sendResult);
4873
+ }
4874
+ catch (error) {
4875
+ const detail = error instanceof Error ? error.message : String(error);
4876
+ logger.error(`${this.logPrefix()} AUN accepted the durable file but its receipt could not be persisted: id=${entry.id} mid=${sentMid} error=${detail}`);
4877
+ return { status: 'permanent', error: detail, code: 'OUTBOX_RECEIPT_CHECKPOINT_FAILED' };
4491
4878
  }
4879
+ logger.info(`${this.logPrefix()} File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
4880
+ const fileText = filePayload.text;
4881
+ appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: sentMid, kind: 'file', len: fileText.length, ...(isGroup && { groupId: channelId }) });
4882
+ const statsContext = isGroup ? await this.groupStatsContext(channelId) : undefined;
4883
+ this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(fileText, 'utf-8'), fileText, false, !!params.encrypt, context?.metadata?.chatmode, 'send', statsContext);
4884
+ this.appendOutboundJsonl(channelId, {
4885
+ ...classifyAunPayloadForLog(filePayload), msgId: sentMid, encrypt: !!params.encrypt,
4886
+ context, isGroup, source, transport: this.sendReceiptFromResult(sendResult),
4887
+ });
4492
4888
  if (sendResult)
4493
4889
  this.forwardOutbound(sendResult);
4494
- return true;
4890
+ return sentOutboxResult();
4495
4891
  }
4496
4892
  catch (e) {
4893
+ if (entry.deliveryReceipt) {
4894
+ const detail = e instanceof Error ? e.message : String(e);
4895
+ logger.error(`${this.logPrefix()} AUN accepted the durable file but local post-send processing failed; retaining receipt for recovery: id=${entry.id} error=${detail}`);
4896
+ return { status: 'retry', error: detail, code: 'OUTBOX_POST_SEND_FAILED' };
4897
+ }
4497
4898
  this.trace('OUT', 'sendFile.error', { channelId, filePath: absPath, error: String(e) });
4498
- logger.error(`${this.logPrefix()} sendFile failed for ${channelId} (outbox id=${entry.id}): ${e}`);
4499
- return false;
4899
+ const failure = classifyAunSendFailure(e, `sendFile failed for ${channelId}`);
4900
+ logger.error(`${this.logPrefix()} sendFile failed for ${channelId} (outbox id=${entry.id}): ${failure.error}; disposition=${failure.status}`);
4901
+ return failure;
4500
4902
  }
4501
4903
  }
4502
4904
  // ── Outbox drain ───────────────────────────────────────────
@@ -4519,58 +4921,80 @@ export class AUNChannel {
4519
4921
  async drainOutbox() {
4520
4922
  if (!this.connected || !this.client)
4521
4923
  return;
4522
- await this.migrateLegacyBootstrapRoutes();
4924
+ this.repairBootstrapRoutes();
4523
4925
  if (!outbox.hasPending(this.config.aid))
4524
4926
  return;
4525
4927
  logger.info(`${this.logPrefix()} Draining outbox...`);
4526
4928
  const result = await outbox.drain(this.config.aid, async (entry) => {
4527
4929
  if (!isDeliveryTarget(entry.delivery)) {
4528
4930
  logger.warn(`${this.logPrefix()} Discarding legacy/malformed outbox entry without a trusted delivery route: id=${entry.id} channel=${entry.channelId}`);
4529
- return true;
4931
+ return {
4932
+ status: 'permanent',
4933
+ error: 'outbox entry has no valid delivery route',
4934
+ code: 'AUN_OUTBOUND_ROUTE_REQUIRED',
4935
+ };
4530
4936
  }
4531
4937
  if (entry.type === 'text') {
4532
- return this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
4938
+ return this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
4533
4939
  }
4534
4940
  else if (entry.type === 'file') {
4535
- return this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
4941
+ return this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
4536
4942
  }
4537
4943
  else if (entry.type === 'image') {
4538
- return this.withOutboxInFlight(entry, () => this.deliverImageEntry(entry), false);
4944
+ return this.withOutboxInFlight(entry, () => this.deliverImageEntry(entry), { status: 'retry', error: 'outbox send is already in flight' });
4539
4945
  }
4540
4946
  else if (entry.type === 'payload') {
4541
- const sent = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
4542
- return sent.ok;
4947
+ const sent = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false, status: 'retry', error: 'outbox send is already in flight' });
4948
+ return sent.ok ? { status: 'sent' } : sent;
4543
4949
  }
4544
- return true; // unknown type, discard
4950
+ return { status: 'permanent', error: `unsupported outbox entry type: ${entry.type}`, code: 'UNSUPPORTED_OUTBOX_TYPE' };
4545
4951
  });
4546
- if (result.sent > 0 || result.expired > 0) {
4547
- logger.info(`${this.logPrefix()} Outbox drained: sent=${result.sent} expired=${result.expired} failed=${result.failed}`);
4952
+ if (result.sent > 0 || result.expired > 0 || result.permanent) {
4953
+ logger.info(`${this.logPrefix()} Outbox drained: sent=${result.sent} expired=${result.expired} failed=${result.failed} permanent=${result.permanent ?? 0}`);
4548
4954
  }
4549
4955
  }
4550
- /** Repair pre-route bootstrap entries once the AUN group endpoint is available. */
4551
- async migrateLegacyBootstrapRoutes() {
4956
+ /** Repair bootstrap outbox entries to the configured personal Owner route. */
4957
+ repairBootstrapRoutes() {
4958
+ const aid = this.config.aid.replace(/^@/, '');
4959
+ const owner = getFirstStaticAgentOwner(aid);
4960
+ if (!owner)
4961
+ return;
4552
4962
  const operationIds = new Set([
4553
- bootstrapInitialMessageOperationId(this.config.aid),
4554
- postBootstrapWelcomeOperationId(this.config.aid),
4963
+ bootstrapInitialMessageOperationId(aid),
4964
+ postBootstrapWelcomeOperationId(aid),
4555
4965
  ]);
4556
- const entries = outbox.load(this.config.aid).filter(entry => typeof entry.dedupeKey === 'string'
4557
- && operationIds.has(entry.dedupeKey)
4558
- && entry.channelId
4559
- && entry.delivery?.chatType === 'private');
4966
+ const entries = [...operationIds]
4967
+ .map(operationId => outbox.findByDedupeKey(aid, operationId, { includeTerminal: true }))
4968
+ .filter((entry) => !!entry?.channelId);
4560
4969
  for (const entry of entries) {
4561
- const group = await this.isGroup(entry.channelId);
4562
- if (group !== true)
4970
+ const delivery = { chatType: 'private' };
4971
+ const nestedDelivery = entry.context?.delivery;
4972
+ const routeAlreadyPrivate = entry.channelId === owner
4973
+ && entry.delivery?.chatType === 'private'
4974
+ && (nestedDelivery === undefined || nestedDelivery?.chatType === 'private');
4975
+ if (routeAlreadyPrivate)
4563
4976
  continue;
4564
- const delivery = { chatType: 'group', groupId: entry.channelId };
4565
- if (!outbox.replace(this.config.aid, {
4977
+ const repaired = {
4566
4978
  ...entry,
4979
+ channelId: owner,
4567
4980
  delivery,
4568
4981
  context: entry.context
4569
4982
  ? { ...entry.context, delivery }
4570
4983
  : entry.context,
4571
- }))
4984
+ };
4985
+ // A receipt is scoped to the original transport target. Never treat a
4986
+ // send accepted for a stale group/private address as proof that the
4987
+ // corrected Owner route was delivered.
4988
+ if (entry.channelId !== owner || entry.delivery?.chatType !== 'private') {
4989
+ delete repaired.deliveryReceipt;
4990
+ }
4991
+ delete repaired.terminal;
4992
+ delete repaired.lastError;
4993
+ delete repaired.lastErrorCode;
4994
+ delete repaired.attempts;
4995
+ if (outbox.replaceIfRouteMatches(aid, entry, repaired) !== 'replaced')
4572
4996
  continue;
4573
- logger.warn(`${this.logPrefix()} Migrated legacy bootstrap outbox route to group.send: entry=${entry.id} group=${entry.channelId}`);
4997
+ logger.warn(`${this.logPrefix()} Repaired bootstrap outbox route to private Owner delivery: entry=${entry.id} owner=${owner}`);
4574
4998
  }
4575
4999
  }
4576
5000
  acknowledge(messageId) {
@@ -4673,7 +5097,7 @@ export class AUNChannel {
4673
5097
  catch {
4674
5098
  payloadObj = { text: payload };
4675
5099
  }
4676
- await this.sendStructured(channelId, payloadObj, context);
5100
+ await this.sendStructuredOrThrow(channelId, payloadObj, context);
4677
5101
  }
4678
5102
  async disconnect() {
4679
5103
  this.intentionalDisconnect = true;
@@ -4839,18 +5263,6 @@ export class AUNChannel {
4839
5263
  return undefined; // 不写缓存,下次仍可重试
4840
5264
  }
4841
5265
  }
4842
- /** Query the authoritative group endpoint without guessing from an AID string. */
4843
- async isGroup(groupId) {
4844
- if (!groupId || !this.client)
4845
- return undefined;
4846
- try {
4847
- const result = await this.callAndTrace('group.get_info', { group_id: groupId });
4848
- return !!(result?.group || result?.group_id || result?.groupId);
4849
- }
4850
- catch {
4851
- return undefined;
4852
- }
4853
- }
4854
5266
  /** 统计关系键继续使用群 ID,仅为智能体预览补充可读群名。 */
4855
5267
  async groupStatsContext(groupId) {
4856
5268
  return {
@@ -4895,7 +5307,6 @@ export class AUNChannelPlugin {
4895
5307
  const channel = new AUNChannel({
4896
5308
  aid,
4897
5309
  keystorePath: inst.keystorePath,
4898
- gatewayUrl: inst.gatewayUrl,
4899
5310
  defaultEncrypt: ctx.aunDefaultEncrypt,
4900
5311
  accessToken: inst.accessToken,
4901
5312
  flushDelay: inst.flushDelay,
@@ -5133,7 +5544,6 @@ export class AUNChannelPlugin {
5133
5544
  uploadAgentMd: (content) => channel.uploadAgentMd(content),
5134
5545
  downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
5135
5546
  getGroupName: (groupId) => channel.getGroupName(groupId),
5136
- isGroup: (groupId) => channel.isGroup(groupId),
5137
5547
  getGroupMemberRole: (groupId, aid) => channel.getGroupMemberRole(groupId, aid),
5138
5548
  _selfAid: () => channel.getStatus().aid,
5139
5549
  _selfName: () => channel.getSelfName(),