evolcore 0.0.26 → 0.0.27

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 (169) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/codex-managed-acl.ps1 +43 -0
  3. package/dist/agents/baseagent-availability.js +68 -0
  4. package/dist/agents/claude-runner.js +123 -22
  5. package/dist/agents/codex/app-server-client.js +63 -26
  6. package/dist/agents/codex-runner.js +316 -411
  7. package/dist/agents/ecagent-runner.js +170 -25
  8. package/dist/agents/gemini-runner.js +6 -0
  9. package/dist/agents/runner-types.js +50 -2
  10. package/dist/aun/aid/agentmd.js +73 -31
  11. package/dist/aun/aid/client.js +1 -1
  12. package/dist/aun/aid/control-aid.js +180 -1
  13. package/dist/aun/aid/identity.js +3 -3
  14. package/dist/aun/group/history.js +303 -0
  15. package/dist/aun/group/identity.js +43 -0
  16. package/dist/aun/group/index.js +5 -0
  17. package/dist/aun/{msg/group.js → group/operations.js} +161 -38
  18. package/dist/aun/{menu-client.js → menu/client.js} +12 -7
  19. package/dist/aun/menu/index.js +2 -0
  20. package/dist/aun/menu/transport.js +49 -0
  21. package/dist/aun/msg/index.js +2 -1
  22. package/dist/aun/msg/managed-operation.js +84 -18
  23. package/dist/aun/msg/mention-schema.js +51 -0
  24. package/dist/aun/msg/p2p.js +13 -8
  25. package/dist/aun/msg/upload.js +43 -88
  26. package/dist/aun/outbox.js +29 -7
  27. package/dist/aun/rpc/client.js +1 -0
  28. package/dist/aun/storage/client.js +39 -50
  29. package/dist/channels/aun.js +522 -233
  30. package/dist/channels/daemon.js +10 -6
  31. package/dist/cli/agent-command.js +108 -6
  32. package/dist/cli/agent-create.js +515 -2
  33. package/dist/cli/agent.js +109 -8
  34. package/dist/cli/aun-commands.js +191 -42
  35. package/dist/cli/config.js +8 -5
  36. package/dist/cli/contact.js +2 -2
  37. package/dist/cli/ctl-command.js +2 -2
  38. package/dist/cli/daemon-commands.js +35 -23
  39. package/dist/cli/diag.js +2 -2
  40. package/dist/cli/fs-command.js +3 -3
  41. package/dist/cli/handoff-command.js +6 -6
  42. package/dist/cli/index.js +8 -2
  43. package/dist/cli/init.js +34 -1
  44. package/dist/cli/managed-command-guard.js +2 -1
  45. package/dist/cli/menu-client.js +9 -5
  46. package/dist/cli/model.js +3 -3
  47. package/dist/cli/queue-command.js +2 -2
  48. package/dist/cli/response.js +2 -2
  49. package/dist/cli/restart-monitor.js +16 -2
  50. package/dist/cli/task-command.js +59 -0
  51. package/dist/cli/task-context.js +97 -49
  52. package/dist/cli/trigger-command.js +20 -10
  53. package/dist/config/builtin-roles.js +10 -0
  54. package/dist/config/config-field-policy.js +11 -0
  55. package/dist/config/config-manager.js +32 -4
  56. package/dist/config/config-operation-service.js +20 -5
  57. package/dist/config/contact-book-store.js +1 -1
  58. package/dist/config/contact-book.js +1 -1
  59. package/dist/config/contact-operation-service.js +1 -1
  60. package/dist/config/peer-role-resolver.js +1 -1
  61. package/dist/config/resolved-config-op.js +3 -0
  62. package/dist/config-store.js +1 -1
  63. package/dist/core/agent-application-service.js +5 -0
  64. package/dist/core/audit/event-key.js +17 -2
  65. package/dist/core/audit/log-integrity.js +34 -8
  66. package/dist/core/auth/agent-delegation.js +48 -14
  67. package/dist/core/auth/auth-gateway.js +2 -0
  68. package/dist/core/auth/authorization-audit.js +34 -38
  69. package/dist/core/auth/operation-authorizer.js +67 -5
  70. package/dist/core/auth/operation-catalog.js +110 -0
  71. package/dist/core/auth/remote-access-store.js +115 -9
  72. package/dist/core/bootstrap-messages.js +66 -7
  73. package/dist/core/bootstrap-service.js +110 -19
  74. package/dist/core/command/agent-control.js +88 -7
  75. package/dist/core/command/agent-storage-menu.js +443 -0
  76. package/dist/core/command/cli-intent-parser.js +19 -0
  77. package/dist/core/command/command-handler.js +76 -39
  78. package/dist/core/command/data-root-resolver.js +273 -0
  79. package/dist/core/command/group-menu.js +7 -2
  80. package/dist/core/command/menu-catalog.js +34 -4
  81. package/dist/core/command/menu-handler.js +348 -77
  82. package/dist/core/command/menu-protocol.js +40 -6
  83. package/dist/core/command/role-menu.js +2 -2
  84. package/dist/core/command/slash-handler.js +37 -22
  85. package/dist/core/event-catalog.js +4 -0
  86. package/dist/core/evolagent-registry.js +28 -2
  87. package/dist/core/group/invite-service.js +924 -0
  88. package/dist/core/interaction-router.js +22 -8
  89. package/dist/core/message/im-renderer.js +76 -15
  90. package/dist/core/message/message-bridge.js +94 -11
  91. package/dist/core/message/message-log.js +5 -3
  92. package/dist/core/message/message-queue.js +73 -41
  93. package/dist/core/message/response-engine.js +709 -117
  94. package/dist/core/permission/approval-gateway.js +36 -3
  95. package/dist/core/permission/ec-command-parser.js +61 -29
  96. package/dist/core/permission/protected-paths.js +134 -16
  97. package/dist/core/permission/sandbox-runtime.js +72 -3
  98. package/dist/core/permission/tool-error-code.js +15 -0
  99. package/dist/core/permission/tool-policy.js +7 -7
  100. package/dist/core/relation/peer-identity.js +57 -6
  101. package/dist/core/relation/shared-data.js +106 -0
  102. package/dist/core/role/runtime-policy.js +64 -0
  103. package/dist/core/runtime-lock.js +10 -26
  104. package/dist/core/session/runner-task-history.js +170 -0
  105. package/dist/core/session/session-manager.js +34 -6
  106. package/dist/core/session/session-mapper.js +41 -0
  107. package/dist/core/session/session-turn-coordinator.js +37 -7
  108. package/dist/core/session/session-turns.js +48 -7
  109. package/dist/core/task/task-recovery-service.js +176 -0
  110. package/dist/core/task/task-resume-operation.js +32 -0
  111. package/dist/eck/group-rules-sync.js +5 -3
  112. package/dist/eck/kit-renderer.js +1 -0
  113. package/dist/index.js +308 -54
  114. package/dist/ipc.js +141 -15
  115. package/dist/paths.js +17 -4
  116. package/dist/product.js +2 -0
  117. package/dist/trigger/event-source.js +19 -0
  118. package/dist/trigger/feedback.js +233 -40
  119. package/dist/trigger/parser.js +55 -5
  120. package/dist/trigger/patch.js +9 -0
  121. package/dist/trigger/scheduler.js +30 -4
  122. package/dist/trigger/validation.js +20 -3
  123. package/dist/utils/ecweb-utils.js +1 -0
  124. package/dist/utils/error-utils.js +40 -2
  125. package/dist/utils/logger.js +23 -13
  126. package/dist/utils/tool-summary.js +59 -3
  127. package/dist/utils/welcome.js +174 -1
  128. package/ecagent/dist/harness/agent-harness.js +3 -0
  129. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  130. package/kits/docs/INDEX.md +0 -6
  131. package/kits/docs/channels/aun.md +15 -2
  132. package/kits/docs/channels/feishu.md +1 -1
  133. package/kits/docs/context-assembly.md +29 -17
  134. package/kits/docs/evolcore/INDEX.md +8 -3
  135. package/kits/docs/evolcore/agent-create.md +39 -7
  136. package/kits/docs/evolcore/agent.md +33 -1
  137. package/kits/docs/evolcore/aid.md +9 -1
  138. package/kits/docs/evolcore/config.md +40 -1
  139. package/kits/docs/evolcore/group-collaboration.md +1 -1
  140. package/kits/docs/evolcore/group.md +117 -2
  141. package/kits/docs/evolcore/menu-client.md +16 -0
  142. package/kits/docs/evolcore/msg.md +3 -0
  143. package/kits/docs/evolcore/storage.md +3 -0
  144. package/kits/docs/evolcore/task.md +14 -0
  145. package/kits/docs/evolcore/trigger.md +51 -3
  146. package/kits/docs/venues/group.md +5 -0
  147. package/kits/eck_manifest.json +27 -0
  148. package/kits/rules/01-overview.md +11 -7
  149. package/kits/rules/04-relation.md +2 -1
  150. package/kits/rules/05-venue.md +1 -1
  151. package/kits/rules/06-channel.md +6 -4
  152. package/kits/schemas/agent-config.schema.12.json +25 -1
  153. package/kits/schemas/daemon.schema.6.json +2 -2
  154. package/kits/schemas/defaults.schema.5.json +1 -1
  155. package/kits/schemas/relation-config.schema.1.json +1 -1
  156. package/kits/schemas/relation-config.schema.5.json +1 -1
  157. package/kits/schemas/role-config.schema.1.json +14 -0
  158. package/kits/templates/roles/member.json +5 -0
  159. package/kits/templates/roles/visitor.json +5 -0
  160. package/kits/templates/system-fragments/baseagent.md +1 -1
  161. package/kits/templates/system-fragments/bootstrap.md +26 -9
  162. package/kits/templates/system-fragments/channel.md +8 -44
  163. package/kits/templates/system-fragments/commands.md +10 -2
  164. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  165. package/kits/templates/system-fragments/reply.md +56 -0
  166. package/package.json +7 -3
  167. package/dist/aun/group-identity.js +0 -9
  168. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  169. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -1,8 +1,12 @@
1
+ import { initializationBlocksTasks } from '../../cli/agent-create.js';
1
2
  import path from 'path';
2
3
  import fs from 'fs';
3
4
  import os from 'os';
4
5
  import crypto from 'crypto';
5
- import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken } from '../../agents/runner-types.js';
6
+ import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken, formatAsyncQuestionAnswer, parseAsyncUserQuestions, } from '../../agents/runner-types.js';
7
+ import { loadedBaseagentIds } from '../../agents/baseagent-availability.js';
8
+ import { evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission } from './message-admission.js';
9
+ import { authorizeAccess, authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
6
10
  import { buildTaskRuntimeEnv, ensureRuntimeLockDir, ensureSessionRuntimeDir } from '../../cli/task-context.js';
7
11
  import { IMRenderer } from './im-renderer.js';
8
12
  import { createSendFileMarkerPattern } from './file-markers.js';
@@ -10,11 +14,11 @@ import { createTextInferenceProvider } from '../inference/text-inference.js';
10
14
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
11
15
  import { logger } from '../../utils/logger.js';
12
16
  import { isHostChinese } from '../../utils/locale.js';
13
- import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, isAbortTerminalReason, prefixErrorType, isRetryableError, isContextTooLongText } from '../../utils/error-utils.js';
17
+ import { ExecutionTimeoutError, OrphanedTargetSessionError, isExecutionFailure, getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, isAbortTerminalReason, prefixErrorType, isRetryableError, isContextTooLongText } from '../../utils/error-utils.js';
14
18
  import { readEvolcoreRuntimeFlavor, tagErrorTextWithEvolcoreVersion } from '../../utils/evolcore-version.js';
