dsh-ant 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/delegation.js CHANGED
@@ -5,14 +5,25 @@ import { dirname, join, resolve } from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { CONVERSATION_TOOL_NAMES, TASK_DRAFT_TTL_MS, cancelSession, conversationKey,
7
7
  buildTaskContext, cardReviewers, createConversationEngine, giteaPullRequestContext, isHelpRequest,
8
- modelFor, requestsPrivateReply, teamModelProvider, truncateTaskOutput,
9
- promptAndCollectSessionTurn, taskErrorText } from './conversation.js'
8
+ modelFor, prompt, requestsPrivateReply, restrictedSession, teamModelProvider, truncateTaskOutput,
9
+ promptAndCollectSessionTurn, taskErrorText, waitForSignal } from './conversation.js'
10
10
 
11
11
  const MAX_DIFF_BYTES = 240_000
12
12
  const MAX_GITEA_JSON_BYTES = 64_000
13
13
  const MAX_REPLY_LENGTH = 3_800
14
14
  const LEDGER_EVENT_TTL_MS = 30 * 86_400_000
15
15
  const MAX_LEDGER_EVENTS = 1_000
16
+ const OWNER_APPROVAL_TTL_MS = 90 * 86_400_000
17
+ const MAX_OWNER_APPROVAL_ENTRIES = 1_000
18
+ const OWNER_APPROVAL_COMPACT_STATUSES = new Set([
19
+ 'done', 'failed', 'canceled', 'rejected', 'expired', 'completed',
20
+ ])
21
+ const OWNER_APPROVAL_TERMINAL_STATUSES = new Set(['done', 'failed', 'canceled', 'rejected', 'expired'])
22
+ const OWNER_APPROVAL_RETAINED_FIELDS = new Set([
23
+ 'key', 'kind', 'status', 'outcome', 'approval_id', 'request_sha256', 'task_id',
24
+ 'service_url', 'scope', 'actor_id', 'conversation_kind', 'conversation_id',
25
+ 'botId', 'fingerprint', 'route', 'created_at', 'failure_reason', 'finish_error',
26
+ ])
16
27
  const TASK_PRESET = 'ant-delegated-task'
17
28
  const MODEL_TIMEOUT_MS = 180_000
18
29
  const REVIEW_EXECUTORS = new Set(['dsh', 'codex', 'claude-code'])
