openvisio-agent 0.17.5 → 0.18.0
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 +12 -3
- package/src/events.mjs +84 -3
- package/src/watch.mjs +215 -61
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -23,6 +23,7 @@ run('diff whitespace check', 'git', ['diff', '--check'], repo)
|
|
|
23
23
|
const watcher = readFileSync(join(root, 'src', 'watch.mjs'), 'utf8')
|
|
24
24
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
25
25
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
26
|
+
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
26
27
|
const spec = readFileSync(join(repo, 'docs', 'CODEX_BYO_AGENT_SPEC.md'), 'utf8')
|
|
27
28
|
|
|
28
29
|
const assertions = [
|
|
@@ -33,7 +34,10 @@ const assertions = [
|
|
|
33
34
|
['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes("callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })")],
|
|
34
35
|
['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
|
|
35
36
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
37
|
+
['verified completion is persisted as one ticket comment', watcher.includes("callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
36
38
|
['ticket comments cannot masquerade as channel completion', watcher.includes('didChannelMessage') && watcher.includes("mcpCalls.includes('post_message')")],
|
|
39
|
+
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
40
|
+
['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
37
41
|
['two internal lanes exist', watcher.includes('work: createCycleRunner') && watcher.includes('reply: createCycleRunner')],
|
|
38
42
|
['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')")],
|
|
39
43
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
@@ -44,14 +48,19 @@ const assertions = [
|
|
|
44
48
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
45
49
|
['frontend consumes typing event', activityHook.includes("'channel:agent:typing'")],
|
|
46
50
|
['frontend activity TTL distinguishes work from typing', activityHook.includes('thinking: 6_000') && activityHook.includes('typing: 5_000') && activityHook.includes('working: 30_000')],
|
|
51
|
+
['frontend consumes documented task comment events', taskHook.includes("'task:comment':") && taskHook.includes("'task:comment_updated':") && taskHook.includes("'task:comment_deleted':") && taskHook.includes("'task:comment_reacted':")],
|
|
47
52
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
48
|
-
['
|
|
53
|
+
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
54
|
+
['OpenCode acknowledgements cannot satisfy coding completion', watcher.includes("agent === 'codex' || agent === 'opencode'") && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
55
|
+
['Codex policy rejection is captured from stderr', watcher.includes("stdio: ['ignore', 'pipe', 'pipe']") && watcher.includes('forwardDiagnostic(d)') && watcher.includes('inspectDiagnostic(incoming)')],
|
|
56
|
+
['Codex recoverable subprocess diagnostics are not surfaced as activity', watcher.includes('shouldSuppressCodexDiagnostic(line)') && watcher.includes("forwardDiagnostic('', true)") && watcher.includes('RECOVER DEAD COMMAND SESSIONS')],
|
|
49
57
|
['policy rejection cannot be logged as successful', watcher.includes("subtype = policyBlock ? 'blocked'")],
|
|
50
58
|
['policy-blocked tickets are persisted and paused', watcher.includes('blockedTasks: [...blockedTasks]') && watcher.includes('WORK_CYCLE_BLOCKED')],
|
|
51
|
-
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes(
|
|
59
|
+
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
|
|
52
60
|
['blocker routing carries explicit task identity', watcher.includes('taskRefs: []') && watcher.includes('activeTaskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
53
61
|
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
|
54
|
-
['ticket blocker cannot self-authorize', watcher.includes(
|
|
62
|
+
['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })')],
|
|
63
|
+
['agent messages use first-person voice', watcher.includes('FIRST-PERSON VOICE') && watcher.includes("I'm blocked") && !watcher.includes('Alex is blocked') && !watcher.includes('Alex is paused')],
|
|
55
64
|
['normative certification gates are documented', spec.includes('## Mandatory certification gates')],
|
|
56
65
|
]
|
|
57
66
|
for (const [label, ok] of assertions) {
|
package/src/events.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function taskIsAwaitingReview(task, reviewTypeIds = new Set()) {
|
|
|
46
46
|
return /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(state)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function buildTaskCompletionReport(task, { projectId,
|
|
49
|
+
export function buildTaskCompletionReport(task, { projectId, fallbackText = '' } = {}) {
|
|
50
50
|
if (!task || typeof task !== 'object' || task.id == null) return null
|
|
51
51
|
if (!taskIsCompleted(task) && !taskIsAwaitingReview(task)) return null
|
|
52
52
|
|
|
@@ -59,13 +59,62 @@ export function buildTaskCompletionReport(task, { projectId, agentName, fallback
|
|
|
59
59
|
const title = String(task.title || 'Untitled task').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
60
60
|
const status = String(task.type?.name || task.task_type?.name || task.status || task.state || 'review').replace(/\s+/g, ' ').trim()
|
|
61
61
|
const verification = /\bVerification:\s*([^\n]{1,180})/i.exec(evidence)?.[1]?.replace(/\s+/g, ' ').trim().replace(/[.]+$/, '') || ''
|
|
62
|
-
const who = String(agentName || 'The agent').trim()
|
|
63
62
|
const mention = requester ? `@${requester} ` : ''
|
|
64
|
-
const content = `${mention}
|
|
63
|
+
const content = `${mention}I finished ticket #${task.id} “${title}” and moved it to ${status}. PR: ${prUrl}.${verification ? ` Verification: ${verification}.` : ''}`
|
|
65
64
|
const revision = task.updated_at ?? task.updatedAt ?? prUrl
|
|
66
65
|
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
|
|
67
66
|
}
|
|
68
67
|
|
|
68
|
+
// OpenCode's `run --format json` emits one JSON object per completed text/tool
|
|
69
|
+
// part. Reduce each event to the small set of facts the watcher is allowed to
|
|
70
|
+
// trust. In particular, model prose and process exit 0 are never work evidence.
|
|
71
|
+
export function opencodeEventEvidence(event) {
|
|
72
|
+
if (!event || typeof event !== 'object') return {}
|
|
73
|
+
const part = event.part ?? event.properties?.part ?? event.item ?? event
|
|
74
|
+
if (!part || typeof part !== 'object') return {}
|
|
75
|
+
|
|
76
|
+
const eventType = String(event.type ?? '')
|
|
77
|
+
const partType = String(part.type ?? '')
|
|
78
|
+
if (eventType === 'text' || partType === 'text') {
|
|
79
|
+
return { outputText: typeof part.text === 'string' ? part.text : '' }
|
|
80
|
+
}
|
|
81
|
+
if (eventType === 'error') {
|
|
82
|
+
return { runtimeError: String(event.error?.message ?? event.message ?? part.error ?? 'OpenCode runtime error') }
|
|
83
|
+
}
|
|
84
|
+
if (eventType !== 'tool_use' && partType !== 'tool') return {}
|
|
85
|
+
|
|
86
|
+
const tool = String(part.tool ?? part.name ?? event.tool ?? '').trim()
|
|
87
|
+
if (!tool) return {}
|
|
88
|
+
const state = part.state && typeof part.state === 'object' ? part.state : {}
|
|
89
|
+
const status = String(state.status ?? part.status ?? '').toLowerCase()
|
|
90
|
+
const failed = /error|failed|denied|rejected/.test(status) || state.error != null || part.error != null
|
|
91
|
+
const completed = !failed && (!status || /completed|success|succeeded|ok/.test(status))
|
|
92
|
+
const input = state.input && typeof state.input === 'object' ? state.input : (part.input && typeof part.input === 'object' ? part.input : {})
|
|
93
|
+
const command = String(input.command ?? input.cmd ?? '')
|
|
94
|
+
|
|
95
|
+
const lowerTool = tool.toLowerCase()
|
|
96
|
+
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(tool)
|
|
97
|
+
const bareTool = lowerTool.replace(/[-.]/g, '_')
|
|
98
|
+
const knownMcp = /^(?:get_ticket|list_tasks|list_task_types|update_ticket|post_message|comment_ticket|react_message|list_projects|list_agents|list_activity)$/
|
|
99
|
+
const mcpTool = (prefixed?.[1] ? prefixed[1].replace(/[-.]/g, '_') : (knownMcp.test(bareTool) ? bareTool : '')).toLowerCase()
|
|
100
|
+
const mutationTool = /^(?:edit|write|patch|apply_patch|multiedit|multi_edit)$/i.test(tool)
|
|
101
|
+
const bashTool = /^(?:bash|shell|terminal|exec|command)$/i.test(tool)
|
|
102
|
+
const commandMutation = /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(command)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
tool,
|
|
106
|
+
...(mcpTool ? { mcpTool } : {}),
|
|
107
|
+
failed,
|
|
108
|
+
completed,
|
|
109
|
+
didCode: completed && (mutationTool || bashTool),
|
|
110
|
+
didRepoMutation: completed && (mutationTool || (bashTool && commandMutation)),
|
|
111
|
+
didMcpTaskRead: completed && /^(?:get_ticket|list_tasks|list_task_types)$/.test(mcpTool),
|
|
112
|
+
didMcpTaskUpdate: completed && mcpTool === 'update_ticket',
|
|
113
|
+
didMessage: completed && /^(?:post_message|comment_ticket)$/.test(mcpTool),
|
|
114
|
+
didChannelMessage: completed && mcpTool === 'post_message',
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
70
119
|
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
71
120
|
const id = Number(channelId)
|
|
@@ -91,6 +140,38 @@ export function codexPolicyBlock(value) {
|
|
|
91
140
|
return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
|
|
92
141
|
}
|
|
93
142
|
|
|
143
|
+
// Codex emits this plumbing notice whenever `exec` sees non-interactive stdin,
|
|
144
|
+
// even when stdin is intentionally closed and there is nothing to read. It is
|
|
145
|
+
// not agent activity, progress, or a blocker, so keep it out of watcher logs.
|
|
146
|
+
export function shouldSuppressCodexDiagnostic(value) {
|
|
147
|
+
const line = String(value || '').trim()
|
|
148
|
+
if (/^Reading additional input from stdin\.\.\.$/.test(line)) return true
|
|
149
|
+
|
|
150
|
+
// `codex exec` can emit these after it has already recovered: the watcher
|
|
151
|
+
// pins the requested model, so a background catalog-refresh timeout does not
|
|
152
|
+
// change the active cycle, and an unknown write_stdin pid means that one
|
|
153
|
+
// command session exited before Codex polled it. The model still receives the
|
|
154
|
+
// tool error and can start a fresh command; these internals should not masquerade
|
|
155
|
+
// as agent activity or an OpenVisio work failure in the user's watcher log.
|
|
156
|
+
if (/\bERROR codex_models_manager::manager: failed to refresh available models: timeout waiting for child process to exit$/.test(line)) return true
|
|
157
|
+
return /\bERROR codex_core::tools::router: error=write_stdin failed: Unknown process id \d+$/.test(line)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build both a durable id key and a short-lived content signature for a mention.
|
|
161
|
+
// The backend can surface one logical message through WebSocket delivery and
|
|
162
|
+
// activity reconciliation with different envelope ids; the signature closes that
|
|
163
|
+
// gap without permanently suppressing a genuinely repeated question later.
|
|
164
|
+
export function mentionDedupeKeys(message, channelId) {
|
|
165
|
+
const m = message && typeof message === 'object' ? message : {}
|
|
166
|
+
const id = m.message_id ?? m.messageId ?? m.id
|
|
167
|
+
const parent = m.parent_id ?? m.parentId ?? ''
|
|
168
|
+
const text = String(m.content ?? m.body ?? m.text ?? m.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
169
|
+
return {
|
|
170
|
+
idKey: id != null ? `id:${id}` : '',
|
|
171
|
+
signatureKey: text ? `sig:${channelId ?? '?'}|${parent}|${text}` : '',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
94
175
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
95
176
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
96
177
|
// model starts, while keeping explicitly shared requests addressed to both.
|
package/src/watch.mjs
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// than written to disk from a pasted heredoc.
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
-
import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs'
|
|
9
9
|
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, buildTaskCompletionReport, codexPolicyBlock, requestTargetsLaterAgent, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
13
|
+
import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, mentionDedupeKeys, opencodeEventEvidence, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
14
14
|
|
|
15
15
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
16
16
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -27,6 +27,7 @@ import { agentStateRequest, buildTaskCompletionReport, codexPolicyBlock, request
|
|
|
27
27
|
// then walking it back.
|
|
28
28
|
const REPLY_DISCIPLINE = [
|
|
29
29
|
'REPLY DISCIPLINE — read the recent messages FIRST, then decide whether to speak at all:',
|
|
30
|
+
' • FIRST-PERSON VOICE. Speak as yourself: use “I”, “I\'m”, and “my”. Never refer to yourself by your agent name or in the third person, and never restate your own name in introductions, acknowledgements, progress, blockers, or results. The app already shows who sent the message. Sound like a warm, accountable teammate, not a status bot.',
|
|
30
31
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
31
32
|
' • NO DUPLICATES. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to or acknowledged this exact request, do NOT post again. One acknowledgement per task; one answer per question. While a task is in progress, post again ONLY when you have something genuinely NEW (a result, a link, a real blocker) — never re-post "on it".',
|
|
32
33
|
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Never assert then contradict yourself. If you are unsure, verify FIRST, then give ONE clear, final answer instead of thinking out loud across several messages.',
|
|
@@ -37,7 +38,7 @@ const REPLY_DISCIPLINE = [
|
|
|
37
38
|
|
|
38
39
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
39
40
|
const CHAT_CHARTER = [
|
|
40
|
-
'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP
|
|
41
|
+
'YOU ARE a connected agent in an OpenVisio team, running in CHAT-ONLY mode. Use only tools that actually appear in your openvisio-team tool list. Backend MCP provides post_message, react_message, list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity. Some relay runtimes also provide poll_inbox or get_marching_orders. Never call a tool that is absent. You have NO file/Bash/git tools in this mode, so you cannot write code yourself.',
|
|
41
42
|
'WORK ETHIC — behave like a dependable teammate: never leave a promise dangling. Either ACT now (reply, or file a ticket) or say plainly you can\'t and offer to file a ticket / tag a coding agent who can. Never invent progress. Close the loop every cycle — the human should never have to remind you to circle back.',
|
|
42
43
|
'',
|
|
43
44
|
REPLY_DISCIPLINE,
|
|
@@ -65,7 +66,7 @@ const COORDINATE = [
|
|
|
65
66
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
66
67
|
const CODE_CHARTER = [
|
|
67
68
|
'YOU ARE a connected CODING agent in an OpenVisio team, running ON THE USER\'S LAPTOP. You have REAL tools — use them; do NOT claim you lack a capability without checking what you actually hold. Your toolbox:',
|
|
68
|
-
' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox
|
|
69
|
+
' • openvisio-team tools — use the names actually present. Backend MCP provides project/task discovery through list_agents, list_projects, list_tasks, get_ticket, update_ticket, and comment_ticket, plus post_message/react_message/list_activity. Relay runtimes may additionally expose poll_inbox or get_marching_orders.',
|
|
69
70
|
' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
|
|
70
71
|
' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
|
|
71
72
|
'YOUR WORKSPACE: your working directory is a WORKSPACE ROOT that holds the org\'s repos as subfolders. Reuse existing clones and the context you already verified. Read repository AGENTS.md instructions before changing code. For any task: locate the relevant repo under the workspace; clone it only when it is genuinely absent, then work inside that subfolder. Never ask the user for a path you can discover yourself.',
|
|
@@ -76,6 +77,7 @@ const CODE_CHARTER = [
|
|
|
76
77
|
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the project channel after the PR and ticket handoff are confirmed.',
|
|
77
78
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
78
79
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
80
|
+
' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
|
|
79
81
|
'',
|
|
80
82
|
REPLY_DISCIPLINE,
|
|
81
83
|
].join('\n')
|
|
@@ -89,7 +91,7 @@ const CODE_FULL = [
|
|
|
89
91
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
90
92
|
' 4. COMMIT + PUSH YOUR BRANCH: git add -A && git commit -m "…"; then git push -u origin agent/<slug>. Only ever push your own agent/* branch. Never --force, never push to main/master, never merge.',
|
|
91
93
|
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
92
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
|
|
94
|
+
' 6. CLOSE THE LOOP: use comment_ticket for a concrete ticket-scoped blocker or clarification, then move/update the ticket with update_ticket. The watcher adds one evidence-verified final ticket comment after handoff. Reply with the summary + PR link in a source thread explicitly supplied by the event. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified project-channel completion message and deduplicates it across reconnects.',
|
|
93
95
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
94
96
|
].join('\n')
|
|
95
97
|
|
|
@@ -107,7 +109,7 @@ const CODE_FAST = [
|
|
|
107
109
|
// missed — TASKS especially.
|
|
108
110
|
const INTRO = [
|
|
109
111
|
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
110
|
-
'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE:
|
|
112
|
+
'Find the most general channel — call poll_inbox (or list channels) and pick the "general"/main one — then post_message there ONCE: say in first person that you are here, pick up tasks assigned to you, and answer @mentions. Do not state your name; the app already displays it. Keep it to 1-2 warm, professional sentences.',
|
|
111
113
|
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
112
114
|
].join('\n')
|
|
113
115
|
const SWEEP = [
|
|
@@ -177,18 +179,31 @@ function claimStartupSweep(key) {
|
|
|
177
179
|
// are taken over. Returns { release } or { conflict: <pid> }.
|
|
178
180
|
function acquireSingleInstance(key) {
|
|
179
181
|
const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
|
|
180
|
-
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
try { mkdirSync(OV_DIR, { recursive: true }) } catch (error) { return { error } }
|
|
183
|
+
// `existsSync` followed by `writeFileSync` is a race: two KeepAlive starts can
|
|
184
|
+
// both observe no file and both become watchers. `wx` makes creation atomic.
|
|
185
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
186
|
+
let fd = null
|
|
187
|
+
try {
|
|
188
|
+
fd = openSync(lockPath, 'wx')
|
|
189
|
+
writeFileSync(fd, String(process.pid))
|
|
190
|
+
closeSync(fd); fd = null
|
|
191
|
+
break
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (fd != null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
194
|
+
if (error?.code !== 'EEXIST') return { error }
|
|
195
|
+
let pid = 0
|
|
196
|
+
try { pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) } catch { /* an in-flight creator owns it */ }
|
|
197
|
+
if (!pid) return { conflict: 'unknown' }
|
|
198
|
+
let alive = false
|
|
199
|
+
try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
|
|
200
|
+
if (alive) return { conflict: pid }
|
|
201
|
+
// Exact stale lock only. If another process wins the retry, its atomic file
|
|
202
|
+
// remains and this process will return conflict on the next iteration.
|
|
203
|
+
try { unlinkSync(lockPath) } catch (e) { if (e?.code !== 'ENOENT') return { error: e } }
|
|
204
|
+
if (attempt === 1) return { conflict: 'unknown' }
|
|
189
205
|
}
|
|
190
|
-
|
|
191
|
-
} catch { /* if the lock can't be written, don't block the agent from running */ }
|
|
206
|
+
}
|
|
192
207
|
const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
|
|
193
208
|
return { release }
|
|
194
209
|
}
|
|
@@ -228,6 +243,7 @@ export async function runWatch({ flags }) {
|
|
|
228
243
|
// both connect as the same agent and both answer every mention. Refuse to start.
|
|
229
244
|
if (!flags.install) {
|
|
230
245
|
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
246
|
+
if (lock.error) fail(`Could not acquire the single-watcher lock for "${slug || 'openvisio'}": ${lock.error.message || lock.error}`)
|
|
231
247
|
if (lock.conflict) {
|
|
232
248
|
const watcherName = slug || 'openvisio'
|
|
233
249
|
const logs = process.platform === 'darwin'
|
|
@@ -273,7 +289,7 @@ export async function runWatch({ flags }) {
|
|
|
273
289
|
// The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
|
|
274
290
|
// (opencode reads it from there). `--auto` approves tool use non-interactively.
|
|
275
291
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
276
|
-
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
292
|
+
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
277
293
|
// opencode reads opencode.json from its CWD: the code workspace, or a dedicated
|
|
278
294
|
// per-agent dir for chat-only agents.
|
|
279
295
|
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
@@ -302,18 +318,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
302
318
|
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
303
319
|
// charter/creds into the message (still not re-accumulated across cycles).
|
|
304
320
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
321
|
+
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
322
|
+
// OpenCode exited; raw events tell us which tools actually completed.
|
|
323
|
+
const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
|
|
324
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
325
|
+
let outputText = '', jsonlBuffer = ''
|
|
326
|
+
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
327
|
+
const finish = (o) => {
|
|
328
|
+
if (done) return
|
|
329
|
+
done = true
|
|
330
|
+
clearTimeout(timer)
|
|
331
|
+
const calls = [...mcpCalls]
|
|
332
|
+
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
333
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
334
|
+
}
|
|
335
|
+
const inspectLine = (line) => {
|
|
336
|
+
const value = String(line || '').trim()
|
|
337
|
+
if (!value) return
|
|
338
|
+
let event
|
|
339
|
+
try { event = JSON.parse(value) } catch {
|
|
340
|
+
if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
const evidence = opencodeEventEvidence(event)
|
|
344
|
+
if (evidence.outputText) {
|
|
345
|
+
outputText += ' ' + evidence.outputText
|
|
346
|
+
if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
|
|
347
|
+
}
|
|
348
|
+
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
349
|
+
if (!evidence.tool) return
|
|
350
|
+
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
351
|
+
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
352
|
+
if (evidence.mcpTool) {
|
|
353
|
+
mcpCalls.add(evidence.mcpTool)
|
|
354
|
+
if (evidence.failed) mcpErrors.add(evidence.mcpTool)
|
|
355
|
+
else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
|
|
356
|
+
}
|
|
357
|
+
didCode ||= !!evidence.didCode
|
|
358
|
+
didRepoMutation ||= !!evidence.didRepoMutation
|
|
359
|
+
didMessage ||= !!evidence.didMessage
|
|
360
|
+
didChannelMessage ||= !!evidence.didChannelMessage
|
|
361
|
+
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
362
|
+
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
363
|
+
}
|
|
308
364
|
const timer = setTimeout(() => {
|
|
309
365
|
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
310
366
|
try { child && child.kill() } catch { /* gone */ }
|
|
311
367
|
finish({ type: 'result', subtype: 'timeout' })
|
|
312
368
|
}, maxCycleMs)
|
|
313
369
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
314
|
-
try {
|
|
370
|
+
try {
|
|
371
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
|
|
372
|
+
child.stdout?.on('data', (data) => {
|
|
373
|
+
jsonlBuffer += String(data)
|
|
374
|
+
const lines = jsonlBuffer.split('\n')
|
|
375
|
+
jsonlBuffer = lines.pop() ?? ''
|
|
376
|
+
for (const line of lines) inspectLine(line)
|
|
377
|
+
})
|
|
378
|
+
}
|
|
315
379
|
catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
|
|
316
|
-
child.on('
|
|
380
|
+
child.on('close', (code) => {
|
|
381
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''
|
|
382
|
+
const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
|
|
383
|
+
log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
384
|
+
finish({ type: 'result', subtype })
|
|
385
|
+
})
|
|
317
386
|
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
318
387
|
})
|
|
319
388
|
}
|
|
@@ -345,7 +414,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
345
414
|
...(m ? ['--model', m] : []),
|
|
346
415
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
347
416
|
full]
|
|
348
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
417
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
349
418
|
let policyBlock = null
|
|
350
419
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
351
420
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
@@ -362,6 +431,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
362
431
|
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
363
432
|
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
364
433
|
}
|
|
434
|
+
const forwardDiagnostic = (value, flush = false) => {
|
|
435
|
+
const incoming = String(value || '')
|
|
436
|
+
inspectDiagnostic(incoming)
|
|
437
|
+
stderrLineBuffer += incoming
|
|
438
|
+
const lines = stderrLineBuffer.split('\n')
|
|
439
|
+
stderrLineBuffer = lines.pop() ?? ''
|
|
440
|
+
for (const line of lines) {
|
|
441
|
+
if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
|
|
442
|
+
}
|
|
443
|
+
if (flush && stderrLineBuffer) {
|
|
444
|
+
if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
|
|
445
|
+
stderrLineBuffer = ''
|
|
446
|
+
}
|
|
447
|
+
}
|
|
365
448
|
const inspectLine = (line) => {
|
|
366
449
|
const s = line.trim()
|
|
367
450
|
if (!s) return
|
|
@@ -402,15 +485,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
402
485
|
for (const line of lines) inspectLine(line)
|
|
403
486
|
})
|
|
404
487
|
if (child.stderr) child.stderr.on('data', (d) => {
|
|
405
|
-
|
|
406
|
-
process.stderr.write(d)
|
|
488
|
+
forwardDiagnostic(d)
|
|
407
489
|
})
|
|
408
490
|
} catch (e) {
|
|
409
491
|
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
410
492
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
411
493
|
}
|
|
412
494
|
child.on('close', (code) => {
|
|
413
|
-
inspectLine(jsonlBuffer); jsonlBuffer = '';
|
|
495
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
|
|
414
496
|
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
415
497
|
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
416
498
|
finish({ type: 'result', subtype })
|
|
@@ -432,7 +514,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
432
514
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
433
515
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
434
516
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
435
|
-
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt })
|
|
517
|
+
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
436
518
|
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
437
519
|
let child = null
|
|
438
520
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
@@ -582,7 +664,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
582
664
|
const canCode = !!workdir
|
|
583
665
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
584
666
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
585
|
-
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, and list_activity; get_marching_orders
|
|
667
|
+
const credNote = `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. Use ONLY names shown in the current tool list. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, get_ticket, update_ticket, comment_ticket, and list_activity; get_marching_orders and poll_inbox may be absent. Tools may be namespaced — call whichever names actually appear. The credentials are given here; do NOT hunt for them. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
586
668
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
587
669
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
588
670
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -619,29 +701,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
619
701
|
let replayState = {}
|
|
620
702
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
621
703
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
704
|
+
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
622
705
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
623
706
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
624
707
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
625
708
|
// without re-running the model or posting the same result twice.
|
|
626
709
|
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
627
710
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
711
|
+
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
628
712
|
// A policy-blocked task stays paused across reconnects. It is released only
|
|
629
713
|
// after the ticket itself carries explicit authorization or is completed/
|
|
630
714
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
631
715
|
// attempting the same rejected egress action.
|
|
632
716
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
633
717
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
718
|
+
const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
|
|
719
|
+
const pruneMentionSignatures = () => {
|
|
720
|
+
const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
|
|
721
|
+
for (const [key, at] of recentMentionSignatures) if (Number(at) < cutoff) recentMentionSignatures.delete(key)
|
|
722
|
+
while (recentMentionSignatures.size > 500) recentMentionSignatures.delete(recentMentionSignatures.keys().next().value)
|
|
723
|
+
}
|
|
634
724
|
const persistReplay = () => {
|
|
635
725
|
try {
|
|
726
|
+
pruneMentionSignatures()
|
|
636
727
|
writeJson(replayPath, {
|
|
637
728
|
seenMentions: [...seenMentions],
|
|
729
|
+
recentMentionSignatures: [...recentMentionSignatures],
|
|
638
730
|
seenActivities: [...seenActivities],
|
|
639
731
|
blockedTasks: [...blockedTasks],
|
|
640
732
|
pendingCompletionReports: [...pendingCompletionReports],
|
|
641
733
|
reportedCompletions: [...reportedCompletions],
|
|
734
|
+
reportedTaskComments: [...reportedTaskComments],
|
|
642
735
|
}, true)
|
|
643
736
|
} catch { /* best-effort */ }
|
|
644
737
|
}
|
|
738
|
+
const markMentionHandled = (message, channelId) => {
|
|
739
|
+
pruneMentionSignatures()
|
|
740
|
+
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
741
|
+
const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
|
|
742
|
+
if (duplicate) return true
|
|
743
|
+
if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
|
|
744
|
+
if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
|
|
745
|
+
persistReplay()
|
|
746
|
+
return false
|
|
747
|
+
}
|
|
645
748
|
// Context lines from the events themselves (the WS payload already carries the
|
|
646
749
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
647
750
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -651,7 +754,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
651
754
|
let lastTaskTriggeredAt = 0
|
|
652
755
|
let lastInboxSignature = ''
|
|
653
756
|
let selfAgentId = null
|
|
654
|
-
let selfAgentName = slug
|
|
655
757
|
let mcpSessionId = ''
|
|
656
758
|
let mcpRpcId = 0
|
|
657
759
|
|
|
@@ -676,7 +778,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
676
778
|
}
|
|
677
779
|
const ensureMcpSession = async () => {
|
|
678
780
|
if (mcpSessionId) return
|
|
679
|
-
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.
|
|
781
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.0' } } }, false)
|
|
680
782
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
681
783
|
await mcpPayload(res)
|
|
682
784
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -734,8 +836,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
734
836
|
const taskKey = `${projectId}:${ticketId}`
|
|
735
837
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
736
838
|
const ticket = current.ticket ?? current.task ?? current
|
|
737
|
-
const report = buildTaskCompletionReport(ticket, { projectId,
|
|
839
|
+
const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
|
|
738
840
|
if (!report) return false
|
|
841
|
+
// Ticket comments are now a backend first-class surface. The watcher owns the
|
|
842
|
+
// final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
|
|
843
|
+
// loop consistently, and the persisted report key prevents reconnect repeats.
|
|
844
|
+
if (!reportedTaskComments.has(report.key)) {
|
|
845
|
+
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
846
|
+
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
847
|
+
log('posted verified ticket comment for #' + ticketId)
|
|
848
|
+
}
|
|
739
849
|
if (reportedCompletions.has(report.key)) {
|
|
740
850
|
pendingCompletionReports.delete(taskKey)
|
|
741
851
|
persistReplay()
|
|
@@ -794,7 +904,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
794
904
|
delivered = true
|
|
795
905
|
return
|
|
796
906
|
} catch (e) {
|
|
797
|
-
log('comment_ticket
|
|
907
|
+
log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
|
|
798
908
|
}
|
|
799
909
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
800
910
|
const ticket = current.ticket ?? current.task ?? current
|
|
@@ -814,10 +924,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
814
924
|
const command = block?.command || 'the requested external repository action'
|
|
815
925
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
816
926
|
const approval = block?.commit && block?.branch
|
|
817
|
-
? `Reply with: “I explicitly authorize
|
|
927
|
+
? `Reply with: “I explicitly authorize you to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
|
|
818
928
|
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
819
|
-
const notice = `Action required:
|
|
820
|
-
const ticketNotice = `
|
|
929
|
+
const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
|
|
930
|
+
const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the project channel; I won't retry automatically.`
|
|
821
931
|
return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
|
|
822
932
|
}
|
|
823
933
|
|
|
@@ -832,11 +942,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
832
942
|
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
833
943
|
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
834
944
|
selfAgentId = Number(self.id)
|
|
835
|
-
selfAgentName = String(self.name || selfAgentName)
|
|
836
945
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
837
946
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
838
947
|
const assigned = []
|
|
839
948
|
const mentionActivity = []
|
|
949
|
+
let activityReplayTouched = false
|
|
840
950
|
for (const project of projects) {
|
|
841
951
|
const [tasksData, typesData, activityData] = await Promise.all([
|
|
842
952
|
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
@@ -880,12 +990,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
880
990
|
const lower = text.toLowerCase()
|
|
881
991
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
882
992
|
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
883
|
-
|
|
993
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
994
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
995
|
+
? item.message
|
|
996
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
997
|
+
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
998
|
+
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
999
|
+
// The same logical mention may already have arrived over WebSocket.
|
|
1000
|
+
// Share the id/signature guard instead of starting a second model turn.
|
|
1001
|
+
if (markMentionHandled(activityMessage, activityChannelId)) continue
|
|
884
1002
|
mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
|
|
885
1003
|
}
|
|
886
1004
|
}
|
|
887
1005
|
}
|
|
888
|
-
if (
|
|
1006
|
+
if (activityReplayTouched) persistReplay()
|
|
889
1007
|
if (!assigned.length) lastTaskSignature = ''
|
|
890
1008
|
else {
|
|
891
1009
|
const signature = JSON.stringify(assigned)
|
|
@@ -916,6 +1034,45 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
916
1034
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
917
1035
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
918
1036
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
1037
|
+
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1038
|
+
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1039
|
+
// shape is different and remains on its existing completion path.
|
|
1040
|
+
const evidenceGatedRuntime = agent === 'codex' || agent === 'opencode'
|
|
1041
|
+
const missingWorkEvidence = (result, ticketCycle) => {
|
|
1042
|
+
const missing = [
|
|
1043
|
+
ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
|
|
1044
|
+
!result?.didRepoMutation && 'perform and verify the repository change',
|
|
1045
|
+
ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
|
|
1046
|
+
].filter(Boolean)
|
|
1047
|
+
if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
1048
|
+
return missing
|
|
1049
|
+
}
|
|
1050
|
+
const combineWorkEvidence = (first, second) => ({
|
|
1051
|
+
...second,
|
|
1052
|
+
didCode: !!first?.didCode || !!second?.didCode,
|
|
1053
|
+
didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
|
|
1054
|
+
didMessage: !!first?.didMessage || !!second?.didMessage,
|
|
1055
|
+
didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
|
|
1056
|
+
didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
|
|
1057
|
+
didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
|
|
1058
|
+
mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
|
|
1059
|
+
// A focused recovery is allowed to clear an earlier MCP failure. Only calls
|
|
1060
|
+
// still failing in the recovery remain blockers.
|
|
1061
|
+
mcpErrors: second?.mcpErrors || [],
|
|
1062
|
+
outputText: [first?.outputText, second?.outputText].filter(Boolean).join(' '),
|
|
1063
|
+
})
|
|
1064
|
+
const releaseTaskForRetry = (taskRef, prompt) => {
|
|
1065
|
+
const directProject = Number(taskRef?.projectId)
|
|
1066
|
+
const directTicket = Number(taskRef?.ticketId)
|
|
1067
|
+
if (Number.isFinite(directProject) && Number.isFinite(directTicket)) seenTasks.delete(`${directProject}:${directTicket}`)
|
|
1068
|
+
else {
|
|
1069
|
+
const match = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
1070
|
+
if (match) seenTasks.delete(`${match[2]}:${match[1]}`)
|
|
1071
|
+
}
|
|
1072
|
+
// The next task update or periodic reconciliation must be allowed to queue
|
|
1073
|
+
// this work again. An acknowledgement is not a terminal task signature.
|
|
1074
|
+
lastTaskSignature = ''
|
|
1075
|
+
}
|
|
919
1076
|
|
|
920
1077
|
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
921
1078
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
@@ -953,14 +1110,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
953
1110
|
return
|
|
954
1111
|
}
|
|
955
1112
|
if (kind === 'full' && ['timeout', 'spawn-failed', 'error'].includes(result?.subtype)) {
|
|
956
|
-
const notice = `
|
|
1113
|
+
const notice = `I'm blocked because the coding cycle ended with ${result.subtype}. I'm not claiming completion, and I've left the ticket open for retry.`
|
|
957
1114
|
log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
|
|
958
1115
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
959
1116
|
catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
|
|
1117
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
960
1118
|
return
|
|
961
1119
|
}
|
|
962
1120
|
if (kind !== 'full' && result?.mcpErrors?.length) {
|
|
963
|
-
const notice = `
|
|
1121
|
+
const notice = `I'm blocked by failed OpenVisio actions: ${result.mcpErrors.join(', ')}. I'm not claiming success; this needs a retry or intervention.`
|
|
964
1122
|
log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
|
|
965
1123
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
966
1124
|
catch (e) { log('failed to publish coordination blocker: ' + (e?.message || e)) }
|
|
@@ -969,38 +1127,37 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
969
1127
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
970
1128
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
971
1129
|
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
972
|
-
const
|
|
973
|
-
if (
|
|
974
|
-
|
|
975
|
-
if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
976
|
-
log('coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1130
|
+
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1131
|
+
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1132
|
+
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
977
1133
|
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)
|
|
978
|
-
const
|
|
979
|
-
|
|
1134
|
+
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1135
|
+
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1136
|
+
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
980
1137
|
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
981
1138
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
|
|
982
1139
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
983
1140
|
} else {
|
|
984
|
-
const
|
|
1141
|
+
const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
|
|
1142
|
+
const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've left the ticket open and I'm not claiming completion.`
|
|
985
1143
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
986
1144
|
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
987
1145
|
}
|
|
988
|
-
|
|
989
|
-
else {
|
|
990
|
-
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
991
|
-
if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
|
|
992
|
-
}
|
|
993
|
-
lastTaskSignature = ''
|
|
1146
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
994
1147
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
995
1148
|
return
|
|
996
1149
|
}
|
|
997
|
-
completionResult =
|
|
1150
|
+
completionResult = recoveredResult
|
|
998
1151
|
}
|
|
999
1152
|
if (kind === 'full' && activeTaskRef) {
|
|
1000
1153
|
try {
|
|
1001
1154
|
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1002
|
-
if (!delivered)
|
|
1155
|
+
if (!delivered) {
|
|
1156
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1157
|
+
log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence; ticket remains retryable')
|
|
1158
|
+
}
|
|
1003
1159
|
} catch (e) {
|
|
1160
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1004
1161
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1005
1162
|
}
|
|
1006
1163
|
}
|
|
@@ -1065,7 +1222,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1065
1222
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1066
1223
|
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1067
1224
|
selfAgentId = self?.id != null ? Number(self.id) : null
|
|
1068
|
-
selfAgentName = String(self?.name || selfAgentName)
|
|
1069
1225
|
}
|
|
1070
1226
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1071
1227
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
@@ -1126,9 +1282,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1126
1282
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
1127
1283
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1128
1284
|
// when the payload carries no id.
|
|
1129
|
-
|
|
1130
|
-
if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
|
|
1131
|
-
seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
|
|
1285
|
+
if (markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1132
1286
|
if (requestTargetsLaterAgent(text, [slug, identifier])) {
|
|
1133
1287
|
log('agent:mention addressed to a later-mentioned agent — skipped')
|
|
1134
1288
|
return
|