openvisio-agent 0.18.13 → 0.18.15

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.15",
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') && watcher.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
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.
@@ -439,13 +453,14 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
439
453
  // approval/sandbox bypass flag.
440
454
  function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
441
455
  const bin = onPath('codex') || 'codex'
442
- const cwd = workdir || OV_DIR
456
+ const defaultCwd = workdir || OV_DIR
443
457
  const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
444
458
  let cancelActive = null
445
459
 
446
460
  function runCycle(prompt, cycleModel, cycleOptions = {}) {
447
461
  return new Promise((resolve) => {
448
462
  const m = cycleModel || model
463
+ const cycleCwd = cycleOptions.workdir || defaultCwd
449
464
  const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
450
465
  const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
451
466
  const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
@@ -528,7 +543,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
528
543
  // mistaken for completed work. Keep it out of normal logs unless debug.
529
544
  child = spawn(bin, args, {
530
545
  ...modelProcessOptions,
531
- cwd,
546
+ cwd: cycleCwd,
532
547
  env: {
533
548
  ...process.env,
534
549
  ...(mcpUrl ? {
@@ -563,7 +578,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
563
578
  }
564
579
 
565
580
  if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
566
- log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + cwd + ']' : ' [CHAT-ONLY]'))
581
+ log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
567
582
  return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
568
583
  }
569
584
 
@@ -817,7 +832,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
817
832
  const queues = Object.fromEntries(['work', 'reply'].map((laneName) => [laneName, createCycleQueue({
818
833
  run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery),
819
834
  onError: (error, item) => {
820
- releaseTaskForRetry(item.taskRef, item.context || '')
835
+ pauseFailedTask(item.taskRef)
836
+ void finalizeFailedTaskPause(item.taskRef)
821
837
  log(laneName + ' cycle failed: ' + (error?.message || error))
822
838
  },
823
839
  })]))
@@ -842,6 +858,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
842
858
  const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
843
859
  const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
844
860
  const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
861
+ // Evidence-gate failures are held at the ticket revision that produced them.
862
+ // This is distinct from a policy block: any later human ticket change resumes
863
+ // the work, but reconnects and the watcher's own blocker update do not.
864
+ const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
845
865
  // A policy-blocked task stays paused across reconnects. It is released only
846
866
  // after the ticket itself carries explicit authorization or is completed/
847
867
  // unassigned. This prevents a 30-minute reconciliation retry from repeatedly
@@ -849,6 +869,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
849
869
  const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
850
870
  const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
851
871
  const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
872
+ const trimMap = (map) => { while (map.size > 500) map.delete(map.keys().next().value) }
852
873
  const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
853
874
  const pruneMentionSignatures = () => {
854
875
  const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
@@ -863,6 +884,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
863
884
  recentMentionSignatures: [...recentMentionSignatures],
864
885
  seenActivities: [...seenActivities],
865
886
  deliveredReplies: [...deliveredReplies],
887
+ failedTaskVersions: [...failedTaskVersions],
866
888
  blockedTasks: [...blockedTasks],
867
889
  blockedTaskRepos: [...blockedTaskRepos],
868
890
  pendingCompletionReports: [...pendingCompletionReports],
@@ -871,6 +893,49 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
871
893
  }, true)
872
894
  } catch { /* best-effort */ }
873
895
  }
896
+ const pauseFailedTask = (taskRef) => {
897
+ const projectId = Number(taskRef?.projectId)
898
+ const ticketId = Number(taskRef?.ticketId)
899
+ if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return
900
+ const key = `${projectId}:${ticketId}`
901
+ // Persist before publishing the blocker. Its update_ticket event can arrive
902
+ // while publishBlocker is awaiting the backend response.
903
+ failedTaskVersions.set(key, 'pending')
904
+ trimMap(failedTaskVersions)
905
+ seenTasks.add(key)
906
+ trimSeen(seenTasks)
907
+ lastTaskSignature = ''
908
+ persistReplay()
909
+ }
910
+ const finalizeFailedTaskPause = async (taskRef) => {
911
+ const projectId = Number(taskRef?.projectId)
912
+ const ticketId = Number(taskRef?.ticketId)
913
+ if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return
914
+ const key = `${projectId}:${ticketId}`
915
+ try {
916
+ const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
917
+ const ticket = current.ticket ?? current.task ?? current
918
+ failedTaskVersions.set(key, taskRevision(ticket) || 'pending')
919
+ trimMap(failedTaskVersions)
920
+ persistReplay()
921
+ } catch (e) {
922
+ log('could not snapshot failed ticket revision for #' + ticketId + ': ' + (e?.message || e) + '; keeping it paused')
923
+ }
924
+ }
925
+ const clearFailedTask = (key) => {
926
+ if (!failedTaskVersions.delete(key)) return false
927
+ persistReplay()
928
+ return true
929
+ }
930
+ const failedTaskIsPaused = (key, ticket) => {
931
+ if (!failedTaskVersions.has(key)) return false
932
+ const failedRevision = failedTaskVersions.get(key)
933
+ if (failedTaskRevisionIsCurrent(failedRevision, ticket)) return true
934
+ failedTaskVersions.delete(key)
935
+ seenTasks.delete(key)
936
+ persistReplay()
937
+ return false
938
+ }
874
939
  const markMentionHandled = (message, channelId) => {
875
940
  pruneMentionSignatures()
876
941
  const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
@@ -1229,12 +1294,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1229
1294
  catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
1230
1295
  }
1231
1296
  if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
