openvisio-agent 0.17.3 → 0.17.5
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 +1 -1
- package/scripts/certify.mjs +6 -0
- package/src/events.mjs +32 -0
- package/src/watch.mjs +120 -23
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -28,6 +28,12 @@ const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
|
28
28
|
const assertions = [
|
|
29
29
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
30
30
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
31
|
+
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
|
|
32
|
+
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
33
|
+
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
|
|
34
|
+
['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
|
|
35
|
+
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
36
|
+
['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
|
|
31
37
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
32
38
|
['work and reply activity targets are isolated', watcher.includes("laneStatusTargets = { work: new Set(), reply: new Set() }") && watcher.includes("emitLaneStatus('work', 'typing')") && watcher.includes("emitLaneStatus('reply', 'typing')")],
|
|
33
39
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
package/src/events.mjs
CHANGED
|
@@ -34,6 +34,38 @@ export function taskIsCompleted(task, completedTypeIds = new Set()) {
|
|
|
34
34
|
return /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(state)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// A coding agent has handed work off once the board reaches review, testing, QA,
|
|
38
|
+
// or approval. These are not "completed" states: a reviewer may still send the
|
|
39
|
+
// ticket back to an actionable column. Treating them as settled for the watcher,
|
|
40
|
+
// however, prevents reconnect reconciliation from reopening the same PR every
|
|
41
|
+
// 30 minutes while it waits for a human.
|
|
42
|
+
export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
|
|
43
|
+
if (!task || typeof task !== 'object') return false
|
|
44
|
+
if (reviewTypeIds.has(Number(task.type_id ?? task.typeId ?? task.status_id ?? task.statusId))) return true
|
|
45
|
+
const state = [task.status, task.state, task.type?.name, task.task_type?.name].filter(Boolean).join(' ')
|
|
46
|
+
return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildTaskCompletionReport(task, { projectId, agentName, fallbackText = '' } = {}) {
|
|
50
|
+
if (!task || typeof task !== 'object' || task.id == null) return null
|
|
51
|
+
if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
|
|
52
|
+
|
|
53
|
+
const evidence = [task.description, fallbackText].filter(Boolean).join('\n')
|
|
54
|
+
const prUrl = /https:\/\/github\.com\/[^\s)\]}>]+\/pull\/\d+/i.exec(evidence)?.[0]?.replace(/[.,;:]+$/, '') || ''
|
|
55
|
+
if (!prUrl) return null
|
|
56
|
+
|
|
57
|
+
const creator = task.creator || task.created_by_user || task.createdByUser || {}
|
|
58
|
+
const requester = (`${creator.first_name || creator.firstName || ''} ${creator.last_name || creator.lastName || ''}`.trim() || creator.name || '').trim()
|
|
59
|
+
const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
60
|
+
const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
|
|
61
|
+
const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
|
|
62
|
+
const who = String(agentName || 'The agent').trim()
|
|
63
|
+
const mention = requester ? `@${requester} ` : ''
|
|
64
|
+
const content = `${mention}${who} finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
|
|
65
|
+
const revision = task.updated_at ?? task.updatedAt ?? prUrl
|
|
66
|
+
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
38
70
|
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
39
71
|
const id = Number(channelId)
|
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from 'node:os'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
14
14
|
|
|
15
15
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
16
16
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -73,7 +73,7 @@ const CODE_CHARTER = [
|
|
|
73
73
|
'',
|
|
74
74
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
75
75
|
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back.',
|
|
76
|
-
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket.
|
|
76
|
+
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
|
|
77
77
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
78
78
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
79
79
|
'',
|
|
@@ -89,7 +89,7 @@ const CODE_FULL = [
|
|
|
89
89
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
90
90
|
' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
|
|
91
91
|
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
92
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link
|
|
92
|
+
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
|
|
93
93
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
94
94
|
].join('\n')
|
|
95
95
|
|
|
@@ -345,7 +345,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
345
345
|
...(m ? ['--model', m] : []),
|
|
346
346
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
347
347
|
full]
|
|
348
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
348
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
349
349
|
let policyBlock = null
|
|
350
350
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
351
351
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
@@ -355,7 +355,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
355
355
|
clearTimeout(timer)
|
|
356
356
|
const calls = [...mcpCalls]
|
|
357
357
|
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
358
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
|
|
358
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
|
|
359
359
|
}
|
|
360
360
|
const inspectDiagnostic = (value) => {
|
|
361
361
|
const s = String(value || '')
|
|
@@ -379,6 +379,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
379
379
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
380
380
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
381
381
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
382
|
+
if (/post_message/.test(tool)) didChannelMessage = true
|
|
382
383
|
if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
|
|
383
384
|
}
|
|
384
385
|
} catch { /* non-JSON diagnostic */ }
|
|
@@ -442,8 +443,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
442
443
|
let sessionStartedAt = 0
|
|
443
444
|
let resolveTurn = null
|
|
444
445
|
let cycleTimer = null
|
|
446
|
+
let turnToolCalls = new Set()
|
|
445
447
|
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
446
|
-
const settleTurn = (o) => {
|
|
448
|
+
const settleTurn = (o) => {
|
|
449
|
+
clearCycleTimer()
|
|
450
|
+
const r = resolveTurn
|
|
451
|
+
resolveTurn = null
|
|
452
|
+
const mcpCalls = [...turnToolCalls]
|
|
453
|
+
if (r) r({ ...o, mcpCalls, didChannelMessage: mcpCalls.includes('post_message') })
|
|
454
|
+
}
|
|
447
455
|
|
|
448
456
|
// --debug: surface what the cycle actually does (tool calls, tool errors, text)
|
|
449
457
|
// so a silent/hung cycle is diagnosable.
|
|
@@ -483,7 +491,10 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
483
491
|
if (debug) logEvent(o)
|
|
484
492
|
// Surface tool calls (e.g. post_message) so the loop can emit a live status.
|
|
485
493
|
if (onTool && o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
|
486
|
-
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) {
|
|
494
|
+
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) {
|
|
495
|
+
turnToolCalls.add(String(b.name).replace(/^mcp__openvisio-team__/, ''))
|
|
496
|
+
try { onTool(b.name) } catch { /* status is best-effort */ }
|
|
497
|
+
}
|
|
487
498
|
}
|
|
488
499
|
if (o.type === 'result') {
|
|
489
500
|
log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
|
|
@@ -520,6 +531,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
520
531
|
}
|
|
521
532
|
ensureSession()
|
|
522
533
|
turnsThisSession++
|
|
534
|
+
turnToolCalls = new Set()
|
|
523
535
|
resolveTurn = resolve
|
|
524
536
|
// Backstop: abandon a cycle that never returns a result so `busy` is released
|
|
525
537
|
// and queued mentions can proceed.
|
|
@@ -608,6 +620,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
608
620
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
609
621
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
610
622
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
623
|
+
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
624
|
+
// pending and delivered keys so a reconnect can finish a missed notification
|
|
625
|
+
// without re-running the model or posting the same result twice.
|
|
626
|
+
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
627
|
+
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
611
628
|
// A policy-blocked task stays paused across reconnects. It is released only
|
|
612
629
|
// after the ticket itself carries explicit authorization or is completed/
|
|
613
630
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
@@ -615,7 +632,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
615
632
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
616
633
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
617
634
|
const persistReplay = () => {
|
|
618
|
-
try {
|
|
635
|
+
try {
|
|
636
|
+
writeJson(replayPath, {
|
|
637
|
+
seenMentions: [...seenMentions],
|
|
638
|
+
seenActivities: [...seenActivities],
|
|
639
|
+
blockedTasks: [...blockedTasks],
|
|
640
|
+
pendingCompletionReports: [...pendingCompletionReports],
|
|
641
|
+
reportedCompletions: [...reportedCompletions],
|
|
642
|
+
}, true)
|
|
643
|
+
} catch { /* best-effort */ }
|
|
619
644
|
}
|
|
620
645
|
// Context lines from the events themselves (the WS payload already carries the
|
|
621
646
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
@@ -626,6 +651,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
626
651
|
let lastTaskTriggeredAt = 0
|
|
627
652
|
let lastInboxSignature = ''
|
|
628
653
|
let selfAgentId = null
|
|
654
|
+
let selfAgentName = slug
|
|
629
655
|
let mcpSessionId = ''
|
|
630
656
|
let mcpRpcId = 0
|
|
631
657
|
|
|
@@ -650,7 +676,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
650
676
|
}
|
|
651
677
|
const ensureMcpSession = async () => {
|
|
652
678
|
if (mcpSessionId) return
|
|
653
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.
|
|
679
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.17.5' } } }, false)
|
|
654
680
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
655
681
|
await mcpPayload(res)
|
|
656
682
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -701,6 +727,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
701
727
|
}
|
|
702
728
|
}
|
|
703
729
|
|
|
730
|
+
const announceTaskCompletion = async (taskRef, result = {}) => {
|
|
731
|
+
const projectId = Number(taskRef?.projectId)
|
|
732
|
+
const ticketId = Number(taskRef?.ticketId)
|
|
733
|
+
if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
|
|
734
|
+
const taskKey = `${projectId}:${ticketId}`
|
|
735
|
+
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
736
|
+
const ticket = current.ticket ?? current.task ?? current
|
|
737
|
+
const report = buildTaskCompletionReport(ticket, { projectId, agentName: selfAgentName, fallbackText: result.outputText })
|
|
738
|
+
if (!report) return false
|
|
739
|
+
if (reportedCompletions.has(report.key)) {
|
|
740
|
+
pendingCompletionReports.delete(taskKey)
|
|
741
|
+
persistReplay()
|
|
742
|
+
return true
|
|
743
|
+
}
|
|
744
|
+
// A model may already have replied when the task originated in a real source
|
|
745
|
+
// thread. Mark that verified completion as delivered instead of posting a
|
|
746
|
+
// second top-level copy.
|
|
747
|
+
if (result.didChannelMessage) {
|
|
748
|
+
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
749
|
+
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
750
|
+
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
751
|
+
return true
|
|
752
|
+
}
|
|
753
|
+
const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
|
|
754
|
+
if (!Number.isFinite(channelId)) return false
|
|
755
|
+
sendStatus(channelId, 'typing')
|
|
756
|
+
await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
|
|
757
|
+
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
758
|
+
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
759
|
+
log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
|
|
760
|
+
return true
|
|
761
|
+
}
|
|
762
|
+
|
|
704
763
|
const publishBlocker = async ({ prompt, taskRef, notice, ticketNotice = notice, pause = false }) => {
|
|
705
764
|
const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
706
765
|
const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
|
|
@@ -773,6 +832,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
773
832
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
774
833
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
775
834
|
selfAgentId = Number(self.id)
|
|
835
|
+
selfAgentName = String(self.name || selfAgentName)
|
|
776
836
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
777
837
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
778
838
|
const assigned = []
|
|
@@ -783,21 +843,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
783
843
|
callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
|
|
784
844
|
callMcpTool('list_activity', { project_id: project.id }).then(toolData),
|
|
785
845
|
])
|
|
786
|
-
const
|
|
846
|
+
const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
847
|
+
const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
848
|
+
const reviewIds = new Set(taskTypes.filter((t) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
787
849
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
788
850
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
789
851
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
} else continue
|
|
852
|
+
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
853
|
+
const taskKey = `${project.id}:${task.id}`
|
|
854
|
+
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
855
|
+
if (pendingCompletionReports.has(taskKey)) {
|
|
856
|
+
const activityChannel = await projectStatusChannel(project.id)
|
|
857
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
858
|
+
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
798
859
|
}
|
|
799
|
-
|
|
860
|
+
if (blockedTasks.delete(taskKey)) persistReplay()
|
|
861
|
+
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
862
|
+
// the ticket back to an actionable column, that update must start a
|
|
863
|
+
// fresh work cycle.
|
|
864
|
+
seenTasks.delete(taskKey)
|
|
865
|
+
continue
|
|
866
|
+
}
|
|
867
|
+
if (blockedTasks.has(taskKey)) {
|
|
868
|
+
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
869
|
+
if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
870
|
+
blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
871
|
+
log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
|
|
872
|
+
} else continue
|
|
800
873
|
}
|
|
874
|
+
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
801
875
|
}
|
|
802
876
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
803
877
|
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
@@ -823,7 +897,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
823
897
|
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
824
898
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
825
899
|
const activityChannel = await projectStatusChannel(next.projectId)
|
|
826
|
-
|
|
900
|
+
pendingCompletionReports.add(`${next.projectId}:${next.id}`); trimSeen(pendingCompletionReports); persistReplay()
|
|
901
|
+
void drain('full', `Backlog reconciliation verified this open ticket is assigned to YOU: ${JSON.stringify(next)}. Process this ONE ticket only. This is backlog-only work with NO source message or requester thread: do NOT call post_message yourself. Call get_ticket, use list_task_types and update_ticket to move it active, complete and verify the work, open the PR, then update the ticket with evidence and move it to review or done when appropriate. The watcher will publish exactly one verified completion result to the project channel.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
|
|
827
902
|
}
|
|
828
903
|
}
|
|
829
904
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -870,6 +945,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
870
945
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
871
946
|
try {
|
|
872
947
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
948
|
+
let completionResult = result
|
|
873
949
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
874
950
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
875
951
|
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
@@ -916,6 +992,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
916
992
|
}
|
|
917
993
|
lastTaskSignature = ''
|
|
918
994
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
completionResult = recovery
|
|
998
|
+
}
|
|
999
|
+
if (kind === 'full' && activeTaskRef) {
|
|
1000
|
+
try {
|
|
1001
|
+
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1002
|
+
if (!delivered) log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence')
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
919
1005
|
}
|
|
920
1006
|
}
|
|
921
1007
|
} finally {
|
|
@@ -979,6 +1065,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
979
1065
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
980
1066
|
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
981
1067
|
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
1068
|
+
selfAgentName = String(self?.name || selfAgentName)
|
|
982
1069
|
}
|
|
983
1070
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
984
1071
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -986,8 +1073,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
986
1073
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
987
1074
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
988
1075
|
const key = `${projectId}:${ticketId}`
|
|
989
|
-
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
990
|
-
if (taskIsCompleted(ticket)
|
|
1076
|
+
if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
1077
|
+
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1078
|
+
if (pendingCompletionReports.has(key)) {
|
|
1079
|
+
const activityChannel = await projectStatusChannel(projectId)
|
|
1080
|
+
try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
|
|
1081
|
+
catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
|
|
1082
|
+
}
|
|
1083
|
+
blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1084
|
+
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
991
1087
|
if (blockedTasks.has(key)) {
|
|
992
1088
|
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
993
1089
|
if (/\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
@@ -1006,7 +1102,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1006
1102
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
1007
1103
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1008
1104
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1009
|
-
|
|
1105
|
+
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1106
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ticket #${ticketId} in project ${projectId} is open and assigned to YOU: ${JSON.stringify({ id: ticketId, projectId, 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 })
|
|
1010
1107
|
} catch (e) {
|
|
1011
1108
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1012
1109
|
}
|