openvisio-agent 0.18.13 → 0.18.14

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/bin/cli.mjs CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.13",
3
+ "version": "0.18.14",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -92,6 +92,8 @@ const assertions = [
92
92
  ['bare-agent posts have a live conversation guard', bareRun.includes('const canPostMessage') && bareRun.includes('{ canPostMessage }') && bareRuntime.includes('reply suppressed because the live thread was redirected, cancelled, or already answered')],
93
93
  ['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
94
94
  ['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
95
+ ['failed evidence revisions persist and suppress self-triggered retries', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("failedTaskVersions.set(key, 'pending')") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)') && watcher.includes('ticket paused until its revision changes')],
96
+ ['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && watcher.includes('Do not call list_codebases, codebase_tree, or get_codebase')],
95
97
  ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
96
98
  ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
97
99
  ['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
package/src/events.mjs CHANGED
@@ -36,6 +36,23 @@ export function ticketDisplaySlug(task) {
36
36
  return typeof value === 'string' ? value.trim().toUpperCase() : ''
37
37
  }
38
38
 
39
+ // Failed coding cycles are paused at a concrete backend revision. The watcher
40
+ // may write its own blocker onto the ticket after the failure, so a temporary
41
+ // `pending` marker must also count as current until that write is observed.
42
+ export function taskRevision(task) {
43
+ if (!task || typeof task !== 'object') return ''
44
+ const value = task.updated_at ?? task.updatedAt ?? task.modified_at ?? task.modifiedAt
45
+ return value == null ? '' : String(value).trim()
46
+ }
47
+
48
+ export function failedTaskRevisionIsCurrent(failedRevision, task) {
49
+ const failed = String(failedRevision || '').trim()
50
+ if (!failed) return false
51
+ if (failed === 'pending') return true
52
+ const current = taskRevision(task)
53
+ return !current || current === failed
54
+ }
55
+
39
56
  export function taskIsCompleted(task, completedTypeIds = new Set()) {
40
57
  if (!task || typeof task !== 'object') return false
41
58
  if (task.deleted_at || task.deletedAt || task.completed_at || task.completedAt || task.closed_at || task.closedAt || task.archived_at || task.archivedAt) return true
package/src/watch.mjs CHANGED
@@ -5,13 +5,13 @@
5
5
  // than written to disk from a pasted heredoc.
6
6
 
7
7
  import { spawn, spawnSync } from 'node:child_process'
8
- import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs'
8
+ import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
13
13
  import { connectAgentWs, assertWebSocket } from './ws.mjs'
14
- import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, ticketDisplaySlug } from './events.mjs'
14
+ import { agentAddedByName, agentStateRequest, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
15
15
  import { createByoMemoryGraph } from './memory.mjs'
16
16
  import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
17
17
  import { createMcpHttpClient } from './mcp-http.mjs'
@@ -20,6 +20,20 @@ import { buildCodexMcpOverride } from './codex-config.mjs'
20
20
  import { createCycleQueue } from './cycle-queue.mjs'
21
21
  import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
22
22
 
23
+ function findTicketWorktree(workspaceRoot, ticketId) {
24
+ if (!workspaceRoot || ticketId == null) return ''
25
+ try {
26
+ if (existsSync(join(workspaceRoot, '.git'))) return workspaceRoot
27
+ const suffix = `-ticket-${String(ticketId).toLowerCase()}`
28
+ for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
29
+ if (!entry.isDirectory() || !entry.name.toLowerCase().endsWith(suffix)) continue
30
+ const candidate = join(workspaceRoot, entry.name)
31
+ if (existsSync(join(candidate, '.git'))) return candidate
32
+ }
33
+ } catch { /* workspace discovery is best-effort */ }
34
+ return ''
35
+ }
36
+
23
37
  // Behaviour prompts. The openvisio-team MCP bridge requires the agent's
24
38
  // credentials as ARGUMENTS on every tool call — those are injected at runtime by
25
39
  // loopBackendWs (see credNote), NOT baked in here, so nothing needs hunting.
@@ -817,7 +831,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
817
831
  const queues = Object.fromEntries(['work', 'reply'].map((laneName) => [laneName, createCycleQueue({
818
832
  run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery),
819
833
  onError: (error, item) => {
820
- releaseTaskForRetry(item.taskRef, item.context || '')
834
+ pauseFailedTask(item.taskRef)
835
+ void finalizeFailedTaskPause(item.taskRef)
821
836
  log(laneName + ' cycle failed: ' + (error?.message || error))
822
837
  },
823
838
  })]))
@@ -842,6 +857,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
842
857
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
843
858
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
844
859
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
860
+ // Evidence-gate failures are held at the ticket revision that produced them.
861
+ // This is distinct from a policy block: any later human ticket change resumes
862
+ // the work, but reconnects and the watcher's own blocker update do not.
863
+ const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
845
864
  // A policy-blocked task stays paused across reconnects. It is released only
846
865
  // after the ticket itself carries explicit authorization or is completed/
847
866
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
@@ -849,6 +868,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
849
868
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
850
869
  const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
851
870
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
871
+ const trimMap = (map) => { while (map.size > 500) map.delete(map.keys().next().value) }
852
872
  const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
853
873
  const pruneMentionSignatures = () => {
854
874
  const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
@@ -863,6 +883,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
863
883
  recentMentionSignatures: [...recentMentionSignatures],
864
884
  seenActivities: [...seenActivities],
865
885
  deliveredReplies: [...deliveredReplies],
886
+ failedTaskVersions: [...failedTaskVersions],
866
887
  blockedTasks: [...blockedTasks],
867
888
  blockedTaskRepos: [...blockedTaskRepos],
868
889
  pendingCompletionReports: [...pendingCompletionReports],
@@ -871,6 +892,49 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
871
892
  }, true)
872
893
  } catch { /* best-effort */ }
873
894
  }
895
+ const pauseFailedTask = (taskRef) => {
896
+ const projectId = Number(taskRef?.projectId)
897
+ const ticketId = Number(taskRef?.ticketId)
898
+ if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return
899
+ const key = `${projectId}:${ticketId}`
900
+ // Persist before publishing the blocker. Its update_ticket event can arrive
901
+ // while publishBlocker is awaiting the backend response.
902
+ failedTaskVersions.set(key, 'pending')
903
+ trimMap(failedTaskVersions)
904
+ seenTasks.add(key)
905
+ trimSeen(seenTasks)
906
+ lastTaskSignature = ''
907
+ persistReplay()
908
+ }
909
+ const finalizeFailedTaskPause = async (taskRef) => {
910
+ const projectId = Number(taskRef?.projectId)
911
+ const ticketId = Number(taskRef?.ticketId)
912
+ if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return
913
+ const key = `${projectId}:${ticketId}`
914
+ try {
915
+ const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
916
+ const ticket = current.ticket ?? current.task ?? current
917
+ failedTaskVersions.set(key, taskRevision(ticket) || 'pending')
918
+ trimMap(failedTaskVersions)
919
+ persistReplay()
920
+ } catch (e) {
921
+ log('could not snapshot failed ticket revision for #' + ticketId + ': ' + (e?.message || e) + '; keeping it paused')
922
+ }
923
+ }
924
+ const clearFailedTask = (key) => {
925
+ if (!failedTaskVersions.delete(key)) return false
926
+ persistReplay()
927
+ return true
928
+ }
929
+ const failedTaskIsPaused = (key, ticket) => {
930
+ if (!failedTaskVersions.has(key)) return false
931
+ const failedRevision = failedTaskVersions.get(key)
932
+ if (failedTaskRevisionIsCurrent(failedRevision, ticket)) return true
933
+ failedTaskVersions.delete(key)
934
+ seenTasks.delete(key)
935
+ persistReplay()
936
+ return false
937
+ }
874
938
  const markMentionHandled = (message, channelId) => {
875
939
  pruneMentionSignatures()
876
940
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
@@ -1229,12 +1293,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1229
1293
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
1230
1294
  }
1231
1295
  if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
1296
+ clearFailedTask(taskKey)
1232
1297
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
1233
1298
  // the ticket back to an actionable column, that update must start a
1234
1299
  // fresh work cycle.
1235
1300
  seenTasks.delete(taskKey)
1236
1301
  continue
1237
1302
  }
