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 +0 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +2 -0
- package/src/events.mjs +17 -0
- package/src/watch.mjs +105 -20
package/bin/cli.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -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
|
|
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 ' +
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|