@@ -161,6 +172,13 @@ export async function writeLocalDelegationSettings(patch, settingsPath = LOCAL_S
161
172
  function pruneLedgerEntries(entries, now) {
162
173
  const next = new Map(entries)
163
174
  for (const [key, saved] of next) {
175
+ if (saved.kind === 'owner_approval' && OWNER_APPROVAL_COMPACT_STATUSES.has(saved.status)) {
176
+ next.set(key, compactOwnerApproval(saved))
177
+ if (!OWNER_APPROVAL_TERMINAL_STATUSES.has(saved.status)) continue
178
+ const age = now - Date.parse(saved.created_at)
179
+ if (Number.isFinite(age) && age > OWNER_APPROVAL_TTL_MS) next.delete(key)
180
+ continue
181
+ }
164
182
  if (!['conversation_input', 'conversation_ignored', 'group_notice', 'chat_reply',
165
183
  'card_draft', 'card_task', 'legacy_pr_task'].includes(saved.kind)) continue
166
184
  const age = now - Date.parse(saved.created_at)
@@ -172,6 +190,10 @@ function pruneLedgerEntries(entries, now) {
172
190
  next.delete(key)
173
191
  }
174
192
  }
193
+ const ownerApprovals = [...next.values()].filter((saved) => saved.kind === 'owner_approval'
194
+ && OWNER_APPROVAL_TERMINAL_STATUSES.has(saved.status))
195
+ .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
196
+ for (const saved of ownerApprovals.slice(MAX_OWNER_APPROVAL_ENTRIES)) next.delete(saved.key)
175
197
  const recentChats = [...next.values()].filter((saved) => saved.kind === 'chat_reply'
176
198
  && saved.status === 'done').sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
177
199
  for (const saved of recentChats.slice(1_000)) next.delete(saved.key)
@@ -189,6 +211,14 @@ function pruneLedgerEntries(entries, now) {
189
211
  return next
190
212
  }
191
213
 
214
+ function compactOwnerApproval(entry) {
215
+ const retainedFields = entry.status === 'done' ? OWNER_APPROVAL_RETAINED_FIELDS
216
+ : new Set([...OWNER_APPROVAL_RETAINED_FIELDS, 'reply'])
217
+ const compact = Object.fromEntries(Object.entries(entry)
218
+ .filter(([key]) => retainedFields.has(key)))
219
+ return Object.keys(compact).length === Object.keys(entry).length ? entry : compact
220
+ }
221
+
192
222
  export class DelegationLedger {
193
223
  constructor(path = join(dshRoot(), 'ant-delegation', 'events.json')) {
194
224
  this.path = path
@@ -223,7 +253,8 @@ export class DelegationLedger {
223
253
  }
224
254
  }
225
255
  const next = pruneLedgerEntries(loaded, Date.now())
226
- if (next.size !== loaded.size) changed = true
256
+ if (next.size !== loaded.size
257
+ || [...next.entries()].some(([key, entry]) => loaded.get(key) !== entry)) changed = true
227
258
  if (changed) await this.writeSnapshot(next)
228
259
  else this.entries = next
229
260
  return this
@@ -274,7 +305,8 @@ function statusErrorText(value) {
274
305
  export async function recentDelegationStatus(serviceUrl, limit = 12) {
275
306
  const ledger = await new DelegationLedger().load()
276
307
  return ledger.values().filter((entry) => entry.service_url === serviceUrl && entry.status !== 'ignored'
277
- && (!entry.kind || ['card_task', 'legacy_pr_task'].includes(entry.kind)))
308
+ && (!entry.kind || ['card_task', 'legacy_pr_task', 'owner_approval'].includes(entry.kind))
309
+ && (entry.kind !== 'owner_approval' || typeof entry.task_id === 'string'))
278
310
  .sort((left, right) => (right.created_at || '').localeCompare(left.created_at || ''))
279
311
  .slice(0, limit)
280
312
  .map((entry) => ({
@@ -284,9 +316,9 @@ export async function recentDelegationStatus(serviceUrl, limit = 12) {
284
316
  repository: entry.grant?.repository || null,
285
317
  pr_number: entry.grant?.pr_number || null,
286
318
  task_id: entry.task_id || null,
287
- title: entry.card?.title || null,
288
- outcome: entry.outcome || null,
289
- failure_reason: statusErrorText(entry.failure_reason)
319
+ title: entry.card?.title || entry.proposal?.title || null,
320
+ outcome: entry.status === 'finishing' ? null : entry.outcome || null,
321
+ failure_reason: statusErrorText(entry.finish_error) || statusErrorText(entry.failure_reason)
290
322
  || (entry.outcome === 'failed' ? statusErrorText(entry.reply) : null),
291
323
  delivery_error: statusErrorText(entry.delivery_error),
292
324
  }))
@@ -994,7 +1026,19 @@ export async function runDshSubagentTask(ctx, executor, task, resources = [], se
994
1026
  const boundedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout
995
1027
  let removeAbort = () => {}
996
1028
  try {
997
- const created = await ctx.sessionController.create({ cwd: workspace, agentPreset: TASK_PRESET })
1029
+ const createPromise = Promise.resolve().then(() => {
1030
+ boundedSignal.throwIfAborted()
1031
+ return ctx.sessionController.create({ cwd: workspace, agentPreset: TASK_PRESET })
1032
+ })
1033
+ let created
1034
+ try { created = await waitForSignal(createPromise, boundedSignal) }
1035
+ catch (error) {
1036
+ if (boundedSignal.aborted) {
1037
+ void createPromise.then((lateSession) => cancelSession(ctx, lateSession.sessionId))
1038
+ .catch((cleanupError) => logger.warn?.('ANT 迟到的任务会话清理失败:', cleanupError?.message))
1039
+ }
1040
+ throw error
1041
+ }
998
1042
  sessionId = created.sessionId
999
1043
  if (created.agentPreset !== TASK_PRESET || typeof sessionId !== 'string' || !sessionId) {
1000
1044
  throw new Error('DSH 未启用 ANT 受限任务父会话。')
@@ -1253,6 +1297,11 @@ export function taskGate(initialLimit = 3) {
1253
1297
  }
1254
1298
  }
1255
1299
 
1300
+ // A card task can span several messages; the owner must see the text the executor will receive.
1301
+ export function ownerApprovalRequestText(messageText, cardTask) {
1302
+ return (cardTask?.authorization?.request_text || messageText).slice(0, 8_192)
1303
+ }
1304
+
1256
1305
  export function startDelegationHost(ctx, readConnection, { ledger = new DelegationLedger(), logger = console,
1257
1306
  workspaceRoot = dshRoot(), readSettings = readLocalDelegationSettings } = {}) {
1258
1307
  const controller = new AbortController()
@@ -1268,9 +1317,11 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1268
1317
  const processing = new Set()
1269
1318
  const processingTasks = new Set()
1270
1319
  const legacyTaskControllers = new Map()
1320
+ const ownerApprovalTaskControllers = new Map()
1271
1321
  const receiptPending = new Set()
1272
1322
  const accepting = new Map()
1273
1323
  const sessionIds = new Set()
1324
+ const pendingOwnerChallenges = new Map()
1274
1325
  const gate = taskGate()
1275
1326
  let refreshing = false
1276
1327
  let refreshDone = Promise.resolve()
@@ -1286,7 +1337,8 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1286
1337
  const event = turn.event
1287
1338
  const title = typeof args?.title === 'string' ? args.title.trim().slice(0, 100) : ''
1288
1339
  const taskPrompt = typeof args?.task_prompt === 'string' ? args.task_prompt.trim().slice(0, 4_000) : ''
1289
- const requestText = addressedText(event).slice(0, 8_192)
1340
+ const cardTask = args?.card_task || null
1341
+ const requestText = ownerApprovalRequestText(addressedText(event), cardTask)
1290
1342
  if (!title || !taskPrompt || !requestText) throw new Error('本次审批请求缺少标题、执行说明或原始请求。')
1291
1343
  if (turn.owner_approval_bound !== true) {
1292
1344
  throw new Error('机器人主人尚未绑定审批私聊,请联系 B 在 ANT 协作设置中绑定。')
@@ -1303,14 +1355,16 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1303
1355
  requester_id: event.actor.id, message_id: event.messageId,
1304
1356
  conversation_kind: event.conversation.kind,
1305
1357
  ...(event.conversation.kind === 'group' ? { conversation_id: event.conversation.id } : {}),
1306
- request_text: requestText, title, task_prompt: taskPrompt }
1358
+ request_text: requestText, title, task_prompt: taskPrompt,
1359
+ ...(cardTask ? { card_id: cardTask.card.id, parameters: cardTask.parameters } : {}) }
1307
1360
  const requestHash = createHash('sha256').update(JSON.stringify(bound)).digest('hex')
1308
1361
  const approval = await memberJson(turn.connection, '/api/me/im-delegations/owner-approvals', {
1309
1362
  method: 'POST', body: JSON.stringify({ bot_id: event.botId,
1310
1363
  bot_fingerprint: event.fingerprint, requester_id: event.actor.id,
1311
1364
  message_id: event.messageId, conversation_kind: event.conversation.kind,
1312
1365
  ...(event.conversation.kind === 'group' ? { conversation_id: event.conversation.id } : {}),
1313
- request_sha256: requestHash }), signal: requestSignal,
1366
+ request_sha256: requestHash, ...(cardTask ? { card_id: cardTask.card.id } : {}) }),
1367
+ signal: requestSignal,
1314
1368
  })
1315
1369
  if (!/^[A-F0-9]{12}$/u.test(approval.id || '') || !approval.target_id
1316
1370
  || !approval.owner_sender_id || !Number.isSafeInteger(approval.expires_at)) {
@@ -1323,13 +1377,17 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1323
1377
  ...(event.conversation.kind === 'group' ? { conversation_id: event.conversation.id } : {}),
1324
1378
  service_url: turn.connection.serviceUrl, route: event.reply, scope: turn.scope,
1325
1379
  target_id: approval.target_id, owner_sender_id: approval.owner_sender_id,
1326
- created_at: event.at, expires_at: approval.expires_at, status: 'pending' })
1380
+ created_at: event.at, expires_at: approval.expires_at, status: 'pending',
1381
+ ...(cardTask ? { card_task: cardTask } : {}) })
1327
1382
  const available = []
1328
- if (ctx.sessionController && ctx.agents) available.push('dsh')
1329
- try {
1330
- const providers = (ctx.get?.('subagents') ?? ctx.subagents)?.list?.() || []
1331
- for (const runner of ['codex', 'claude-code']) if (providers.includes(runner)) available.push(runner)
1332
- } catch {}
1383
+ if (cardTask) available.push(cardTask.card.runner)
1384
+ else {
1385
+ if (ctx.sessionController && ctx.agents) available.push('dsh')
1386
+ try {
1387
+ const providers = (ctx.get?.('subagents') ?? ctx.subagents)?.list?.() || []
1388
+ for (const runner of ['codex', 'claude-code']) if (providers.includes(runner)) available.push(runner)
1389
+ } catch {}
1390
+ }
1333
1391
  if (!available.length) {
1334
1392
  await memberJson(turn.connection, `/api/me/im-delegations/owner-approvals/${approval.id}/cancel`,
1335
1393
  { method: 'POST', body: '{}', signal: requestSignal })
@@ -1346,7 +1404,9 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1346
1404
  `A 的原始请求:${requestText}`,
1347
1405
  `拟执行任务:${title}`,
1348
1406
  `执行说明:${taskPrompt}`,
1349
- `可选执行器:${available.join('、')}`,
1407
+ ...(cardTask ? [`任务参数:${JSON.stringify(cardTask.parameters)}`,
1408
+ `任务卡执行器:${(cardTask.parallel_agents || cardTask.card.cross_review_always
1409
+ ? cardTask.card.reviewers : available).join('、')}`] : [`可选执行器:${available.join('、')}`]),
1350
1410
  '权限说明:DSH 仅使用团队模型;Codex/Claude Code 按本机 DSH Provider Profile 的文件、Git、网络权限运行,ANT 不会把这些权限隔离到本次任务。结果返回原会话。批准前不会启动。',
1351
1411
  ...available.map((runner) => `批准:批准 ${approval.id} ${runner}`),
1352
1412
  `拒绝:拒绝 ${approval.id}`,
@@ -1384,7 +1444,7 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1384
1444
  async function runApprovedDshTask(entry, connection, taskSignal) {
1385
1445
  const model = await modelFor(ctx, memberJson, connection)
1386
1446
  const { sessionId, cwd } = await restrictedSession(ctx, model, workspaceRoot,
1387
- sessionIds, null, null, TASK_PRESET)
1447
+ sessionIds, null, null, TASK_PRESET, taskSignal)
1388
1448
  try {
1389
1449
  const text = [
1390
1450
  '这是一项由机器人主人 B 明确批准、只执行一次的任务。只按 B 已确认的执行说明处理,不扩大任务范围。',
@@ -1402,14 +1462,19 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1402
1462
  }
1403
1463
  }
1404
1464
 
1405
- async function executeOwnerApproval(entry, acceptedConnection, signal) {
1465
+ async function executeOwnerApproval(entry, acceptedConnection) {
1406
1466
  if (processing.has(entry.key)) return
1407
1467
  processing.add(entry.key)
1468
+ const runningTask = { controller: new AbortController(), finished: false }
1469
+ const taskSignal = AbortSignal.any([controller.signal, runningTask.controller.signal])
1470
+ ownerApprovalTaskControllers.set(entry.key, runningTask)
1408
1471
  let release = null
1409
1472
  let outcome = 'failed'
1410
1473
  let resultText = '任务未完成,请重新发起审批请求。'
1474
+ let interruptedForRestart = false
1411
1475
  try {
1412
- release = await gate.acquire(signal)
1476
+ release = await gate.acquire(taskSignal)
1477
+ entry = await ledger.put({ ...entry, status: 'running' })
1413
1478
  const connection = await readConnection()
1414
1479
  if (!connection || connection.serviceUrl !== acceptedConnection.serviceUrl
1415
1480
  || connection.serviceUrl !== entry.service_url) throw new Error('ANT 团队连接已变化,任务没有启动。')
@@ -1420,28 +1485,43 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1420
1485
  for (const runner of ['codex', 'claude-code']) if (providers.includes(runner)) available.push(runner)
1421
1486
  } catch {}
1422
1487
  if (!available.includes(entry.runner)) throw new Error(`这台 DSH 当前没有启用 ${entry.runner} 执行器。`)
1423
- signal.throwIfAborted()
1488
+ taskSignal.throwIfAborted()
1424
1489
  const task = { title: entry.proposal.title, task_prompt: entry.proposal.task_prompt,
1425
- request_text: entry.request_text, owner_approval_id: entry.approval_id }
1490
+ request_text: entry.request_text, owner_approval_id: entry.approval_id, task_id: entry.task_id }
1426
1491
  const result = entry.runner === 'dsh'
1427
- ? await runApprovedDshTask(entry, connection, signal)
1492
+ ? await runApprovedDshTask(entry, connection, taskSignal)
1428
1493
  : await runDshSubagentTask(ctx, entry.runner, task, [], sessionIds,
1429
- signal, workspaceRoot, logger)
1430
- signal.throwIfAborted()
1494
+ taskSignal, workspaceRoot, logger)
1495
+ taskSignal.throwIfAborted()
1496
+ runningTask.finished = true
1431
1497
  outcome = 'completed'
1432
1498
  resultText = `${entry.proposal.title}已完成:\n${truncateTaskOutput(result, MAX_REPLY_LENGTH - entry.proposal.title.length - 20)}`
1433
1499
  } catch (error) {
1434
- outcome = signal.aborted ? 'canceled' : 'failed'
1435
- resultText = outcome === 'canceled' ? '主人审批的任务已取消,未自动重试。'
1436
- : `主人审批的任务未完成:${taskErrorText(error, '请检查本机执行器状态后重新发起。')}`
1500
+ runningTask.finished = true
1501
+ if (controller.signal.aborted && !runningTask.controller.signal.aborted) {
1502
+ interruptedForRestart = true
1503
+ } else {
1504
+ outcome = runningTask.controller.signal.aborted ? 'canceled' : 'failed'
1505
+ if (outcome === 'canceled') resultText = '主人审批的任务已取消,未自动重试。'
1506
+ else {
1507
+ entry = { ...entry, failure_reason: taskErrorText(error, '请检查本机执行器状态后重新发起。') }
1508
+ resultText = `主人审批的任务未完成:${entry.failure_reason}`
1509
+ }
1510
+ }
1437
1511
  } finally {
1438
1512
  release?.()
1439
1513
  }
1514
+ if (interruptedForRestart) {
1515
+ ownerApprovalTaskControllers.delete(entry.key)
1516
+ processing.delete(entry.key)
1517
+ return
1518
+ }
1440
1519
  try {
1520
+ entry = await ledger.put({ ...entry, status: 'finishing', outcome, reply: resultText })
1441
1521
  const currentConnection = await readConnection()
1442
1522
  if (!currentConnection || currentConnection.serviceUrl !== entry.service_url) {
1443
- await ledger.put({ ...entry, status: outcome, outcome,
1444
- failure_reason: 'ANT 团队连接已变化,任务结果没有发送。' })
1523
+ await ledger.put({ ...entry, status: 'finishing', outcome,
1524
+ finish_error: 'ANT 团队连接已变化,服务端终态和任务结果待核对。' })
1445
1525
  return
1446
1526
  }
1447
1527
  const finished = await memberJson(currentConnection,
@@ -1449,34 +1529,63 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1449
1529
  method: 'POST', body: JSON.stringify({ outcome }), signal: controller.signal,
1450
1530
  })
1451
1531
  if (finished?.finished !== true) {
1452
- await ledger.put({ ...entry, status: outcome, outcome,
1453
- failure_reason: '服务端未确认审批任务仍有效;结果没有发送。' })
1532
+ await ledger.put({ ...entry, status: 'finishing', outcome,
1533
+ finish_error: '服务端未确认审批任务终态;请检查主人绑定和私聊权限后重试。' })
1454
1534
  return
1455
1535
  }
1456
- const final = await ledger.put({ ...entry, status: outcome, outcome, reply: resultText })
1536
+ const final = await ledger.put({ ...entry, status: outcome, outcome, reply: resultText,
1537
+ finish_error: null })
1457
1538
  await replyOwnerApprovalResult(final, resultText, currentConnection)
1458
1539
  } catch (error) {
1459
1540
  logger.warn?.('ANT 主人审批任务状态无法回写:', error?.message)
1460
- await ledger.put({ ...entry, status: 'failed', outcome: 'failed',
1461
- failure_reason: '服务端未确认任务终态;结果没有发送。' })
1541
+ await ledger.put({ ...entry, status: 'finishing', outcome,
1542
+ finish_error: statusErrorText(error?.message) || '服务端未确认审批任务终态;结果待核对。' })
1462
1543
  } finally {
1544
+ ownerApprovalTaskControllers.delete(entry.key)
1463
1545
  processing.delete(entry.key)
1464
1546
  }
1465
1547
  }