1303
+ if (failedTaskIsPaused(taskKey, task)) {
1304
+ seenTasks.add(taskKey)
1305
+ log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
1306
+ continue
1307
+ }
1238
1308
  if (blockedTasks.has(taskKey)) {
1239
1309
  const blockedRepo = blockedTaskRepos.get(taskKey)
1240
1310
  const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
@@ -1399,11 +1469,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1399
1469
  }
1400
1470
  if (!cycleSucceeded(result)) {
1401
1471
  const outcome = result?.subtype || 'an unknown runtime error'
1402
- const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}. I'm not claiming completion.${activeTaskRef ? " I've left the ticket available for retry." : ''}`
1472
+ const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}. I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
1403
1473
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
1474
+ if (activeTaskRef) pauseFailedTask(activeTaskRef)
1404
1475
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1405
1476
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1406
- releaseTaskForRetry(activeTaskRef, prompt)
1477
+ finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1407
1478
  return
1408
1479
  }
1409
1480
  if (kind !== 'full' && result?.mcpErrors?.length) {
@@ -1431,12 +1502,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1431
1502
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
1432
1503
  } else {
1433
1504
  const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
1434
- const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've left the ticket open and I'm not claiming completion.`
1505
+ const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've paused this revision until the ticket changes, and I'm not claiming completion.`
1506
+ if (activeTaskRef) pauseFailedTask(activeTaskRef)
1435
1507
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1436
1508
  catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
1509
+ finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1437
1510
  }