15
19
  import { describeProactiveSendCommandFailure, isExactEvolcoreSendCommandForSession } from '../permission/ec-command-parser.js';
16
- import { summarizeToolInput } from '../../utils/tool-summary.js';
17
- import { agentDir as resolveAgentDir, agentMdPath, getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
20
+ import { summarizeToolInputForDisplay } from '../../utils/tool-summary.js';
21
+ import { agentDataDir, agentDir as resolveAgentDir, agentMdPath, getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
18
22
  import { renderKitSections } from '../../eck/kit-renderer.js';
19
23
  import { renderMessageBody } from '../../eck/message-renderer.js';
20
24
  import { syncGroupRulesContext } from '../../eck/group-rules-sync.js';
@@ -22,8 +26,9 @@ import { consumeHints, hintsToSubMessages, composeHintFallback } from './pending
22
26
  import { createRootCausation, deriveCausation, normalizeCausation } from '../causation/context.js';
23
27
  import { recordCausationLink, recordCausationSpan } from '../causation/audit.js';
24
28
  import { normalizeBaseagent } from '../../agents/baseagent.js';
29
+ import { renderActionAsText } from '../interaction-router.js';
25
30
  import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
26
- import { buildEnvelope, replyContextFromSession, withEnvelopeReplyContext } from './message-utils.js';
31
+ import { buildEnvelope, isInteractionSendAccepted, replyContextFromSession, sendInteractionPayload, withEnvelopeReplyContext } from './message-utils.js';
27
32
  import { isSystemOrServicePeer, resolveChatMode } from './peer-mode.js';
28
33
  import { AGENT_DELEGATION_TOKEN_ENV } from '../auth/agent-delegation.js';
29
34
  import { hasTrustedPrincipal } from '../auth/authenticated-actor.js';
@@ -52,7 +57,290 @@ import { classifyToolErrorCode, normalizeToolErrorCode } from '../permission/too
52
57
  import { normalizeExecutionPermissionMode } from '../permission/mode.js';
53
58
  import { buildToolLifecycleEventKey } from '../audit/event-key.js';
54
59
  import { isFullAccessEnabled, loadDaemonConfig } from '../../config-store.js';
60
+ import { assertAgentPolicyAuthorization } from '../role/runtime-policy.js';
55
61
  import { auditFullAccessEvent } from '../auth/authorization-audit.js';
62
+ import { TaskRecoveryService, isTaskRecoveryCurrent } from '../task/task-recovery-service.js';
63
+ const QUESTION_TTL_MS = 24 * 60 * 60 * 1000;
64
+ /** Pending async questions outlive the originating turn, but never change its backend binding. */
65
+ export class AsyncQuestionService {
66
+ deps;
67
+ registered = new Map();
68
+ deliveries = new Map();
69
+ constructor(deps) {
70
+ this.deps = deps;
71
+ }
72
+ directory(selfAid) {
73
+ return this.deps.directory?.(selfAid) ?? path.join(agentDataDir(selfAid), 'async-questions');
74
+ }
75
+ file(selfAid, id) {
76
+ if (!/^aq-[a-f0-9]{40}$/.test(id))
77
+ throw new Error('Invalid async question id');
78
+ return path.join(this.directory(selfAid), `${id}.json`);
79
+ }
80
+ save(record) {
81
+ fs.mkdirSync(this.directory(record.selfAid), { recursive: true, mode: 0o700 });
82
+ const destination = this.file(record.selfAid, record.id);
83
+ const temporary = `${destination}.tmp`;
84
+ const fd = fs.openSync(temporary, 'w', 0o600);
85
+ try {
86
+ fs.writeFileSync(fd, JSON.stringify(record));
87
+ fs.fsyncSync(fd);
88
+ }
89
+ finally {
90
+ fs.closeSync(fd);
91
+ }
92
+ // Replace atomically, without unlinking the previous durable state first.
93
+ fs.renameSync(temporary, destination);
94
+ }
95
+ read(selfAid, id) {
96
+ try {
97
+ const value = JSON.parse(fs.readFileSync(this.file(selfAid, id), 'utf8'));
98
+ if (value.version !== 1 || value.selfAid !== selfAid || value.id !== id
99
+ || !value.sessionId || !value.providerThreadId || !value.itemId
100
+ || !value.origin?.peerId || !value.channelKey || !Number.isFinite(value.expiresAt))
101
+ return undefined;
102
+ parseAsyncUserQuestions([value.question]);
103
+ return value;
104
+ }
105
+ catch (error) {
106
+ if (error.code !== 'ENOENT')
107
+ throw error;
108
+ return undefined;
109
+ }
110
+ }
111
+ async present(session, request, message, adapter, envelope) {
112
+ if (!session.selfAID || !message.peerId)
113
+ throw new Error('Async question requires an authenticated route');
114
+ const questions = parseAsyncUserQuestions(request.questions);
115
+ const records = [];
116
+ for (const [index, question] of questions.entries()) {
117
+ const hash = crypto.createHash('sha256').update(JSON.stringify([
118
+ session.selfAID, session.id, request.providerThreadId, request.itemId, index,
119
+ ])).digest('hex').slice(0, 40);
120
+ const id = `aq-${hash}`;
121
+ const existing = this.read(session.selfAID, id);
122
+ if (existing && existing.status !== 'pending')
123
+ continue;
124
+ const replyContext = {
125
+ delivery: envelope.delivery ?? envelope.replyContext?.delivery,
126
+ threadId: session.threadId || undefined,
127
+ metadata: {
128
+ encrypted: message.encrypted ?? envelope.replyContext?.metadata?.encrypted,
129
+ chatmode: envelope.chatmode,
130
+ },
131
+ };
132
+ const record = existing ?? {
133
+ version: 1, id, sessionId: session.id, selfAid: session.selfAID,
134
+ providerThreadId: request.providerThreadId, providerTurnId: request.providerTurnId,
135
+ itemId: request.itemId, question,
136
+ origin: {
137
+ channel: message.channel, channelType: session.channelType,
138
+ channelId: session.channelId, peerId: message.peerId, peerType: message.peerType,
139
+ chatType: session.chatType === 'group' ? 'group' : 'private',
140
+ threadId: session.threadId || undefined, selfAID: session.selfAID,
141
+ },
142
+ envelope: { ...envelope, sessionId: session.id, replyContext, delivery: replyContext.delivery },
143
+ channelKey: adapter.channelKey, createdAt: Date.now(), expiresAt: Date.now() + QUESTION_TTL_MS,
144
+ status: 'pending',
145
+ };
146
+ if (!existing)
147
+ this.save(record);
148
+ records.push(record);
149
+ }
150
+ // Persist the complete batch before dispatch; one failed card must not lose the other questions.
151
+ const errors = [];
152
+ for (const record of records) {
153
+ try {
154
+ await this.deliver(record, adapter);
155
+ }
156
+ catch (error) {
157
+ errors.push(error);
158
+ }
159
+ }
160
+ if (errors.length)
161
+ throw new AggregateError(errors, 'Async question delivery failed; pending questions retained');
162
+ }
163
+ async restore(adapter, selfAid) {
164
+ let names;
165
+ try {
166
+ names = fs.readdirSync(this.directory(selfAid));
167
+ }
168
+ catch (error) {
169
+ if (error.code === 'ENOENT')
170
+ return;
171
+ throw error;
172
+ }
173
+ for (const name of names) {
174
+ if (!/^aq-[a-f0-9]{40}\.json$/.test(name))
175
+ continue;
176
+ try {
177
+ const record = this.read(selfAid, name.slice(0, -5));
178
+ if (!record || record.channelKey !== adapter.channelKey)
179
+ continue;
180
+ if (this.registered.get(record.id) === adapter)
181
+ continue;
182
+ if (record.expiresAt <= Date.now()) {
183
+ fs.unlinkSync(this.file(selfAid, record.id));
184
+ continue;
185
+ }
186
+ if (record.status === 'submitting') {
187
+ // A crash may have happened after Codex accepted the steer. Never replay it blindly.
188
+ record.status = 'uncertain';
189
+ record.reason = 'restart_during_submission';
190
+ this.save(record);
191
+ }
192
+ if (record.status === 'pending')
193
+ await this.deliver(record, adapter);
194
+ }
195
+ catch (error) {
196
+ logger.warn(`[AsyncQuestions] restore failed: id=${name} error=${String(error)}`);
197
+ }
198
+ }
199
+ }
200
+ async validSession(record) {
201
+ const session = await this.deps.getSession(record.sessionId);
202
+ if (!session || session.baseagent !== 'codex' || session.selfAID !== record.selfAid
203
+ || session.agentSessionId !== record.providerThreadId
204
+ || session.channel !== record.origin.channel
205
+ || session.channelType !== record.origin.channelType
206
+ || session.channelId !== record.origin.channelId
207
+ || (session.threadId || '') !== (record.origin.threadId || '')
208
+ || !(await this.deps.authorize(record, session)))
209
+ return undefined;
210
+ return session;
211
+ }
212
+ /** Recheck persisted answers after queue delays/restarts, immediately before a provider run. */
213
+ async validateQueuedAnswer(session, answer) {
214
+ if (!session.selfAID)
215
+ return false;
216
+ const record = this.read(session.selfAID, answer.requestId);
217
+ return !!record && record.sessionId === session.id
218
+ && ['submitting', 'answered', 'uncertain'].includes(record.status)
219
+ && record.providerThreadId === answer.providerThreadId
220
+ && record.itemId === answer.questionItemId && record.question.title === answer.question
221
+ && record.answer === answer.answer && !!(await this.validSession(record));
222
+ }
223
+ async deliver(record, adapter) {
224
+ if (this.registered.get(record.id) === adapter)
225
+ return;
226
+ // A channel reload replaces its callback/card registry. Rebind pending questions to the new adapter.
227
+ if (this.registered.has(record.id))
228
+ await this.deps.router.cancel(record.id, 'daemon_restart');
229
+ const previous = this.deliveries.get(record.id);
230
+ if (previous)
231
+ return previous;
232
+ const delivery = this.deliverOnce(record, adapter);
233
+ this.deliveries.set(record.id, delivery);
234
+ try {
235
+ await delivery;
236
+ }
237
+ finally {
238
+ this.deliveries.delete(record.id);
239
+ }
240
+ }
241
+ async deliverOnce(record, adapter) {
242
+ if (record.expiresAt <= Date.now() || !(await this.validSession(record))) {
243
+ record.status = 'cancelled';
244
+ record.reason = 'expired_or_binding_changed';
245
+ this.save(record);
246
+ return;
247
+ }
248
+ const options = record.question.options ?? [];
249
+ const interaction = {
250
+ type: 'interaction', id: record.id, sessionId: record.sessionId, channelId: record.origin.channelId,
251
+ initiatorId: record.origin.peerId, blocking: false, expiresAt: record.expiresAt,
252
+ kind: {
253
+ kind: 'action', title: '请补充信息', body: record.question.title, bodyFormat: 'plain',
254
+ buttons: options.map((label, index) => ({ key: `opt-${index}`, label })),
255
+ allowCustomInput: true,
256
+ },
257
+ fallback: {
258
+ command: 'ask',
259
+ buttonArgMap: Object.fromEntries(options.map((_, index) => [`opt-${index}`, `${record.id} ${index + 1}`])),
260
+ acceptFreeText: true, freeTextHint: `回复 /ask ${record.id} <回答>`,
261
+ },
262
+ };
263
+ // Register before dispatch: a fast reply must not race card delivery.
264
+ this.registered.set(record.id, adapter);
265
+ this.deps.router.register(record.id, record.sessionId, async (action, values) => {
266
+ const current = this.read(record.selfAid, record.id);
267
+ if (!current || current.status !== 'pending' || current.expiresAt <= Date.now()) {
268
+ await this.deps.router.cancel(record.id, 'expired_or_resolved');
269
+ return false;
270
+ }
271
+ const answer = this.answerText(current.question, action, values);
272
+ if (!answer)
273
+ return false;
274
+ const session = await this.validSession(current);
275
+ if (!session) {
276
+ await this.deps.router.cancel(record.id, 'binding_or_authorization_changed');
277
+ return false;
278
+ }
279
+ current.answer = answer;
280
+ current.status = 'submitting';
281
+ this.save(current);
282
+ try {
283
+ await this.deps.submit(current, session, {
284
+ requestId: current.id, providerThreadId: current.providerThreadId,
285
+ questionItemId: current.itemId, question: current.question.title, answer,
286
+ });
287
+ current.status = 'answered';
288
+ this.save(current);
289
+ this.registered.delete(record.id);
290
+ logger.info(`[AsyncQuestions] answer accepted: session=${record.sessionId} request=${record.id}`);
291
+ return true;
292
+ }
293
+ catch (error) {
294
+ current.status = 'uncertain';
295
+ current.reason = 'submission_failed';
296
+ this.save(current);
297
+ logger.warn(`[AsyncQuestions] answer submission failed: request=${record.id} error=${String(error)}`);
298
+ throw error;
299
+ }
300
+ }, {
301
+ initiatorId: record.origin.peerId, initiatorChannelKey: adapter.channelKey,
302
+ blocking: false, retainOnRejected: true, fallbackCommand: 'ask', timeoutMs: record.expiresAt - Date.now(),
303
+ onCancel: async (reason) => {
304
+ this.registered.delete(record.id);
305
+ if (reason === 'daemon_restart')
306
+ return;
307
+ const current = this.read(record.selfAid, record.id);
308
+ if (current?.status === 'pending') {
309
+ current.status = 'cancelled';
310
+ current.reason = reason;
311
+ this.save(current);
312
+ }
313
+ await adapter.invalidateInteraction?.(record.id, reason);
314
+ },
315
+ });
316
+ try {
317
+ const receipt = await sendInteractionPayload(adapter, {
318
+ ...record.envelope, operationId: `${record.id}-${Date.now()}`,
319
+ }, interaction, renderActionAsText(interaction), record.envelope.replyContext);
320
+ if (!isInteractionSendAccepted(receipt))
321
+ throw new Error('Async question card was not accepted for delivery');
322
+ }
323
+ catch (error) {
324
+ // Keep the durable pending record for a later channel reconnect/daemon restore.
325
+ await this.deps.router.cancel(record.id, 'daemon_restart');
326
+ throw error;
327
+ }
328
+ }
329
+ answerText(question, action, values) {
330
+ if (action === 'cancel' || action === 'dismiss' || values?.behavior === 'dismiss')
331
+ return undefined;
332
+ let answer;
333
+ if (action === '_custom_input')
334
+ answer = typeof values?.custom_text === 'string' ? values.custom_text.trim() : undefined;
335
+ else if (/^opt-\d+$/.test(action))
336
+ answer = question.options?.[Number(action.slice(4))];
337
+ else if (question.options?.length && /^\d+$/.test(action))
338
+ answer = question.options[Number(action) - 1];
339
+ else
340
+ answer = action.trim();
341
+ return answer && answer.length <= 65_536 ? answer : undefined;
342
+ }
343
+ }
56
344
  const INBOUND_MESSAGE_LOG_TEXT_MAX_LENGTH = 240;
57
345
  export function formatInboundMessageLogText(text) {
58
346
  const singleLine = text.replace(/\s+/gu, ' ').trim();
@@ -519,12 +807,15 @@ export class ResponseEngine {
519
807
  primaryRunnerKey;
520
808
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
521
809
  timeoutErrors = new Map();
810
+ activeTimeoutHandlers = new Map();
522
811
  /** sessionId → 已成功的模型降级状态(进程重启清零) */
523
812
  modelFallbackMap = new Map();
524
813
  /** sessionId → interruptible task-level API retry delays. */
525
814
  retryDelayWaiters = new Map();
526
815
  interactionRouter;
816
+ asyncQuestions;
527
817
  messageQueue;
818
+ taskRecoveryService;
528
819
  handoffRuntime;
529
820
  /** sessionId → 活跃的空闲监控器,用于等待用户交互期间暂停/恢复计时 */
530
821
  activeMonitors = new Map();
@@ -598,10 +889,7 @@ export class ResponseEngine {
598
889
  return createTextInferenceProvider(baseagent, config);
599
890
  }
600
891
  getAvailableBaseagentsForOwner(evolName) {
601
- const prefix = `${evolName}::`;
602
- return [...this.agentMap.keys()]
603
- .filter(key => key.startsWith(prefix))
604
- .map(key => key.slice(prefix.length));
892
+ return loadedBaseagentIds(this.agentMap.keys(), evolName);
605
893
  }
606
894
  async sendBaseagentMismatch(channelKey, channelId, session, error, replyContext) {
607
895
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -742,6 +1030,12 @@ export class ResponseEngine {
742
1030
  }
743
1031
  setInteractionRouter(router) {
744
1032
  this.interactionRouter = router;
1033
+ this.asyncQuestions = new AsyncQuestionService({
1034
+ router,
1035
+ getSession: id => this.sessionManager.getSessionById(id),
1036
+ authorize: async (record, session) => this.authorizeAsyncQuestion(record, session),
1037
+ submit: (record, session, answer) => this.submitAsyncQuestionAnswer(record, session, answer),
1038
+ });
745
1039
  // 等待用户交互期间暂停 idle 监控,应答/取消/超时后恢复——
746
1040
  // 避免把「正在等用户点按钮」误判为「任务卡死」而中断任务。
747
1041
  router.setWaitHooks({
@@ -752,9 +1046,118 @@ export class ResponseEngine {
752
1046
  this.activeMonitors.get(sessionId)?.resume();
753
1047
  },
754
1048
  });
1049
+ for (const { adapter } of this.channels.values())
1050
+ this.restoreAsyncQuestions(adapter);
755
1051
  }
756
1052
  setMessageQueue(queue) {
757
1053
  this.messageQueue = queue;
1054
+ this.taskRecoveryService = new TaskRecoveryService({
1055
+ sessions: this.sessionManager,
1056
+ queue,
1057
+ getRunner: (session) => this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, session.selfAID),
1058
+ resumePause: (sessionId) => this.resumeSession(sessionId),
1059
+ hasChannel: (session) => !!this.resolveChannelInfo(session.metadata?.channelKey || session.channel),
1060
+ });
1061
+ }
1062
+ async recoverTask(request) {
1063
+ if (!this.taskRecoveryService) {
1064
+ return { ok: false, status: 'not_recoverable', sessionId: request.sessionId, message: '消息队列尚未初始化' };
1065
+ }
1066
+ return this.taskRecoveryService.resume(request);
1067
+ }
1068
+ authorizeAsyncQuestion(record, session) {
1069
+ const origin = record.origin;
1070
+ const owner = this.agentRegistry?.resolveByChannel(origin.channel);
1071
+ if (owner?.aid !== record.selfAid)
1072
+ return false;
1073
+ const chatType = origin.chatType === 'group' ? 'group' : 'private';
1074
+ const channelType = origin.channelType || record.channelKey.split('#')[0];
1075
+ const preflight = evaluateInboundAdmissionPreflight({
1076
+ selfAid: record.selfAid, channelKey: record.channelKey, channelType, chatType, actorId: origin.peerId,
1077
+ });
1078
+ if (!preflight.allow || !evaluateOrdinaryInboundAdmission(preflight.context).allow)
1079
+ return false;
1080
+ const identity = this.inferDirectSessionIdentity({ ...origin, content: '' }, owner);
1081
+ if (!identity || identity.role === 'none')
1082
+ return false;
1083
+ return authorizeAccess(buildAuthSubject({
1084
+ selfAid: record.selfAid, channel: record.channelKey, channelType, chatType,
1085
+ actorId: origin.peerId, channelId: session.channelId, conversationId: session.channelId, identity,
1086
+ })).allow;
1087
+ }
1088
+ async submitAsyncQuestionAnswer(record, session, answer) {
1089
+ const runner = this.getAgent(record.origin.channel, 'codex', record.selfAid);
1090
+ if (!runner.steerAsyncQuestionAnswer)
1091
+ throw new Error('Codex async input is unavailable');
1092
+ if (await runner.steerAsyncQuestionAnswer(session.id, answer) === 'accepted')
1093
+ return;
1094
+ if (!this.messageQueue)
1095
+ throw new Error('Async question resume queue is unavailable');
1096
+ // The queue owns idle-thread restoration and a fresh task/permission context.
1097
+ // Keep the exact Session/backend identity; never create a new topic for an old card.
1098
+ await this.messageQueue.enqueuePersisted(session.id, {
1099
+ ...record.origin, content: formatAsyncQuestionAnswer(answer),
1100
+ asyncQuestionAnswer: answer, boundSessionId: session.id, baseagent: 'codex',
1101
+ source: 'card-trigger', messageId: `async-answer-${record.id}`,
1102
+ timestamp: Date.now(), receivedAt: Date.now(),
1103
+ encrypted: record.envelope.replyContext?.metadata?.encrypted,
1104
+ replyContext: record.envelope.replyContext,
1105
+ }, session.projectPath, {
1106
+ interruptible: false, interruptSamePeer: false,
1107
+ agentName: this.agentRegistry?.resolveByChannel(record.origin.channel)?.name,
1108
+ selfAID: record.selfAid, sessionKeyField: session.sessionKey,
1109
+ });
1110
+ }
1111
+ /** Read the exact attempt's lifecycle without touching any newer task. */
1112
+ async inspectTargetExecution(sessionId, attemptId, selfAid) {
1113
+ const session = await this.sessionManager.getSessionById(sessionId);
1114
+ if (!session || session.deletedAt || session.selfAID !== selfAid)
1115
+ return { state: 'missing' };
1116
+ const state = this.turnCoordinator.current(session);
1117
+ const terminal = state.lastTerminal;
1118
+ const matches = (context) => (context?.triggerAttemptId ?? context?.recovery?.attemptId) === attemptId;
1119
+ if (terminal && matches(terminal.recoveryContext)) {
1120
+ return { state: 'terminal', taskId: terminal.taskId, generation: terminal.generation,
1121
+ outcome: terminal.status === 'committed' ? 'completed'
1122
+ : ['cancelled', 'superseded', 'interrupted'].includes(terminal.status) ? 'skipped' : 'failed',
1123
+ reason: terminal.reason };
1124
+ }
1125
+ const queued = this.messageQueue?.getMessageState(sessionId, attemptId, selfAid);
1126
+ if (queued?.state === 'queued')
1127
+ return { state: 'live' };
1128
+ const active = state.active;
1129
+ if (active && matches(active.recoveryContext)) {
1130
+ const identity = { taskId: active.taskId, generation: active.generation };
1131
+ if (active.status === 'waiting_interaction' || active.status === 'interrupting'
1132
+ || active.interactions.some(item => !item.settlement)
1133
+ || this.isPauseRequested(sessionId) || this.retryDelayWaiters.has(sessionId))
1134
+ return { state: 'live', ...identity };
1135
+ const runner = this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, selfAid);
1136
+ return { state: runner.hasActiveStream(sessionId) ? 'live' : 'missing', ...identity,
1137
+ hasTerminalOwner: this.activeTimeoutHandlers.has(sessionId) };
1138
+ }
1139
+ // Never adopt or interrupt a newer task to clean up an older attempt.
1140
+ if (active) {
1141
+ return { state: 'terminal', outcome: 'skipped', reason: 'stale_generation',
1142
+ taskId: active.taskId, generation: active.generation };
1143
+ }
1144
+ if (queued?.state === 'active') {
1145
+ return { state: 'missing', hasTerminalOwner: this.activeTimeoutHandlers.has(sessionId) };
1146
+ }
1147
+ if (queued?.state === 'missing' && (active || terminal)) {
1148
+ const current = active ?? terminal;
1149
+ return { state: 'terminal', outcome: 'skipped', reason: 'stale_generation',
1150
+ taskId: current.taskId, generation: current.generation };
1151
+ }
1152
+ return { state: 'missing' };
1153
+ }
1154
+ /** Outer execution deadlines use the same terminal owner as runner idle timeouts. */
1155
+ async timeoutSession(sessionId, error) {
1156
+ const timeout = this.activeTimeoutHandlers.get(sessionId);
1157
+ if (timeout)
1158
+ await timeout(error);
1159
+ else
1160
+ await this.interruptSession(sessionId, error instanceof OrphanedTargetSessionError ? 'execution_error' : 'timeout');
758
1161
  }
759
1162
  async interruptSession(sessionId, reason) {
760
1163
  this.clearPauseSession(sessionId);
@@ -808,7 +1211,7 @@ export class ResponseEngine {
808
1211
  // TimeoutController owns timeout delivery (including user-facing timeout
809
1212
  // details) and already guarantees a single terminal status. Sending it
810
1213
  // here as well would race that barrier and duplicate status.timeout.
811
- if (!interruptedTaskId || effectiveReason === 'timeout')
1214
+ if (!interruptedTaskId || effectiveReason === 'timeout' || effectiveReason === 'execution_error')
812
1215
  return Promise.resolve();
813
1216
  const channelInfo = this.resolveChannelInfo(channelKey);
814
1217
  if (!channelInfo)
@@ -930,6 +1333,8 @@ export class ResponseEngine {
930
1333
  agentRegistry;
931
1334
  setAgentRegistry(registry) {
932
1335
  this.agentRegistry = registry;
1336
+ for (const { adapter } of this.channels.values())
1337
+ this.restoreAsyncQuestions(adapter);
933
1338
  }
934
1339
  /** 更新 EvolAgent.lastActivity —— 每次发出 status.* 事件(含 progress)时调用 */
935
1340
  touchAgentActivity(channelKey) {
@@ -1026,6 +1431,15 @@ export class ResponseEngine {
1026
1431
  if (!this.channelTypeMap.has(type)) {
1027
1432
  this.channelTypeMap.set(type, adapter.channelName);
1028
1433
  }
1434
+ this.restoreAsyncQuestions(adapter);
1435
+ }
1436
+ restoreAsyncQuestions(adapter) {
1437
+ const selfAid = this.agentRegistry?.resolveByChannel(adapter.channelKey)?.aid;
1438
+ if (selfAid && this.asyncQuestions) {
1439
+ void this.asyncQuestions.restore(adapter, selfAid).catch(error => {
1440
+ logger.warn(`[AsyncQuestions] channel restore failed: ${String(error)}`);
1441
+ });
1442
+ }
1029
1443
  }
1030
1444
  /**
1031
1445
  * 注销渠道适配器(热重载断开渠道时调用,避免遗留死实例)。
@@ -1219,7 +1633,10 @@ export class ResponseEngine {
1219
1633
  && (!authorization.triggerId || !authorization.runId || !authorization.attemptId)) {
1220
1634
  throw new Error('fullaccess Trigger authorization lacks scheduler identity or approval provenance');
1221
1635
  }
1222
- if (authorization.source !== 'fullaccess-command' && authorization.source !== 'trigger') {
1636
+ if (authorization.source === 'agent-policy') {
1637
+ assertAgentPolicyAuthorization(authorization);
1638
+ }
1639
+ else if (authorization.source !== 'fullaccess-command' && authorization.source !== 'trigger') {
1223
1640
  throw new Error('fullaccess execution authorization has an unsupported source');
1224
1641
  }
1225
1642
  if (!isFullAccessEnabled()) {
@@ -1241,6 +1658,8 @@ export class ResponseEngine {
1241
1658
  const originChannelKey = message.triggerMeta.originChannelKey;
1242
1659
  if (!selfAid || !actorId || !channelType || !originChannelKey)
1243
1660
  return undefined;
1661
+ const chatType = message.triggerMeta.originChatType ?? 'private';
1662
+ const conversationId = message.triggerMeta.originConversationId ?? actorId;
1244
1663
  const control = message.triggerMeta.originControl === true;
1245
1664
  let actor;
1246
1665
  let resolvedRole = 'none';
@@ -1249,9 +1668,9 @@ export class ResponseEngine {
1249
1668
  selfAid,
1250
1669
  channelKey: originChannelKey,
1251
1670
  channelType,
1252
- chatType: 'private',
1671
+ chatType,
1253
1672
  actorId,
1254
- conversationId: actorId,
1673
+ conversationId,
1255
1674
  });
1256
1675
  actor = detail.actor;
1257
1676
  resolvedRole = detail.effectiveRole || 'none';
@@ -1271,7 +1690,7 @@ export class ResponseEngine {
1271
1690
  channelType,
1272
1691
  originChannelKey,
1273
1692
  control,
1274
- peerKey: formatPeerKey(channelType, actorId),
1693
+ peerKey: formatPeerKey(channelType, chatType === 'group' ? conversationId : actorId),
1275
1694
  };
1276
1695
  }
1277
1696
  resolveHandoffOriginExecutionIdentity(message, selfAid) {
@@ -1437,7 +1856,8 @@ export class ResponseEngine {
1437
1856
  auditFullAccessEvent({
1438
1857
  event: fallbackFullAccessAudit.authorization.source === 'trigger'
1439
1858
  ? 'fullaccess.trigger.execution.ended'
1440
- : 'fullaccess.execution.ended',
1859
+ : fallbackFullAccessAudit.authorization.source === 'agent-policy'
1860
+ ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
1441
1861
  source: fallbackFullAccessAudit.authorization.source,
1442
1862
  actorId: fallbackFullAccessAudit.authorization.authorizedBy,
1443
1863
  processRole: fallbackFullAccessAudit.processRole,
@@ -1458,6 +1878,15 @@ export class ResponseEngine {
1458
1878
  async processMessageWithMonitors(message) {
1459
1879
  const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
1460
1880
  const totalExecutionMs = this.totalExecutionLimitMs();
1881
+ if (message.taskRecovery) {
1882
+ const current = message.boundSessionId
1883
+ ? await this.sessionManager.getSessionById(message.boundSessionId)
1884
+ : undefined;
1885
+ if (!current || current.selfAID !== message.selfAID || !isTaskRecoveryCurrent(current, message.taskRecovery)) {
1886
+ this.publishTriggerExecutionSkipped(message, 'recovery_stale');
1887
+ return;
1888
+ }
1889
+ }
1461
1890
  if (message.handoffDelivery
1462
1891
  && this.handoffRuntime
1463
1892
  && this.handoffRuntime.isDiscardedDelivery(message)) {
@@ -1520,7 +1949,8 @@ export class ResponseEngine {
1520
1949
  const userRole = session.identity?.role || 'none';
1521
1950
  const isInternalHandoff = message.source === 'handoff';
1522
1951
  const fullAccessAuditCandidate = this.resolveFullAccessAuditCandidate(message);
1523
- const isCurrentFullAccessCommand = fullAccessAuditCandidate?.authorization.source === 'fullaccess-command'
1952
+ const isCurrentFullAccessCommand = (fullAccessAuditCandidate?.authorization.source === 'fullaccess-command'
1953
+ || fullAccessAuditCandidate?.authorization.source === 'agent-policy')
1524
1954
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
1525
1955
  const isCurrentFullAccessTrigger = fullAccessAuditCandidate?.authorization.source === 'trigger'
1526
1956
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
@@ -1609,11 +2039,17 @@ export class ResponseEngine {
1609
2039
  claimedByInternal: false,
1610
2040
  terminalDelivered: false,
1611
2041
  claim: () => {
1612
- if (!timeoutControl.error || this.interruptedSessions.get(session.id) !== 'timeout')
2042
+ if (!timeoutControl.error || !['timeout', 'execution_error'].includes(this.interruptedSessions.get(session.id) ?? ''))
1613
2043
  return undefined;
1614
2044
  timeoutControl.claimedByInternal = true;
1615
2045
  return timeoutControl.error;
1616
2046
  },
2047
+ claimTerminal: () => {
2048
+ if (timeoutControl.terminalDelivered)
2049
+ return false;
2050
+ timeoutControl.terminalDelivered = true;
2051
+ return true;
2052
+ },
1617
2053
  };
1618
2054
  const rejectAfterInterruptBarrier = (error) => {
1619
2055
  if (timeoutTriggered)
@@ -1622,9 +2058,10 @@ export class ResponseEngine {
1622
2058
  timeoutControl.error = error;
1623
2059
  this.timeoutErrors.set(session.id, error);
1624
2060
  this.turnCoordinator.hydrate(session);
1625
- this.turnCoordinator.invalidate(session.id, 'timeout');
1626
- this.interruptedSessions.set(session.id, 'timeout');
1627
- const barrier = this.interruptSession(session.id, 'timeout')
2061
+ const reason = error instanceof OrphanedTargetSessionError ? 'execution_error' : 'timeout';
2062
+ this.turnCoordinator.invalidate(session.id, reason);
2063
+ this.interruptedSessions.set(session.id, reason);
2064
+ const barrier = this.interruptSession(session.id, reason)
1628
2065
  .catch(interruptError => {
1629
2066
  logger.debug(`[ResponseEngine] Timeout interrupt barrier failed: ${interruptError instanceof Error ? interruptError.message : String(interruptError)}`);
1630
2067
  });
@@ -1635,6 +2072,11 @@ export class ResponseEngine {
1635
2072
  });
1636
2073
  };
1637
2074
  const triggerAttemptId = message.replyContext?.metadata?.triggerAttemptId;
2075
+ const externalTimeout = async (error) => {
2076
+ rejectAfterInterruptBarrier(error);
2077
+ await timeoutControl.barrier;
2078
+ };
2079
+ this.activeTimeoutHandlers.set(session.id, externalTimeout);
1638
2080
  const resetTimer = (eventType, toolName) => {
1639
2081
  monitor?.recordEvent(eventType || 'unknown', toolName);
1640
2082
  if (typeof triggerAttemptId === 'string') {
@@ -1714,11 +2156,9 @@ export class ResponseEngine {
1714
2156
  await Promise.race(processingPromises);
1715
2157
  }
1716
2158
  catch (error) {
1717
- if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
2159
+ if (error instanceof Error && (isExecutionFailure(error) || error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
1718
2160
  await timeoutControl.barrier;
1719
- if (!timeoutControl.terminalDelivered) {
1720
- await timeoutControl.deliverUnhandled?.(error);
1721
- }
2161
+ await timeoutControl.deliverUnhandled?.(error);
1722
2162
  this.timeoutErrors.delete(session.id);
1723
2163
  }
1724
2164
  // 超时错误:kill 级别已发送诊断信息,无需再发
@@ -1759,6 +2199,8 @@ export class ResponseEngine {
1759
2199
  if (totalExecutionTimer)
1760
2200
  clearTimeout(totalExecutionTimer);
1761
2201
  this.activeMonitors.delete(streamKey);
2202
+ if (this.activeTimeoutHandlers.get(session.id) === externalTimeout)
2203
+ this.activeTimeoutHandlers.delete(session.id);
1762
2204
  }
1763
2205
  }
1764
2206
  resolveFullAccessAuditCandidate(message) {
@@ -1766,15 +2208,18 @@ export class ResponseEngine {
1766
2208
  if (commandAuthorization?.permissionMode === 'fullaccess'
1767
2209
  && commandAuthorization.processRole === 'fullaccess-run'
1768
2210
  && commandAuthorization.dataScope === 'daemon'
1769
- && commandAuthorization.source === 'fullaccess-command'
2211
+ && (commandAuthorization.source === 'fullaccess-command' || commandAuthorization.source === 'agent-policy')
1770
2212
  && typeof commandAuthorization.authorizedBy === 'string'
1771
2213
  && commandAuthorization.authorizedBy.length > 0) {
1772
2214
  let currentOwner = false;
1773
2215
  try {
1774
2216
  currentOwner = isFullAccessEnabled()
1775
2217
  && new Set(loadDaemonConfig().owners ?? []).has(commandAuthorization.authorizedBy);
2218
+ if (commandAuthorization.source === 'agent-policy')
2219
+ assertAgentPolicyAuthorization(commandAuthorization);
1776
2220
  }
1777
2221
  catch {
2222
+ currentOwner = false;
1778
2223
  // Runtime validation will fail closed; the audit must still retain the
1779
2224
  // attempted authorization without falsely labeling it active.
1780
2225
  }
@@ -1894,7 +2339,7 @@ export class ResponseEngine {
1894
2339
  // states. An untransitioned `created` agent or malformed lifecycle must
1895
2340
  // not fall through to a base runner's default prompt. Missing lifecycle
1896
2341
  // is normalized to active for legacy configurations.
1897
- if (owningAgentForTask && !isActive && !isBootstrapping) {
2342
+ if (owningAgentForTask && ((!isActive && !isBootstrapping) || initializationBlocksTasks(owningAgentForTask.config))) {
1898
2343
  const blockedLifecycle = lifecycle
1899
2344
  ?? `invalid (${String(owningAgentForTask.config.lifecycle)})`;
1900
2345
  logger.error(`[ResponseEngine] Agent lifecycle is not runnable: agent=${owningAgentForTask.aid} lifecycle=${blockedLifecycle}`);
@@ -1991,7 +2436,7 @@ export class ResponseEngine {
1991
2436
  ?? this.resolveHandoffOriginExecutionIdentity(message, selfAid);
1992
2437
  const configChannelType = authorizationIdentity?.channelType ?? currentChannelType;
1993
2438
  const configChatType = authorizationIdentity
1994
- ? (message.handoffDelivery?.originAuthorization?.chatType ?? 'private')
2439
+ ? (message.triggerMeta?.originChatType ?? message.handoffDelivery?.originAuthorization?.chatType ?? 'private')
1995
2440
  : (chatType === 'group' ? 'group' : 'private');
1996
2441
  // A group channel ID identifies the conversation, not the authenticated
1997
2442
  // operator. Keep the historical private-chat fallback (where channelId
@@ -2179,25 +2624,30 @@ export class ResponseEngine {
2179
2624
  });
2180
2625
  if (timeoutControl) {
2181
2626
  timeoutControl.deliverUnhandled = async (timeoutError) => {
2182
- if (timeoutControl.terminalDelivered)
2627
+ if (!timeoutControl.claimTerminal())
2183
2628
  return;
2184
- timeoutControl.terminalDelivered = true;
2185
2629
  const isTotalExecutionTimeout = timeoutError.message === 'TOTAL_EXECUTION_TIMEOUT';
2186
- const totalExecutionMs = this.totalExecutionLimitMs();
2187
- const statusPayload = {
2630
+ const totalExecutionMs = timeoutError instanceof ExecutionTimeoutError ? timeoutError.timeoutMs : this.totalExecutionLimitMs();
2631
+ const statusPayload = timeoutError instanceof OrphanedTargetSessionError ? {
2632
+ kind: 'status.error',
2633
+ metadata: { errorType: timeoutError.reason, message: '任务执行状态丢失,已结束本次执行。' },
2634
+ } : {
2188
2635
  kind: 'status.timeout',
2189
2636
  metadata: isTotalExecutionTimeout
2190
- ? (totalExecutionMs === undefined ? {} : { totalExecutionMs })
2637
+ ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
2638
+ ...(timeoutError instanceof ExecutionTimeoutError ? { reason: timeoutError.reason } : {}) }
2191
2639
  : { idleSec: getLastIdleSec?.() || undefined },
2192
2640
  };
2193
2641
  const idleSec = getLastIdleSec?.() || 0;
2194
- const userMessage = isTotalExecutionTimeout
2195
- ? (totalExecutionMs === undefined
2196
- ? '⚠️ 任务超过总执行时限,已自动中断'
2197
- : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
2198
- : idleSec > 0
2199
- ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
2200
- : '⚠️ 任务超时,已自动中断';
2642
+ const userMessage = timeoutError instanceof OrphanedTargetSessionError
2643
+ ? '⚠️ 任务执行状态丢失,已结束本次执行。'
2644
+ : isTotalExecutionTimeout
2645
+ ? (totalExecutionMs === undefined
2646
+ ? '⚠️ 任务超过总执行时限,已自动中断'
2647
+ : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
2648
+ : idleSec > 0
2649
+ ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
2650
+ : '⚠️ 任务超时,已自动中断';
2201
2651
  agent.cleanupStream(streamKey);
2202
2652
  this.sessionManager.clearProcessingIfTask(session.id, taskId);
2203
2653
  if (this.activeTaskRuntimeContexts.get(session.id)?.taskId === taskId) {
@@ -2211,13 +2661,13 @@ export class ResponseEngine {
2211
2661
  await adapter.send(envelope, {
2212
2662
  kind: 'result.error',
2213
2663
  text: userMessage,
2214
- reason: isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
2664
+ reason: isExecutionFailure(timeoutError) ? timeoutError.reason : isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
2215
2665
  }).catch(() => { });
2216
2666
  if (daemonTrigger) {
2217
2667
  await adapter.send(envelope, statusPayload).catch(() => { });
2218
2668
  }
2219
2669
  this.touchAgentActivity(channelKey);
2220
- this.publishTriggerExecutionFailure(message, timeoutError.message, {
2670
+ this.publishTriggerExecutionFailure(message, isExecutionFailure(timeoutError) ? timeoutError.reason : timeoutError.message, {
2221
2671
  messageId,
2222
2672
  causation: taskCausation,
2223
2673
  });
@@ -2225,9 +2675,14 @@ export class ResponseEngine {
2225
2675
  this.eventBus.publish({
2226
2676
  type: 'task:error',
2227
2677
  sessionId: session.id,
2678
+ agentAid: runnerSelfAid,
2679
+ peerId: configActorId,
2680
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
2681
+ originTriggerId: message.triggerMeta?.triggerId,
2228
2682
  taskId,
2229
2683
  generation: turnLease?.generation,
2230
2684
  error: timeoutError.message,
2685
+ terminalReason: isExecutionFailure(timeoutError) ? timeoutError.reason : undefined,
2231
2686
  errorType,
2232
2687
  agentName: agentNameForStats,
2233
2688
  causation: taskCausation,
@@ -2356,14 +2811,28 @@ export class ResponseEngine {
2356
2811
  // message is authoritative; session metadata is only a fallback for
2357
2812
  // legacy paths that do not carry a per-message display name.
2358
2813
  const peerName = message.peerName ?? session.metadata?.peerName;
2359
- const peerId = session.metadata?.peerId ?? message.peerId ?? message.channelId;
2814
+ const peerId = message.peerId ?? session.metadata?.peerId ?? message.channelId;
2360
2815
  const peerShort = peerId ? peerId.split('.')[0].split(':')[0] : '?';
2361
2816
  const peerLabel = peerName && peerName !== peerShort ? `${peerShort}(${peerName})` : peerShort;
2362
2817
  logger.info(`[ResponseEngine] session=${session.id} task=${taskId} peer=${peerLabel} chatType=${session.chatType} chatMode=${effectiveChatMode} baseagent=${session.baseagent} msgChatType=${message.chatType ?? 'n/a'}`);
2363
2818
  // Establish the durable turn before exposing any processing state. This
2364
2819
  // lets an immediate /stop or replacement message close the same task and
2365
2820
  // prevents a BusinessTrip reaction from becoming orphaned during setup.
2366
- turnLease = await this.turnCoordinator.begin(session, taskId);
2821
+ if (message.taskRecovery && !isTaskRecoveryCurrent({
2822
+ ...session, metadata: { ...session.metadata, turnState: this.turnCoordinator.current(session) },
2823
+ }, message.taskRecovery)) {
2824
+ this.publishTriggerExecutionSkipped(message, 'recovery_stale');
2825
+ return;
2826
+ }
2827
+ turnLease = await this.turnCoordinator.begin(session, taskId, undefined, configActorId ? {
2828
+ peerId: configActorId,
2829
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
2830
+ baseagent: session.baseagent,
2831
+ agentSessionId: session.agentSessionId,
2832
+ originTriggerId: message.triggerMeta?.triggerId,
2833
+ recovery: message.taskRecovery?.origin,
2834
+ triggerAttemptId: message.triggerMeta?.attemptId,
2835
+ } : undefined);
2367
2836
  // 记录开始处理
2368
2837
  const taskEncrypt = message.replyContext?.metadata?.encrypted != null ? !!(message.replyContext.metadata.encrypted) : undefined;
2369
2838
  this.eventBus.publish({
@@ -2440,7 +2909,10 @@ export class ResponseEngine {
2440
2909
  if (payload.kind === 'result.text' && payload.isFinal) {
2441
2910
  opts.title = '\u2705 \u6700\u7ec8\u56de\u590d:';
2442
2911
  }
2443
- opts.metadata = withTaskMetadata(opts.metadata);
2912
+ opts.metadata = withTaskMetadata({
2913
+ ...(opts.metadata ?? {}),
2914
+ ...(payload.kind === 'result.text' ? payload.metadata : {}),
2915
+ });
2444
2916
  // status.notice is a diagnostic observation, not execution activity.
2445
2917
  if (payload.kind.startsWith('status.') && payload.kind !== 'status.notice') {
2446
2918
  this.touchAgentActivity(channelKey);
@@ -2585,10 +3057,10 @@ export class ResponseEngine {
2585
3057
  };
2586
3058
  })();
2587
3059
  let effectivePermissionMode = 'readonly';
2588
- // Allocate/register the session-owned root before any runner preflight
2589
- // context is installed. Codex may already own a stronger directory;
2590
- // other runners receive a private child of the process TMPDIR.
2591
- const sessionRuntimeDir = agent.getManagedTempDir?.(session.id) ?? ensureSessionRuntimeDir(session.id);
3060
+ // Allocate the stable session-owned root before any runner preflight
3061
+ // context is installed. Every runner for this EvolCore session receives
3062
+ // the same directory, independent of daemon or runner lifetimes.
3063
+ const sessionRuntimeDir = ensureSessionRuntimeDir(session.id);
2592
3064
  const recordExecutionAnomaly = triggerRunId
2593
3065
  ? (anomaly) => {
2594
3066
  recordTriggerExecutionAnomaly(triggerRunId, {
@@ -2636,8 +3108,8 @@ export class ResponseEngine {
2636
3108
  pauseController: taskPauseController,
2637
3109
  preToolUsePolicyHook: pureSessionPolicyHook,
2638
3110
  armApprovedDelegationCommand: this.agentDelegationRegistry
2639
- ? (carrierToken, commandHash) => this.agentDelegationRegistry.armApprovedCommand({
2640
- carrierToken,
3111
+ ? (sessionToken, commandHash) => this.agentDelegationRegistry.armApprovedCommand({
3112
+ sessionToken,
2641
3113
  sessionId: session.id,
2642
3114
  taskId,
2643
3115
  commandHash,
@@ -2812,10 +3284,19 @@ export class ResponseEngine {
2812
3284
  if (commandFullAccess.permissionMode !== 'fullaccess'
2813
3285
  || commandFullAccess.processRole !== 'fullaccess-run'
2814
3286
  || commandFullAccess.dataScope !== 'daemon'
2815
- || commandFullAccess.source !== 'fullaccess-command'
3287
+ || (commandFullAccess.source !== 'fullaccess-command' && commandFullAccess.source !== 'agent-policy')
2816
3288
  || !commandFullAccess.authorizedBy) {
2817
3289
  throw new Error('fullaccess command authorization is missing or malformed');
2818
3290
  }
3291
+ if (commandFullAccess.source === 'agent-policy'
3292
+ && (commandFullAccess.agentAid !== runnerSelfAid
3293
+ || configChatType !== 'private'
3294
+ || (message.source !== undefined && message.source !== 'user')
3295
+ || !hasTrustedPrincipal(authenticatedActor)
3296
+ || authenticatedActor.principalId !== commandFullAccess.authorizedBy
3297
+ || authenticatedActor.operatorId !== commandFullAccess.policyActor?.operatorId)) {
3298
+ throw new Error('Agent fullaccess policy authorization is missing its Agent identity');
3299
+ }
2819
3300
  fullAccessAuthorization = commandFullAccess;
2820
3301
  }
2821
3302
  else {
@@ -3030,18 +3511,25 @@ export class ResponseEngine {
3030
3511
  // happen before task-start auto compact because compact is a backend
3031
3512
  // management action just like the subsequent runQuery.
3032
3513
  let runAgentSessionId = session.agentSessionId;
3033
- if (session.threadId
3514
+ if ((session.threadId || message.taskRecovery)
3034
3515
  && typeof this.sessionManager.getSessionById === 'function') {
3035
3516
  const latestBackendSession = await this.sessionManager.getSessionById(session.id);
3036
- if (!latestBackendSession || latestBackendSession.threadId !== session.threadId) {
3517
+ if (!latestBackendSession || latestBackendSession.deletedAt || latestBackendSession.threadId !== session.threadId) {
3037
3518
  throw new Error('topic session changed before backend run started');
3038
3519
  }
3520
+ if (message.taskRecovery && (latestBackendSession.agentSessionId !== message.taskRecovery.agentSessionId
3521
+ || latestBackendSession.baseagent !== message.taskRecovery.baseagent)) {
3522
+ throw new Error('recovery backend changed before execution');
3523
+ }
3039
3524
  runAgentSessionId = latestBackendSession.agentSessionId;
3040
3525
  session.agentSessionId = runAgentSessionId;
3041
3526
  session.metadata = latestBackendSession.metadata;
3042
3527
  }
3043
3528
  modelOverride = {
3044
3529
  ...(modelOverride || {}),
3530
+ ...(message.taskRecovery
3531
+ ? { recovery: { agentSessionId: message.taskRecovery.agentSessionId } }
3532
+ : {}),
3045
3533
  ...(session.threadId
3046
3534
  ? { backend: { kind: 'topic', agentSessionId: runAgentSessionId ?? null } }
3047
3535
  : {}),
@@ -3054,13 +3542,17 @@ export class ResponseEngine {
3054
3542
  },
3055
3543
  };
3056
3544
  const refreshTopicBindingForLogicalRetry = async () => {
3057
- if (!session.threadId || typeof this.sessionManager.getSessionById !== 'function')
3545
+ if ((!session.threadId && !message.taskRecovery) || typeof this.sessionManager.getSessionById !== 'function')
3058
3546
  return;
3059
3547
  const latest = await this.sessionManager.getSessionById(session.id);
3060
3548
  if (!latest || latest.threadId !== session.threadId) {
3061
3549
  throw new Error('topic session changed during backend run');
3062
3550
  }
3063
3551
  const latestAgentSessionId = latest.agentSessionId;
3552
+ if (message.taskRecovery && (latestAgentSessionId !== message.taskRecovery.agentSessionId
3553
+ || latest.baseagent !== message.taskRecovery.baseagent)) {
3554
+ throw new Error('recovery backend changed during execution');
3555
+ }
3064
3556
  session.agentSessionId = latestAgentSessionId;
3065
3557
  session.metadata = latest.metadata;
3066
3558
  if (latestAgentSessionId === runAgentSessionId)
@@ -3097,6 +3589,26 @@ export class ResponseEngine {
3097
3589
  groupId: session.metadata?.groupId || message.channelId,
3098
3590
  channel: currentChannelType || message.channel,
3099
3591
  });
3592
+ const historyGroupId = session.metadata?.groupId || message.channelId;
3593
+ const canUseGroupHistory = (operation) => {
3594
+ if (currentChannelType !== 'aun' || session.chatType !== 'group' || !selfAid || !historyGroupId)
3595
+ return false;
3596
+ const historySubject = buildAuthSubject({
3597
+ selfAid, actorId: configActorId, channel: channelKey, channelType: configChannelType,
3598
+ chatType: configChatType, channelId: message.channelId,
3599
+ conversationId: configChatType === 'group' ? historyGroupId : configActorId,
3600
+ permissionMode: effectivePermissionMode,
3601
+ });
3602
+ return authorizeOperation({
3603
+ source: 'agent-tool', subject: historySubject, audit: false,
3604
+ intent: {
3605
+ operation, scope: ['owner', 'admin'].includes(historySubject.role) ? 'agent' : 'relation',
3606
+ source: 'agent-tool',
3607
+ args: { self: selfAid, targetId: historyGroupId, peer: historyGroupId,
3608
+ peerKey: formatPeerKey('aun', historyGroupId) },
3609
+ },
3610
+ }).allow;
3611
+ };
3100
3612
  // Kit renderer: 组装上下文
3101
3613
  const pkgRoot = getPackageRoot();
3102
3614
  const kitCtx = {
@@ -3128,11 +3640,12 @@ export class ResponseEngine {
3128
3640
  peerKey,
3129
3641
  peerName: peerName || undefined,
3130
3642
  peerRole,
3131
- peerType: message.peerType || session.metadata?.peerType || undefined,
3643
+ // Display missing identity explicitly without changing the mode resolver's legacy fallback.
3644
+ peerType: peerType || 'unknown',
3132
3645
  sameDevice: message.sameDevice ?? false,
3133
3646
  sameNetwork: message.sameNetwork ?? false,
3134
3647
  sameEgressIp: message.sameEgressIp ?? false,
3135
- groupId: session.metadata?.groupId || undefined,
3648
+ groupId: session.chatType === 'group' ? historyGroupId : undefined,
3136
3649
  groupName: session.metadata?.groupName || undefined,
3137
3650
  // 信封展示用:有群名则「名<ID>」,否则纯 ID。规避模板引擎无 not/else 的限制。
3138
3651
  groupLabel: session.metadata?.groupId
@@ -3147,6 +3660,7 @@ export class ResponseEngine {
3147
3660
  ?? session.metadata?.mentionMode
3148
3661
  ?? message.mentionMode
3149
3662
  ?? undefined,
3663
+ groupHistoryAvailable: canUseGroupHistory('ec.group.history'),
3150
3664
  clientType: message.clientType || undefined,
3151
3665
  permissionMode: effectivePermissionMode,
3152
3666
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
@@ -3343,11 +3857,9 @@ export class ResponseEngine {
3343
3857
  }
3344
3858
  }
3345
3859
  }
3346
- // The runner may already own a stronger per-session directory (Codex
3347
- // registers one before constructing its thread). Other runners use a
3348
- // private child of the process-provided TMPDIR. Both paths are passed
3349
- // explicitly so sandboxed helper commands never fall back to a shared
3350
- // agent or project directory for transient state.
3860
+ // Pass the stable session directory explicitly so sandboxed helper
3861
+ // commands never fall back to a process, agent, or project directory
3862
+ // for transient state.
3351
3863
  const taskRuntimeContext = {
3352
3864
  taskId,
3353
3865
  sessionId: session.id,
@@ -3430,7 +3942,8 @@ export class ResponseEngine {
3430
3942
  auditFullAccessEvent({
3431
3943
  event: fullAccessAuthorization.source === 'trigger'
3432
3944
  ? 'fullaccess.trigger.execution.started'
3433
- : 'fullaccess.execution.started',
3945
+ : fullAccessAuthorization.source === 'agent-policy'
3946
+ ? 'fullaccess.agent-policy.execution.started' : 'fullaccess.execution.started',
3434
3947
  source: fullAccessAuthorization.source,
3435
3948
  actorId: fullAccessAuthorization.authorizedBy,
3436
3949
  processRole: fullAccessAuditProcessRole,
@@ -3454,6 +3967,23 @@ export class ResponseEngine {
3454
3967
  }
3455
3968
  fullAccessRunnerInvoked = true;
3456
3969
  }
3970
+ if (message.asyncQuestionAnswer) {
3971
+ if (session.baseagent !== 'codex' || runAgentSessionId !== message.asyncQuestionAnswer.providerThreadId) {
3972
+ throw new Error('Async question backend changed before queued resume');
3973
+ }
3974
+ if (!(await this.asyncQuestions?.validateQueuedAnswer(session, message.asyncQuestionAnswer))) {
3975
+ throw new Error('Async question authorization or persisted answer changed before queued resume');
3976
+ }
3977
+ // An active queue item restored after submission resumes the unfinished work.
3978
+ // Its native answer is already in Codex history and must not be appended twice.
3979
+ if (!message.restartResume?.submitted) {
3980
+ modelOverride = { ...modelOverride, asyncQuestionAnswer: message.asyncQuestionAnswer };
3981
+ }
3982
+ }
3983
+ if (session.baseagent === 'codex') {
3984
+ modelOverride = { ...modelOverride,
3985
+ enableRequestUserInput: effectiveAgentConfig?.baseagents?.codex?.enableRequestUserInput ?? true };
3986
+ }
3457
3987
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, runAgentSessionId, renderResult?.images.length ? renderResult.images : message.images, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
3458
3988
  // The turn may be superseded while the runner is still creating its
3459
3989
  // backend query. Replay the interrupt after runQuery resolves, when
@@ -3470,7 +4000,23 @@ export class ResponseEngine {
3470
4000
  const processAttempt = this.processEventStream(stream, session, agent, renderer, (eventType, toolName) => {
3471
4001
  resetTimer(eventType, toolName);
3472
4002
  attemptTimeout?.reset();
3473
- }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode, selfAid);
4003
+ }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode, selfAid, async (request) => {
4004
+ if (!this.asyncQuestions)
4005
+ throw new Error('Async question service is unavailable');
4006
+ if (permissionContext.approvalInteractionPolicy === 'deny') {
4007
+ logger.info(`[AsyncQuestions] no interactive recipient for unattended task: session=${session.id}`);
4008
+ return;
4009
+ }
4010
+ await renderer.flush(false);
4011
+ try {
4012
+ await this.asyncQuestions.present(session, request, message, adapter, envelope);
4013
+ }
4014
+ catch (error) {
4015
+ // Card dispatch is non-blocking business input. Keep consuming the running turn;
4016
+ // pending records can be delivered after reconnect/restart.
4017
+ logger.warn(`[AsyncQuestions] presentation failed: session=${session.id} error=${String(error)}`);
4018
+ }
4019
+ });
3474
4020
  streamResult = attemptTimeout
3475
4021
  ? await Promise.race([processAttempt, attemptTimeout.promise])
3476
4022
  : await processAttempt;
@@ -3889,7 +4435,7 @@ export class ResponseEngine {
3889
4435
  // `hasReceivedText` 是任务级计数。工具调用前的文字已经显示,并不表示最后一轮
3890
4436
  // (通常来自 complete.result)的文字也已显示;用本轮投影标记避免终态丢失。
3891
4437
  if (shouldSuppress() || !streamResult.hasProjectedCurrentReplyText) {
3892
- renderer.addText(visibleFinalReplyText);
4438
+ await renderer.addText(visibleFinalReplyText, undefined, undefined, streamResult.assistantUuid);
3893
4439
  }
3894
4440
  }
3895
4441
  // 先清理流和处理中状态(保证即使 flush 卡住,session 也不会永久处于"处理中")
@@ -3908,7 +4454,7 @@ export class ResponseEngine {
3908
4454
  const suffix = showHint
3909
4455
  ? `\n\n---\n⚠️ [降级模型: ${usedFallback.model} | 可告诉我"帮我检查可用模型"来诊断]`
3910
4456
  : `\n\n---\n⚠️ [降级模型: ${usedFallback.model}]`;
3911
- renderer.addText(suffix);
4457
+ await renderer.addText(suffix, undefined, undefined, streamResult.assistantUuid);
3912
4458
  if (showHint && curFbState) {
3913
4459
  curFbState.hintShown = true;
3914
4460
  this.modelFallbackMap.set(session.id, curFbState);
@@ -3921,7 +4467,14 @@ export class ResponseEngine {
3921
4467
  }
3922
4468
  else {
3923
4469
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
3924
- await renderer.flush(true);
4470
+ // A final text flush can also contain failed output. Preserve the
4471
+ // outcome so AUN does not label that text as normal completion.
4472
+ if (streamResult.isError && !isExpectedTerminalInterrupt(streamResult.terminalReason)) {
4473
+ await renderer.flush(true, 'failed');
4474
+ }
4475
+ else {
4476
+ await renderer.flush(!isExpectedTerminalInterrupt(streamResult.terminalReason));
4477
+ }
3925
4478
  }
3926
4479
  const terminalInterrupt = isExpectedTerminalInterrupt(streamResult.terminalReason);
3927
4480
  if (!streamResult.isError && !terminalInterrupt) {
@@ -3944,7 +4497,8 @@ export class ResponseEngine {
3944
4497
  }
3945
4498
  }
3946
4499
  else {
3947
- await this.turnCoordinator.finish(session, turnLease, terminalInterrupt ? 'cancelled' : 'failed', streamResult.protocolIncompleteReason || streamResult.terminalReason || streamResult.subtype);
4500
+ await this.turnCoordinator.finish(session, turnLease, terminalInterrupt ? 'cancelled' : 'failed', permissionTerminalFailure ? 'permission_denied'
4501
+ : streamResult.protocolIncompleteReason || streamResult.terminalReason || streamResult.subtype);
3948
4502
  }
3949
4503
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
3950
4504
  const interruptReason = this.interruptedSessions.get(session.id);
@@ -3978,7 +4532,7 @@ export class ResponseEngine {
3978
4532
  // DaemonChannel settles a trigger when it receives a terminal
3979
4533
  // payload. Send the metadata-bearing status first so failed runs do
3980
4534
  // not lose their tool counts and recovery diagnostics.
3981
- if (message.source === 'trigger') {
4535
+ if (this.isTrustedDaemonTrigger(message)) {
3982
4536
  await adapter.send(envelope, { kind: 'status.error', metadata: terminalMetadata }).catch(() => { });
3983
4537
  await adapter.send(envelope, { kind: 'result.error', text: userErrorSummary, reason: rawSubtype }).catch(() => { });
3984
4538
  }
@@ -4019,6 +4573,10 @@ export class ResponseEngine {
4019
4573
  this.eventBus.publish({
4020
4574
  type: 'task:error',
4021
4575
  sessionId: session.id,
4576
+ agentAid: runnerSelfAid,
4577
+ peerId: configActorId,
4578
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
4579
+ originTriggerId: message.triggerMeta?.triggerId,
4022
4580
  taskId,
4023
4581
  generation: turnLease?.generation,
4024
4582
  error: errorSummary,
@@ -4297,7 +4855,8 @@ export class ResponseEngine {
4297
4855
  await timeoutControl?.barrier;
4298
4856
  }
4299
4857
  const handlingTimeout = error instanceof Error
4300
- && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
4858
+ && (isExecutionFailure(error) || error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
4859
+ const ownsTimeoutTerminal = !handlingTimeout || !timeoutControl || timeoutControl.claimTerminal();
4301
4860
  if (handlingTimeout) {
4302
4861
  this.timeoutErrors.delete(session.id);
4303
4862
  }
@@ -4319,11 +4878,12 @@ export class ResponseEngine {
4319
4878
  const errType = classifyError(error);
4320
4879
  const interruptReason = this.interruptedSessions.get(session.id);
4321
4880
  const isTotalExecutionTimeout = error instanceof Error && error.message === 'TOTAL_EXECUTION_TIMEOUT';
4322
- const isTimeout = error instanceof Error && error.message === 'SDK_TIMEOUT';
4881
+ const isTimeout = error instanceof Error && (error.message === 'SDK_TIMEOUT' || error instanceof ExecutionTimeoutError);
4323
4882
  const canPublishTerminal = !turnLease || this.turnCoordinator.canPublish(turnLease);
4324
4883
  const staleSupersededTurn = !!turnLease
4325
4884
  && !canPublishTerminal
4326
4885
  && interruptReason !== 'timeout'
4886
+ && interruptReason !== 'execution_error'
4327
4887
  && !isTimeout
4328
4888
  && !isTotalExecutionTimeout;
4329
4889
  const isUserInterrupt = isNonErrorInterruptReason(interruptReason) || staleSupersededTurn;
@@ -4331,7 +4891,7 @@ export class ResponseEngine {
4331
4891
  if (isUserInterrupt) {
4332
4892
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'stale_turn', taskCausation);
4333
4893
  }
4334
- else {
4894
+ else if (ownsTimeoutTerminal) {
4335
4895
  this.publishTriggerExecutionFailure(message, errorMsg, {
4336
4896
  messageId,
4337
4897
  causation: taskCausation,
@@ -4340,24 +4900,27 @@ export class ResponseEngine {
4340
4900
  if (leaseWasCurrent && turnLease) {
4341
4901
  await this.turnCoordinator.finish(session, turnLease, 'failed', error instanceof Error ? error.message : String(error));
4342
4902
  }
4343
- const totalExecutionMs = this.totalExecutionLimitMs();
4903
+ const totalExecutionMs = error instanceof ExecutionTimeoutError ? error.timeoutMs : this.totalExecutionLimitMs();
4344
4904
  const procStatus = errType === ErrorType.SDK_TIMEOUT || isTotalExecutionTimeout ? 'timeout'
4345
4905
  : errType === ErrorType.STREAM_ERROR ? 'interrupted'
4346
4906
  : 'error';
4347
4907
  const daemonTrigger = this.isTrustedDaemonTrigger(message);
4348
- const statusPayload = procStatus === 'timeout'
4349
- ? { kind: 'status.timeout', metadata: isTotalExecutionTimeout
4350
- ? (totalExecutionMs === undefined ? {} : { totalExecutionMs })
4351
- : { idleSec: getLastIdleSec?.() || undefined } }
4352
- : procStatus === 'interrupted'
4353
- ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
4354
- : { kind: 'status.error' };
4908
+ const statusPayload = error instanceof OrphanedTargetSessionError
4909
+ ? { kind: 'status.error', metadata: { errorType: error.reason, message: '任务执行状态丢失,已结束本次执行。' } }
4910
+ : procStatus === 'timeout'
4911
+ ? { kind: 'status.timeout', metadata: isTotalExecutionTimeout
4912
+ ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
4913
+ ...(error instanceof ExecutionTimeoutError ? { reason: error.reason } : {}) }
4914
+ : { idleSec: getLastIdleSec?.() || undefined } }
4915
+ : procStatus === 'interrupted'
4916
+ ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
4917
+ : { kind: 'status.error' };
4355
4918
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
4356
- if (!isUserInterrupt && !daemonTrigger) {
4919
+ if (!isUserInterrupt && ownsTimeoutTerminal && !daemonTrigger) {
4357
4920
  await adapter.send(envelope, statusPayload).catch(() => { });
4358
4921
  this.touchAgentActivity(channelKey);
4359
4922
  }
4360
- if (!isUserInterrupt && daemonTrigger)
4923
+ if (!isUserInterrupt && ownsTimeoutTerminal && daemonTrigger)
4361
4924
  this.touchAgentActivity(channelKey);
4362
4925
  // 用户主动中断时降级日志;其余仍按 error 记录
4363
4926
  if (isUserInterrupt) {
@@ -4371,13 +4934,18 @@ export class ResponseEngine {
4371
4934
  // (message-queue / slash-handler)已发过 task:interrupted(它填充了
4372
4935
  // interruptedSessions,isUserInterrupt 才会为真),stats 侧已据此收尾任务。
4373
4936
  // 此处不再发任何事件——发 task:error 会误归类,重发 task:interrupted 会重复记账。
4374
- if (!isUserInterrupt) {
4937
+ if (!isUserInterrupt && ownsTimeoutTerminal) {
4375
4938
  this.eventBus.publish({
4376
4939
  type: 'task:error',
4377
4940
  sessionId: session.id,
4941
+ agentAid: runnerSelfAid,
4942
+ peerId: configActorId,
4943
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
4944
+ originTriggerId: message.triggerMeta?.triggerId,
4378
4945
  taskId,
4379
4946
  generation: turnLease?.generation,
4380
4947
  error: errorMsg,
4948
+ terminalReason: isExecutionFailure(error) ? error.reason : undefined,
4381
4949
  errorType,
4382
4950
  agentName: agentNameForStats,
4383
4951
  causation: taskCausation,
@@ -4385,14 +4953,19 @@ export class ResponseEngine {
4385
4953
  recordCausationSpan(taskCausation, 'task.run', { status: 'failed', refs: { taskId, sessionId: session.id, messageId }, reason: errorType });
4386
4954
  }
4387
4955
  // 记录处理失败
4388
- logger.message({
4389
- msgId: messageId,
4390
- sessionId: session.id,
4391
- dir: 'inbound',
4392
- status: isUserInterrupt ? 'interrupted' : 'failed',
4393
- error: error instanceof Error ? error.message : String(error)
4394
- });
4395
- if (error instanceof Error && !isUserInterrupt) {
4956
+ if (ownsTimeoutTerminal) {
4957
+ logger.message({
4958
+ msgId: messageId,
4959
+ sessionId: session.id,
4960
+ dir: 'inbound',
4961
+ status: isUserInterrupt ? 'interrupted' : 'failed',
4962
+ error: error instanceof Error ? error.message : String(error)
4963
+ });
4964
+ }
4965
+ else {
4966
+ logger.info(`[ResponseEngine] Duplicate timeout terminal suppressed: session=${session.id} task=${taskId}`);
4967
+ }
4968
+ if (error instanceof Error && !isUserInterrupt && ownsTimeoutTerminal) {
4396
4969
  logger.error(`[${message.channel}] Error stack:`, error.stack);
4397
4970
  }
4398
4971
  // 发送用户友好的错误消息
@@ -4402,7 +4975,11 @@ export class ResponseEngine {
4402
4975
  const retryExhaustedCount = getRetryExhaustedCount(error);
4403
4976
  const modelFallbackExhaustedMessage = getModelFallbackExhaustedMessage(error);
4404
4977
  const retryInputAlreadySubmitted = wasRetryInputAlreadySubmitted(error);
4405
- if (isUserInterrupt) {
4978
+ if (!ownsTimeoutTerminal) {
4979
+ // The monitor path already owns delivery for this timeout. Cleanup and
4980
+ // durable turn finalization above still run, but no terminal is emitted twice.
4981
+ }
4982
+ else if (isUserInterrupt) {
4406
4983
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
4407
4984
  }
4408
4985
  else if (error?._errorAlreadySent && !daemonTrigger && !retryExhaustedCount && !isTimeout && !isTotalExecutionTimeout) {
@@ -4411,19 +4988,21 @@ export class ResponseEngine {
4411
4988
  else {
4412
4989
  // SDK_TIMEOUT:status.timeout 已发结构化状态,此处再补一条用户可见的错误文本(result.error)
4413
4990
  const idleSec = getLastIdleSec?.() || 0;
4414
- const userMessage = retryExhaustedCount
4415
- ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
4416
- : retryInputAlreadySubmitted
4417
- ? formatRetryInputAlreadySubmittedMessage(error)
4418
- : modelFallbackExhaustedMessage
4419
- ? modelFallbackExhaustedMessage
4420
- : isTotalExecutionTimeout
4421
- ? (totalExecutionMs === undefined
4422
- ? '⚠️ 任务超过总执行时限,已自动中断'
4423
- : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
4424
- : isTimeout
4425
- ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
4426
- : getErrorMessage(error, undefined);
4991
+ const userMessage = error instanceof OrphanedTargetSessionError
4992
+ ? '⚠️ 任务执行状态丢失,已结束本次执行。'
4993
+ : retryExhaustedCount
4994
+ ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
4995
+ : retryInputAlreadySubmitted
4996
+ ? formatRetryInputAlreadySubmittedMessage(error)
4997
+ : modelFallbackExhaustedMessage
4998
+ ? modelFallbackExhaustedMessage
4999
+ : isTotalExecutionTimeout
5000
+ ? (totalExecutionMs === undefined
5001
+ ? '⚠️ 任务超过总执行时限,已自动中断'
5002
+ : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
5003
+ : isTimeout
5004
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
5005
+ : getErrorMessage(error, undefined);
4427
5006
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
4428
5007
  let sendOpts;
4429
5008
  try {
@@ -4440,7 +5019,7 @@ export class ResponseEngine {
4440
5019
  const errorPayload = {
4441
5020
  kind: 'result.error',
4442
5021
  text: userMessage,
4443
- reason: retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
5022
+ reason: isExecutionFailure(error) ? error.reason : retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
4444
5023
  };
4445
5024
  if (!isUserInterrupt && daemonTrigger && procStatus === 'error') {
4446
5025
  // DaemonChannel settles a trigger on the first terminal payload. Put
@@ -4483,9 +5062,6 @@ export class ResponseEngine {
4483
5062
  }).catch(() => { });
4484
5063
  }
4485
5064
  }
4486
- if (handlingTimeout && timeoutControl) {
4487
- timeoutControl.terminalDelivered = true;
4488
- }
4489
5065
  }
4490
5066
  finally {
4491
5067
  if (fullAccessAuthorization) {
@@ -4496,7 +5072,8 @@ export class ResponseEngine {
4496
5072
  auditFullAccessEvent({
4497
5073
  event: fullAccessAuthorization.source === 'trigger'
4498
5074
  ? 'fullaccess.trigger.execution.ended'
4499
- : 'fullaccess.execution.ended',
5075
+ : fullAccessAuthorization.source === 'agent-policy'
5076
+ ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
4500
5077
  source: fullAccessAuthorization.source,
4501
5078
  actorId: fullAccessAuthorization.authorizedBy,
4502
5079
  processRole: fullAccessAuditProcessRole,
@@ -4694,7 +5271,7 @@ export class ResponseEngine {
4694
5271
  logger.warn(`[ResponseEngine] Bound session ${boundSessionId} not found, falling back to latest`);
4695
5272
  }
4696
5273
  }
4697
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, resolvedBaseagent, message.selfAID, message.channelType, message.peerType, resolvedIdentity);
5274
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, resolvedBaseagent, message.selfAID, message.channelType, message.peerType, resolvedIdentity, message.lable);
4698
5275
  this.ensureSessionBaseagent(session, resolvedBaseagent);
4699
5276
  // 群名解析:群会话首次取群显示名(group.get),缓存到 metadata,供信封渲染。
4700
5277
  // 渠道私有方法 getGroupName 自带进程缓存 + 容错;取不到不阻塞(groupName 保持空,模板回退 groupId)。
@@ -4752,7 +5329,7 @@ export class ResponseEngine {
4752
5329
  */
4753
5330
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive,
4754
5331
  /** [迁移点4/5] 响应模式插件 + 状态,用于调 onToolUse/onComplete 钩子 */
4755
- modeHooks, causation, turnLease, permissionMode, proactiveSelfAid) {
5332
+ modeHooks, causation, turnLease, permissionMode, proactiveSelfAid, onAsyncQuestion) {
4756
5333
  // Per-session agent name for stats bucketing
4757
5334
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
4758
5335
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
@@ -4839,6 +5416,15 @@ export class ResponseEngine {
4839
5416
  // It must never be projected as progress, activity, text, or transcript.
4840
5417
  if (event.type === 'heartbeat')
4841
5418
  continue;
5419
+ if (event.type === 'user_question') {
5420
+ if (!onAsyncQuestion)
5421
+ throw new Error('Async question has no delivery context');
5422
+ await onAsyncQuestion(event.request);
5423
+ madeProgress = true;
5424
+ // A structured question is user-visible progress without being a text reply.
5425
+ textCount++;
5426
+ continue;
5427
+ }
4842
5428
  if (event.type === 'input_accepted') {
4843
5429
  inputAccepted = !turnLease || event.inputId === turnLease.inputId;
4844
5430
  logger.debug(`[ResponseEngine] Input accepted: session=${session.id} input=${event.inputId}`);
@@ -4934,7 +5520,7 @@ export class ResponseEngine {
4934
5520
  || (typeof input?.prompt === 'string' ? input.prompt.slice(0, 80) : '')
4935
5521
  || (typeof input?.query === 'string' ? input.query.slice(0, 80) : '')
4936
5522
  || '';
4937
- eventDetail = ` tool=${event.name}${desc ? ` desc="${desc}"` : ''}`;
5523
+ eventDetail = ` tool=${event.name}${desc ? ` desc="${String(desc).replace(/\s+/g, ' ')}"` : ''}`;
4938
5524
  }
4939
5525
  else if (event.type === 'tool_result') {
4940
5526
  const decision = event.decision ?? (event.isError ? 'error' : 'allow');
@@ -4954,6 +5540,10 @@ export class ResponseEngine {
4954
5540
  }
4955
5541
  // IMRenderer 旁路:proactive 模式逐事件投影为 thought(fire-and-forget)
4956
5542
  renderer.emit(event);
5543
+ if (event.type === 'text_start') {
5544
+ await renderer.sendTextStart(event.contentIndex, event.turn);
5545
+ continue;
5546
+ }
4957
5547
  // session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
4958
5548
  if (event.type === 'session_id') {
4959
5549
  logger.debug(`[ResponseEngine] Session ID updated: ${event.sessionId} for session: ${session.id}`);
@@ -5115,7 +5705,7 @@ export class ResponseEngine {
5115
5705
  this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: visibleText, isFinal: false });
5116
5706
  }
5117
5707
  if (visibleText && !shouldSuppress()) {
5118
- renderer.addText(visibleText, event.outputTokens, event.turn);
5708
+ await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid);
5119
5709
  hasProjectedCurrentReplyText = true;
5120
5710
  }
5121
5711
  }
@@ -5175,7 +5765,7 @@ export class ResponseEngine {
5175
5765
  timestamp: Date.now(),
5176
5766
  causation,
5177
5767
  });
5178
- const desc = summarizeToolInput(event.name, event.input || {});
5768
+ const desc = summarizeToolInputForDisplay(event.name, event.input || {});
5179
5769
  const pendingInput = { name: event.name, input: event.input || {} };
5180
5770
  if (event.callId)
5181
5771
  toolInputByCallId.set(event.callId, pendingInput);
@@ -5351,7 +5941,7 @@ export class ResponseEngine {
5351
5941
  let errorMsg = event.error || (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)) || '\u6267\u884c\u5931\u8d25';
5352
5942
  // 移除 XML 风格的错误标签
5353
5943
  errorMsg = errorMsg.replace(/<tool_use_error>(.*?)<\/tool_use_error>/gs, '$1');
5354
- renderer.addToolResult(event.name || '\u5de5\u5177', false, undefined, errorMsg, event.callId, event.durationMs, cachedDesc);
5944
+ renderer.addToolResult(event.name || '\u5de5\u5177', false, undefined, errorMsg, event.callId, event.durationMs, cachedDesc, event);
5355
5945
  }
5356
5946
  else if (!event.isError && !shouldSuppress()) {
5357
5947
  renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, event.durationMs, cachedDesc);
@@ -5452,7 +6042,7 @@ export class ResponseEngine {
5452
6042
  // User interrupt (AbortError) is expected, log at info level
5453
6043
  const catchInterruptReason = this.interruptedSessions.get(session.id);
5454
6044
  const catchIsUserInterrupt = isNonErrorInterruptReason(catchInterruptReason);
5455
- if (catchInterruptReason === 'timeout') {
6045
+ if (catchInterruptReason === 'timeout' || catchInterruptReason === 'execution_error') {
5456
6046
  logger.info('[ResponseEngine] Stream closed by timeout barrier');
5457
6047
  throw this.timeoutErrors.get(session.id) ?? new Error('SDK_TIMEOUT');
5458
6048
  }
@@ -5490,7 +6080,9 @@ export class ResponseEngine {
5490
6080
  if (hasErrorSuppressingContent) {
5491
6081
  let errorOutputFlushed = false;
5492
6082
  try {
5493
- await renderer.flush(true);
6083
+ // The outer retry/terminal handler has not decided the outcome yet.
6084
+ // Pending stream output must not announce successful completion.
6085
+ await renderer.flush(false);
5494
6086
  errorOutputFlushed = true;
5495
6087
  }
5496
6088
  catch (flushError) {