1466
1548
 
1549
+ // The approval is spent once the card task is queued; the card pipeline owns the task from there.
1550
+ async function startApprovedCardTask(entry, connection) {
1551
+ const finished = await memberJson(connection,
1552
+ `/api/me/im-delegations/owner-approvals/${entry.approval_id}/finish`, {
1553
+ method: 'POST', body: JSON.stringify({ outcome: 'completed' }), signal: controller.signal,
1554
+ })
1555
+ if (finished?.finished !== true) throw new Error('服务端未确认审批状态。')
1556
+ await conversation.enqueueApprovedCardTask(entry)
1557
+ await ledger.put({ ...entry, status: 'done', outcome: 'completed' })
1558
+ }
1559
+
1467
1560
  async function recoverOwnerApproval(entry, connection) {
1468
1561
  try {
1469
1562
  let state = await memberJson(connection,
1470
1563
  `/api/me/im-delegations/owner-approvals/${entry.approval_id}`)
1564
+ if (entry.card_task && ['claimed', 'completed'].includes(state.status)) {
1565
+ if (state.status === 'claimed') return await startApprovedCardTask(entry, connection)
1566
+ await conversation.enqueueApprovedCardTask(entry)
1567
+ await ledger.put({ ...entry, status: 'done', outcome: 'completed' })
1568
+ return
1569
+ }
1471
1570
  const wasClaimed = state.status === 'claimed'
1472
1571
  if (state.status === 'claimed') {
1572
+ const finishOutcome = ['completed', 'failed', 'canceled'].includes(entry.outcome)
1573
+ ? entry.outcome : 'failed'
1473
1574
  const finished = await memberJson(connection,
1474
1575
  `/api/me/im-delegations/owner-approvals/${entry.approval_id}/finish`, {
1475
- method: 'POST', body: JSON.stringify({ outcome: 'failed' }),
1576
+ method: 'POST', body: JSON.stringify({ outcome: finishOutcome }),
1476
1577
  })
1477
- if (finished?.finished) state = { status: 'failed', outcome: 'failed', runner: state.runner }
1478
- else state = await memberJson(connection,
1479
- `/api/me/im-delegations/owner-approvals/${entry.approval_id}`)
1578
+ if (finished?.finished) state = { status: finishOutcome, outcome: finishOutcome, runner: state.runner }
1579
+ else {
1580
+ state = await memberJson(connection,
1581
+ `/api/me/im-delegations/owner-approvals/${entry.approval_id}`)
1582
+ if (state.status === 'claimed') {
1583
+ await ledger.put({ ...entry, status: 'finishing',
1584
+ finish_error: entry.finish_error
1585
+ || '服务端未确认审批任务终态;请检查主人绑定和私聊权限后重试。' })
1586
+ return
1587
+ }
1588
+ }
1480
1589
  }
1481
1590
  if (['pending', 'approved'].includes(state.status)) return
1482
1591
  if (['reply_pending', 'done'].includes(entry.status)) return
@@ -1484,6 +1593,7 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1484
1593
  if (state.status === 'expired') text = '主人审批已过期,任务没有启动。请重新发起请求。'
1485
1594
  else if (state.status === 'rejected') text = '主人拒绝了这次任务请求。'
1486
1595
  else if (state.status === 'canceled') text = '这次审批任务已取消,没有自动重试。'
1596
+ else if (state.status === 'failed' && entry.reply) text = entry.reply
1487
1597
  else if (wasClaimed && state.status === 'failed') {
1488
1598
  text = 'DSH 上次中断,任务执行结果未知;为避免重复操作,没有自动重跑。请检查本机后重新发起。'
1489
1599
  } else if (state.status === 'completed' && entry.reply) text = entry.reply
@@ -1494,10 +1604,15 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1494
1604
  else return
1495
1605
  const outcome = state.outcome || (state.status === 'expired' ? 'expired' : state.status)
1496
1606
  const current = await ledger.put({ ...entry, status: outcome, outcome,
1497
- reply: text, failure_reason: state.status === 'completed' ? null : text })
1607
+ reply: text, failure_reason: state.status === 'completed' ? null : text, finish_error: null })
1498
1608
  await replyOwnerApprovalResult(current, text, connection)