1438
- releaseTaskForRetry(activeTaskRef, prompt)
1439
- log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
1511
+ log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket paused until its revision changes')
1440
1512
  return
1441
1513
  }
1442
1514
  completionResult = recoveredResult
@@ -1445,12 +1517,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1445
1517
  try {
1446
1518
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1447
1519
  if (!delivered) {
1448
- releaseTaskForRetry(activeTaskRef, prompt)
1449
- log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence; ticket remains retryable')
1520
+ log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence without rerunning repository work')
1450
1521
  }
1451
1522
  } catch (e) {
1452
- releaseTaskForRetry(activeTaskRef, prompt)
1453
- log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1523
+ log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for delivery retry without rerunning repository work')
1454
1524
  }
1455
1525
  }
1456
1526
  if (delivery?.watcherOwned) {
@@ -1536,7 +1606,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1536
1606
  })
1537
1607
  }
1538
1608
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1539
- blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1609
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
1540
1610
  }
1541
1611
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1542
1612
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
@@ -1545,10 +1615,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1545
1615
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1546
1616
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1547
1617
  }
1548
- blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1618
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key)
1549
1619
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1550
1620
  return
1551
1621
  }
1622
+ const hadFailedRevision = failedTaskVersions.has(key)
1623
+ if (failedTaskIsPaused(key, ticket)) {
1624
+ seenTasks.add(key)
1625
+ log(kind + ' ticket #' + ticketId + ' is paused at its failed revision; waiting for a ticket change')
1626
+ return
1627
+ }
1628
+ if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
1552
1629
  if (blockedTasks.has(key)) {
1553
1630
  const blockedRepo = blockedTaskRepos.get(key)
1554
1631
  const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
@@ -1567,12 +1644,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1567
1644
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1568
1645
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1569
1646
  const cycleKind = canCode && !taskIsCoordinationOnly(taskText) ? 'full' : 'coord'
1647
+ const preparedWorktree = cycleKind === 'full' ? findTicketWorktree(workdir, ticketId) : ''
1570
1648
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1571
1649
  const activityChannel = await projectStatusChannel(projectId)
1572
1650
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1573
1651
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1574
1652
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1575
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}. This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1653
+ const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
1654
+ void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
1576
1655
  } catch (e) {
1577
1656
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1578
1657
  }