openvisio-agent 0.17.6 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/bin/cli.mjs +40 -0
- package/package.json +1 -1
- package/scripts/certify.mjs +25 -2
- package/src/events.mjs +141 -0
- package/src/memory.mjs +82 -0
- package/src/pr-push.mjs +112 -0
- package/src/watch.mjs +422 -94
package/src/watch.mjs
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
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, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, requestTargetsLaterAgent, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted } from './events.mjs'
|
|
14
|
+
import { createByoMemoryGraph } from './memory.mjs'
|
|
15
|
+
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
14
16
|
|
|
15
17
|
// Behaviour prompts. The openvisio-team MCP bridge requires the agent's
|
|
16
18
|
// credentials as ARGUMENTS on every tool call — those are injected at runtime by
|
|
@@ -38,7 +40,7 @@ const REPLY_DISCIPLINE = [
|
|
|
38
40
|
|
|
39
41
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
40
42
|
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
|
|
43
|
+
'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
44
|
'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
45
|
'',
|
|
44
46
|
REPLY_DISCIPLINE,
|
|
@@ -66,7 +68,7 @@ const COORDINATE = [
|
|
|
66
68
|
// A stable "who you are / how you work" charter prepended to every code cycle.
|
|
67
69
|
const CODE_CHARTER = [
|
|
68
70
|
'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
|
|
71
|
+
' • 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
72
|
' • Read / Grep / Glob / Edit / Write / MultiEdit — inspect AND change code.',
|
|
71
73
|
' • Bash — git (branch, commit, push a branch), gh (clone repos, open PRs), run tests/builds.',
|
|
72
74
|
'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 +79,7 @@ const CODE_CHARTER = [
|
|
|
77
79
|
' 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
80
|
' 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
81
|
' 4. One reply per channel per cycle; answer several nudges together.',
|
|
82
|
+
' 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
83
|
'',
|
|
81
84
|
REPLY_DISCIPLINE,
|
|
82
85
|
].join('\n')
|
|
@@ -90,7 +93,7 @@ const CODE_FULL = [
|
|
|
90
93
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
91
94
|
' 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
95
|
' 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.',
|
|
96
|
+
' 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
97
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
95
98
|
].join('\n')
|
|
96
99
|
|
|
@@ -178,18 +181,31 @@ function claimStartupSweep(key) {
|
|
|
178
181
|
// are taken over. Returns { release } or { conflict: <pid> }.
|
|
179
182
|
function acquireSingleInstance(key) {
|
|
180
183
|
const lockPath = join(OV_DIR, 'watch-' + key + '.lock')
|
|
181
|
-
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
try { mkdirSync(OV_DIR, { recursive: true }) } catch (error) { return { error } }
|
|
185
|
+
// `existsSync` followed by `writeFileSync` is a race: two KeepAlive starts can
|
|
186
|
+
// both observe no file and both become watchers. `wx` makes creation atomic.
|
|
187
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
188
|
+
let fd = null
|
|
189
|
+
try {
|
|
190
|
+
fd = openSync(lockPath, 'wx')
|
|
191
|
+
writeFileSync(fd, String(process.pid))
|
|
192
|
+
closeSync(fd); fd = null
|
|
193
|
+
break
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (fd != null) { try { closeSync(fd) } catch { /* already closed */ } }
|
|
196
|
+
if (error?.code !== 'EEXIST') return { error }
|
|
197
|
+
let pid = 0
|
|
198
|
+
try { pid = parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) } catch { /* an in-flight creator owns it */ }
|
|
199
|
+
if (!pid) return { conflict: 'unknown' }
|
|
200
|
+
let alive = false
|
|
201
|
+
try { process.kill(pid, 0); alive = true } catch (e) { alive = !!(e && e.code === 'EPERM') }
|
|
202
|
+
if (alive) return { conflict: pid }
|
|
203
|
+
// Exact stale lock only. If another process wins the retry, its atomic file
|
|
204
|
+
// remains and this process will return conflict on the next iteration.
|
|
205
|
+
try { unlinkSync(lockPath) } catch (e) { if (e?.code !== 'ENOENT') return { error: e } }
|
|
206
|
+
if (attempt === 1) return { conflict: 'unknown' }
|
|
190
207
|
}
|
|
191
|
-
|
|
192
|
-
} catch { /* if the lock can't be written, don't block the agent from running */ }
|
|
208
|
+
}
|
|
193
209
|
const release = () => { try { if (parseInt(String(readFileSync(lockPath, 'utf8')).trim(), 10) === process.pid) unlinkSync(lockPath) } catch { /* already gone */ } }
|
|
194
210
|
return { release }
|
|
195
211
|
}
|
|
@@ -229,6 +245,7 @@ export async function runWatch({ flags }) {
|
|
|
229
245
|
// both connect as the same agent and both answer every mention. Refuse to start.
|
|
230
246
|
if (!flags.install) {
|
|
231
247
|
const lock = acquireSingleInstance(slug || 'openvisio')
|
|
248
|
+
if (lock.error) fail(`Could not acquire the single-watcher lock for "${slug || 'openvisio'}": ${lock.error.message || lock.error}`)
|
|
232
249
|
if (lock.conflict) {
|
|
233
250
|
const watcherName = slug || 'openvisio'
|
|
234
251
|
const logs = process.platform === 'darwin'
|
|
@@ -274,7 +291,7 @@ export async function runWatch({ flags }) {
|
|
|
274
291
|
// The openvisio-team MCP is declared in an `opencode.json` written into the run cwd
|
|
275
292
|
// (opencode reads it from there). `--auto` approves tool use non-interactively.
|
|
276
293
|
// Same { runCycle, canCode } contract as the Claude runner.
|
|
277
|
-
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, systemPrompt }) {
|
|
294
|
+
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
278
295
|
// opencode reads opencode.json from its CWD: the code workspace, or a dedicated
|
|
279
296
|
// per-agent dir for chat-only agents.
|
|
280
297
|
const cwd = workdir || join(OV_DIR, 'opencode-' + (cfgKey || 'agent'))
|
|
@@ -303,18 +320,71 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
303
320
|
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
304
321
|
// charter/creds into the message (still not re-accumulated across cycles).
|
|
305
322
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
323
|
+
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
324
|
+
// OpenCode exited; raw events tell us which tools actually completed.
|
|
325
|
+
const args = ['run', full, '--auto', '--format', 'json', ...(m ? ['--model', m] : [])]
|
|
326
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
327
|
+
let outputText = '', jsonlBuffer = ''
|
|
328
|
+
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
329
|
+
const finish = (o) => {
|
|
330
|
+
if (done) return
|
|
331
|
+
done = true
|
|
332
|
+
clearTimeout(timer)
|
|
333
|
+
const calls = [...mcpCalls]
|
|
334
|
+
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
335
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
336
|
+
}
|
|
337
|
+
const inspectLine = (line) => {
|
|
338
|
+
const value = String(line || '').trim()
|
|
339
|
+
if (!value) return
|
|
340
|
+
let event
|
|
341
|
+
try { event = JSON.parse(value) } catch {
|
|
342
|
+
if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
const evidence = opencodeEventEvidence(event)
|
|
346
|
+
if (evidence.outputText) {
|
|
347
|
+
outputText += ' ' + evidence.outputText
|
|
348
|
+
if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
|
|
349
|
+
}
|
|
350
|
+
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
351
|
+
if (!evidence.tool) return
|
|
352
|
+
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
353
|
+
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
354
|
+
if (evidence.mcpTool) {
|
|
355
|
+
mcpCalls.add(evidence.mcpTool)
|
|
356
|
+
if (evidence.failed) mcpErrors.add(evidence.mcpTool)
|
|
357
|
+
else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
|
|
358
|
+
}
|
|
359
|
+
didCode ||= !!evidence.didCode
|
|
360
|
+
didRepoMutation ||= !!evidence.didRepoMutation
|
|
361
|
+
didMessage ||= !!evidence.didMessage
|
|
362
|
+
didChannelMessage ||= !!evidence.didChannelMessage
|
|
363
|
+
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
364
|
+
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
365
|
+
}
|
|
309
366
|
const timer = setTimeout(() => {
|
|
310
367
|
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
311
368
|
try { child && child.kill() } catch { /* gone */ }
|
|
312
369
|
finish({ type: 'result', subtype: 'timeout' })
|
|
313
370
|
}, maxCycleMs)
|
|
314
371
|
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
315
|
-
try {
|
|
372
|
+
try {
|
|
373
|
+
child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'inherit'] })
|
|
374
|
+
child.stdout?.on('data', (data) => {
|
|
375
|
+
jsonlBuffer += String(data)
|
|
376
|
+
const lines = jsonlBuffer.split('\n')
|
|
377
|
+
jsonlBuffer = lines.pop() ?? ''
|
|
378
|
+
for (const line of lines) inspectLine(line)
|
|
379
|
+
})
|
|
380
|
+
}
|
|
316
381
|
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('
|
|
382
|
+
child.on('close', (code) => {
|
|
383
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''
|
|
384
|
+
const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
|
|
385
|
+
log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
386
|
+
finish({ type: 'result', subtype })
|
|
387
|
+
})
|
|
318
388
|
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
319
389
|
})
|
|
320
390
|
}
|
|
@@ -334,19 +404,21 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
334
404
|
const tomlString = (v) => JSON.stringify(String(v))
|
|
335
405
|
const headerEntries = Object.entries(mcpHeaders || {}).map(([k, v]) => `${JSON.stringify(k)} = ${tomlString(v)}`).join(', ')
|
|
336
406
|
|
|
337
|
-
function runCycle(prompt, cycleModel) {
|
|
407
|
+
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
338
408
|
return new Promise((resolve) => {
|
|
339
409
|
const m = cycleModel || model
|
|
340
410
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
411
|
+
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
412
|
+
const disabledToolConfig = disabledMcpTools.length ? `, disabled_tools = [${disabledMcpTools.map(tomlString).join(', ')}]` : ''
|
|
341
413
|
const mcpOverride = mcpUrl
|
|
342
|
-
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''} } }`
|
|
414
|
+
? `mcp_servers={ openvisio-team = { url = ${tomlString(mcpUrl)}${headerEntries ? `, http_headers = { ${headerEntries} }` : ''}${disabledToolConfig} } }`
|
|
343
415
|
: ''
|
|
344
416
|
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
345
417
|
...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
|
|
346
418
|
...(m ? ['--model', m] : []),
|
|
347
419
|
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
348
420
|
full]
|
|
349
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = ''
|
|
421
|
+
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
350
422
|
let policyBlock = null
|
|
351
423
|
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
352
424
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
@@ -363,11 +435,26 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
363
435
|
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
364
436
|
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
365
437
|
}
|
|
438
|
+
const forwardDiagnostic = (value, flush = false) => {
|
|
439
|
+
const incoming = String(value || '')
|
|
440
|
+
inspectDiagnostic(incoming)
|
|
441
|
+
stderrLineBuffer += incoming
|
|
442
|
+
const lines = stderrLineBuffer.split('\n')
|
|
443
|
+
stderrLineBuffer = lines.pop() ?? ''
|
|
444
|
+
for (const line of lines) {
|
|
445
|
+
if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
|
|
446
|
+
}
|
|
447
|
+
if (flush && stderrLineBuffer) {
|
|
448
|
+
if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
|
|
449
|
+
stderrLineBuffer = ''
|
|
450
|
+
}
|
|
451
|
+
}
|
|
366
452
|
const inspectLine = (line) => {
|
|
367
453
|
const s = line.trim()
|
|
368
454
|
if (!s) return
|
|
455
|
+
policyBlock = codexPolicyBlock(s) || policyBlock
|
|
369
456
|
if (/command_execution|file_change|apply_patch|shell_command|exec_command/i.test(s)) didCode = true
|
|
370
|
-
if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create)\b/i.test(s)) didRepoMutation = true
|
|
457
|
+
if (/file_change|apply_patch/i.test(s) || /\b(?:git\s+(?:commit|push)|gh\s+pr\s+create|openvisio-agent\s+push-pr-branch)\b/i.test(s)) didRepoMutation = true
|
|
371
458
|
if (/post_message|comment_ticket/i.test(s)) didMessage = true
|
|
372
459
|
try {
|
|
373
460
|
const event = JSON.parse(s)
|
|
@@ -379,6 +466,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
379
466
|
try { onTool && onTool(tool) } catch { /* activity is best-effort */ }
|
|
380
467
|
if (/^(?:get_ticket|list_tasks|list_task_types)$/.test(tool)) didMcpTaskRead = true
|
|
381
468
|
if (tool === 'update_ticket') didMcpTaskUpdate = true
|
|
469
|
+
if (/^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(tool)) { didCode = true; didRepoMutation = true }
|
|
382
470
|
if (/post_message|comment_ticket/.test(tool)) didMessage = true
|
|
383
471
|
if (/post_message/.test(tool)) didChannelMessage = true
|
|
384
472
|
if (/fail|error/i.test(String(item.status || '')) || item.error) mcpErrors.add(tool)
|
|
@@ -403,15 +491,14 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
403
491
|
for (const line of lines) inspectLine(line)
|
|
404
492
|
})
|
|
405
493
|
if (child.stderr) child.stderr.on('data', (d) => {
|
|
406
|
-
|
|
407
|
-
process.stderr.write(d)
|
|
494
|
+
forwardDiagnostic(d)
|
|
408
495
|
})
|
|
409
496
|
} catch (e) {
|
|
410
497
|
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
498
|
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
412
499
|
}
|
|
413
500
|
child.on('close', (code) => {
|
|
414
|
-
inspectLine(jsonlBuffer); jsonlBuffer = '';
|
|
501
|
+
inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
|
|
415
502
|
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
416
503
|
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
417
504
|
finish({ type: 'result', subtype })
|
|
@@ -433,7 +520,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
433
520
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
434
521
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
435
522
|
// 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 })
|
|
523
|
+
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
437
524
|
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
438
525
|
let child = null
|
|
439
526
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
@@ -583,7 +670,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
583
670
|
const canCode = !!workdir
|
|
584
671
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
585
672
|
// 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
|
|
673
|
+
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
674
|
// The STATIC charter + creds are the session system prompt (cached, billed once),
|
|
588
675
|
// NOT re-sent in every cycle's user message — the big token saving.
|
|
589
676
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
@@ -597,7 +684,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
597
684
|
work: createCycleRunner({ ...runnerOptions, onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('work', 'typing') } }),
|
|
598
685
|
reply: createCycleRunner({ ...runnerOptions, cfgKey: identifier + '-reply', onTool: (name) => { if (/post_message/.test(name)) emitLaneStatus('reply', 'typing') } }),
|
|
599
686
|
}
|
|
600
|
-
const
|
|
687
|
+
const codexPushGuide = agent === 'codex' && canCode
|
|
688
|
+
? '\n\nCODEX PR DELIVERY: first inspect the OpenVisio MCP tools. When list_codebases, create_codebase_branch, create_codebase_commit (or write_codebase_file), and create_pull_request are available, use that authenticated linked-codebase flow to create the agent/* branch, publish the verified changed files, and open the PR. This is the preferred path and requires no local git push. If those tools are unavailable for the repository, do not run git push directly. From the repository run `openvisio-agent push-pr-branch`. It is a user-authorized constrained fallback that can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker.'
|
|
689
|
+
: ''
|
|
690
|
+
const fullPrompt = canCode ? CODE_FULL + codexPushGuide : CYCLE
|
|
601
691
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
602
692
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
603
693
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
@@ -606,8 +696,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
606
696
|
let liteModel = chatModel || model
|
|
607
697
|
|
|
608
698
|
const lanes = {
|
|
609
|
-
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
610
|
-
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [] },
|
|
699
|
+
work: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
700
|
+
reply: { busy: false, queued: null, pending: [], targets: new Set(), taskRefs: [], deferred: [] },
|
|
611
701
|
}
|
|
612
702
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
613
703
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
@@ -620,29 +710,55 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
620
710
|
let replayState = {}
|
|
621
711
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
622
712
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
713
|
+
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
623
714
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
715
|
+
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
716
|
+
const memory = createByoMemoryGraph({ path: join(OV_DIR, 'watch-' + slug + '-memory.json') })
|
|
624
717
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
625
718
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
626
719
|
// without re-running the model or posting the same result twice.
|
|
627
720
|
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
628
721
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
722
|
+
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
629
723
|
// A policy-blocked task stays paused across reconnects. It is released only
|
|
630
724
|
// after the ticket itself carries explicit authorization or is completed/
|
|
631
725
|
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
632
726
|
// attempting the same rejected egress action.
|
|
633
727
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
728
|
+
const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
|
|
634
729
|
const trimSeen = (set) => { while (set.size > 500) set.delete(set.values().next().value) }
|
|
730
|
+
const MENTION_SIGNATURE_TTL_MS = 10 * 60 * 1000
|
|
731
|
+
const pruneMentionSignatures = () => {
|
|
732
|
+
const cutoff = Date.now() - MENTION_SIGNATURE_TTL_MS
|
|
733
|
+
for (const [key, at] of recentMentionSignatures) if (Number(at) < cutoff) recentMentionSignatures.delete(key)
|
|
734
|
+
while (recentMentionSignatures.size > 500) recentMentionSignatures.delete(recentMentionSignatures.keys().next().value)
|
|
735
|
+
}
|
|
635
736
|
const persistReplay = () => {
|
|
636
737
|
try {
|
|
738
|
+
pruneMentionSignatures()
|
|
637
739
|
writeJson(replayPath, {
|
|
638
740
|
seenMentions: [...seenMentions],
|
|
741
|
+
recentMentionSignatures: [...recentMentionSignatures],
|
|
639
742
|
seenActivities: [...seenActivities],
|
|
743
|
+
deliveredReplies: [...deliveredReplies],
|
|
640
744
|
blockedTasks: [...blockedTasks],
|
|
745
|
+
blockedTaskRepos: [...blockedTaskRepos],
|
|
641
746
|
pendingCompletionReports: [...pendingCompletionReports],
|
|
642
747
|
reportedCompletions: [...reportedCompletions],
|
|
748
|
+
reportedTaskComments: [...reportedTaskComments],
|
|
643
749
|
}, true)
|
|
644
750
|
} catch { /* best-effort */ }
|
|
645
751
|
}
|
|
752
|
+
const markMentionHandled = (message, channelId) => {
|
|
753
|
+
pruneMentionSignatures()
|
|
754
|
+
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
755
|
+
const duplicate = (idKey && seenMentions.has(idKey)) || (signatureKey && recentMentionSignatures.has(signatureKey))
|
|
756
|
+
if (duplicate) return true
|
|
757
|
+
if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
|
|
758
|
+
if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
|
|
759
|
+
persistReplay()
|
|
760
|
+
return false
|
|
761
|
+
}
|
|
646
762
|
// Context lines from the events themselves (the WS payload already carries the
|
|
647
763
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
648
764
|
// hoping poll_inbox re-surfaces the same item. Accumulated across coalesced
|
|
@@ -676,7 +792,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
676
792
|
}
|
|
677
793
|
const ensureMcpSession = async () => {
|
|
678
794
|
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.
|
|
795
|
+
const res = await mcpPost({ jsonrpc: '2.0', id: ++mcpRpcId, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openvisio-agent', version: '0.18.1' } } }, false)
|
|
680
796
|
if (!res.ok) throw new Error('MCP initialize HTTP ' + res.status)
|
|
681
797
|
await mcpPayload(res)
|
|
682
798
|
mcpSessionId = res.headers.get('mcp-session-id') || ''
|
|
@@ -695,7 +811,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
695
811
|
if (payload.error) throw new Error(`MCP ${name}: ${payload.error.message || 'tool error'}`)
|
|
696
812
|
const result = payload.result ?? payload
|
|
697
813
|
if (result?.isError) {
|
|
698
|
-
const detail = result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error'
|
|
814
|
+
const detail = String(result.content?.find?.((c) => c?.type === 'text')?.text || 'tool error').split(apiKey).join('[redacted]')
|
|
699
815
|
throw new Error(`MCP ${name}: ${detail}`)
|
|
700
816
|
}
|
|
701
817
|
return result
|
|
@@ -706,6 +822,48 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
706
822
|
try { return JSON.parse(text) } catch { return { text } }
|
|
707
823
|
}
|
|
708
824
|
|
|
825
|
+
// All watcher-owned message delivery goes through this gate. For threaded
|
|
826
|
+
// replies it first reads the live backend thread and inspects rows already
|
|
827
|
+
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
828
|
+
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
829
|
+
const messageDeliveries = new Map()
|
|
830
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '' }) => {
|
|
831
|
+
const deliveryKey = String(key || '')
|
|
832
|
+
const message = String(content || '').trim()
|
|
833
|
+
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
834
|
+
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
835
|
+
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
836
|
+
|
|
837
|
+
const run = (async () => {
|
|
838
|
+
if (parentId != null) {
|
|
839
|
+
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
840
|
+
const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
|
|
841
|
+
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
842
|
+
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
843
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
844
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: duplicate ? message : rendered.at(-1)?.content, refs: { channelId: Number(channelId), threadId: Number(parentId) }, meta: { discoveredFromBackend: true } })
|
|
845
|
+
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
846
|
+
log('message delivery ' + deliveryKey + ' already rendered — skipped')
|
|
847
|
+
return { posted: false, reason: duplicate ? 'same-content-rendered' : 'agent-reply-rendered' }
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
sendStatus(Number(channelId), 'typing')
|
|
852
|
+
const result = toolData(await callMcpTool('post_message', {
|
|
853
|
+
...(Number.isFinite(Number(projectId)) ? { project_id: Number(projectId) } : {}),
|
|
854
|
+
channel_id: Number(channelId),
|
|
855
|
+
...(parentId != null ? { parent_id: Number(parentId) } : {}),
|
|
856
|
+
content: message,
|
|
857
|
+
}))
|
|
858
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
859
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
|
|
860
|
+
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
861
|
+
return { posted: true, result }
|
|
862
|
+
})().finally(() => messageDeliveries.delete(deliveryKey))
|
|
863
|
+
messageDeliveries.set(deliveryKey, run)
|
|
864
|
+
return run
|
|
865
|
+
}
|
|
866
|
+
|
|
709
867
|
const statusChannelCache = new Map()
|
|
710
868
|
const projectStatusChannel = async (projectId) => {
|
|
711
869
|
const key = Number(projectId)
|
|
@@ -727,6 +885,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
727
885
|
}
|
|
728
886
|
}
|
|
729
887
|
|
|
888
|
+
const announceIntroduction = async () => {
|
|
889
|
+
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
890
|
+
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
891
|
+
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
892
|
+
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
893
|
+
const channelId = await projectStatusChannel(project.id)
|
|
894
|
+
if (!Number.isFinite(Number(channelId))) return false
|
|
895
|
+
await postMessageOnce({
|
|
896
|
+
key: `intro:${identifier}:${project.id}`,
|
|
897
|
+
projectId: Number(project.id),
|
|
898
|
+
channelId: Number(channelId),
|
|
899
|
+
content: "I'm here, I pick up tasks assigned to me, and I respond to @mentions. Send work my way whenever you need me.",
|
|
900
|
+
})
|
|
901
|
+
return true
|
|
902
|
+
}
|
|
903
|
+
|
|
730
904
|
const announceTaskCompletion = async (taskRef, result = {}) => {
|
|
731
905
|
const projectId = Number(taskRef?.projectId)
|
|
732
906
|
const ticketId = Number(taskRef?.ticketId)
|
|
@@ -736,6 +910,23 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
736
910
|
const ticket = current.ticket ?? current.task ?? current
|
|
737
911
|
const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText })
|
|
738
912
|
if (!report) return false
|
|
913
|
+
const memoryKey = `ticket:${projectId}:${ticketId}`
|
|
914
|
+
memory.remember({
|
|
915
|
+
key: memoryKey,
|
|
916
|
+
kind: 'ticket',
|
|
917
|
+
state: 'handoff',
|
|
918
|
+
summary: report.content,
|
|
919
|
+
refs: { projectId, ticketId },
|
|
920
|
+
meta: { reportKey: report.key, prUrl: report.prUrl },
|
|
921
|
+
})
|
|
922
|
+
// Ticket comments are now a backend first-class surface. The watcher owns the
|
|
923
|
+
// final comment so every runtime (Claude, Codex, OpenCode) closes the ticket
|
|
924
|
+
// loop consistently, and the persisted report key prevents reconnect repeats.
|
|
925
|
+
if (!reportedTaskComments.has(report.key)) {
|
|
926
|
+
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
927
|
+
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
928
|
+
log('posted verified ticket comment for #' + ticketId)
|
|
929
|
+
}
|
|
739
930
|
if (reportedCompletions.has(report.key)) {
|
|
740
931
|
pendingCompletionReports.delete(taskKey)
|
|
741
932
|
persistReplay()
|
|
@@ -752,10 +943,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
752
943
|
}
|
|
753
944
|
const channelId = Number.isFinite(Number(taskRef.channelId)) ? Number(taskRef.channelId) : await projectStatusChannel(projectId)
|
|
754
945
|
if (!Number.isFinite(channelId)) return false
|
|
755
|
-
|
|
756
|
-
await callMcpTool('post_message', { project_id: projectId, channel_id: channelId, content: report.content })
|
|
946
|
+
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
757
947
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
758
948
|
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
949
|
+
memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
|
|
759
950
|
log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
|
|
760
951
|
return true
|
|
761
952
|
}
|
|
@@ -772,11 +963,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
772
963
|
let delivered = false
|
|
773
964
|
if (Number.isFinite(channelId)) {
|
|
774
965
|
try {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
content: notice,
|
|
779
|
-
})
|
|
966
|
+
const parentId = parentMatch ? Number(parentMatch[1]) : null
|
|
967
|
+
const blockerKey = `blocker:${channelId}:${parentId ?? 'top'}:${normalizeRenderedMessageText(notice).slice(0, 180)}`
|
|
968
|
+
await postMessageOnce({ key: blockerKey, channelId, parentId, projectId, content: notice, sourceKey: Number.isFinite(ticketId) && Number.isFinite(projectId) ? `ticket:${projectId}:${ticketId}` : '' })
|
|
780
969
|
delivered = true
|
|
781
970
|
} catch (e) {
|
|
782
971
|
log('failed to post blocker in channel ' + channelId + ': ' + (e?.message || e))
|
|
@@ -789,12 +978,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
789
978
|
|
|
790
979
|
const key = `${projectId}:${ticketId}`
|
|
791
980
|
if (pause) { blockedTasks.add(key); persistReplay() }
|
|
981
|
+
memory.remember({
|
|
982
|
+
key: `ticket:${projectId}:${ticketId}`,
|
|
983
|
+
kind: 'ticket',
|
|
984
|
+
state: pause ? 'authorization-blocked' : 'blocked',
|
|
985
|
+
summary: ticketNotice,
|
|
986
|
+
refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
|
|
987
|
+
})
|
|
792
988
|
try {
|
|
793
989
|
await callMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: ticketNotice })
|
|
794
990
|
delivered = true
|
|
795
991
|
return
|
|
796
992
|
} catch (e) {
|
|
797
|
-
log('comment_ticket
|
|
993
|
+
log('comment_ticket failed for blocker; falling back to the ticket description for #' + ticketId)
|
|
798
994
|
}
|
|
799
995
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
800
996
|
const ticket = current.ticket ?? current.task ?? current
|
|
@@ -811,6 +1007,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
811
1007
|
}
|
|
812
1008
|
|
|
813
1009
|
const reportPolicyBlock = async (prompt, taskRef, block) => {
|
|
1010
|
+
if (block?.kind === 'pr-push-authorization-required') {
|
|
1011
|
+
const location = block.root ? ` from \`${block.root}\`` : ' from the repository'
|
|
1012
|
+
const notice = `Action required: run \`openvisio-agent authorize-pr-push\`${location}. This is a one-time, repository-scoped opt-in. It permits only the constrained \`openvisio-agent push-pr-branch\` helper for the current \`agent/*\` branch, never main/master, force pushes, another remote, or merges. I've paused the ticket until it is enabled.`
|
|
1013
|
+
const ticketNotice = `I'm paused before the PR push. Run \`openvisio-agent authorize-pr-push\`${location}; the watcher will resume this ticket after the repository-scoped helper is authorized.`
|
|
1014
|
+
const projectId = Number(taskRef?.projectId)
|
|
1015
|
+
const ticketId = Number(taskRef?.ticketId)
|
|
1016
|
+
if (block.root && Number.isFinite(projectId) && Number.isFinite(ticketId)) {
|
|
1017
|
+
blockedTaskRepos.set(`${projectId}:${ticketId}`, block.root)
|
|
1018
|
+
persistReplay()
|
|
1019
|
+
}
|
|
1020
|
+
return publishBlocker({ prompt, taskRef, notice, ticketNotice, pause: true })
|
|
1021
|
+
}
|
|
814
1022
|
const command = block?.command || 'the requested external repository action'
|
|
815
1023
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
816
1024
|
const approval = block?.commit && block?.branch
|
|
@@ -836,6 +1044,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
836
1044
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
837
1045
|
const assigned = []
|
|
838
1046
|
const mentionActivity = []
|
|
1047
|
+
let activityReplayTouched = false
|
|
839
1048
|
for (const project of projects) {
|
|
840
1049
|
const [tasksData, typesData, activityData] = await Promise.all([
|
|
841
1050
|
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
@@ -851,12 +1060,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
851
1060
|
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
852
1061
|
const taskKey = `${project.id}:${task.id}`
|
|
853
1062
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1063
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
854
1064
|
if (pendingCompletionReports.has(taskKey)) {
|
|
855
1065
|
const activityChannel = await projectStatusChannel(project.id)
|
|
856
1066
|
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
857
1067
|
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
858
1068
|
}
|
|
859
|
-
if (blockedTasks.delete(taskKey)) persistReplay()
|
|
1069
|
+
if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
|
|
860
1070
|
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
861
1071
|
// the ticket back to an actionable column, that update must start a
|
|
862
1072
|
// fresh work cycle.
|
|
@@ -865,11 +1075,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
865
1075
|
}
|
|
866
1076
|
if (blockedTasks.has(taskKey)) {
|
|
867
1077
|
const approvalText = [task.title, task.description].filter(Boolean).join(' ')
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1078
|
+
const blockedRepo = blockedTaskRepos.get(taskKey)
|
|
1079
|
+
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1080
|
+
if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
1081
|
+
blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
1082
|
+
log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
|
|
871
1083
|
} else continue
|
|
872
1084
|
}
|
|
1085
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
|
|
873
1086
|
assigned.push({ id: task.id, projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
874
1087
|
}
|
|
875
1088
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
@@ -879,12 +1092,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
879
1092
|
const lower = text.toLowerCase()
|
|
880
1093
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
881
1094
|
if (!seenActivities.has(activityKey) && mentionNeedles.some((needle) => lower.includes(needle)) && /message|mention|channel/i.test(text)) {
|
|
882
|
-
|
|
883
|
-
|
|
1095
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1096
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1097
|
+
? item.message
|
|
1098
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1099
|
+
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1100
|
+
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1101
|
+
// The same logical mention may already have arrived over WebSocket.
|
|
1102
|
+
// Share the id/signature guard instead of starting a second model turn.
|
|
1103
|
+
if (markMentionHandled(activityMessage, activityChannelId)) continue
|
|
1104
|
+
mentionActivity.push({
|
|
1105
|
+
projectId: project.id,
|
|
1106
|
+
project: project.name,
|
|
1107
|
+
channelId: activityChannelId,
|
|
1108
|
+
message: activityMessage,
|
|
1109
|
+
activity: item,
|
|
1110
|
+
})
|
|
884
1111
|
}
|
|
885
1112
|
}
|
|
886
1113
|
}
|
|
887
|
-
if (
|
|
1114
|
+
if (activityReplayTouched) persistReplay()
|
|
888
1115
|
if (!assigned.length) lastTaskSignature = ''
|
|
889
1116
|
else {
|
|
890
1117
|
const signature = JSON.stringify(assigned)
|
|
@@ -903,8 +1130,18 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
903
1130
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
904
1131
|
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
905
1132
|
lastInboxSignature = inboxSignature
|
|
906
|
-
log('backlog reconciliation found mention
|
|
907
|
-
|
|
1133
|
+
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1134
|
+
// Replay each real message through the exact same delivery path as a live
|
|
1135
|
+
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1136
|
+
// consult the rendered thread before any reply is emitted.
|
|
1137
|
+
for (const mention of mentionActivity) {
|
|
1138
|
+
onEvent('agent:mention', {
|
|
1139
|
+
project_id: mention.projectId,
|
|
1140
|
+
channel_id: mention.channelId,
|
|
1141
|
+
message: mention.message,
|
|
1142
|
+
_mentionAlreadyMarked: true,
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
908
1145
|
} else if (!mentionActivity.length) lastInboxSignature = ''
|
|
909
1146
|
} catch (e) {
|
|
910
1147
|
mcpSessionId = ''
|
|
@@ -915,10 +1152,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
915
1152
|
// Higher rank wins when coalescing cycles requested while one is running.
|
|
916
1153
|
const RANK = { fast: 0, intro: 1, coord: 2, sweep: 2, full: 3 }
|
|
917
1154
|
const baseFor = (kind) => kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? COORDINATE : fastPrompt
|
|
1155
|
+
// Codex and OpenCode expose structured action streams. Require runtime facts
|
|
1156
|
+
// from those streams before accepting a full coding cycle. Claude's evidence
|
|
1157
|
+
// shape is different and remains on its existing completion path.
|
|
1158
|
+
const evidenceGatedRuntime = agent === 'codex' || agent === 'opencode'
|
|
1159
|
+
const missingWorkEvidence = (result, ticketCycle) => {
|
|
1160
|
+
const missing = [
|
|
1161
|
+
ticketCycle && !result?.didMcpTaskRead && 'read the ticket through get_ticket/list_tasks',
|
|
1162
|
+
!result?.didRepoMutation && 'perform and verify the repository change',
|
|
1163
|
+
ticketCycle && !result?.didMcpTaskUpdate && 'update the ticket through update_ticket',
|
|
1164
|
+
].filter(Boolean)
|
|
1165
|
+
if (result?.mcpErrors?.length) missing.push('resolve failed MCP calls: ' + result.mcpErrors.join(', '))
|
|
1166
|
+
return missing
|
|
1167
|
+
}
|
|
1168
|
+
const combineWorkEvidence = (first, second) => ({
|
|
1169
|
+
...second,
|
|
1170
|
+
didCode: !!first?.didCode || !!second?.didCode,
|
|
1171
|
+
didRepoMutation: !!first?.didRepoMutation || !!second?.didRepoMutation,
|
|
1172
|
+
didMessage: !!first?.didMessage || !!second?.didMessage,
|
|
1173
|
+
didChannelMessage: !!first?.didChannelMessage || !!second?.didChannelMessage,
|
|
1174
|
+
didMcpTaskRead: !!first?.didMcpTaskRead || !!second?.didMcpTaskRead,
|
|
1175
|
+
didMcpTaskUpdate: !!first?.didMcpTaskUpdate || !!second?.didMcpTaskUpdate,
|
|
1176
|
+
mcpCalls: [...new Set([...(first?.mcpCalls || []), ...(second?.mcpCalls || [])])],
|
|
1177
|
+
// A focused recovery is allowed to clear an earlier MCP failure. Only calls
|
|
1178
|
+
// still failing in the recovery remain blockers.
|
|
1179
|
+
mcpErrors: second?.mcpErrors || [],
|
|
1180
|
+
outputText: [first?.outputText, second?.outputText].filter(Boolean).join(' '),
|
|
1181
|
+
})
|
|
1182
|
+
const releaseTaskForRetry = (taskRef, prompt) => {
|
|
1183
|
+
const directProject = Number(taskRef?.projectId)
|
|
1184
|
+
const directTicket = Number(taskRef?.ticketId)
|
|
1185
|
+
if (Number.isFinite(directProject) && Number.isFinite(directTicket)) seenTasks.delete(`${directProject}:${directTicket}`)
|
|
1186
|
+
else {
|
|
1187
|
+
const match = /ticket\s+#?(\d+).*?project\s+(\d+)/i.exec(prompt)
|
|
1188
|
+
if (match) seenTasks.delete(`${match[2]}:${match[1]}`)
|
|
1189
|
+
}
|
|
1190
|
+
// The next task update or periodic reconciliation must be allowed to queue
|
|
1191
|
+
// this work again. An acknowledgement is not a terminal task signature.
|
|
1192
|
+
lastTaskSignature = ''
|
|
1193
|
+
}
|
|
918
1194
|
|
|
919
|
-
async function drain(kind, context, targetChannels = [], taskRef = null) {
|
|
1195
|
+
async function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
920
1196
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
921
1197
|
const lane = lanes[laneName]
|
|
1198
|
+
// A guarded reply is never coalesced with another event. It carries one
|
|
1199
|
+
// source message and one delivery key, so queue it as an independent cycle.
|
|
1200
|
+
if (lane.busy && delivery) {
|
|
1201
|
+
lane.deferred.push({ kind, context, targetChannels, taskRef, delivery })
|
|
1202
|
+
log(laneName + ' lane busy — queued one guarded ' + kind + ' cycle')
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
922
1205
|
if (context) lane.pending.push(context)
|
|
923
1206
|
if (taskRef) lane.taskRefs.push(taskRef)
|
|
924
1207
|
for (const channelId of targetChannels) if (Number.isFinite(Number(channelId))) lane.targets.add(Number(channelId))
|
|
@@ -931,7 +1214,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
931
1214
|
laneStatusTargets[laneName] = new Set(targets)
|
|
932
1215
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
933
1216
|
// message is just the event context + the small base instruction.
|
|
934
|
-
const
|
|
1217
|
+
const memoryRefs = delivery
|
|
1218
|
+
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1219
|
+
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1220
|
+
const recalled = memory.context(memoryRefs)
|
|
1221
|
+
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind)
|
|
935
1222
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
936
1223
|
// work (full/sweep) uses the main model.
|
|
937
1224
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
@@ -943,7 +1230,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
943
1230
|
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
944
1231
|
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
945
1232
|
try {
|
|
946
|
-
const result = await runners[laneName].runCycle(prompt, useModel)
|
|
1233
|
+
const result = await runners[laneName].runCycle(prompt, useModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
|
|
947
1234
|
let completionResult = result
|
|
948
1235
|
if (agent === 'codex' && result?.subtype === 'blocked' && result?.policyBlock) {
|
|
949
1236
|
log('WORK_CYCLE_BLOCKED authorization required; pausing this ticket and publishing an action-required notice')
|
|
@@ -956,6 +1243,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
956
1243
|
log('WORK_CYCLE_BLOCKED ' + result.subtype + '; publishing blocker')
|
|
957
1244
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
958
1245
|
catch (e) { log('failed to publish cycle blocker: ' + (e?.message || e)) }
|
|
1246
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
959
1247
|
return
|
|
960
1248
|
}
|
|
961
1249
|
if (kind !== 'full' && result?.mcpErrors?.length) {
|
|
@@ -968,46 +1256,56 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
968
1256
|
// Model prose never proves success or a blocker. Full cycles must produce
|
|
969
1257
|
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
970
1258
|
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
971
|
-
const
|
|
972
|
-
if (
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
if (recoveryIncomplete) {
|
|
1259
|
+
const missing = missingWorkEvidence(result, ticketCycle)
|
|
1260
|
+
if (evidenceGatedRuntime && kind === 'full' && result?.subtype === 'ok' && missing.length) {
|
|
1261
|
+
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1262
|
+
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, publish an agent/* branch, open the PR, and call update_ticket with the correct board column. ${agent === 'codex' ? 'Prefer the available OpenVisio create_codebase_branch/create_codebase_commit/create_pull_request tools. Only when that linked-codebase flow is unavailable, run openvisio-agent push-pr-branch; never retry a rejected direct git push.' : ''} Post only when the original context supplies a source thread.`, codeModel, agent === 'codex' && delivery ? { disabledMcpTools: ['post_message'] } : {})
|
|
1263
|
+
const recoveredResult = combineWorkEvidence(result, recovery)
|
|
1264
|
+
const recoveryMissing = missingWorkEvidence(recoveredResult, ticketCycle)
|
|
1265
|
+
if (recovery?.subtype !== 'ok' || recoveryMissing.length) {
|
|
979
1266
|
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
980
1267
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock) }
|
|
981
1268
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
982
1269
|
} else {
|
|
983
|
-
const
|
|
1270
|
+
const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
|
|
1271
|
+
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
1272
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, notice }) }
|
|
985
1273
|
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
986
1274
|
}
|
|
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 = ''
|
|
1275
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
993
1276
|
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket retained for retry')
|
|
994
1277
|
return
|
|
995
1278
|
}
|
|
996
|
-
completionResult =
|
|
1279
|
+
completionResult = recoveredResult
|
|
997
1280
|
}
|
|
998
1281
|
if (kind === 'full' && activeTaskRef) {
|
|
999
1282
|
try {
|
|
1000
1283
|
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1001
|
-
if (!delivered)
|
|
1284
|
+
if (!delivered) {
|
|
1285
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1286
|
+
log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for verified review/done state and PR evidence; ticket remains retryable')
|
|
1287
|
+
}
|
|
1002
1288
|
} catch (e) {
|
|
1289
|
+
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1003
1290
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry')
|
|
1004
1291
|
}
|
|
1005
1292
|
}
|
|
1293
|
+
if (agent === 'codex' && delivery) {
|
|
1294
|
+
const reply = String(completionResult?.outputText || '').trim()
|
|
1295
|
+
if (!reply) {
|
|
1296
|
+
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
1297
|
+
} else {
|
|
1298
|
+
try { await postMessageOnce({ ...delivery, content: reply }) }
|
|
1299
|
+
catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1006
1302
|
} finally {
|
|
1007
1303
|
if (heartbeat) clearInterval(heartbeat)
|
|
1008
1304
|
laneStatusTargets[laneName].clear()
|
|
1009
1305
|
lane.busy = false
|
|
1010
|
-
|
|
1306
|
+
const deferred = lane.deferred.shift()
|
|
1307
|
+
if (deferred) void drain(deferred.kind, deferred.context, deferred.targetChannels, deferred.taskRef, deferred.delivery)
|
|
1308
|
+
else if (lane.queued || lane.pending.length) { const next = lane.queued || (laneName === 'work' ? 'full' : 'fast'); lane.queued = null; void drain(next) }
|
|
1011
1309
|
}
|
|
1012
1310
|
}
|
|
1013
1311
|
|
|
@@ -1071,22 +1369,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1071
1369
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1072
1370
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1073
1371
|
const key = `${projectId}:${ticketId}`
|
|
1074
|
-
if (!belongsToSelf) {
|
|
1372
|
+
if (!belongsToSelf) {
|
|
1373
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1374
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
|
|
1375
|
+
}
|
|
1075
1376
|
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1377
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1076
1378
|
if (pendingCompletionReports.has(key)) {
|
|
1077
1379
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1078
1380
|
try { await announceTaskCompletion({ projectId, ticketId, channelId: activityChannel }) }
|
|
1079
1381
|
catch (e) { log('completion report failed for ticket #' + ticketId + ': ' + (e?.message || e) + '; retained for reconnect retry') }
|
|
1080
1382
|
}
|
|
1081
|
-
blockedTasks.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1383
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); persistReplay(); seenTasks.delete(key)
|
|
1082
1384
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1083
1385
|
return
|
|
1084
1386
|
}
|
|
1085
1387
|
if (blockedTasks.has(key)) {
|
|
1086
1388
|
const approvalText = [ticket.title, ticket.description].filter(Boolean).join(' ')
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1389
|
+
const blockedRepo = blockedTaskRepos.get(key)
|
|
1390
|
+
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1391
|
+
if (helperAuthorized || /\b(?:i\s+)?(?:explicitly\s+)?(?:approve|authorize)\b[\s\S]{0,240}\b(?:git\s+push|push(?:ing)?\s+(?:the\s+)?(?:branch|code)|github|remote)\b/i.test(approvalText)) {
|
|
1392
|
+
blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
|
|
1393
|
+
log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
|
|
1090
1394
|
} else {
|
|
1091
1395
|
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
1092
1396
|
return
|
|
@@ -1095,6 +1399,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1095
1399
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1096
1400
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1097
1401
|
const title = String(ticket.title || hinted.title || '')
|
|
1402
|
+
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'queued', summary: title, refs: { projectId, ticketId }, meta: { sourceEvent: kind } })
|
|
1098
1403
|
const taskText = [title, ticket.description, ticket.type, ticket.kind].filter(Boolean).join(' ')
|
|
1099
1404
|
const cycleKind = canCode && !coordinationOnly(taskText) ? 'full' : 'coord'
|
|
1100
1405
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
@@ -1124,22 +1429,28 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1124
1429
|
// De-dupe: the same mention re-delivered (reconnect replay / dup fan-out) must
|
|
1125
1430
|
// NOT trigger a second reply. Key by message id, or a channel+text signature
|
|
1126
1431
|
// 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()
|
|
1432
|
+
if (!raw._mentionAlreadyMarked && markMentionHandled(msg, cid)) { log('agent:mention (dup) — skipped'); return }
|
|
1130
1433
|
if (requestTargetsLaterAgent(text, [slug, identifier])) {
|
|
1131
1434
|
log('agent:mention addressed to a later-mentioned agent — skipped')
|
|
1132
1435
|
return
|
|
1133
1436
|
}
|
|
1437
|
+
const mentionKeys = mentionDedupeKeys(msg, cid)
|
|
1438
|
+
const sourceKey = `mention:${cid ?? '?'}:${mentionKeys.idKey || mentionKeys.signatureKey || threadRoot || Date.now()}`
|
|
1439
|
+
memory.remember({ key: sourceKey, kind: 'mention', state: 'received', summary: text, refs: { channelId: cid, threadId: threadRoot, messageId: mid } })
|
|
1440
|
+
const guardedDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => agent === 'codex' && cid != null
|
|
1441
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey }
|
|
1442
|
+
: null
|
|
1134
1443
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1135
1444
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
1136
1445
|
if (mcmd) {
|
|
1137
1446
|
const thread = threadRoot != null ? `, parent_id ${threadRoot}` : ''
|
|
1138
1447
|
if (mcmd.report) {
|
|
1139
1448
|
log('model query → code ' + codeModel + ' / chat ' + liteModel)
|
|
1140
|
-
|
|
1449
|
+
const delivery = guardedDelivery('model')
|
|
1450
|
+
void drain('fast', `An engineer asked which model you're running. ${delivery ? `Return exactly this one-line final answer without calling post_message: "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” The watcher will verify and deliver it once.` : `Reply once in channel ${cid}${thread} (with your agent creds): "I'm on ${codeModel} for code work${liteModel !== codeModel ? ` and ${liteModel} for chat replies` : ' (and chat)'}.” One line. Then stop.`}`, [cid], null, delivery)
|
|
1141
1451
|
} else if (mcmd.invalid) {
|
|
1142
|
-
|
|
1452
|
+
const delivery = guardedDelivery('model')
|
|
1453
|
+
void drain('fast', `An engineer tried to switch your model to "${mcmd.invalid}", which isn't one you recognize. ${delivery ? 'Return one short final answer saying you support "opus", "sonnet", "haiku", or a full model id and asking which they meant. Do not call post_message; the watcher will verify and deliver it once.' : `Reply once in channel ${cid}${thread} (with your agent creds): say you support "opus", "sonnet", "haiku", or a full "claude-…" id, and ask which they meant. One line. Then stop.`}`, [cid], null, delivery)
|
|
1143
1454
|
} else {
|
|
1144
1455
|
const tgt = mcmd.target // 'chat' | 'code' | 'both'
|
|
1145
1456
|
const prev = `code ${codeModel}/chat ${liteModel}`
|
|
@@ -1149,7 +1460,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1149
1460
|
persistModel()
|
|
1150
1461
|
const label = tgt === 'chat' ? 'chat model' : tgt === 'code' ? 'code model' : 'model'
|
|
1151
1462
|
log('model switched (' + tgt + ') ' + prev + ' → code ' + codeModel + '/chat ' + liteModel + (who ? ' (by ' + who + ')' : ''))
|
|
1152
|
-
|
|
1463
|
+
const delivery = guardedDelivery('model')
|
|
1464
|
+
void drain('fast', `An engineer switched your ${label} to "${mcmd.set}" — active for your next ${tgt === 'chat' ? 'chat replies' : tgt === 'code' ? 'code cycles' : 'actions'}. ${delivery ? `Return one short final confirmation such as "Switched my ${label} to ${mcmd.set}. I'll use it from here." Do not call post_message; the watcher will verify and deliver it once.` : `Post ONE short confirmation in channel ${cid}${thread} (with your agent creds): e.g. "Switched my ${label} to ${mcmd.set} — I'll use it from here." Then stop.`}`, [cid], null, delivery)
|
|
1153
1465
|
}
|
|
1154
1466
|
return
|
|
1155
1467
|
}
|
|
@@ -1158,16 +1470,24 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1158
1470
|
// subsequent working/typing heartbeat for its lane.
|
|
1159
1471
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1160
1472
|
const codingMention = canCode && needsCode(text)
|
|
1473
|
+
const replyDelivery = guardedDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1161
1474
|
const ctx = cid != null
|
|
1162
|
-
?
|
|
1475
|
+
? replyDelivery
|
|
1476
|
+
? `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'Complete the repository work and verification first.' : 'Answer the request.'} Do NOT call post_message; it is intentionally unavailable. Return only the final 1-3 sentence reply as your final answer. The watcher will read the real thread, check its persistent memory graph, and render that answer at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} Do not poll_inbox.`
|
|
1477
|
+
: `You were @mentioned in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". This mention is FOR YOU. ${codingMention ? 'This is repository work: complete the coding flow first, then send' : 'Send'} EXACTLY ONE reply with post_message: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above, and a 1-3 sentence reply. Compose the whole answer, then post it ONCE. Do not post a first reply and then a revised version. FIRST read the recent messages in this thread: if you already answered this, or another agent was the one addressed, do NOT post at all. Be sure of your answer before sending.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} You ALREADY have the message here. Do not poll_inbox, and after your single reply, STOP.`
|
|
1163
1478
|
: undefined
|
|
1164
1479
|
if (codingMention) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
:
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1480
|
+
if (agent === 'codex' && cid != null) {
|
|
1481
|
+
const ack = `${who ? `@${who} ` : ''}I've picked this up and will return here with the verified result.`
|
|
1482
|
+
void postMessageOnce({ ...guardedDelivery('ack'), content: ack }).catch((e) => log('guarded acknowledgement failed closed: ' + (e?.message || e)))
|
|
1483
|
+
} else {
|
|
1484
|
+
const ack = cid != null
|
|
1485
|
+
? `You were asked for repository work in channel ${cid}${threadRoot != null ? `, thread ${threadRoot}` : ''}. The dedicated work lane has accepted it. Post exactly one short reply with post_message in that same thread saying you have picked it up and will return there with the verified result. Include agent_identifier + agent_api_key. Do not inspect or edit code in this reply lane.`
|
|
1486
|
+
: undefined
|
|
1487
|
+
void drain('coord', ack, cid == null ? [] : [cid])
|
|
1488
|
+
}
|
|
1489
|
+
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1490
|
+
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1171
1491
|
} else if (k === 'error') {
|
|
1172
1492
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
1173
1493
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
@@ -1189,9 +1509,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1189
1509
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
1190
1510
|
const introMarker = join(OV_DIR, 'intro-' + slugify(identifier) + '.done')
|
|
1191
1511
|
if (!existsSync(introMarker)) {
|
|
1192
|
-
|
|
1512
|
+
if (agent !== 'codex') {
|
|
1513
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1514
|
+
}
|
|
1193
1515
|
log('first connection — introducing self to the workspace')
|
|
1194
|
-
introTimer = setTimeout(() =>
|
|
1516
|
+
introTimer = setTimeout(() => {
|
|
1517
|
+
if (agent !== 'codex') { void drain('intro'); return }
|
|
1518
|
+
void announceIntroduction().then((delivered) => {
|
|
1519
|
+
if (!delivered) return
|
|
1520
|
+
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1521
|
+
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
1522
|
+
}, 5000) // let the socket subscribe first
|
|
1195
1523
|
}
|
|
1196
1524
|
// Reconciliation replaces the old model-driven startup/daily sweep. It uses
|
|
1197
1525
|
// supported MCP tools directly, stays silent when empty, and hands verified
|