1499
1609
  } catch (error) {
1500
1610
  logger.warn?.('ANT 主人审批状态恢复失败:', error?.message)
1611
+ if (['claimed', 'running', 'finishing'].includes(entry.status)) {
1612
+ await ledger.put({ ...entry, status: 'finishing',
1613
+ finish_error: statusErrorText(error?.message) || '无法读取服务端审批状态;结果待核对。' })
1614
+ .catch(() => undefined)
1615
+ }
1501
1616
  }
1502
1617
  }
1503
1618
 
@@ -1708,17 +1823,36 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1708
1823
  }
1709
1824
  if (event.conversation?.kind !== 'dm') return { accepted: true }
1710
1825
  const text = addressedText(event)
1711
- const ownerBinding = /^绑定审批身份\s+([a-f0-9]{12})$/iu.exec(text)
1826
+ const ownerBindingCommand = /^绑定审批身份\s+([a-f0-9]{12})$/iu.exec(text)
1827
+ const bareOwnerBindingCode = /^([a-f0-9]{12})$/iu.exec(text)
1828
+ const ownerChallengeKey = event.fingerprint.toLowerCase()
1829
+ const pendingOwnerChallenge = pendingOwnerChallenges.get(ownerChallengeKey)
1830
+ const ownerBinding = ownerBindingCommand || (pendingOwnerChallenge && bareOwnerBindingCode)
1712
1831
  if (ownerBinding) {
1832
+ const code = (ownerBindingCommand?.[1] || bareOwnerBindingCode[1]).toUpperCase()
1833
+ if (pendingOwnerChallenge && pendingOwnerChallenge.expires_at <= Date.now()) {
1834
+ pendingOwnerChallenges.delete(ownerChallengeKey)
1835
+ await ctx.dshIm.replyChecked(event.botId, event.reply,
1836
+ '审批私聊绑定未完成:绑定码已过期,请从 ANT“私聊入口”重新生成。',
1837
+ { expectedFingerprint: event.fingerprint, signal: signal || controller.signal }).catch(() => undefined)
1838
+ return { accepted: true }
1839
+ }
1840
+ if (pendingOwnerChallenge && code !== pendingOwnerChallenge.code) {
1841
+ await ctx.dshIm.replyChecked(event.botId, event.reply,
1842
+ '审批私聊绑定未完成:这不是当前机器人生成的绑定码;请从 ANT“私聊入口”复制最新验证码。',
1843
+ { expectedFingerprint: event.fingerprint, signal: signal || controller.signal }).catch(() => undefined)
1844
+ return { accepted: true }
1845
+ }
1713
1846
  try {
1714
1847
  const result = await memberJson(connection, '/api/me/im-delegations/owner-bind', {
1715
1848
  method: 'POST', body: JSON.stringify({ bot_id: event.botId,
1716
1849
  bot_fingerprint: event.fingerprint, sender_id: event.actor.id,
1717
- code: ownerBinding[1].toUpperCase() }), signal,
1850
+ code }), signal,
1718
1851
  })
