openvisio-agent 0.17.6 → 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 +9 -1
- package/src/events.mjs +82 -0
- package/src/watch.mjs +207 -51
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,8 +48,12 @@ 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
59
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock)') && watcher.includes("Action required: I'm blocked")],
|
package/src/events.mjs
CHANGED
|
@@ -65,6 +65,56 @@ export function buildTaskCompletionReport(task, { projectId, fallbackText = '' }
|
|
|
65
65
|
return { key: `${projectId ?? task.project_id ?? task.projectId ?? '?'}:${task.id}:${revision}`, content, prUrl }
|
|
66
66
|
}
|
|
67
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
|
+
|
|
68
118
|
export function agentStateRequest(backend, channelId, state, apiKey, identifier) {
|
|
69
119
|
if (!['thinking', 'working', 'typing'].includes(state)) throw new Error('invalid agent state')
|
|
70
120
|
const id = Number(channelId)
|
|
@@ -90,6 +140,38 @@ export function codexPolicyBlock(value) {
|
|
|
90
140
|
return { kind: 'authorization-required', command, reason: reason.slice(0, 900), commit, remote: push?.[1] || '', branch: push?.[2] || '' }
|
|
91
141
|
}
|
|
92
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
|
+
|
|
93
175
|
// A mention event means this agent's name appeared somewhere, not necessarily
|
|
94
176
|
// that the request was addressed to it. Reject a later-agent hand-off before a
|
|
95
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
|
|
@@ -38,7 +38,7 @@ const REPLY_DISCIPLINE = [
|
|
|
38
38
|
|
|
39
39
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
40
40
|
const CHAT_CHARTER = [
|
|
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
|
|
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.',
|
|
42
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.',
|
|
43
43
|
'',
|
|
44
44
|
REPLY_DISCIPLINE,
|
|
@@ -66,7 +66,7 @@ const COORDINATE = [
|
|
|
66
66
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
67
67
|
const CODE_CHARTER = [
|
|
68
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:',
|
|
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, 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.',
|
|
70
70
|
' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
|
|
71
71
|
' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
|
|
72
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.',
|
|
@@ -77,6 +77,7 @@ const CODE_CHARTER = [
|
|
|
77
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.',
|
|
78
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.',
|
|
79
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.',
|
|
80
81
|
'',
|
|
81
82
|
REPLY_DISCIPLINE,
|
|
82
83
|
].join('\n')
|
|
@@ -90,7 +91,7 @@ const CODE_FULL = [
|
|
|
90
91
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
91
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.',
|
|
92
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.',
|
|
93
|
-
' 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.',
|
|
94
95
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
95
96
|
].join('\n')
|
|
96
97
|
|
|
@@ -178,18 +179,31 @@ function claimStartupSweep(key) {
|
|
|
178
179
|
// are taken over. Returns { release } or { conflict: <pid> }.
|
|
179
180
|
function acquireSingleInstance(key) {
|
|
180
181
|
const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
|
|
181
|
-
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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' }
|
|
190
205
|
}
|
|
191
|
-
|
|
192
|
-
} catch { /* if the lock can't be written, don't block the agent from running */ }
|
|
206
|
+
}
|
|
193
207
|
const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
|
|
194
208
|
return { release }
|
|
195
209
|
}
|
|
@@ -229,6 +243,7 @@ export async function runWatch({ flags }) {
|
|
|
229
243
|
// both connect as the same agent and both answer every mention. Refuse to start.
|
|
230
244
|
if (!flags.install) {
|
|
231
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}`)
|
|
232
247
|
if (lock.conflict) {
|
|
233
248
|
const watcherName = slug || 'openvisio'
|
|
234
249
|
const logs = process.platform === 'darwin'
|
|
@@ -274,7 +289,7 @@ export async function runWatch({ flags }) {
|
|
|
274
289
|
// The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
|
|
275
290
|
// (opencode reads it from there). `--auto` approves tool use non-interactively.
|
|
276
291
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
277
|
-
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 }) {
|
|
278
293
|
// opencode reads opencode.json from its CWD: the code workspace, or a dedicated
|
|
279
294
|
// per-agent dir for chat-only agents.
|
|
280
295
|
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
@@ -303,18 +318,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
303
318
|
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
304
319
|
// charter/creds into the message (still not re-accumulated across cycles).
|
|
305
320
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
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
|
+
}
|
|
309
364
|
const timer = setTimeout(() => {
|
|
310
365
|
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
311
366
|
try { child && child.kill() } catch { /* gone */ }
|
|
312
367
|
finish({ type: 'result', subtype: 'timeout' })
|
|
313
368
|
}, maxCycleMs)
|
|
314
369
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
315
|
-
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
|
+
}
|
|
316
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' }) }
|
|
317
|
-
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
|
+
})
|
|
318
386
|
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
319
387
|
})
|
|
320
388
|
}
|
|
@@ -346,7 +414,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
346
414
|
...(m ? ['--model', m] : []),
|
|
347
415
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
348
416
|
full]
|
|
349
|
-
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 = ''
|
|
350
418
|
let policyBlock = null
|
|
351
419
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
352
420
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
@@ -363,6 +431,20 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
363
431
|
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
364
432
|
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
365
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
|
+
}
|
|
366
448
|
const inspectLine = (line) => {
|
|
367
449
|
const s = line.trim()
|
|
368
450
|
if (!s) return
|
|
@@ -403,15 +485,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
403
485
|
for (const line of lines) inspectLine(line)
|
|
404
486
|
})
|
|
405
487
|
if (child.stderr) child.stderr.on('data', (d) => {
|
|
406
|
-
|
|
407
|
-
process.stderr.write(d)
|
|
488
|
+
forwardDiagnostic(d)
|
|
408
489
|
})
|
|
409
490
|
} catch (e) {
|
|
410
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`)')
|
|
411
492
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
412
493
|
}
|
|
413
494
|
child.on('close', (code) => {
|
|
414
|
-
inspectLine(jsonlBuffer); jsonlBuffer = '';
|
|
495
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
|
|
415
496
|
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
416
497
|
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
417
498
|
finish({ type: 'result', subtype })
|
|
@@ -433,7 +514,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
433
514
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
434
515
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
435
516
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
436
|
-
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 })
|
|
437
518
|
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
438
519
|
let child = null
|
|
439
520
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
@@ -583,7 +664,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
583
664
|
const canCode = !!workdir
|
|
584
665
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
585
666
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
586
|
-
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.`
|
|
587
668
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
588
669
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
589
670
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -620,29 +701,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
620
701
|
let replayState = {}
|
|
621
702
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
622
703
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
704
|
+
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
623
705
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
624
706
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
625
707
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
626
708
|
// without re-running the model or posting the same result twice.
|
|
627
709
|
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
628
710
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
711
|
+
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
629
712
|
// A policy-blocked task stays paused across reconnects. It is released only
|
|
630
713
|
// after the ticket itself carries explicit authorization or is completed/
|
|
631
714
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
632
715
|
// attempting the same rejected egress action.
|
|
633
716
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
634
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
|
+
}
|
|
635
724
|
const persistReplay = () => {
|
|
636
725
|
try {
|
|
726
|
+
pruneMentionSignatures()
|
|
637
727
|
writeJson(replayPath, {
|
|
638
728
|
seenMentions: [...seenMentions],
|
|
729
|
+
recentMentionSignatures: [...recentMentionSignatures],
|
|
639
730
|
seenActivities: [...seenActivities],
|
|
640
731
|
blockedTasks: [...blockedTasks],
|
|
641
732
|
pendingCompletionReports: [...pendingCompletionReports],
|
|
642
733
|
reportedCompletions: [...reportedCompletions],
|
|
734
|
+
reportedTaskComments: [...reportedTaskComments],
|
|
643
735
|
}, true)
|
|
644
736
|
} catch { /* best-effort */ }
|
|
645
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
|
+
}
|
|
646
748
|
// Context lines from the events themselves (the WS payload already carries the
|
|
647
749
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
648
750
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -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') || ''
|
|
@@ -736,6 +838,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
736
838
|
const ticket = current.ticket ?? current.task ?? current
|
|
737
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
|
|
@@ -836,6 +946,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
836
946
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
837
947
|
const assigned = []
|
|
838
948
|
const mentionActivity = []
|
|
949
|
+
let activityReplayTouched = false
|
|
839
950
|
for (const project of projects) {
|
|
840
951
|
const [tasksData, typesData, activityData] = await Promise.all([
|
|
841
952
|
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
@@ -879,12 +990,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
879
990
|
const lower = text.toLowerCase()
|
|
880
991
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
881
992
|
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
882
|
-
|
|
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
|
|
883
1002
|
mentionActivity.push({ projectId: project.id, project: project.name, activity: item })
|
|
884
1003
|
}
|
|
885
1004
|
}
|
|
886
1005
|
}
|
|
887
|
-
if (
|
|
1006
|
+
if (activityReplayTouched) persistReplay()
|
|
888
1007
|
if (!assigned.length) lastTaskSignature = ''
|
|
889
1008
|
else {
|
|
890
1009
|
const signature = JSON.stringify(assigned)
|
|
@@ -915,6 +1034,45 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
915
1034
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
916
1035
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
917
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
|
+
}
|
|
918
1076
|
|
|
919
1077
|
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
920
1078
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
@@ -956,6 +1114,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
956
1114
|
log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
|
|
957
1115
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
958
1116
|
catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
|
|
1117
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
959
1118
|
return
|
|
960
1119
|
}
|
|
961
1120
|
if (kind !== 'full' && result?.mcpErrors?.length) {
|
|
@@ -968,38 +1127,37 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
968
1127
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
969
1128
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
970
1129
|
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
971
|
-
const
|
|
972
|
-
if (
|
|
973
|
-
|
|
974
|
-
if (result.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
975
|
-
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(', '))
|
|
976
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)
|
|
977
|
-
const
|
|
978
|
-
|
|
1134
|
+
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1135
|
+
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1136
|
+
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
979
1137
|
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
980
1138
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
|
|
981
1139
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
982
1140
|
} else {
|
|
983
|
-
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.`
|
|
984
1143
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
985
1144
|
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
986
1145
|
}
|
|
987
|
-
|
|
988
|
-
else {
|
|
989
|
-
const taskMatch = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
990
|
-
if (taskMatch) seenTasks.delete(`${taskMatch[2]}:${taskMatch[1]}`)
|
|
991
|
-
}
|
|
992
|
-
lastTaskSignature = ''
|
|
1146
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
993
1147
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
994
1148
|
return
|
|
995
1149
|
}
|
|
996
|
-
completionResult =
|
|
1150
|
+
completionResult = recoveredResult
|
|
997
1151
|
}
|
|
998
1152
|
if (kind === 'full' && activeTaskRef) {
|
|
999
1153
|
try {
|
|
1000
1154
|
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1001
|
-
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
|
+
}
|
|
1002
1159
|
} catch (e) {
|
|
1160
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1003
1161
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1004
1162
|
}
|
|
1005
1163
|
}
|
|
@@ -1124,9 +1282,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1124
1282
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
1125
1283
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1126
1284
|
// when the payload carries no id.
|
|
1127
|
-
|
|
1128
|
-
if (seenMentions.has(dedupeKey)) { log('agent:mention (dup) — skipped'); return }
|
|
1129
|
-
seenMentions.add(dedupeKey); trimSeen(seenMentions); persistReplay()
|
|
1285
|
+
if (markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1130
1286
|
if (requestTargetsLaterAgent(text, [slug, identifier])) {
|
|
1131
1287
|
log('agent:mention addressed to a later-mentioned agent — skipped')
|
|
1132
1288
|
return
|