openvisio-agent 0.17.4 → 0.17.6
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 +7 -2
- package/src/events.mjs +19 -0
- package/src/watch.mjs +101 -19
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -30,6 +30,10 @@ const assertions = [
|
|
|
30
30
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
31
31
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
|
|
32
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')")],
|
|
33
37
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
34
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')")],
|
|
35
39
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
@@ -44,10 +48,11 @@ const assertions = [
|
|
|
44
48
|
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
|
|
45
49
|
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
46
50
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
47
|
-
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes(
|
|
51
|
+
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
|
|
48
52
|
['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
49
53
|
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
|
50
|
-
['ticket blocker cannot self-authorize', watcher.includes(
|
|
54
|
+
['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
|
|
55
|
+
['agent messages use first-person voice', watcher.includes('FIRST-PERSON VOICE') && watcher.includes("I'm blocked") && !watcher.includes('Alex is blocked') && !watcher.includes('Alex is paused')],
|
|
51
56
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
52
57
|
]
|
|
53
58
|
for (const [label, ok] of assertions) {
|
package/src/events.mjs
CHANGED
|
@@ -46,6 +46,25 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
|
|
|
46
46
|
return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function buildTaskCompletionReport(task, { projectId, 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 mention = requester ? `@${requester} ` : ''
|
|
63
|
+
const content = `${mention}I finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
|
|
64
|
+
const revision = task.updated_at ?? task.updatedAt ?? prUrl
|
|
65
|
+
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
50
69
|
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
51
70
|
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, taskIsAwaitingReview, 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
|
|
@@ -27,6 +27,7 @@ import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgen
|
|
|
27
27
|
// then walking it back.
|
|
28
28
|
const REPLY_DISCIPLINE = [
|
|
29
29
|
'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
|
|
30
|
+
' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
|
|
30
31
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
31
32
|
' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
|
|
32
33
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
@@ -73,7 +74,7 @@ const CODE_CHARTER = [
|
|
|
73
74
|
'',
|
|
74
75
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
75
76
|
' 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.
|
|
77
|
+
' 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
78
|
' 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
79
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
79
80
|
'',
|
|
@@ -89,7 +90,7 @@ const CODE_FULL = [
|
|
|
89
90
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
90
91
|
' 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
92
|
' 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
|
|
93
|
+
' 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
94
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
94
95
|
].join('\n')
|
|
95
96
|
|
|
@@ -107,7 +108,7 @@ const CODE_FAST = [
|
|
|
107
108
|
// missed — TASKS especially.
|
|
108
109
|
const INTRO = [
|
|
109
110
|
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
110
|
-
'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE:
|
|
111
|
+
'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: say in first person that you are here, pick up tasks assigned to you, and answer @mentions. Do not state your name; the app already displays it. Keep it to 1-2 warm, professional sentences.',
|
|
111
112
|
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
112
113
|
].join('\n')
|
|
113
114
|
const SWEEP = [
|
|
@@ -345,7 +346,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
345
346
|
...(m ? ['--model', m] : []),
|
|
346
347
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
347
348
|
full]
|
|
348
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
349
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
349
350
|
let policyBlock = null
|
|
350
351
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
351
352
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
@@ -355,7 +356,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
355
356
|
clearTimeout(timer)
|
|
356
357
|
const calls = [...mcpCalls]
|
|
357
358
|
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 })
|
|
359
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
|
|
359
360
|
}
|
|
360
361
|
const inspectDiagnostic = (value) => {
|
|
361
362
|
const s = String(value || '')
|
|
@@ -379,6 +380,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
379
380
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
380
381
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
381
382
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
383
|
+
if (/post_message/.test(tool)) didChannelMessage = true
|
|
382
384
|
if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
|
|
383
385
|
}
|
|
384
386
|
} catch { /* non-JSON diagnostic */ }
|
|
@@ -442,8 +444,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
442
444
|
let sessionStartedAt = 0
|
|
443
445
|
let resolveTurn = null
|
|
444
446
|
let cycleTimer = null
|
|
447
|
+
let turnToolCalls = new Set()
|
|
445
448
|
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
446
|
-
const settleTurn = (o) => {
|
|
449
|
+
const settleTurn = (o) => {
|
|
450
|
+
clearCycleTimer()
|
|
451
|
+
const r = resolveTurn
|
|
452
|
+
resolveTurn = null
|
|
453
|
+
const mcpCalls = [...turnToolCalls]
|
|
454
|
+
if (r) r({ ...o, mcpCalls, didChannelMessage: mcpCalls.includes('post_message') })
|
|
455
|
+
}
|
|
447
456
|
|
|
448
457
|
// --debug: surface what the cycle actually does (tool calls, tool errors, text)
|
|
449
458
|
// so a silent/hung cycle is diagnosable.
|
|
@@ -483,7 +492,10 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
483
492
|
if (debug) logEvent(o)
|
|
484
493
|
// Surface tool calls (e.g. post_message) so the loop can emit a live status.
|
|
485
494
|
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) {
|
|
495
|
+
for (const b of o.message.content) if (b.type === 'tool_use' && b.name) {
|
|
496
|
+
turnToolCalls.add(String(b.name).replace(/^mcp__openvisio-team__/, ''))
|
|
497
|
+
try { onTool(b.name) } catch { /* status is best-effort */ }
|
|
498
|
+
}
|
|
487
499
|
}
|
|
488
500
|
if (o.type === 'result') {
|
|
489
501
|
log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
|
|
@@ -520,6 +532,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
520
532
|
}
|
|
521
533
|
ensureSession()
|
|
522
534
|
turnsThisSession++
|
|
535
|
+
turnToolCalls = new Set()
|
|
523
536
|
resolveTurn = resolve
|
|
524
537
|
// Backstop: abandon a cycle that never returns a result so `busy` is released
|
|
525
538
|
// and queued mentions can proceed.
|
|
@@ -608,6 +621,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
608
621
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
609
622
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
610
623
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
624
|
+
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
625
|
+
// pending and delivered keys so a reconnect can finish a missed notification
|
|
626
|
+
// without re-running the model or posting the same result twice.
|
|
627
|
+
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
628
|
+
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
611
629
|
// A policy-blocked task stays paused across reconnects. It is released only
|
|
612
630
|
// after the ticket itself carries explicit authorization or is completed/
|
|
613
631
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
@@ -615,7 +633,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
615
633
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
616
634
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
617
635
|
const persistReplay = () => {
|
|
618
|
-
try {
|
|
636
|
+
try {
|
|
637
|
+
writeJson(replayPath, {
|
|
638
|
+
seenMentions: [...seenMentions],
|
|
639
|
+
seenActivities: [...seenActivities],
|
|
640
|
+
blockedTasks: [...blockedTasks],
|
|
641
|
+
pendingCompletionReports: [...pendingCompletionReports],
|
|
642
|
+
reportedCompletions: [...reportedCompletions],
|
|
643
|
+
}, true)
|
|
644
|
+
} catch { /* best-effort */ }
|
|
619
645
|
}
|
|
620
646
|
// Context lines from the events themselves (the WS payload already carries the
|
|
621
647
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
@@ -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.6' } } }, 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, 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)
|
|
@@ -755,10 +814,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
755
814
|
const command = block?.command || 'the requested external repository action'
|
|
756
815
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
757
816
|
const approval = block?.commit && block?.branch
|
|
758
|
-
? `Reply with: “I explicitly authorize
|
|
817
|
+
? `Reply with: “I explicitly authorize you to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
|
|
759
818
|
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
760
|
-
const notice = `Action required:
|
|
761
|
-
const ticketNotice = `
|
|
819
|
+
const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
|
|
820
|
+
const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; I won't retry automatically.`
|
|
762
821
|
return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
|
|
763
822
|
}
|
|
764
823
|
|
|
@@ -792,6 +851,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
792
851
|
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
793
852
|
const taskKey = `${project.id}:${task.id}`
|
|
794
853
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
854
|
+
if (pendingCompletionReports.has(taskKey)) {
|
|
855
|
+
const activityChannel = await projectStatusChannel(project.id)
|
|
856
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
857
|
+
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
858
|
+
}
|
|
795
859
|
if (blockedTasks.delete(taskKey)) persistReplay()
|
|
796
860
|
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
797
861
|
// the ticket back to an actionable column, that update must start a
|
|
@@ -832,7 +896,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
832
896
|
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
833
897
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
834
898
|
const activityChannel = await projectStatusChannel(next.projectId)
|
|
835
|
-
|
|
899
|
+
pendingCompletionReports.add(`${next.projectId}:${next.id}`); trimSeen(pendingCompletionReports); persistReplay()
|
|
900
|
+
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 })
|
|
836
901
|
}
|
|
837
902
|
}
|
|
838
903
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -879,6 +944,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
879
944
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
880
945
|
try {
|
|
881
946
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
947
|
+
let completionResult = result
|
|
882
948
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
883
949
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
884
950
|
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
@@ -886,14 +952,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
886
952
|
return
|
|
887
953
|
}
|
|
888
954
|
if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
|
|
889
|
-
const notice = `
|
|
955
|
+
const notice = `I'm blocked because the coding cycle ended with ${result.subtype}. I'm not claiming completion, and I've left the ticket open for retry.`
|
|
890
956
|
log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
|
|
891
957
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
892
958
|
catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
|
|
893
959
|
return
|
|
894
960
|
}
|
|
895
961
|
if (kind !== 'full' && result?.mcpErrors?.length) {
|
|
896
|
-
const notice = `
|
|
962
|
+
const notice = `I'm blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. I'm not claiming success; this needs a retry or intervention.`
|
|
897
963
|
log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
|
|
898
964
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
899
965
|
catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
|
|
@@ -914,7 +980,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
914
980
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
|
|
915
981
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
916
982
|
} else {
|
|
917
|
-
const notice = `
|
|
983
|
+
const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. I've left the ticket open and I'm not claiming completion.`
|
|
918
984
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
919
985
|
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
920
986
|
}
|
|
@@ -925,6 +991,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
925
991
|
}
|
|
926
992
|
lastTaskSignature = ''
|
|
927
993
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
994
|
+
return
|
|
995
|
+
}
|
|
996
|
+
completionResult = recovery
|
|
997
|
+
}
|
|
998
|
+
if (kind === 'full' && activeTaskRef) {
|
|
999
|
+
try {
|
|
1000
|
+
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1001
|
+
if (!delivered) log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence')
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
928
1004
|
}
|
|
929
1005
|
}
|
|
930
1006
|
} finally {
|
|
@@ -995,8 +1071,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
995
1071
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
996
1072
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
997
1073
|
const key = `${projectId}:${ticketId}`
|
|
998
|
-
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
1074
|
+
if (!belongsToSelf) { blockedTasks.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
999
1075
|
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1076
|
+
if (pendingCompletionReports.has(key)) {
|
|
1077
|
+
const activityChannel = await projectStatusChannel(projectId)
|
|
1078
|
+
try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
|
|
1079
|
+
catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
|
|
1080
|
+
}
|
|
1000
1081
|
blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1001
1082
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1002
1083
|
return
|
|
@@ -1019,7 +1100,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1019
1100
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
1020
1101
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1021
1102
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1022
|
-
|
|
1103
|
+
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1104
|
+
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 })
|
|
1023
1105
|
} catch (e) {
|
|
1024
1106
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1025
1107
|
}
|