1719
1852
  if (!result?.bound || typeof result.target_id !== 'string') {
1720
1853
  throw new Error('绑定码无效、已过期或尝试次数已用完。')
1721
1854
  }
1855
+ pendingOwnerChallenges.delete(ownerChallengeKey)
1722
1856
  await ensureOwnerApprovalTarget(ctx, event.botId, event.fingerprint,
1723
1857
  event.actor.id, result.target_id)
1724
1858
  ownerBoundByFingerprint.set(event.fingerprint, true)
@@ -1750,6 +1884,9 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1750
1884
  const available = runner === 'dsh' ? Boolean(ctx.sessionController && ctx.agents)
1751
1885
  : (() => { try { return (ctx.get?.('subagents') ?? ctx.subagents)?.list?.().includes(runner) === true } catch { return false } })()
1752
1886
  if (!available) throw new Error(`这台 DSH 尚未启用 ${runner} 子 Agent 提供方;审批仍保留,可改选其他执行器。`)
1887
+ if (pending.card_task && runner !== pending.card_task.card.runner) {
1888
+ throw new Error(`这项任务卡固定由 ${pending.card_task.card.runner} 执行;审批仍保留。`)
1889
+ }
1753
1890
  }
1754
1891
  const result = await memberJson(connection,
1755
1892
  `/api/me/im-delegations/owner-approvals/${approvalId}/decision`, {
@@ -1768,10 +1905,22 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
1768
1905
  method: 'POST', body: JSON.stringify({ request_sha256: pending.request_sha256, runner }), signal,
1769
1906
  })
