openvisio-agent 0.17.3 → 0.17.4
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 +2 -0
- package/src/events.mjs +12 -0
- package/src/watch.mjs +27 -14
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -28,6 +28,8 @@ const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
|
28
28
|
const assertions = [
|
|
29
29
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
30
30
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
31
|
+
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket)')],
|
|
32
|
+
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
31
33
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
32
34
|
['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
35
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
package/src/events.mjs
CHANGED
|
@@ -34,6 +34,18 @@ export function taskIsCompleted(task, completedTypeIds = new Set()) {
|
|
|
34
34
|
return /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(state)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// A coding agent has handed work off once the board reaches review, testing, QA,
|
|
38
|
+
// or approval. These are not "completed" states: a reviewer may still send the
|
|
39
|
+
// ticket back to an actionable column. Treating them as settled for the watcher,
|
|
40
|
+
// however, prevents reconnect reconciliation from reopening the same PR every
|
|
41
|
+
// 30 minutes while it waits for a human.
|
|
42
|
+
export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
|
|
43
|
+
if (!task || typeof task !== 'object') return false
|
|
44
|
+
if (reviewTypeIds.has(Number(task.type_id ?? task.typeId ?? task.status_id ?? task.statusId))) return true
|
|
45
|
+
const state = [task.status, task.state, task.type?.name, task.task_type?.name].filter(Boolean).join(' ')
|
|
46
|
+
return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
38
50
|
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
39
51
|
const id = Number(channelId)
|
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { homedir } from 'node:os'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { agentStateRequest, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, 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
|
|
@@ -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.4' } } }, 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') || ''
|
|
@@ -783,21 +783,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
783
783
|
callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
|
|
784
784
|
callMcpTool('list_activity', { project_id: project.id }).then(toolData),
|
|
785
785
|
])
|
|
786
|
-
const
|
|
786
|
+
const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
787
|
+
const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
788
|
+
const reviewIds = new Set(taskTypes.filter((t) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
787
789
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
788
790
|
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
789
791
|
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
792
|
+
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
793
|
+
const taskKey = `${project.id}:${task.id}`
|
|
794
|
+
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
795
|
+
if (blockedTasks.delete(taskKey)) persistReplay()
|
|
796
|
+
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
797
|
+
// the ticket back to an actionable column, that update must start a
|
|
798
|
+
// fresh work cycle.
|
|
799
|
+
seenTasks.delete(taskKey)
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
if (blockedTasks.has(taskKey)) {
|
|
803
|
+
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
804
|
+
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)) {
|
|
805
|
+
blockedTasks.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
806
|
+
log('backlog ticket #' + task.id + ' now contains explicit push authorization — resuming')
|
|
807
|
+
} else continue
|
|
800
808
|
}
|
|
809
|
+
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
801
810
|
}
|
|
802
811
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
803
812
|
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
@@ -987,7 +996,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
987
996
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
988
997
|
const key = `${projectId}:${ticketId}`
|
|
989
998
|
if (!belongsToSelf) { blockedTasks.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return }
|
|
990
|
-
if (taskIsCompleted(ticket)
|
|
999
|
+
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1000
|
+
blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1001
|
+
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
991
1004
|
if (blockedTasks.has(key)) {
|
|
992
1005
|
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
993
1006
|
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)) {
|