1297
+ clearFailedTask(taskKey)
1232
1298
  // Release the in-flight de-dupe key at handoff. If a reviewer moves
1233
1299
  // the ticket back to an actionable column, that update must start a
1234
1300
  // fresh work cycle.
1235
1301
  seenTasks.delete(taskKey)
1236
1302
  continue
1237
1303
  }
1304
+ if (failedTaskIsPaused(taskKey, task)) {
1305
+ seenTasks.add(taskKey)
1306
+ log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
1307
+ continue
1308
+ }
1238
1309
  if (blockedTasks.has(taskKey)) {
1239
1310
  const blockedRepo = blockedTaskRepos.get(taskKey)
1240
1311
  const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
@@ -1349,6 +1420,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1349
1420
  lane.activeDelivery = delivery
1350
1421
  const ctx = context ? [context] : []
1351
1422
  const activeTaskRef = taskRef
1423
+ const ticketWorktree = kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : ''
1424
+ const runnerOptions = {
1425
+ ...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
1426
+ ...(ticketWorktree ? { workdir: ticketWorktree } : {}),
1427
+ }
1352
1428
  const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
1353
1429
  laneStatusTargets[laneName] = new Set(targets)
1354
1430
  // credNote + charter live in the cached system prompt now — the per-cycle
@@ -1385,7 +1461,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1385
1461
  }
1386
1462
  }
1387
1463
  if (lane.cancelled) return
1388
- const result = await runners[laneName].runCycle(prompt, useModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1464
+ const result = await runners[laneName].runCycle(prompt, useModel, runnerOptions)
1389
1465
  let completionResult = result
1390
1466
  if (lane.cancelled || result?.subtype === 'canceled') {
1391
1467
  log(laneName + ' cycle cancelled; no blocker or reply will be published')
@@ -1399,11 +1475,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1399
1475
  }
1400
1476
  if (!cycleSucceeded(result)) {
1401
1477
  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." : ''}`
1478
+ 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
1479
  log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
1480
+ if (activeTaskRef) pauseFailedTask(activeTaskRef)
1404
1481
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1405
1482
  catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
1406
- releaseTaskForRetry(activeTaskRef, prompt)
1483
+ finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1407
1484
  return
1408
1485
  }
1409
1486
  if (kind !== 'full' && result?.mcpErrors?.length) {
@@ -1421,7 +1498,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1421
1498
  if (kind === 'full' && cycleSucceeded(result) && missing.length) {
1422
1499
  log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
1423
1500
  const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then provide one distinct final result or real blocker using the original delivery rule; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${codexPushGuide}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
1424
- const recovery = await runners.work.runCycle(recoveryPrompt, codeModel, delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {})
1501
+ const recovery = await runners.work.runCycle(recoveryPrompt, codeModel, runnerOptions)
1425
1502
  if (lane.cancelled || recovery?.subtype === 'canceled') return
1426
1503
  const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
1427
1504
  const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
@@ -1431,12 +1508,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1431
1508
  catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
1432
1509
  } else {
1433
1510
  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.`
1511
+ 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.`
1512
+ if (activeTaskRef) pauseFailedTask(activeTaskRef)
1435
1513
  try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
1436
1514
  catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
1515
+ finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
1437
1516
  }
1438
- releaseTaskForRetry(activeTaskRef, prompt)
1439
- log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
1517
+ log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket paused until its revision changes')
1440
1518
  return
1441
1519
  }
1442
1520
  completionResult = recoveredResult
@@ -1445,12 +1523,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1445
1523
  try {
1446
1524
  const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
1447
1525
  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')
1526
+ log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence without rerunning repository work')
1450
1527
  }
1451
1528
  } catch (e) {
1452
- releaseTaskForRetry(activeTaskRef, prompt)
1453
- log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
1529
+ log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for delivery retry without rerunning repository work')
1454
1530
  }
1455
1531
  }
1456
1532
  if (delivery?.watcherOwned) {
@@ -1536,7 +1612,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1536
1612
  })
1537
1613
  }
1538
1614
  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
1615
+ 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
1616
  }
1541
1617
  if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
1542
1618
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
@@ -1545,10 +1621,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1545
1621
  try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
1546
1622
  catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
1547
1623
  }
1548
- blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
1624
+ blockedTasks.delete(key); blockedTaskRepos.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key)
1549
1625
  log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
1550
1626
  return
1551
1627
  }
1628
+ const hadFailedRevision = failedTaskVersions.has(key)
1629
+ if (failedTaskIsPaused(key, ticket)) {
1630
+ seenTasks.add(key)
1631
+ log(kind + ' ticket #' + ticketId + ' is paused at its failed revision; waiting for a ticket change')
1632
+ return
1633
+ }
1634
+ if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
1552
1635
  if (blockedTasks.has(key)) {
1553
1636
  const blockedRepo = blockedTaskRepos.get(key)
1554
1637
  const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
@@ -1567,12 +1650,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
1567
1650
  memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
1568
1651
  const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
1569
1652
  const cycleKind = canCode && !taskIsCoordinationOnly(taskText) ? 'full' : 'coord'
1653
+ const preparedWorktree = cycleKind === 'full' ? findTicketWorktree(workdir, ticketId) : ''
1570
1654
  log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
1571
1655
  const activityChannel = await projectStatusChannel(projectId)
1572
1656
  if (activityChannel != null) sendStatus(activityChannel, 'thinking')
1573
1657
  if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
1574
1658
  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 })
1659
+ 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.` : ''
1660
+ 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
1661
  } catch (e) {
1577
1662
  log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
1578
1663
  }