1770
1907
  if (!claimed?.claimed) throw new Error('审批已被使用、已过期或权限状态已改变。')
1771
- const approved = await ledger.put({ ...pending, status: 'claimed', runner,
1908
+ if (pending.card_task) {
1909
+ await ledger.put({ ...pending, status: 'claimed', runner, decision_sender_id: event.actor.id,
1910
+ decided_at: event.at })
1911
+ await startApprovedCardTask(ledger.get(pending.key), connection)
1912
+ await ctx.dshIm.replyChecked(event.botId, event.reply,
1913
+ `已批准审批 ${approvalId},任务已进入队列;完成后结果返回原会话。`,
1914
+ { expectedFingerprint: event.fingerprint, signal: signal || controller.signal })
1915
+ .catch((error) => logger.warn?.('ANT 主人审批回执发送失败:', error?.message))
1916
+ return { accepted: true }
1917
+ }
1918
+ const taskId = createHash('sha256').update(`${pending.key}\0${pending.approval_id}`)
1919
+ .digest('hex').slice(0, 10).toUpperCase()
1920
+ const approved = await ledger.put({ ...pending, task_id: taskId, status: 'claimed', runner,
1772
1921
  decision_sender_id: event.actor.id, decided_at: event.at })
1773
- reply = `已批准审批 ${approvalId},正在启动 ${runner}。完成后结果返回原会话。`
1774
- const work = executeOwnerApproval(approved, connection, controller.signal)
1922
+ reply = `已批准审批 ${approvalId},任务编号 ${taskId},正在启动 ${runner}。可在 ANT 协作页查看状态或取消;完成后结果返回原会话。`
1923
+ const work = executeOwnerApproval(approved, connection)
1775
1924
  processingTasks.add(work)
1776
1925
  void work.catch((error) => logger.warn?.('ANT 主人审批任务收尾失败:', error?.message))
1777
1926
  .finally(() => processingTasks.delete(work))
@@ -2059,10 +2208,32 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
2059
2208
  dmChatEnabledByFingerprint.set(fingerprint.toLowerCase(), enabled)
2060
2209
  }
2061
2210
  }
