openvisio-agent 0.17.0 → 0.17.1
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 +8 -0
- package/src/events.mjs +9 -0
- package/src/watch.mjs +143 -33
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -29,12 +29,20 @@ 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
31
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
32
|
+
['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
|
+
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
34
|
+
['working heartbeat is battery-conscious', watcher.includes("emitLaneStatus(laneName, 'working'), 20_000")],
|
|
32
35
|
['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
|
|
33
36
|
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
34
37
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
35
38
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
36
39
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
40
|
+
['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
|
|
37
41
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
42
|
+
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('inspectDiagnostic(d)')],
|
|
43
|
+
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
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('[Agent action required]')],
|
|
38
46
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
39
47
|
]
|
|
40
48
|
for (const [label, ok] of assertions) {
|
package/src/events.mjs
CHANGED
|
@@ -48,6 +48,15 @@ export function agentStateRequest(backend, channelId, state, apiKey, identifier)
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export function codexPolicyBlock(value) {
|
|
52
|
+
const text = String(value || '')
|
|
53
|
+
if (!/rejected due to unacceptable risk|action was rejected due to unacceptable risk|explicitly approves? the action/i.test(text)) return null
|
|
54
|
+
const command = /exec_command failed for [`']([^`']+)[`']/.exec(text)?.[1] || ''
|
|
55
|
+
const reasonTail = text.split(/Reason:\s*/i)[1] || ''
|
|
56
|
+
const reason = reasonTail.split(/(?:\\n|\n)The agent\b/i)[0].replace(/[\\"') }]+$/, '').trim() || 'Codex requires explicit user approval for this external action.'
|
|
57
|
+
return { kind: 'authorization-required', command, reason: reason.slice(0, 900) }
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
52
61
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
53
62
|
// model starts, while keeping explicitly shared requests addressed to both.
|
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, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, 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
|
|
@@ -345,7 +345,8 @@ 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 = ''
|
|
348
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
349
|
+
let policyBlock = null
|
|
349
350
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
350
351
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
351
352
|
const finish = (o) => {
|
|
@@ -354,7 +355,12 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
354
355
|
clearTimeout(timer)
|
|
355
356
|
const calls = [...mcpCalls]
|
|
356
357
|
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
357
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
358
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText, policyBlock })
|
|
359
|
+
}
|
|
360
|
+
const inspectDiagnostic = (value) => {
|
|
361
|
+
const s = String(value || '')
|
|
362
|
+
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
363
|
+
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
358
364
|
}
|
|
359
365
|
const inspectLine = (line) => {
|
|
360
366
|
const s = line.trim()
|
|
@@ -387,18 +393,27 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
387
393
|
try {
|
|
388
394
|
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
389
395
|
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
390
|
-
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', '
|
|
396
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
391
397
|
if (child.stdout) child.stdout.on('data', (d) => {
|
|
392
398
|
jsonlBuffer += String(d)
|
|
393
399
|
const lines = jsonlBuffer.split('\n')
|
|
394
400
|
jsonlBuffer = lines.pop() ?? ''
|
|
395
401
|
for (const line of lines) inspectLine(line)
|
|
396
402
|
})
|
|
403
|
+
if (child.stderr) child.stderr.on('data', (d) => {
|
|
404
|
+
inspectDiagnostic(d)
|
|
405
|
+
process.stderr.write(d)
|
|
406
|
+
})
|
|
397
407
|
} catch (e) {
|
|
398
408
|
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
399
409
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
400
410
|
}
|
|
401
|
-
child.on('close', (code) => {
|
|
411
|
+
child.on('close', (code) => {
|
|
412
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''; inspectDiagnostic('')
|
|
413
|
+
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
414
|
+
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
415
|
+
finish({ type: 'result', subtype })
|
|
416
|
+
})
|
|
402
417
|
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
403
418
|
})
|
|
404
419
|
}
|
|
@@ -532,10 +547,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
532
547
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
533
548
|
let handle = null
|
|
534
549
|
const statusBackoff = new Map()
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
const statusTargets = new Set()
|
|
550
|
+
// Activity belongs to a lane. Keeping work/reply targets separate prevents a
|
|
551
|
+
// quick reply from overwriting or clearing a long coding cycle's status.
|
|
552
|
+
const laneStatusTargets = { work: new Set(), reply: new Set() }
|
|
539
553
|
const sendStatus = (channelId, state) => {
|
|
540
554
|
if (!backend || !['thinking', 'working', 'typing'].includes(state)) return
|
|
541
555
|
const key = Number(channelId)
|
|
@@ -552,7 +566,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
552
566
|
log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
|
|
553
567
|
})
|
|
554
568
|
}
|
|
555
|
-
const
|
|
569
|
+
const emitLaneStatus = (lane, state) => { for (const c of laneStatusTargets[lane]) sendStatus(c, state) }
|
|
556
570
|
const canCode = !!workdir
|
|
557
571
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
558
572
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -563,14 +577,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
563
577
|
const runnerOptions = {
|
|
564
578
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
565
579
|
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
566
|
-
// The moment the agent calls post_message it is about to speak → "typing".
|
|
567
|
-
onTool: (name) => { if (/post_message/.test(name)) emitStatus('typing') },
|
|
568
580
|
}
|
|
569
581
|
// One watcher and one WS subscription, but two independent model lanes. This
|
|
570
582
|
// avoids duplicate event delivery while mentions can be answered during code.
|
|
571
583
|
const runners = {
|
|
572
|
-
work: createCycleRunner(runnerOptions),
|
|
573
|
-
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply' }),
|
|
584
|
+
work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
|
|
585
|
+
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
|
|
574
586
|
}
|
|
575
587
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
576
588
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
@@ -581,8 +593,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
581
593
|
let liteModel = chatModel || model
|
|
582
594
|
|
|
583
595
|
const lanes = {
|
|
584
|
-
work: { busy: false, queued: null, pending: [] },
|
|
585
|
-
reply: { busy: false, queued: null, pending: [] },
|
|
596
|
+
work: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
597
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set() },
|
|
586
598
|
}
|
|
587
599
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
588
600
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -596,9 +608,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
596
608
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
597
609
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
598
610
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
611
|
+
// A policy-blocked task stays paused across reconnects. It is released only
|
|
612
|
+
// after the ticket itself carries explicit authorization or is completed/
|
|
613
|
+
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
614
|
+
// attempting the same rejected egress action.
|
|
615
|
+
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
599
616
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
600
617
|
const persistReplay = () => {
|
|
601
|
-
try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities] }, true) } catch { /* best-effort */ }
|
|
618
|
+
try { writeJson(replayPath, { seenMentions: [...seenMentions], seenActivities: [...seenActivities], blockedTasks: [...blockedTasks] }, true) } catch { /* best-effort */ }
|
|
602
619
|
}
|
|
603
620
|
// Context lines from the events themselves (the WS payload already carries the
|
|
604
621
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
@@ -633,7 +650,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
633
650
|
}
|
|
634
651
|
const ensureMcpSession = async () => {
|
|
635
652
|
if (mcpSessionId) return
|
|
636
|
-
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.1' } } }, false)
|
|
637
654
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
638
655
|
await mcpPayload(res)
|
|
639
656
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -663,6 +680,67 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
663
680
|
try { return JSON.parse(text) } catch { return { text } }
|
|
664
681
|
}
|
|
665
682
|
|
|
683
|
+
const statusChannelCache = new Map()
|
|
684
|
+
const projectStatusChannel = async (projectId) => {
|
|
685
|
+
const key = Number(projectId)
|
|
686
|
+
const cached = statusChannelCache.get(key)
|
|
687
|
+
if (cached && cached.expiresAt > Date.now()) return cached.channelId
|
|
688
|
+
try {
|
|
689
|
+
const data = toolData(await callMcpTool('list_channels', { project_id: key }))
|
|
690
|
+
const channels = Array.isArray(data.channels) ? data.channels : Array.isArray(data.data?.channels) ? data.data.channels : Array.isArray(data.data) ? data.data : Array.isArray(data.items) ? data.items : []
|
|
691
|
+
const usable = channels.filter((c) => Number.isFinite(Number(c.id)))
|
|
692
|
+
const preferred = usable.find((c) => /^(?:general|team|project|dev|development)$/i.test(String(c.name || '').trim())) || usable[0]
|
|
693
|
+
const channelId = preferred ? Number(preferred.id) : null
|
|
694
|
+
statusChannelCache.set(key, { channelId, expiresAt: Date.now() + 5 * 60_000 })
|
|
695
|
+
if (channelId == null) log('no channel available for project ' + key + '; task activity cannot be displayed')
|
|
696
|
+
return channelId
|
|
697
|
+
} catch (e) {
|
|
698
|
+
statusChannelCache.set(key, { channelId: null, expiresAt: Date.now() + 30_000 })
|
|
699
|
+
log('cannot resolve activity channel for project ' + key + ': ' + (e?.message || e))
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const reportPolicyBlock = async (prompt, block) => {
|
|
705
|
+
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
706
|
+
const channelMatch = /channel\s+(\d+)/i.exec(prompt)
|
|
707
|
+
const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
|
|
708
|
+
const command = block?.command || 'the requested external repository action'
|
|
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.`
|
|
710
|
+
|
|
711
|
+
if (channelMatch) {
|
|
712
|
+
await callMcpTool('post_message', {
|
|
713
|
+
channel_id: Number(channelMatch[1]),
|
|
714
|
+
...(parentMatch ? { parent_id: Number(parentMatch[1]) } : {}),
|
|
715
|
+
content: notice,
|
|
716
|
+
})
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
if (!taskMatch) { log('policy blocker has no source channel or ticket to update'); return }
|
|
720
|
+
|
|
721
|
+
const ticketId = Number(taskMatch[1])
|
|
722
|
+
const projectId = Number(taskMatch[2])
|
|
723
|
+
const key = `${projectId}:${ticketId}`
|
|
724
|
+
blockedTasks.add(key)
|
|
725
|
+
persistReplay()
|
|
726
|
+
try {
|
|
727
|
+
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
|
|
728
|
+
return
|
|
729
|
+
} catch (e) {
|
|
730
|
+
log('comment_ticket unavailable for policy blocker; recording it on ticket #' + ticketId)
|
|
731
|
+
}
|
|
732
|
+
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
733
|
+
const ticket = current.ticket ?? current.task ?? current
|
|
734
|
+
const description = String(ticket.description || '')
|
|
735
|
+
if (!description.includes('[Agent action required]')) {
|
|
736
|
+
await callMcpTool('update_ticket', {
|
|
737
|
+
project_id: projectId,
|
|
738
|
+
ticket_id: ticketId,
|
|
739
|
+
description: `${description}${description ? '\n\n' : ''}[Agent action required]\n${notice}`,
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
666
744
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
667
745
|
// model tokens unless the tools actually report pending work.
|
|
668
746
|
const reconcileBacklog = async () => {
|
|
@@ -689,6 +767,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
689
767
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
690
768
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
691
769
|
if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
|
|
770
|
+
const taskKey = `${project.id}:${task.id}`
|
|
771
|
+
if (blockedTasks.has(taskKey)) {
|
|
772
|
+
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
773
|
+
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)) {
|
|
774
|
+
blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
775
|
+
log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
|
|
776
|
+
} else continue
|
|
777
|
+
}
|
|
692
778
|
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 })
|
|
693
779
|
}
|
|
694
780
|
}
|
|
@@ -715,7 +801,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
715
801
|
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
716
802
|
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
717
803
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
718
|
-
|
|
804
|
+
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])
|
|
719
806
|
}
|
|
720
807
|
}
|
|
721
808
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -734,13 +821,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
734
821
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
735
822
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
736
823
|
|
|
737
|
-
async function drain(kind, context) {
|
|
824
|
+
async function drain(kind, context, targetChannels = []) {
|
|
738
825
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
739
826
|
const lane = lanes[laneName]
|
|
740
827
|
if (context) lane.pending.push(context)
|
|
828
|
+
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
741
829
|
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 }
|
|
742
830
|
lane.busy = true
|
|
743
831
|
const ctx = lane.pending.splice(0)
|
|
832
|
+
const targets = [...lane.targets]
|
|
833
|
+
lane.targets.clear()
|
|
834
|
+
laneStatusTargets[laneName] = new Set(targets)
|
|
744
835
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
745
836
|
// message is just the event context + the small base instruction.
|
|
746
837
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
@@ -750,11 +841,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
750
841
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
751
842
|
// Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
|
|
752
843
|
// through a long cycle; onTool flips it to "typing" when post_message fires.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
844
|
+
emitLaneStatus(laneName, 'working')
|
|
845
|
+
// Backend activity TTL is refreshed well before expiry, but only once every
|
|
846
|
+
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
847
|
+
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
756
848
|
try {
|
|
757
849
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
850
|
+
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
851
|
+
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
852
|
+
try { await reportPolicyBlock(prompt, result.policyBlock) }
|
|
853
|
+
catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
|
|
854
|
+
return
|
|
855
|
+
}
|
|
758
856
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
759
857
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
760
858
|
const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
@@ -774,7 +872,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
774
872
|
}
|
|
775
873
|
} finally {
|
|
776
874
|
if (heartbeat) clearInterval(heartbeat)
|
|
777
|
-
|
|
875
|
+
laneStatusTargets[laneName].clear()
|
|
778
876
|
lane.busy = false
|
|
779
877
|
if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
780
878
|
}
|
|
@@ -840,15 +938,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
840
938
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
841
939
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
842
940
|
const key = `${projectId}:${ticketId}`
|
|
843
|
-
if (!belongsToSelf) { seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
844
|
-
if (taskIsCompleted(ticket)) { seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
|
|
941
|
+
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
942
|
+
if (taskIsCompleted(ticket)) { blockedTasks.delete(key); persistReplay(); seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
|
|
943
|
+
if (blockedTasks.has(key)) {
|
|
944
|
+
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
945
|
+
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)) {
|
|
946
|
+
blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
|
|
947
|
+
log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
|
|
948
|
+
} else {
|
|
949
|
+
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
}
|
|
845
953
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
846
954
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
847
955
|
const title = String(ticket.title || hinted.title || '')
|
|
848
956
|
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
849
957
|
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
850
958
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
851
|
-
|
|
959
|
+
const activityChannel = await projectStatusChannel(projectId)
|
|
960
|
+
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])
|
|
852
962
|
} catch (e) {
|
|
853
963
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
854
964
|
}
|
|
@@ -901,9 +1011,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
901
1011
|
return
|
|
902
1012
|
}
|
|
903
1013
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
904
|
-
// Light up the live status the instant we pick this up
|
|
905
|
-
//
|
|
906
|
-
if (cid != null)
|
|
1014
|
+
// Light up the live status the instant we pick this up; drain owns the
|
|
1015
|
+
// subsequent working/typing heartbeat for its lane.
|
|
1016
|
+
if (cid != null) sendStatus(cid, 'thinking')
|
|
907
1017
|
const codingMention = canCode && needsCode(text)
|
|
908
1018
|
const ctx = cid != null
|
|
909
1019
|
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
|
|
@@ -912,9 +1022,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
912
1022
|
const ack = cid != null
|
|
913
1023
|
? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
|
|
914
1024
|
: undefined
|
|
915
|
-
void drain('coord', ack)
|
|
916
|
-
void drain('full', ctx)
|
|
917
|
-
} else void drain('fast', ctx)
|
|
1025
|
+
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1026
|
+
void drain('full', ctx, cid == null ? [] : [cid])
|
|
1027
|
+
} else void drain('fast', ctx, cid == null ? [] : [cid])
|
|
918
1028
|
} else if (k === 'error') {
|
|
919
1029
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
920
1030
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|