evolcore 0.0.19 → 0.0.21

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 (136) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +60 -9
  3. package/bin/install-codex-managed-hooks.mjs +61 -0
  4. package/dist/agents/baseagent.js +10 -6
  5. package/dist/agents/claude-runner.js +383 -111
  6. package/dist/agents/codex-app-server-client.js +10 -2
  7. package/dist/agents/codex-runner.js +405 -137
  8. package/dist/agents/ecagent-runner.js +174 -63
  9. package/dist/agents/gemini-runner.js +130 -30
  10. package/dist/agents/request-identity.js +25 -0
  11. package/dist/agents/runner-types.js +19 -0
  12. package/dist/aun/aid/agentmd.js +66 -2
  13. package/dist/aun/aid/identity.js +4 -1
  14. package/dist/aun/aid/index.js +1 -1
  15. package/dist/aun/msg/group.js +86 -9
  16. package/dist/aun/msg/history.js +213 -36
  17. package/dist/aun/msg/managed-operation.js +58 -9
  18. package/dist/aun/msg/p2p.js +26 -11
  19. package/dist/aun/outbox.js +295 -68
  20. package/dist/aun/service-proxy.js +43 -25
  21. package/dist/channels/aun.js +1004 -273
  22. package/dist/channels/daemon.js +6 -1
  23. package/dist/cli/agent-command.js +4 -3
  24. package/dist/cli/agent.js +66 -56
  25. package/dist/cli/aun-commands.js +177 -42
  26. package/dist/cli/command-log.js +10 -11
  27. package/dist/cli/contact.js +1 -0
  28. package/dist/cli/daemon-commands.js +81 -121
  29. package/dist/cli/index.js +1 -0
  30. package/dist/cli/init.js +76 -24
  31. package/dist/cli/restart-monitor.js +3 -3
  32. package/dist/cli/task-context.js +46 -0
  33. package/dist/cli/trigger-command.js +1 -1
  34. package/dist/cli/watch-logs.js +10 -3
  35. package/dist/config/builtin-roles.js +1 -0
  36. package/dist/config/config-field-policy.js +16 -5
  37. package/dist/config/config-manager.js +226 -24
  38. package/dist/config/config-operation-service.js +1 -2
  39. package/dist/config/contact-operation-service.js +32 -1
  40. package/dist/config/contact-request-service.js +44 -0
  41. package/dist/config/daemon-services.js +186 -0
  42. package/dist/config/gateway-config.js +29 -16
  43. package/dist/config/lifecycle.js +16 -5
  44. package/dist/config/role-service.js +54 -3
  45. package/dist/config/schema-migration.js +550 -0
  46. package/dist/config-store.js +161 -12
  47. package/dist/core/agent-application-service.js +279 -0
  48. package/dist/core/audit/log-integrity.js +102 -0
  49. package/dist/core/auth/agent-delegation.js +31 -1
  50. package/dist/core/auth/auth-gateway.js +33 -4
  51. package/dist/core/auth/authorization-audit.js +155 -4
  52. package/dist/core/auth/operation-authorizer.js +41 -1
  53. package/dist/core/auth/operation-catalog.js +9 -1
  54. package/dist/core/bootstrap-messages.js +2 -2
  55. package/dist/core/bootstrap-service.js +27 -38
  56. package/dist/core/causation/aun-association.js +7 -4
  57. package/dist/core/channel-loader.js +0 -2
  58. package/dist/core/command/agent-control.js +56 -16
  59. package/dist/core/command/command-handler.js +290 -44
  60. package/dist/core/command/connect-menu.js +3 -4
  61. package/dist/core/command/group-menu.js +5 -7
  62. package/dist/core/command/menu-handler.js +279 -80
  63. package/dist/core/command/role-menu.js +21 -11
  64. package/dist/core/command/slash-gate.js +85 -18
  65. package/dist/core/command/slash-handler.js +350 -32
  66. package/dist/core/event-catalog.js +5 -0
  67. package/dist/core/evolagent.js +9 -4
  68. package/dist/core/handoff/dispatcher.js +4 -0
  69. package/dist/core/handoff/runtime.js +10 -0
  70. package/dist/core/handoff/store.js +32 -9
  71. package/dist/core/inference/text-inference.js +7 -15
  72. package/dist/core/message/im-renderer.js +83 -84
  73. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  74. package/dist/core/message/message-bridge.js +124 -10
  75. package/dist/core/message/message-log.js +14 -7
  76. package/dist/core/message/message-queue.js +206 -16
  77. package/dist/core/message/message-utils.js +12 -5
  78. package/dist/core/message/response-engine.js +495 -72
  79. package/dist/core/message/send-receipt.js +1 -0
  80. package/dist/core/message/stream-debouncer.js +9 -2
  81. package/dist/core/model/model-catalog.js +23 -15
  82. package/dist/core/model/model-diagnostics.js +28 -10
  83. package/dist/core/permission/approval-gateway.js +180 -6
  84. package/dist/core/permission/ec-command-parser.js +465 -56
  85. package/dist/core/permission/mode.js +18 -3
  86. package/dist/core/{protected-paths.js → permission/protected-paths.js} +17 -5
  87. package/dist/core/permission/readonly-shell-query.js +263 -9
  88. package/dist/core/permission/sandbox-runtime.js +205 -13
  89. package/dist/core/permission/tool-policy.js +673 -62
  90. package/dist/core/relation/peer-identity.js +18 -0
  91. package/dist/core/session/session-fs-store.js +154 -5
  92. package/dist/core/session/session-manager.js +299 -30
  93. package/dist/core/session/session-renew.js +19 -12
  94. package/dist/core/session/session-turn-coordinator.js +11 -4
  95. package/dist/eck/kit-renderer.js +18 -9
  96. package/dist/index.js +283 -65
  97. package/dist/ipc.js +374 -24
  98. package/dist/paths.js +64 -7
  99. package/dist/response-system/context-builder.js +1 -7
  100. package/dist/trigger/anomaly-store.js +1 -0
  101. package/dist/trigger/feedback.js +56 -5
  102. package/dist/trigger/history.js +79 -4
  103. package/dist/trigger/legacy-session-history.js +2 -2
  104. package/dist/trigger/parser.js +3 -2
  105. package/dist/trigger/validation.js +6 -1
  106. package/dist/utils/atomic-write.js +27 -0
  107. package/dist/utils/ecweb-utils.js +16 -2
  108. package/dist/utils/error-utils.js +4 -1
  109. package/dist/utils/logger.js +21 -2
  110. package/dist/utils/process-tree-stats.js +24 -4
  111. package/dist/utils/process-tree-worker.js +31 -0
  112. package/dist/utils/project-path.js +1 -2
  113. package/dist/utils/stats.js +52 -18
  114. package/dist/utils/welcome.js +2 -2
  115. package/kits/docs/INDEX.md +1 -1
  116. package/kits/docs/evolcore/INDEX.md +1 -1
  117. package/kits/docs/evolcore/contact.md +7 -1
  118. package/kits/docs/evolcore/msg.md +16 -0
  119. package/kits/rules/01-overview.md +1 -1
  120. package/kits/rules/03-identity.md +1 -1
  121. package/kits/rules/05-venue.md +1 -1
  122. package/kits/schemas/_meta.json +10 -5
  123. package/kits/schemas/agent-config.schema.10.json +2 -1
  124. package/kits/schemas/agent-config.schema.11.json +421 -0
  125. package/kits/schemas/daemon.schema.5.json +135 -0
  126. package/kits/schemas/daemon.schema.6.json +131 -0
  127. package/kits/schemas/defaults.schema.5.json +119 -0
  128. package/kits/schemas/migrations/README.md +3 -1
  129. package/kits/schemas/relation-config.schema.8.json +13 -0
  130. package/kits/schemas/role-config.schema.1.json +1 -2
  131. package/kits/schemas/single-session.schema.3.json +32 -0
  132. package/kits/templates/roles/admin.json +1 -0
  133. package/kits/templates/roles/member.json +1 -0
  134. package/kits/templates/roles/visitor.json +1 -0
  135. package/package.json +7 -3
  136. package/skills/eclink/SKILL.md +2 -0
