openvisio-agent 0.17.1 → 0.17.2
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 +3 -1
- package/src/events.mjs +3 -1
- package/src/watch.mjs +75 -28
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -42,7 +42,9 @@ const assertions = [
|
|
|
42
42
|
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
|
|
43
43
|
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
44
44
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
45
|
-
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, result.policyBlock)') && watcher.includes('
|
|
45
|
+
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes('Action required: Alex is blocked')],
|
|
46
|
+
['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
47
|
+
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
|
46
48
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
47
49
|
]
|
|
48
50
|
for (const [label, ok] of assertions) {
|
package/src/events.mjs
CHANGED
|
@@ -54,7 +54,9 @@ export function codexPolicyBlock(value) {
|
|
|
54
54
|
const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
|
|
55
55
|
const reasonTail = text.split(/Reason:\s*/i)[1] || ''
|
|
56
56
|
const reason = reasonTail.split(/(?:\\n|\n)The agent\b/i)[0].replace(/[\\"') }]+$/, '').trim() || 'Codex requires explicit user approval for this external action.'
|
|
57
|
-
|
|
57
|
+
const commit = /\bcommit\s+([0-9a-f]{7,40})\b/i.exec(reason)?.[1] || ''
|
|
58
|
+
const push = /\bgit\s+push(?:\s+-\S+)*(?:\s+\S+)*\s+(\S+)\s+(\S+)\s*$/i.exec(command)
|
|
59
|
+
return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
package/src/watch.mjs
CHANGED
|
@@ -593,8 +593,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
593
593
|
let liteModel = chatModel || model
|
|
594
594
|
|
|
595
595
|
const lanes = {
|
|
596
|
-
work: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
597
|
-
reply: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
596
|
+
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
597
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
598
598
|
}
|
|
599
599
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
600
600
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -650,7 +650,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
650
650
|
}
|
|
651
651
|
const ensureMcpSession = async () => {
|
|
652
652
|
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.
|
|
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.2' } } }, false)
|
|
654
654
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
655
655
|
await mcpPayload(res)
|
|
656
656
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -701,44 +701,64 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
701
701
|
}
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
const
|
|
705
|
-
const
|
|
704
|
+
const publishBlocker = async ({ prompt, taskRef, notice, pause = false }) => {
|
|
705
|
+
const sentenceTask = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
706
|
+
const jsonTask = /"id"\s*:\s*(\d+)[\s\S]{0,300}?"projectId"\s*:\s*(\d+)/i.exec(prompt)
|
|
707
|
+
const ticketId = Number(taskRef?.ticketId ?? sentenceTask?.[1] ?? jsonTask?.[1])
|
|
708
|
+
const projectId = Number(taskRef?.projectId ?? sentenceTask?.[2] ?? jsonTask?.[2])
|
|
706
709
|
const channelMatch = /channel\s+(\d+)/i.exec(prompt)
|
|
707
710
|
const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
|
|
708
|
-
const
|
|
709
|
-
const notice = `Action required: Codex reached ${command.includes('git push') ? 'the branch push step' : 'an external action'}, but the safety layer blocked \`${command}\` because private repository code cannot be sent to a remote without explicit approval for the destination and payload. Please explicitly authorize this exact push (repository remote + branch). Alex has paused the ticket and will not retry it automatically.`
|
|
711
|
+
const channelId = Number(channelMatch?.[1] ?? taskRef?.channelId)
|
|
710
712
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
713
|
+
let delivered = false
|
|
714
|
+
if (Number.isFinite(channelId)) {
|
|
715
|
+
try {
|
|
716
|
+
await callMcpTool('post_message', {
|
|
717
|
+
channel_id: channelId,
|
|
718
|
+
...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
|
|
719
|
+
content: notice,
|
|
720
|
+
})
|
|
721
|
+
delivered = true
|
|
722
|
+
} catch (e) {
|
|
723
|
+
log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!Number.isFinite(ticketId) || !Number.isFinite(projectId)) {
|
|
727
|
+
if (!Number.isFinite(channelId)) log('blocker has no source channel or ticket to update')
|
|
717
728
|
return
|
|
718
729
|
}
|
|
719
|
-
if (!taskMatch) { log('policy blocker has no source channel or ticket to update'); return }
|
|
720
730
|
|
|
721
|
-
const ticketId = Number(taskMatch[1])
|
|
722
|
-
const projectId = Number(taskMatch[2])
|
|
723
731
|
const key = `${projectId}:${ticketId}`
|
|
724
|
-
blockedTasks.add(key)
|
|
725
|
-
persistReplay()
|
|
732
|
+
if (pause) { blockedTasks.add(key); persistReplay() }
|
|
726
733
|
try {
|
|
727
734
|
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
|
|
735
|
+
delivered = true
|
|
728
736
|
return
|
|
729
737
|
} catch (e) {
|
|
730
|
-
log('comment_ticket unavailable for
|
|
738
|
+
log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
|
|
731
739
|
}
|
|
732
740
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
733
741
|
const ticket = current.ticket ?? current.task ?? current
|
|
734
742
|
const description = String(ticket.description || '')
|
|
735
|
-
if (!description.includes(
|
|
743
|
+
if (!description.includes(notice)) {
|
|
736
744
|
await callMcpTool('update_ticket', {
|
|
737
745
|
project_id: projectId,
|
|
738
746
|
ticket_id: ticketId,
|
|
739
|
-
description: `${description}${description ? '\n\n' : ''}[Agent
|
|
747
|
+
description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${notice}`,
|
|
740
748
|
})
|
|
749
|
+
delivered = true
|
|
741
750
|
}
|
|
751
|
+
if (!delivered) throw new Error('no blocker delivery path succeeded')
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const reportPolicyBlock = async (prompt, taskRef, block) => {
|
|
755
|
+
const command = block?.command || 'the requested external repository action'
|
|
756
|
+
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
757
|
+
const approval = block?.commit && block?.branch
|
|
758
|
+
? `Reply with: “I explicitly authorize Alex to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
|
|
759
|
+
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
760
|
+
const notice = `Action required: Alex is blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} The ticket is paused until that approval is recorded.`
|
|
761
|
+
return publishBlocker({ prompt, taskRef, notice, pause: true })
|
|
742
762
|
}
|
|
743
763
|
|
|
744
764
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
@@ -802,7 +822,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
802
822
|
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
803
823
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
804
824
|
const activityChannel = await projectStatusChannel(next.projectId)
|
|
805
|
-
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 post_message and do not announce progress or completion in any channel. 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 done when appropriate.`, activityChannel == null ? [] : [activityChannel])
|
|
825
|
+
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 post_message and do not announce progress or completion in any channel. 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 done when appropriate.`, activityChannel == null ? [] : [activityChannel], { projectId: next.projectId, ticketId: next.id, channelId: activityChannel })
|
|
806
826
|
}
|
|
807
827
|
}
|
|
808
828
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -821,14 +841,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
821
841
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
822
842
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
823
843
|
|
|
824
|
-
async function drain(kind, context, targetChannels = []) {
|
|
844
|
+
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
825
845
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
826
846
|
const lane = lanes[laneName]
|
|
827
847
|
if (context) lane.pending.push(context)
|
|
848
|
+
if (taskRef) lane.taskRefs.push(taskRef)
|
|
828
849
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
829
850
|
if (lane.busy) { lane.queued = (RANK[kind] ?? 0) >= (RANK[lane.queued] ?? 0) ? kind : lane.queued; log(laneName + ' lane busy — queued a ' + kind + ' follow-up cycle'); return }
|
|
830
851
|
lane.busy = true
|
|
831
852
|
const ctx = lane.pending.splice(0)
|
|
853
|
+
const activeTaskRef = lane.taskRefs.splice(0)[0] || null
|
|
832
854
|
const targets = [...lane.targets]
|
|
833
855
|
lane.targets.clear()
|
|
834
856
|
laneStatusTargets[laneName] = new Set(targets)
|
|
@@ -849,13 +871,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
849
871
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
850
872
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
851
873
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
852
|
-
try { await reportPolicyBlock(prompt, result.policyBlock) }
|
|
874
|
+
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
853
875
|
catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
|
|
854
876
|
return
|
|
855
877
|
}
|
|
878
|
+
if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
|
|
879
|
+
const notice = `Alex is blocked: the coding cycle ended with ${result.subtype}. No completion is being claimed and the ticket remains open for retry.`
|
|
880
|
+
log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
|
|
881
|
+
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
882
|
+
catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
if (kind !== 'full' && result?.mcpErrors?.length) {
|
|
886
|
+
const notice = `Alex is blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. No success is being claimed; the item needs retry or intervention.`
|
|
887
|
+
log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
|
|
888
|
+
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
889
|
+
catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
|
|
890
|
+
return
|
|
891
|
+
}
|
|
856
892
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
857
893
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
858
|
-
const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
894
|
+
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
859
895
|
const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
|
|
860
896
|
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
|
|
861
897
|
const missing = [ticketCycle && !result.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks', !result.didRepoMutation && 'perform and verify the repository change', ticketCycle && !result.didMcpTaskUpdate && 'update the ticket through update_ticket'].filter(Boolean)
|
|
@@ -864,8 +900,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
864
900
|
const recovery = await runners.work.runCycle(`The assigned task is NOT complete. Missing runtime evidence: ${missing.join('; ')}. Do not post an acknowledgement or claim success. Resume now. Use get_ticket/list_tasks and list_task_types, perform and verify the repository work, commit and push an agent/* branch, open the PR, and call update_ticket with the correct board column. Post only when the original context supplies a source thread.`, codeModel)
|
|
865
901
|
const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
|
|
866
902
|
if (recoveryIncomplete) {
|
|
867
|
-
|
|
868
|
-
|
|
903
|
+
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
904
|
+
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
|
|
905
|
+
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
906
|
+
} else {
|
|
907
|
+
const notice = `Alex is blocked after one recovery attempt. Missing required evidence: ${missing.join('; ')}. The ticket remains open and no completion is being claimed.`
|
|
908
|
+
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
909
|
+
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
910
|
+
}
|
|
911
|
+
if (activeTaskRef) seenTasks.delete(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`)
|
|
912
|
+
else {
|
|
913
|
+
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
914
|
+
if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
|
|
915
|
+
}
|
|
869
916
|
lastTaskSignature = ''
|
|
870
917
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
871
918
|
}
|
|
@@ -958,7 +1005,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
958
1005
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
959
1006
|
const activityChannel = await projectStatusChannel(projectId)
|
|
960
1007
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
961
|
-
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 channel: do not post_message. 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. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel])
|
|
1008
|
+
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 channel: do not post_message. 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. Never announce it in a channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
962
1009
|
} catch (e) {
|
|
963
1010
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
964
1011
|
}
|