2062
- stop.recognizeRule = async (description) => {
2063
- await ready
2211
+ stop.rememberOwnerChallenge = (fingerprint, code) => {
2212
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/iu.test(fingerprint)
2213
+ || typeof code !== 'string' || !/^[a-f0-9]{12}$/iu.test(code)) return false
2214
+ pendingOwnerChallenges.set(fingerprint.toLowerCase(), {
2215
+ code: code.toUpperCase(), expires_at: Date.now() + 5 * 60_000,
2216
+ })
2217
+ return true
2218
+ }
2219
+ stop.ownerChallengeStatus = (fingerprint) => {
2220
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/iu.test(fingerprint)) return null
2221
+ const key = fingerprint.toLowerCase()
2222
+ const challenge = pendingOwnerChallenges.get(key)
2223
+ if (!challenge || challenge.expires_at <= Date.now()) {
2224
+ pendingOwnerChallenges.delete(key)
2225
+ return { pending: false }
2226
+ }
2227
+ return { pending: true }
2228
+ }
2229
+ stop.cancelOwnerChallenge = (fingerprint) => {
2230
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/iu.test(fingerprint)) return false
2231
+ return pendingOwnerChallenges.delete(fingerprint.toLowerCase())
2232
+ }
2233
+ stop.recognizeRule = async (description, requestSignal = null) => {
2234
+ await waitForSignal(ready, requestSignal)
2064
2235
  if (stopped || !ledgerReady) throw new Error('本机协作服务尚未就绪,请稍后重试。')
2065
- return conversation.recognizeRule(description)
2236
+ return conversation.recognizeRule(description, requestSignal)
2066
2237
  }
2067
2238
  stop.cancelTask = async (taskId, serviceUrl) => {
2068
2239
  await ready
@@ -2079,6 +2250,14 @@ export function startDelegationHost(ctx, readConnection, { ledger = new Delegati
2079
2250
  }
2080
2251
  return true
2081
2252
  }
2253
+ const approval = ledger.values().find((entry) => entry.kind === 'owner_approval'
2254
+ && entry.task_id === taskId && entry.service_url === serviceUrl)
2255
+ if (approval && ['claimed', 'running'].includes(approval.status)) {
2256
+ const running = ownerApprovalTaskControllers.get(approval.key)
2257
+ if (!running || running.finished) return false
2258
+ running.controller.abort()
2259
+ return true
2260
+ }
2082
2261
  return conversation.cancelTask(taskId, serviceUrl)
2083
2262
  }
2084
2263
  return stop
package/index.js CHANGED
@@ -6,6 +6,7 @@ import { readFile } from 'node:fs/promises'
6
6
  import { installSkill, listSkills, uninstallSkill } from './skill-install.js'
7
7
  import { readLocalDelegationSettings, recentDelegationStatus, startDelegationHost,
8
8
  writeLocalDelegationSettings } from './delegation.js'
9
+ import { waitForSignal } from './conversation.js'
9
10
 
10
11
  export const inject = []
11
12
 