@@ -1,11 +1,35 @@
1
1
  import crypto from 'crypto';
2
- import { agentOutboxPath } from '../paths.js';
2
+ import { agentActivityOutboxPath, agentOutboxPath } from '../paths.js';
3
3
  import { atomicRead, atomicWrite } from '../utils/atomic-write.js';
4
4
  import { isDeliveryTarget, isDeliveryTargetForChannel, sameDeliveryTarget } from '../core/message/message-utils.js';
5
- const MAX_ENTRIES_PER_AID = 20;
6
- const DEFAULT_TTL = 300_000; // 5 minutes
7
- function outboxFile(aid) {
8
- return agentOutboxPath(aid);
5
+ export const DEFAULT_OUTBOX_MAX_ENTRIES = 200;
6
+ export const DEFAULT_OUTBOX_TTL_MS = 60 * 60 * 1000;
7
+ export const ACTIVITY_OUTBOX_MAX_ENTRIES = 500;
8
+ export const ACTIVITY_OUTBOX_TTL_MS = 30 * 60 * 1000;
9
+ const MAX_TERMINAL_ENTRIES_PER_AID = 100;
10
+ const QUEUE_CONFIG = {
11
+ default: {
12
+ file: agentOutboxPath,
13
+ maxEntries: DEFAULT_OUTBOX_MAX_ENTRIES,
14
+ defaultTtl: DEFAULT_OUTBOX_TTL_MS,
15
+ },
16
+ activity: {
17
+ file: agentActivityOutboxPath,
18
+ maxEntries: ACTIVITY_OUTBOX_MAX_ENTRIES,
19
+ defaultTtl: ACTIVITY_OUTBOX_TTL_MS,
20
+ },
21
+ };
22
+ function queueConfig(queue = 'default') {
23
+ return QUEUE_CONFIG[queue];
24
+ }
25
+ export function defaultTtl(queue = 'default') {
26
+ return queueConfig(queue).defaultTtl;
27
+ }
28
+ function outboxFile(aid, queue = 'default') {
29
+ return queueConfig(queue).file(aid);
30
+ }
31
+ function entryQueue(entry) {
32
+ return entry.queue ?? 'default';
9
33
  }
10
34
  function generateId() {
11
35
  const ts = Date.now();
@@ -13,6 +37,11 @@ function generateId() {
13
37
  return `out-${ts}-${rand}`;
14
38
  }
15
39
  function isExpired(entry) {
40
+ // Terminal failures are retained as durable diagnostics. They are excluded
41
+ // from `load()`/`hasPending()` and must not disappear merely because the
42
+ // original delivery TTL elapsed.
43
+ if (entry.terminal || entry.deliveryResult)
44
+ return false;
16
45
  if (entry.critical)
17
46
  return false;
18
47
  return Date.now() - entry.ts > entry.ttl;
@@ -20,8 +49,8 @@ function isExpired(entry) {
20
49
  export function isDeliveryForChannel(value, channelId) {
21
50
  return isDeliveryTargetForChannel(value, channelId);
22
51
  }
23
- function readEntries(aid) {
24
- const file = outboxFile(aid);
52
+ function readEntries(aid, queue = 'default') {
53
+ const file = outboxFile(aid, queue);
25
54
  try {
26
55
  const content = atomicRead(file)?.trim();
27
56
  if (!content)
@@ -39,22 +68,45 @@ function readEntries(aid) {
39
68
  return [];
40
69
  }
41
70
  }
42
- function writeEntries(aid, entries) {
43
- const file = outboxFile(aid);
44
- const content = entries.length > 0
45
- ? entries.map(e => JSON.stringify(e)).join('\n') + '\n'
71
+ function writeEntries(aid, entries, queue = 'default') {
72
+ const file = outboxFile(aid, queue);
73
+ const boundedEntries = trimFinalEntries(entries);
74
+ const content = boundedEntries.length > 0
75
+ ? boundedEntries.map(e => JSON.stringify(e)).join('\n') + '\n'
46
76
  : '';
47
77
  atomicWrite(file, content);
48
78
  }
79
+ function trimFinalEntries(entries) {
80
+ const finalEntries = entries.filter(entry => !!entry.terminal || !!entry.deliveryResult);
81
+ if (finalEntries.length <= MAX_TERMINAL_ENTRIES_PER_AID)
82
+ return entries;
83
+ const retainedFinalIds = new Set(finalEntries
84
+ .sort((left, right) => (left.terminal?.at ?? left.deliveryResult?.at ?? left.ts) - (right.terminal?.at ?? right.deliveryResult?.at ?? right.ts))
85
+ .slice(-MAX_TERMINAL_ENTRIES_PER_AID)
86
+ .map(entry => entry.id));
87
+ return entries.filter(entry => (!entry.terminal && !entry.deliveryResult) || retainedFinalIds.has(entry.id));
88
+ }
49
89
  export function enqueue(aid, opts) {
90
+ const queue = opts.queue ?? 'default';
91
+ const config = queueConfig(queue);
50
92
  const delivery = opts.delivery;
51
93
  if (!isDeliveryForChannel(delivery, opts.channelId)) {
52
94
  const code = isDeliveryTarget(delivery) ? 'AUN_OUTBOUND_ROUTE_MISMATCH' : 'AUN_OUTBOUND_ROUTE_REQUIRED';
53
95
  throw Object.assign(new Error(`invalid AUN outbox delivery route for channelId=${opts.channelId}`), { code });
54
96
  }
55
- let existingEntries = readEntries(aid);
97
+ let existingEntries = readEntries(aid, queue);
98
+ for (const existing of existingEntries) {
99
+ if (isExpired(existing))
100
+ markExpired(existing);
101
+ }
56
102
  if (opts.dedupeKey) {
57
- const existing = existingEntries.find(entry => entry.dedupeKey === opts.dedupeKey && !isExpired(entry));
103
+ // Terminal results are decisions for this logical operation. Reusing the
104
+ // same route must not silently turn a permanent rejection into a retry;
105
+ // an explicit route correction is the only case that replaces it.
106
+ const existing = existingEntries.find(entry => entry.dedupeKey === opts.dedupeKey
107
+ && !entry.terminal
108
+ && !entry.deliveryResult
109
+ && !isExpired(entry));
58
110
  if (existing
59
111
  && existing.channelId === opts.channelId
60
112
  && isDeliveryForChannel(existing.delivery, existing.channelId)
@@ -71,6 +123,7 @@ export function enqueue(aid, opts) {
71
123
  id: generateId(),
72
124
  ts: Date.now(),
73
125
  aid,
126
+ ...(queue === 'activity' ? { queue } : {}),
74
127
  channelId: opts.channelId,
75
128
  delivery,
76
129
  dedupeKey: opts.dedupeKey,
@@ -83,60 +136,160 @@ export function enqueue(aid, opts) {
83
136
  image: opts.image,
84
137
  logText: opts.logText,
85
138
  context: opts.context,
86
- ttl: opts.ttl ?? DEFAULT_TTL,
139
+ ttl: opts.ttl ?? config.defaultTtl,
87
140
  postSend: opts.postSend,
88
141
  };
89
- // Enforce cap: read existing, drop oldest if over limit
90
- let entries = existingEntries;
91
- if (entries.filter(candidate => !candidate.critical).length >= MAX_ENTRIES_PER_AID) {
92
- const dropIndex = entries.findIndex(candidate => !candidate.critical);
93
- if (dropIndex >= 0)
94
- entries.splice(dropIndex, 1);
95
- entries = [...entries, entry];
142
+ const pendingCount = existingEntries.filter(candidate => !candidate.critical && !candidate.terminal && !candidate.deliveryResult && !isExpired(candidate)).length;
143
+ if (!opts.critical && pendingCount >= config.maxEntries) {
144
+ throw Object.assign(new Error(`${queue} outbox is full for ${aid} (max ${config.maxEntries})`), { code: 'OUTBOX_FULL', queue, aid, maxEntries: config.maxEntries });
96
145
  }
97
- else {
98
- entries = [...entries, entry];
99
- }
100
- writeEntries(aid, entries);
146
+ const entries = [...existingEntries, entry];
147
+ writeEntries(aid, entries, queue);
101
148
  return entry;
102
149
  }
103
- export function remove(aid, id) {
104
- const entries = readEntries(aid).filter(e => e.id !== id);
105
- writeEntries(aid, entries);
150
+ export function remove(aid, id, queue = 'default') {
151
+ const entries = readEntries(aid, queue).filter(e => e.id !== id);
152
+ writeEntries(aid, entries, queue);
153
+ }
154
+ /** Remove an entry only if it still points at the route that was submitted. */
155
+ export function removeIfRouteMatches(aid, submitted, queue = entryQueue(submitted)) {
156
+ const entries = readEntries(aid, queue);
157
+ const index = entries.findIndex(entry => entry.id === submitted.id);
158
+ if (index < 0 || !hasSamePersistedRoute(entries[index], submitted))
159
+ return false;
160
+ entries.splice(index, 1);
161
+ writeEntries(aid, entries, queue);
162
+ return true;
106
163
  }
107
164
  /** Replace an existing entry without consuming another outbox slot. */
108
165
  export function replace(aid, entry) {
109
- const entries = readEntries(aid);
166
+ const queue = entryQueue(entry);
167
+ const entries = readEntries(aid, queue);
110
168
  const index = entries.findIndex(candidate => candidate.id === entry.id);
111
169
  if (index < 0)
112
170
  return false;
113
171
  entries[index] = entry;
114
- writeEntries(aid, entries);
172
+ writeEntries(aid, entries, queue);
115
173
  return true;
116
174
  }
175
+ /** Replace an entry only while its submitted route is still current. */
176
+ export function replaceIfRouteMatches(aid, submitted, replacement, queue = entryQueue(submitted)) {
177
+ const entries = readEntries(aid, queue);
178
+ const index = entries.findIndex(candidate => candidate.id === submitted.id);
179
+ if (index < 0)
180
+ return 'missing';
181
+ if (!hasSamePersistedRoute(entries[index], submitted))
182
+ return 'route-changed';
183
+ entries[index] = replacement;
184
+ writeEntries(aid, entries, queue);
185
+ return 'replaced';
186
+ }
187
+ /** Update only the delivery receipt while preserving concurrent entry changes. */
188
+ export function updateDeliveryReceiptIfRouteMatches(aid, submitted, receipt, queue = entryQueue(submitted)) {
189
+ const entries = readEntries(aid, queue);
190
+ const index = entries.findIndex(candidate => candidate.id === submitted.id);
191
+ if (index < 0)
192
+ return 'missing';
193
+ if (!hasSamePersistedRoute(entries[index], submitted))
194
+ return 'route-changed';
195
+ entries[index].deliveryReceipt = receipt;
196
+ writeEntries(aid, entries, queue);
197
+ return 'replaced';
198
+ }
117
199
  /** Remove durable interaction cards that were cancelled before delivery. */
118
- export function removeInteractionCards(aid, requestId) {
119
- const entries = readEntries(aid);
200
+ export function removeInteractionCards(aid, requestId, queue = 'default') {
201
+ const entries = readEntries(aid, queue);
120
202
  const retained = entries.filter(entry => !(entry.postSend?.type === 'register_interaction_card'
121
203
  && entry.postSend.requestId === requestId));
122
204
  const removed = entries.length - retained.length;
123
205
  if (removed > 0)
124
- writeEntries(aid, retained);
206
+ writeEntries(aid, retained, queue);
125
207
  return removed;
126
208
  }
127
- export function load(aid) {
128
- return readEntries(aid).filter(e => !isExpired(e));
209
+ export function load(aid, queue = 'default') {
210
+ return readEntries(aid, queue).filter(e => !e.terminal && !e.deliveryResult && !isExpired(e));
129
211
  }
130
- export function findByDedupeKey(aid, dedupeKey) {
131
- return load(aid).find(entry => entry.dedupeKey === dedupeKey);
212
+ export function findByDedupeKey(aid, dedupeKey, opts = {}, queue = 'default') {
213
+ const entries = opts.includeTerminal
214
+ ? readEntries(aid, queue).filter(entry => !isExpired(entry))
215
+ : load(aid, queue);
216
+ return entries.find(entry => entry.dedupeKey === dedupeKey);
132
217
  }
133
- export function cleanup(aid) {
134
- const all = readEntries(aid);
135
- const valid = all.filter(e => !isExpired(e));
136
- const removed = all.length - valid.length;
137
- if (removed > 0)
138
- writeEntries(aid, valid);
139
- return removed;
218
+ export function cleanup(aid, queue = 'default') {
219
+ const all = readEntries(aid, queue);
220
+ let expired = 0;
221
+ for (const entry of all) {
222
+ if (!isExpired(entry))
223
+ continue;
224
+ expired++;
225
+ markExpired(entry);
226
+ }
227
+ if (expired > 0)
228
+ writeEntries(aid, all, queue);
229
+ return expired;
230
+ }
231
+ /** Resolve a durable delivery without treating disappearance as success. */
232
+ export function deliveryState(aid, id, queue = 'default') {
233
+ const entries = readEntries(aid, queue);
234
+ const entry = entries.find(candidate => candidate.id === id);
235
+ if (!entry)
236
+ return { status: 'missing' };
237
+ const deliveredMessageId = entry.deliveryResult?.messageId ?? entry.deliveryReceipt?.messageId;
238
+ if (deliveredMessageId) {
239
+ return { status: 'sent', messageId: deliveredMessageId };
240
+ }
241
+ if (entry.terminal) {
242
+ return {
243
+ status: 'failed',
244
+ error: entry.terminal.error,
245
+ ...(entry.terminal.code !== undefined ? { code: entry.terminal.code } : {}),
246
+ };
247
+ }
248
+ if (isExpired(entry)) {
249
+ markExpired(entry);
250
+ writeEntries(aid, entries, queue);
251
+ return {
252
+ status: 'failed',
253
+ error: entry.terminal.error,
254
+ code: entry.terminal.code,
255
+ };
256
+ }
257
+ return { status: 'queued' };
258
+ }
259
+ function markExpired(entry) {
260
+ entry.lastError = 'outbox delivery expired';
261
+ entry.lastErrorCode = 'OUTBOX_EXPIRED';
262
+ entry.terminal = {
263
+ at: Date.now(),
264
+ error: entry.lastError,
265
+ code: entry.lastErrorCode,
266
+ };
267
+ }
268
+ /** Mark a durable entry terminal while keeping its reason for diagnostics. */
269
+ export function markTerminal(aid, id, failure, submitted, queue = submitted ? entryQueue(submitted) : 'default') {
270
+ const entries = readEntries(aid, queue);
271
+ const entry = entries.find(candidate => candidate.id === id);
272
+ if (!entry || (submitted && !hasSamePersistedRoute(entry, submitted)))
273
+ return false;
274
+ entry.lastError = failure.error ?? 'permanent send failure';
275
+ if (failure.code !== undefined)
276
+ entry.lastErrorCode = failure.code;
277
+ entry.terminal = { at: Date.now(), error: entry.lastError, ...(failure.code !== undefined ? { code: failure.code } : {}) };
278
+ writeEntries(aid, entries, queue);
279
+ return true;
280
+ }
281
+ /** Retain a bounded success receipt for queued Trigger history reconciliation. */
282
+ export function markDelivered(aid, id, messageId, submitted, queue = submitted ? entryQueue(submitted) : 'default') {
283
+ const entries = readEntries(aid, queue);
284
+ const entry = entries.find(candidate => candidate.id === id);
285
+ if (!entry || (submitted && !hasSamePersistedRoute(entry, submitted)))
286
+ return false;
287
+ entry.deliveryResult = { status: 'sent', at: Date.now(), messageId };
288
+ delete entry.lastError;
289
+ delete entry.lastErrorCode;
290
+ delete entry.terminal;
291
+ writeEntries(aid, entries, queue);
292
+ return true;
140
293
  }
141
294
  // Each caller gets its own pass over the current disk state. This prevents
142
295
  // duplicate sends while allowing a later drain trigger to pick up entries
@@ -145,36 +298,95 @@ const activeDrainTails = new Map();
145
298
  function hasRouteForChannel(entry) {
146
299
  return isDeliveryForChannel(entry.delivery, entry.channelId);
147
300
  }
148
- async function drainOnce(aid, sender) {
149
- const entries = readEntries(aid);
301
+ function hasSamePersistedRoute(left, right) {
302
+ if (left.channelId !== right.channelId)
303
+ return false;
304
+ if (left.delivery === undefined && right.delivery === undefined)
305
+ return true;
306
+ return isDeliveryTarget(left.delivery)
307
+ && isDeliveryTarget(right.delivery)
308
+ && sameDeliveryTarget(left.delivery, right.delivery);
309
+ }
310
+ async function drainOnce(aid, sender, queue) {
311
+ const entries = readEntries(aid, queue).filter(entry => !entry.terminal && !entry.deliveryResult);
150
312
  if (entries.length === 0)
151
313
  return { sent: 0, expired: 0, failed: 0 };
152
314
  const drainedIds = new Set(entries.map(e => e.id));
315
+ const drainedById = new Map(entries.map(entry => [entry.id, entry]));
153
316
  let sent = 0;
154
317
  let expired = 0;
155
318
  let failed = 0;
319
+ let permanent = 0;
156
320
  const remaining = [];
157
321
  for (const entry of entries) {
158
322
  if (isExpired(entry)) {
159
323
  expired++;
324
+ markExpired(entry);
325
+ // Keep the terminal record so Trigger history can distinguish an
326
+ // operation that expired from one that was actually sent.
327
+ remaining.push(entry);
160
328
  continue;
161
329
  }
162
330
  if (!hasRouteForChannel(entry)) {
163
331
  // A route is security-sensitive metadata. An entry whose groupId does
164
- // not match its address cannot be repaired from channelId, so remove it
165
- // instead of retrying the same malformed send forever.
166
- expired++;
332
+ // not match its address cannot be repaired from channelId. Keep a
333
+ // terminal diagnostic record instead of retrying the same malformed
334
+ // send forever or silently deleting evidence of the failure.
335
+ permanent++;
336
+ entry.lastError = 'outbox entry has no valid delivery route';
337
+ entry.lastErrorCode = 'AUN_OUTBOUND_ROUTE_REQUIRED';
338
+ entry.terminal = {
339
+ at: Date.now(),
340
+ error: entry.lastError,
341
+ code: entry.lastErrorCode,
342
+ };
343
+ remaining.push(entry);
167
344
  continue;
168
345
  }
169
346
  try {
170
347
  entry.attempts = (entry.attempts ?? 0) + 1;
171
- const ok = await sender(entry);
172
- if (ok) {
173
- sent++;
348
+ const raw = await sender(entry);
349
+ const result = typeof raw === 'boolean'
350
+ ? { status: raw ? 'sent' : 'retry' }
351
+ : raw;
352
+ if (result.status === 'sent') {
353
+ const messageId = result.messageId ?? entry.deliveryReceipt?.messageId;
354
+ if (messageId) {
355
+ sent++;
356
+ entry.deliveryResult = { status: 'sent', at: Date.now(), messageId };
357
+ remaining.push(entry);
358
+ }
359
+ else {
360
+ permanent++;
361
+ entry.lastError = 'outbox sender returned sent without a remote message_id';
362
+ entry.lastErrorCode = 'MISSING_MESSAGE_ID';
363
+ entry.terminal = {
364
+ at: Date.now(),
365
+ error: entry.lastError,
366
+ code: entry.lastErrorCode,
367
+ };
368
+ remaining.push(entry);
369
+ }
370
+ }
371
+ else if (result.status === 'permanent') {
372
+ permanent++;
373
+ entry.lastError = result.error ?? 'permanent send failure';
374
+ if (result.code !== undefined)
375
+ entry.lastErrorCode = result.code;
376
+ entry.terminal = {
377
+ at: Date.now(),
378
+ error: entry.lastError,
379
+ ...(result.code !== undefined ? { code: result.code } : {}),
380
+ };
381
+ // Keep a terminal record for diagnostics, but exclude it from all
382
+ // pending-send views so the timer cannot submit it again.
383
+ remaining.push(entry);
174
384
  }
175
385
  else {
176
386
  failed++;
177
- entry.lastError = 'sender returned false';
387
+ entry.lastError = result.error ?? 'sender returned false';
388
+ if (result.code !== undefined)
389
+ entry.lastErrorCode = result.code;
178
390
  remaining.push(entry);
179
391
  }
180
392
  }
@@ -184,49 +396,64 @@ async function drainOnce(aid, sender) {
184
396
  remaining.push(entry);
185
397
  }
186
398
  }
187
- const current = readEntries(aid);
399
+ const current = readEntries(aid, queue);
188
400
  const failedById = new Map(remaining.map(entry => [entry.id, entry]));
189
401
  const retained = current.flatMap(entry => {
190
402
  if (!drainedIds.has(entry.id))
191
403
  return [entry];
192
404
  const failedEntry = failedById.get(entry.id);
193
- if (!failedEntry)
194
- return [];
405
+ if (!failedEntry) {
406
+ const submittedEntry = drainedById.get(entry.id);
407
+ return submittedEntry && !hasSamePersistedRoute(entry, submittedEntry)
408
+ ? [entry]
409
+ : [];
410
+ }
411
+ // A failure belongs to the route that was actually submitted. If another
412
+ // writer corrected that route while the RPC was in flight, preserve the
413
+ // corrected entry as pending instead of terminating it with a stale
414
+ // target's permanent error.
415
+ if (!hasSamePersistedRoute(entry, failedEntry))
416
+ return [entry];
417
+ if (entry.terminal && !failedEntry.terminal)
418
+ return [entry];
195
419
  return [{
196
420
  ...failedEntry,
197
421
  ...entry,
198
422
  attempts: failedEntry.attempts,
199
423
  lastError: failedEntry.lastError,
424
+ lastErrorCode: failedEntry.lastErrorCode,
425
+ ...(failedEntry.terminal ? { terminal: failedEntry.terminal } : {}),
200
426
  }];
201
427
  });
202
- writeEntries(aid, retained);
203
- return { sent, expired, failed };
428
+ writeEntries(aid, retained, queue);
429
+ return { sent, expired, failed, ...(permanent > 0 ? { permanent } : {}) };
204
430
  }
205
- export async function drain(aid, sender) {
206
- const previous = activeDrainTails.get(aid);
431
+ export async function drain(aid, sender, queue = 'default') {
432
+ const drainKey = `${queue}:${aid}`;
433
+ const previous = activeDrainTails.get(drainKey);
207
434
  let releaseTurn;
208
435
  const turn = new Promise(resolve => { releaseTurn = resolve; });
209
436
  const tail = (previous ?? Promise.resolve()).then(() => turn);
210
- activeDrainTails.set(aid, tail);
437
+ activeDrainTails.set(drainKey, tail);
211
438
  if (previous)
212
439
  await previous;
213
440
  try {
214
- return await drainOnce(aid, sender);
441
+ return await drainOnce(aid, sender, queue);
215
442
  }
216
443
  finally {
217
444
  releaseTurn();
218
- if (activeDrainTails.get(aid) === tail)
219
- activeDrainTails.delete(aid);
445
+ if (activeDrainTails.get(drainKey) === tail)
446
+ activeDrainTails.delete(drainKey);
220
447
  }
221
448
  }
222
- export function hasPending(aid) {
223
- return readEntries(aid).some(entry => !isExpired(entry));
449
+ export function hasPending(aid, queue = 'default') {
450
+ return readEntries(aid, queue).some(entry => !entry.terminal && !entry.deliveryResult && !isExpired(entry));
224
451
  }
225
452
  /**
226
453
  * 当前 outbox 中待发送条目数。用于诊断发送管线堵塞:depth 持续增长说明
227
454
  * message.send 出队速度跟不上入队速度(网关慢 / 链路抖动 / 卡片洪泛),
228
455
  * 是命令「回复慢/无回复」的先行指标。
229
456
  */
230
- export function pendingCount(aid) {
231
- return load(aid).length;
457
+ export function pendingCount(aid, queue = 'default') {
458
+ return load(aid, queue).length;
232
459
  }
@@ -11,7 +11,7 @@
11
11
  * 所以不能把 client 引用直接交给 ServiceProxyClient;传一个动态解引用的
12
12
  * facade,每次 .call()/.authenticate()/._tokenStore 都读 channel 当前 client。
13
13
  * serveForever(persistent) 的指数退避会在 client 缺失时自动等待恢复。
14
- * - endpoint 发现:source='ecweb' 时读 data/instance/ecweb-*.json port
14
+ * - endpoint 发现:source='instance' 时读服务实例登记文件的 port;source='static' 时使用显式 endpoint
15
15
  * - 失败降级:任何异常只 warn,绝不影响 daemon 主流程。
16
16
  */
17
17
  import fs from 'fs';
@@ -20,16 +20,16 @@ import { ServiceProxyClient, EndpointPolicy } from '@agentunion/fastaun';
20
20
  import { resolvePaths } from '../paths.js';
21
21
  import { logger } from '../utils/logger.js';
22
22
  const LOG = '[ServiceProxy]';
23
- const ECWEB_DISCOVERY_TIMEOUT_MS = 10_000;
24
- const ECWEB_DISCOVERY_INTERVAL_MS = 250;
23
+ const INSTANCE_DISCOVERY_TIMEOUT_MS = 10_000;
24
+ const INSTANCE_DISCOVERY_INTERVAL_MS = 250;
25
25
  function sleep(ms) {
26
26
  return new Promise(resolve => setTimeout(resolve, ms));
27
27
  }
28
28
  /**
29
- * 读 data/instance/ 下存活的 ecweb 实例端口。
29
+ * 读 data/instance/ 下指定服务的存活实例端口。
30
30
  * 取 startedAt 最新的一条(多实例保护下通常只有一条存活)。
31
31
  */
32
- function discoverEcwebPort() {
32
+ function discoverInstancePort(serviceName) {
33
33
  const dir = resolvePaths().instanceDir;
34
34
  let files;
35
35
  try {
@@ -40,11 +40,25 @@ function discoverEcwebPort() {
40
40
  }
41
41
  const records = [];
42
42
  for (const file of files) {
43
- if (!/^(ecweb|watch-web)-\d+\.json$/.test(file))
43
+ const match = serviceName === 'ecweb'
44
+ ? file.match(/^(?:ecweb|watch-web)-(\d+)\.json$/)
45
+ : file.match(new RegExp(`^${serviceName.replace(/[^a-z0-9_-]/gi, '')}-(\\d+)\\.json$`));
46
+ if (!match)
44
47
  continue;
48
+ const filePid = Number(match[1]);
45
49
  try {
46
50
  const rec = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
47
- if (rec && typeof rec.pid === 'number' && isProcessAlive(rec.pid) && typeof rec.port === 'number') {
51
+ if (rec
52
+ && Number.isInteger(rec.pid)
53
+ && rec.pid > 0
54
+ && rec.pid === filePid
55
+ && isProcessAlive(rec.pid)
56
+ && typeof rec.startedAt === 'number'
57
+ && Number.isFinite(rec.startedAt)
58
+ && rec.startedAt >= 0
59
+ && Number.isInteger(rec.port)
60
+ && rec.port >= 1
61
+ && rec.port <= 65535) {
48
62
  records.push(rec);
49
63
  }
50
64
  }
@@ -67,18 +81,18 @@ function isProcessAlive(pid) {
67
81
  return e?.code === 'EPERM';
68
82
  }
69
83
  }
70
- async function waitForEcwebPort(serviceName) {
71
- const deadline = Date.now() + ECWEB_DISCOVERY_TIMEOUT_MS;
84
+ async function waitForInstancePort(serviceName) {
85
+ const deadline = Date.now() + INSTANCE_DISCOVERY_TIMEOUT_MS;
72
86
  let loggedWait = false;
73
87
  while (Date.now() <= deadline) {
74
- const port = discoverEcwebPort();
88
+ const port = discoverInstancePort(serviceName);
75
89
  if (port)
76
90
  return port;
77
91
  if (!loggedWait) {
78
- logger.info(`${LOG} 服务 "${serviceName}" source=ecweb,等待 ecweb 实例就绪`);
92
+ logger.info(`${LOG} 服务 "${serviceName}" source=instance,等待实例就绪`);
79
93
  loggedWait = true;
80
94
  }
81
- await sleep(ECWEB_DISCOVERY_INTERVAL_MS);
95
+ await sleep(INSTANCE_DISCOVERY_INTERVAL_MS);
82
96
  }
83
97
  return null;
84
98
  }
@@ -86,18 +100,22 @@ async function waitForEcwebPort(serviceName) {
86
100
  * 把单个服务配置解析为本地回连 endpoint。无法解析返回 null。
87
101
  */
88
102
  async function resolveEndpoint(svc) {
89
- if (svc.source === 'ecweb') {
90
- const port = await waitForEcwebPort(svc.name);
103
+ if (svc.proxy?.source === 'instance') {
104
+ const port = await waitForInstancePort(svc.name);
91
105
  if (!port) {
92
- logger.warn(`${LOG} 服务 "${svc.name}" source=ecweb 等待 ${ECWEB_DISCOVERY_TIMEOUT_MS}ms 后仍未发现存活的 ecweb 实例,跳过`);
106
+ logger.warn(`${LOG} 服务 "${svc.name}" source=instance 等待 ${INSTANCE_DISCOVERY_TIMEOUT_MS}ms 后仍未发现存活实例,跳过`);
93
107
  return null;
94
108
  }
95
109
  return `http://127.0.0.1:${port}`;
96
110
  }
97
- // static
98
- if (svc.endpoint && svc.endpoint.trim())
99
- return svc.endpoint.trim();
100
- logger.warn(`${LOG} 服务 "${svc.name}" source=static 但未配置 endpoint,跳过`);
111
+ if (svc.proxy?.source === 'static') {
112
+ const endpoint = svc.proxy.endpoint?.trim();
113
+ if (endpoint)
114
+ return endpoint;
115
+ logger.warn(`${LOG} 服务 "${svc.name}" source=static 但未配置 endpoint,跳过`);
116
+ return null;
117
+ }
118
+ logger.warn(`${LOG} 服务 "${svc.name}" 未配置有效 proxy.source,跳过`);
101
119
  return null;
102
120
  }
103
121
  /**
@@ -105,8 +123,8 @@ async function resolveEndpoint(svc) {
105
123
  * 返回 handle 供关停;没有启用服务时返回 null。endpoint 发现和隧道启动在后台执行,
106
124
  * 失败只 warn,不抛异常,不阻塞 daemon 主流程。
107
125
  */
108
- export function startServiceProxy(controlChannel, providerAid, config) {
109
- const services = (config.services ?? []).filter((s) => s.enabled !== false);
126
+ export function startServiceProxy(controlChannel, providerAid, configuredServices) {
127
+ const services = configuredServices.filter((s) => s.enabled === true && s.proxy?.enabled === true);
110
128
  if (services.length === 0) {
111
129
  logger.info(`${LOG} 无启用的服务,跳过`);
112
130
  return null;
@@ -177,12 +195,12 @@ export function startServiceProxy(controlChannel, providerAid, config) {
177
195
  continue;
178
196
  try {
179
197
  proxyClient.registerService(svc.name, endpoint, {
180
- serviceType: svc.serviceType ?? 'http',
181
- visibility: svc.visibility ?? 'private',
182
- metadata: svc.metadata ?? {},
198
+ serviceType: svc.proxy?.serviceType ?? 'http',
199
+ visibility: svc.proxy?.visibility ?? 'private',
200
+ metadata: svc.proxy?.metadata ?? {},
183
201
  });
184
202
  registered += 1;
185
- logger.info(`${LOG} 注册服务 "${svc.name}" → ${endpoint} (visibility=${svc.visibility ?? 'private'})`);
203
+ logger.info(`${LOG} 注册服务 "${svc.name}" → ${endpoint} (type=${svc.proxy?.serviceType ?? 'http'}, visibility=${svc.proxy?.visibility ?? 'private'})`);
186
204
  }
187
205
  catch (e) {
188
206
  logger.warn(`${LOG} 注册服务 "${svc.name}" 失败: ${e?.message || e}`);