openvisio-agent 0.18.12 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.18.12",
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": {
@@ -68,6 +68,7 @@ const assertions = [
68
68
  ['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
69
69
  ['tickets and guarded replies use the behavior-tested serial queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()')],
70
70
  ['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
71
+ ['feature titles cannot be mistaken for coordination commands', watcher.includes('taskIsCoordinationOnly(taskText)') && events.includes('Uploading files as messages') && events.includes('explicitCoordination')],
71
72
  ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: '', systemPrompt: CHAT_CHARTER") && opencodeConfig.includes("'*': 'deny'")],
72
73
  ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
73
74
  ['BYO memory uses real ticket and thread identities', watcher.includes('createByoMemoryGraph') && watcher.includes('memory.context(memoryRefs)') && memory.includes('sameRef(r.projectId, refs.projectId)') && memory.includes('sameRef(r.threadId, refs.threadId)')],
@@ -91,6 +92,8 @@ const assertions = [
91
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')],
92
93
  ['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
93
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')],
94
97
  ['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
95
98
  ['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
96
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)')],
@@ -24,8 +24,14 @@ export function runCodexMcpProxy() {
24
24
  if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
25
25
  const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
26
26
  const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
27
- const fail = (id, error) => {
27
+ const fail = (id, error, request) => {
28
28
  const message = String(error?.message || error || 'MCP bridge error').split(apiKey).join('[redacted]')
29
+ if (request?.method === 'tools/call') {
30
+ const safeArgs = { ...(request.params?.arguments || {}) }
31
+ delete safeArgs.agent_api_key
32
+ delete safeArgs.agent_identifier
33
+ process.stderr.write(`OpenVisio MCP bridge call failed: ${String(request.params?.name || 'unknown')} ${JSON.stringify(safeArgs).slice(0, 1000)} — ${message.slice(0, 1000)}\n`)
34
+ }
29
35
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: message.slice(0, 1000) } }) + '\n')
30
36
  }
31
37
  const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })
@@ -53,7 +59,7 @@ export function runCodexMcpProxy() {
53
59
  process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
54
60
  }
55
61
  } catch (error) {
56
- if (id != null) fail(id, error)
62
+ if (id != null) fail(id, error, request)
57
63
  }
58
64
  })
59
65
  }
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
@@ -471,6 +488,17 @@ export function conversationNeedsCode(value) {
471
488
  return action && target
472
489
  }
473
490
 
491
+ // Coordination tickets are imperative board/chat actions, not implementation
492
+ // work that merely mentions words such as "message", "status", or "label" in
493
+ // its feature title. Requiring the coordination verb at the start prevents
494
+ // tickets like "Uploading files as messages" from being routed away from the
495
+ // coding lane.
496
+ export function taskIsCoordinationOnly(value) {
497
+ const text = String(value || '').trim()
498
+ const explicitCoordination = /^(?:please\s+)?(?:move|moving|assign|reassign|unassign|comment(?:\s+on)?|reply(?:\s+to)?|send\s+(?:a\s+)?message|triage|prioriti[sz]e|rename|close|reopen|(?:add|remove|change|update)\s+(?:the\s+)?(?:status|column|label))\b/i.test(text)
499
+ return explicitCoordination && !conversationNeedsCode(text)
500
+ }
501
+
474
502
  // A mention event means this agent's name appeared somewhere, not necessarily
475
503
  // that the request was addressed to it. Reject a later-agent hand-off before a
476
504
  // model starts, while keeping explicitly shared requests addressed to both.
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, 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) {
@@ -1479,7 +1549,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1479
1549
 
1480
1550
  // Route only concrete repository requests to the coding lane. Conversation
1481
1551
  // ownership is classified separately before this is consulted.
1482
- const coordinationOnly = (value) => /\b(?:move|moving|status|column|assign|reassign|unassign|comment|reply|message|triage|prioriti[sz]e|label|rename|close|reopen)\b/i.test(String(value || '')) && !conversationNeedsCode(value)
1483
1552
 
1484
1553
  // Engineers change the model under the hood from chat: "/model", "/model sonnet",
1485
1554
  // "use model haiku", "switch model to opus". Returns {report} | {set} | {invalid}.
@@ -1537,7 +1606,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1537
1606
  })
1538
1607
  }
1539
1608
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
1540
- 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
1541
1610
  }
1542
1611
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1543
1612
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
@@ -1546,10 +1615,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1546
1615
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1547
1616
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1548
1617
  }
1549
- blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1618
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key)
1550
1619
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1551
1620
  return
1552
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')
1553
1629
  if (blockedTasks.has(key)) {
1554
1630
  const blockedRepo = blockedTaskRepos.get(key)
1555
1631
  const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
@@ -1567,13 +1643,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1567
1643
  const ticketSlug = ticketDisplaySlug(ticket)
1568
1644
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1569
1645
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1570
- const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
1646
+ const cycleKind = canCode && !taskIsCoordinationOnly(taskText) ? 'full' : 'coord'
1647
+ const preparedWorktree = cycleKind === 'full' ? findTicketWorktree(workdir, ticketId) : ''
1571
1648
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1572
1649
  const activityChannel = await projectStatusChannel(projectId)
1573
1650
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1574
1651
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1575
1652
  const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
1576
- void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. 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 })
1577
1655
  } catch (e) {
1578
1656
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1579
1657
  }