openvisio-agent 0.17.0 → 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 +10 -0
- package/src/events.mjs +11 -0
- package/src/watch.mjs +193 -36
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -29,12 +29,22 @@ 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, 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')],
|
|
38
48
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
39
49
|
]
|
|
40
50
|
for (const [label, ok] of assertions) {
|
package/src/events.mjs
CHANGED
|
@@ -48,6 +48,17 @@ 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
|
+
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] || '' }
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
52
63
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
53
64
|
// 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(), taskRefs: [] },
|
|
597
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
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.2' } } }, 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,87 @@ 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 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])
|
|
709
|
+
const channelMatch = /channel\s+(\d+)/i.exec(prompt)
|
|
710
|
+
const parentMatch = /(?:parent_id|thread)\s+(\d+)/i.exec(prompt)
|
|
711
|
+
const channelId = Number(channelMatch?.[1] ?? taskRef?.channelId)
|
|
712
|
+
|
|
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')
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const key = `${projectId}:${ticketId}`
|
|
732
|
+
if (pause) { blockedTasks.add(key); persistReplay() }
|
|
733
|
+
try {
|
|
734
|
+
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: notice })
|
|
735
|
+
delivered = true
|
|
736
|
+
return
|
|
737
|
+
} catch (e) {
|
|
738
|
+
log('comment_ticket unavailable for blocker; recording it on ticket #' + ticketId)
|
|
739
|
+
}
|
|
740
|
+
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
741
|
+
const ticket = current.ticket ?? current.task ?? current
|
|
742
|
+
const description = String(ticket.description || '')
|
|
743
|
+
if (!description.includes(notice)) {
|
|
744
|
+
await callMcpTool('update_ticket', {
|
|
745
|
+
project_id: projectId,
|
|
746
|
+
ticket_id: ticketId,
|
|
747
|
+
description: `${description}${description ? '\n\n' : ''}[Agent blocker]\n${notice}`,
|
|
748
|
+
})
|
|
749
|
+
delivered = true
|
|
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 })
|
|
762
|
+
}
|
|
763
|
+
|
|
666
764
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
667
765
|
// model tokens unless the tools actually report pending work.
|
|
668
766
|
const reconcileBacklog = async () => {
|
|
@@ -689,6 +787,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
689
787
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
690
788
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
691
789
|
if (!taskIsCompleted(task, doneIds) && (taskAgentId === Number(self.id) || taskIdent === identifier)) {
|
|
790
|
+
const taskKey = `${project.id}:${task.id}`
|
|
791
|
+
if (blockedTasks.has(taskKey)) {
|
|
792
|
+
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
793
|
+
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)) {
|
|
794
|
+
blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
795
|
+
log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
|
|
796
|
+
} else continue
|
|
797
|
+
}
|
|
692
798
|
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
799
|
}
|
|
694
800
|
}
|
|
@@ -715,7 +821,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
715
821
|
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
716
822
|
const next = [...assigned].sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))[0]
|
|
717
823
|
log('backlog reconciliation found ' + assigned.length + ' assigned task(s); queueing only ticket #' + next.id)
|
|
718
|
-
|
|
824
|
+
const activityChannel = await projectStatusChannel(next.projectId)
|
|
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 })
|
|
719
826
|
}
|
|
720
827
|
}
|
|
721
828
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
@@ -734,13 +841,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
734
841
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
735
842
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
736
843
|
|
|
737
|
-
async function drain(kind, context) {
|
|
844
|
+
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
738
845
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
739
846
|
const lane = lanes[laneName]
|
|
740
847
|
if (context) lane.pending.push(context)
|
|
848
|
+
if (taskRef) lane.taskRefs.push(taskRef)
|
|
849
|
+
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
741
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 }
|
|
742
851
|
lane.busy = true
|
|
743
852
|
const ctx = lane.pending.splice(0)
|
|
853
|
+
const activeTaskRef = lane.taskRefs.splice(0)[0] || null
|
|
854
|
+
const targets = [...lane.targets]
|
|
855
|
+
lane.targets.clear()
|
|
856
|
+
laneStatusTargets[laneName] = new Set(targets)
|
|
744
857
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
745
858
|
// message is just the event context + the small base instruction.
|
|
746
859
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + baseFor(kind)
|
|
@@ -750,14 +863,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
750
863
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
751
864
|
// Live status: "working" now + a heartbeat so the UI (and its TTL) stays lit
|
|
752
865
|
// through a long cycle; onTool flips it to "typing" when post_message fires.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
866
|
+
emitLaneStatus(laneName, 'working')
|
|
867
|
+
// Backend activity TTL is refreshed well before expiry, but only once every
|
|
868
|
+
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
869
|
+
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
756
870
|
try {
|
|
757
871
|
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
872
|
+
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
873
|
+
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
874
|
+
try { await reportPolicyBlock(prompt, activeTaskRef, result.policyBlock) }
|
|
875
|
+
catch (e) { log('failed to publish policy blocker: ' + (e?.message || e)) }
|
|
876
|
+
return
|
|
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
|
+
}
|
|
758
892
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
759
893
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
760
|
-
const ticketCycle = /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
894
|
+
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
761
895
|
const incomplete = !result?.didRepoMutation || (ticketCycle && (!result?.didMcpTaskRead || !result?.didMcpTaskUpdate)) || result?.mcpErrors?.length
|
|
762
896
|
if (agent === 'codex' && kind === 'full' && result?.subtype === 'ok' && incomplete) {
|
|
763
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)
|
|
@@ -766,15 +900,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
766
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)
|
|
767
901
|
const recoveryIncomplete = recovery?.subtype !== 'ok' || !recovery?.didRepoMutation || (ticketCycle && (!recovery?.didMcpTaskRead || !recovery?.didMcpTaskUpdate)) || recovery?.mcpErrors?.length
|
|
768
902
|
if (recoveryIncomplete) {
|
|
769
|
-
|
|
770
|
-
|
|
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
|
+
}
|
|
771
916
|
lastTaskSignature = ''
|
|
772
917
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
773
918
|
}
|
|
774
919
|
}
|
|
775
920
|
} finally {
|
|
776
921
|
if (heartbeat) clearInterval(heartbeat)
|
|
777
|
-
|
|
922
|
+
laneStatusTargets[laneName].clear()
|
|
778
923
|
lane.busy = false
|
|
779
924
|
if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
780
925
|
}
|
|
@@ -840,15 +985,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
840
985
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
841
986
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
842
987
|
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 }
|
|
988
|
+
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
989
|
+
if (taskIsCompleted(ticket)) { blockedTasks.delete(key); persistReplay(); seenTasks.add(key); log(kind + ' ticket #' + ticketId + ' is already complete — ignored'); return }
|
|
990
|
+
if (blockedTasks.has(key)) {
|
|
991
|
+
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
992
|
+
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)) {
|
|
993
|
+
blockedTasks.delete(key); seenTasks.delete(key); persistReplay()
|
|
994
|
+
log(kind + ' ticket #' + ticketId + ' contains explicit push authorization — resuming')
|
|
995
|
+
} else {
|
|
996
|
+
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
}
|
|
845
1000
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
846
1001
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
847
1002
|
const title = String(ticket.title || hinted.title || '')
|
|
848
1003
|
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
849
1004
|
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
850
1005
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
851
|
-
|
|
1006
|
+
const activityChannel = await projectStatusChannel(projectId)
|
|
1007
|
+
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
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 })
|
|
852
1009
|
} catch (e) {
|
|
853
1010
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
854
1011
|
}
|
|
@@ -901,9 +1058,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
901
1058
|
return
|
|
902
1059
|
}
|
|
903
1060
|
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)
|
|
1061
|
+
// Light up the live status the instant we pick this up; drain owns the
|
|
1062
|
+
// subsequent working/typing heartbeat for its lane.
|
|
1063
|
+
if (cid != null) sendStatus(cid, 'thinking')
|
|
907
1064
|
const codingMention = canCode && needsCode(text)
|
|
908
1065
|
const ctx = cid != null
|
|
909
1066
|
? `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 +1069,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
912
1069
|
const ack = cid != null
|
|
913
1070
|
? `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
1071
|
: undefined
|
|
915
|
-
void drain('coord', ack)
|
|
916
|
-
void drain('full', ctx)
|
|
917
|
-
} else void drain('fast', ctx)
|
|
1072
|
+
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1073
|
+
void drain('full', ctx, cid == null ? [] : [cid])
|
|
1074
|
+
} else void drain('fast', ctx, cid == null ? [] : [cid])
|
|
918
1075
|
} else if (k === 'error') {
|
|
919
1076
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
920
1077
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|