@@ -320,7 +321,7 @@ async function fetchDelegationBots(request, ctx) {
320
321
  try {
321
322
  const details = await service.describeBot(bot.botId)
322
323
  return {
323
- bot_id: bot.botId, name: details.account?.name || bot.botId,
324
+ bot_id: bot.botId, channel: bot.channel, name: details.account?.name || bot.botId,
324
325
  fingerprint: details.account?.fingerprint,
325
326
  connected: details.connected === true,
326
327
  available: details.capabilities?.includes('exclusive-text-consumer')
@@ -385,14 +386,33 @@ async function fetchDelegationGrants(request, ctx, credentials, settings) {
385
386
  }
386
387
 
387
388
  async function fetchDelegationGroupSettings(request, ctx, credentials, settings, kind, getHost) {
388
- const methods = kind === 'groups' ? ['GET', 'PUT'] : kind === 'dm-chat' ? ['PUT']
389
- : kind === 'challenge' ? ['POST'] : ['GET', 'POST', 'DELETE']
389
+ const methods = kind === 'groups' ? ['GET', 'PUT'] : kind === 'dm-chat' ? ['PUT'] : ['GET', 'POST', 'DELETE']
390
390
  if (!methods.includes(request.method)) return jsonResponse(405, { message: '请求方式不支持。' })
391
+ if (kind === 'challenge' && request.method === 'DELETE') {
392
+ const fingerprint = new URL(request.url).searchParams.get('bot_fingerprint')
393
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/iu.test(fingerprint)) {
394
+ return jsonResponse(400, { message: '请求格式无效。' })
395
+ }
396
+ const host = getHost?.()
397
+ if (!host?.cancelOwnerChallenge) {
398
+ return jsonResponse(503, { message: '本机协作收件尚未就绪,请稍后刷新后重新生成绑定码。' })
399
+ }
400
+ return jsonResponse(200, { cancelled: host.cancelOwnerChallenge(fingerprint) })
401
+ }
391
402
  let connection
392
403
  try { connection = await readMemberConnection(credentials, settings) } catch {
393
404
  return jsonResponse(500, { message: '无法读取 ANT 连接。' })
394
405
  }
395
406
  if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队。' })
407
+ if (kind === 'challenge' && request.method === 'GET') {
408
+ const fingerprint = new URL(request.url).searchParams.get('bot_fingerprint')
409
+ if (typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/iu.test(fingerprint)) {
410
+ return jsonResponse(400, { message: '请求格式无效。' })
411
+ }
412
+ const status = getHost?.()?.ownerChallengeStatus?.(fingerprint)
413
+ if (!status) return jsonResponse(503, { message: '本机协作收件尚未就绪,请稍后刷新后重新生成绑定码。' })
414
+ return jsonResponse(200, status)
415
+ }
396
416
  let body = null
397
417
  if (request.method !== 'GET') {
398
418
  try {
@@ -451,6 +471,16 @@ async function fetchDelegationGroupSettings(request, ctx, credentials, settings,
451
471
  }
452
472
  const text = await readLimitedBody(response)
453
473
  if (text === null) return jsonResponse(502, { message: '团队服务响应过大。' })
474
+ if (kind === 'challenge' && request.method === 'POST' && response.ok) {
475
+ let challenge
476
+ try { challenge = JSON.parse(text) } catch {
477
+ return jsonResponse(502, { message: '团队服务返回的绑定码格式无效。' })
478
+ }
479
+ const host = getHost?.()
480
+ if (!host?.rememberOwnerChallenge?.(body.bot_fingerprint, challenge?.code)) {
481
+ return jsonResponse(503, { message: '本机协作收件尚未就绪,请稍后刷新后重新生成绑定码。' })
482
+ }
483
+ }
454
484
  if (kind === 'groups' && request.method === 'GET' && response.ok) {
455
485
  try {
456
486
  const groups = JSON.parse(text)
@@ -566,9 +596,10 @@ async function fetchDelegationCards(request, ctx, credentials, settings) {
566
596
  async function fetchDelegationRuleRecognition(request, credentials, settings, getHost) {
567
597
  if (request.method !== 'POST') return jsonResponse(405, { message: '只支持 POST 请求。' })
568
598
  try {
569
- const connection = await readMemberConnection(credentials, settings)
599
+ request.signal.throwIfAborted()
600
+ const connection = await waitForSignal(readMemberConnection(credentials, settings), request.signal)
570
601
  if (!connection) return jsonResponse(401, { message: '请先连接 ANT 团队。' })
571
- const raw = await request.text()
602
+ const raw = await waitForSignal(request.text(), request.signal)
572
603
  if (raw.length > 8_192) return jsonResponse(413, { message: '规则描述过长。' })
573
604
  let body
574
605
  try { body = JSON.parse(raw) } catch { return jsonResponse(400, { message: '规则识别请求格式无效。' }) }
@@ -577,7 +608,7 @@ async function fetchDelegationRuleRecognition(request, credentials, settings, ge
577
608
  }
578
609
  const host = getHost?.()
579
610
  if (!host?.recognizeRule) return jsonResponse(503, { message: '本机 DSH 协作会话尚未就绪,请稍后重试。' })
580
- return jsonResponse(200, await host.recognizeRule(body.description))
611
+ return jsonResponse(200, await host.recognizeRule(body.description, request.signal))
581
612
  } catch (error) {
582
613
  return jsonResponse(502, { message: error?.message || '任务规则识别失败,请稍后重试。' })
583
614
  }
@@ -1166,7 +1197,7 @@ export function apply(ctx) {
1166
1197
  [DELEGATION_ENROLLED_BOTS_PATH, ['GET', 'POST', 'DELETE'], 'bots'],
1167
1198
  [DELEGATION_GROUPS_PATH, ['GET', 'PUT'], 'groups'],
1168
1199
  [DELEGATION_DM_CHAT_PATH, ['PUT'], 'dm-chat'],
1169
- [DELEGATION_OWNER_CHALLENGE_PATH, ['POST'], 'challenge'],
1200
+ [DELEGATION_OWNER_CHALLENGE_PATH, ['GET', 'POST', 'DELETE'], 'challenge'],
1170
1201
  ]) {
1171
1202
  connectionCtx.effect(() => connectionCtx.connection.fetch.register({
1172
1203
  path, methods, requestBody: 'buffered',