openvisio-agent 0.19.11 → 0.20.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/README.md +34 -4
- package/USER_GUIDE.md +174 -0
- package/bin/cli.mjs +9 -0
- package/package.json +7 -3
- package/scenarios/index.mjs +16 -0
- package/scenarios/routing.scenarios.mjs +348 -0
- package/scenarios/runtime.scenarios.mjs +451 -0
- package/scenarios/transport.scenarios.mjs +407 -0
- package/scenarios/workspace.scenarios.mjs +324 -0
- package/scripts/certify.mjs +12 -8
- package/scripts/run-scenarios.mjs +32 -0
- package/src/agent-journal.mjs +138 -0
- package/src/assignment-routing.mjs +19 -7
- package/src/authorization-resume.mjs +1 -1
- package/src/channel-routing.mjs +15 -8
- package/src/codex-mcp-proxy.mjs +63 -18
- package/src/concurrency.mjs +5 -3
- package/src/cycle-queue.mjs +16 -3
- package/src/events.mjs +91 -39
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +16 -3
- package/src/pr-push.mjs +21 -8
- package/src/runner-pool.mjs +14 -3
- package/src/studio-cli.mjs +60 -0
- package/src/studio-server.mjs +224 -0
- package/src/task-types.mjs +3 -2
- package/src/thread-context.mjs +62 -0
- package/src/watch.mjs +386 -114
- package/src/ws.mjs +20 -1
- package/studio/app.mjs +658 -0
- package/studio/guide.html +122 -0
- package/studio/index.html +61 -0
- package/studio/style.css +223 -0
package/src/watch.mjs
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// than written to disk from a pasted heredoc.
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
8
9
|
import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
10
|
import { homedir } from 'node:os'
|
|
10
11
|
import { join, dirname } from 'node:path'
|
|
11
12
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
13
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages,
|
|
14
|
+
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
14
15
|
import { createMastraMemory } from './memory.mjs'
|
|
15
16
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
16
17
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
@@ -20,10 +21,24 @@ import { mapConcurrent } from './concurrency.mjs'
|
|
|
20
21
|
import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
21
22
|
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
22
23
|
import { createTaskTypeLookup } from './task-types.mjs'
|
|
24
|
+
import { createThreadContextResolver } from './thread-context.mjs'
|
|
23
25
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
24
26
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
25
27
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
26
28
|
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
29
|
+
import { createAgentJournal } from './agent-journal.mjs'
|
|
30
|
+
|
|
31
|
+
function runtimeJournal(options, supplied) {
|
|
32
|
+
let journal
|
|
33
|
+
try { journal = supplied || createAgentJournal(options) } catch { /* studio is optional */ }
|
|
34
|
+
return {
|
|
35
|
+
runId: journal?.runId || `${process.pid}-${Date.now()}`,
|
|
36
|
+
emit(type, data) {
|
|
37
|
+
try { Promise.resolve(journal?.emit(type, data)).catch(() => {}) } catch { /* telemetry cannot block work */ }
|
|
38
|
+
},
|
|
39
|
+
async close() { try { await journal?.close() } catch { /* shutdown must finish without the viewer */ } },
|
|
40
|
+
}
|
|
41
|
+
}
|
|
27
42
|
|
|
28
43
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
29
44
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -318,13 +333,13 @@ export async function runWatch({ flags }) {
|
|
|
318
333
|
}
|
|
319
334
|
|
|
320
335
|
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
321
|
-
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt, canCoordinate = false }) {
|
|
336
|
+
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false }) {
|
|
322
337
|
const canCode = !!workdir
|
|
323
338
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
324
339
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
325
340
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
326
341
|
// cannot bleed across concurrently assigned tickets.
|
|
327
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
342
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
|
|
328
343
|
if (mastraRunner) return mastraRunner
|
|
329
344
|
let child = null
|
|
330
345
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
@@ -346,11 +361,20 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
346
361
|
let turnDidMcpTaskRead = false
|
|
347
362
|
let turnDidMcpTaskUpdate = false
|
|
348
363
|
let turnOutputText = ''
|
|
364
|
+
let turnCycleId = null
|
|
365
|
+
let turnRuntimePid = null
|
|
366
|
+
const childCycleIds = new WeakMap()
|
|
367
|
+
const safeText = (value, max = 2000) => Object.values(mcpHeaders || {}).filter(Boolean).reduce((text, secret) => text.split(String(secret)).join('[redacted]'), String(value || '')).slice(0, max)
|
|
368
|
+
const emit = (type, data = {}) => {
|
|
369
|
+
try { Promise.resolve(onEvent?.(type, { cycleId: turnCycleId, provider: agent, runtime: 'claude-stream', watcherPid: process.pid, runtimePid: turnRuntimePid, workdir: workdir || '', model: sessionModel, ...data })).catch(() => {}) } catch { /* telemetry is best effort */ }
|
|
370
|
+
}
|
|
349
371
|
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
350
372
|
const settleTurn = (o) => {
|
|
351
373
|
clearCycleTimer()
|
|
352
374
|
const r = resolveTurn
|
|
353
375
|
resolveTurn = null
|
|
376
|
+
const finalText = typeof o.result === 'string' ? o.result.trim() : turnOutputText.trim()
|
|
377
|
+
if (r && finalText) emit('output.final', { text: safeText(finalText, 8000), status: o.subtype || 'ok' })
|
|
354
378
|
const mcpCalls = [...turnToolCalls]
|
|
355
379
|
if (r) r({
|
|
356
380
|
...o,
|
|
@@ -392,6 +416,9 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
392
416
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
393
417
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
394
418
|
child = c
|
|
419
|
+
turnRuntimePid = c.pid || null
|
|
420
|
+
childCycleIds.set(c, turnCycleId)
|
|
421
|
+
emit('process.started', { status: 'starting' })
|
|
395
422
|
turnsThisSession = 0
|
|
396
423
|
sessionStartedAt = Date.now()
|
|
397
424
|
let localBuf = ''
|
|
@@ -403,6 +430,26 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
403
430
|
const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
|
|
404
431
|
if (!line.trim()) continue
|
|
405
432
|
let o; try { o = JSON.parse(line) } catch { continue }
|
|
433
|
+
if (o.type === 'system' && o.subtype === 'init') emit('process.ready', { status: 'running', sessionId: typeof o.session_id === 'string' ? o.session_id : null })
|
|
434
|
+
if (o.type === 'assistant' && Array.isArray(o.message?.content)) {
|
|
435
|
+
for (const block of o.message.content) {
|
|
436
|
+
if (block.type === 'tool_use') {
|
|
437
|
+
const title = block.name === 'Bash' && typeof block.input?.command === 'string' ? block.input.command : block.name
|
|
438
|
+
const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(block.name) ? block.input?.file_path || block.input?.path : ''
|
|
439
|
+
emit('tool.started', { toolCallId: safeText(block.id, 200), name: safeText(block.name, 300), title: safeText(title, 2000), status: 'in_progress', locations: typeof filePath === 'string' && filePath ? [{ path: safeText(filePath, 1000) }] : [] })
|
|
440
|
+
// TodoWrite is an explicit published plan, not model reasoning.
|
|
441
|
+
if (block.name === 'TodoWrite' && Array.isArray(block.input?.todos)) emit('plan.updated', { entries: block.input.todos.slice(0, 100).map((entry) => ({ content: safeText(entry.content), status: ['pending', 'in_progress', 'completed'].includes(entry.status) ? entry.status : 'unknown', priority: entry.priority })) })
|
|
442
|
+
} else if (block.type === 'text' && block.text) emit('output.progress', { messageId: typeof o.message.id === 'string' ? o.message.id : null, text: safeText(block.text, 8000) })
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (o.type === 'user' && Array.isArray(o.message?.content)) {
|
|
446
|
+
for (const block of o.message.content) if (block.type === 'tool_result') {
|
|
447
|
+
const use = turnToolUses.get(block.tool_use_id)
|
|
448
|
+
const title = use?.name === 'Bash' && typeof use.input?.command === 'string' ? use.input.command : use?.name || 'tool'
|
|
449
|
+
const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(use?.name) ? use.input?.file_path || use.input?.path : ''
|
|
450
|
+
emit('tool.finished', { toolCallId: safeText(block.tool_use_id, 200), name: safeText(use?.name || 'tool', 300), title: safeText(title), status: block.is_error ? 'failed' : 'completed', locations: typeof filePath === 'string' && filePath ? [{ path: safeText(filePath, 1000) }] : [] })
|
|
451
|
+
}
|
|
452
|
+
}
|
|
406
453
|
if (debug) logEvent(o)
|
|
407
454
|
const evidence = claudeEventEvidence(o, turnToolUses)
|
|
408
455
|
if (evidence.outputText) turnOutputText += (turnOutputText ? ' ' : '') + evidence.outputText
|
|
@@ -440,13 +487,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
440
487
|
})
|
|
441
488
|
c.on('exit', (code) => { if (c !== child) { log('old session exited ' + code); return } log('session exited ' + code); child = null; settleTurn({ type: 'result', subtype: 'exit' }) })
|
|
442
489
|
c.on('error', () => { if (c !== child) return; child = null; settleTurn({ type: 'result', subtype: 'error' }) })
|
|
490
|
+
c.on('close', (code, signal) => emit('process.stopped', { cycleId: childCycleIds.get(c) ?? null, runtimePid: c.pid || null, status: 'exited', exitCode: code, signal }))
|
|
443
491
|
log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
|
|
444
492
|
? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
|
|
445
493
|
: ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
|
|
446
494
|
}
|
|
447
495
|
|
|
448
|
-
function runCycle(prompt, cycleModel) {
|
|
496
|
+
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
449
497
|
return new Promise((resolve) => {
|
|
498
|
+
turnCycleId = cycleOptions.cycleId ?? null
|
|
450
499
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
451
500
|
// change means the warm session must be respawned with the new --model.
|
|
452
501
|
const resolvedCycleModel = resolveClaudeModel(cycleModel)
|
|
@@ -461,6 +510,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
461
510
|
child = null
|
|
462
511
|
}
|
|
463
512
|
ensureSession()
|
|
513
|
+
if (child) childCycleIds.set(child, turnCycleId)
|
|
464
514
|
turnsThisSession++
|
|
465
515
|
turnToolCalls = new Set()
|
|
466
516
|
turnToolUses = new Map()
|
|
@@ -502,8 +552,35 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
502
552
|
// One WebSocket identity feeds independent serialized work/reply queues. Each
|
|
503
553
|
// accepted source keeps its own context; direct MCP reconciliation recovers work
|
|
504
554
|
// without model spend while idle.
|
|
505
|
-
function loopBackendWs(
|
|
506
|
-
const
|
|
555
|
+
function loopBackendWs(options) {
|
|
556
|
+
const watcher = createBackendWatcher(options)
|
|
557
|
+
const stop = () => void watcher.close().then(() => process.exit(0))
|
|
558
|
+
process.once('SIGTERM', stop)
|
|
559
|
+
process.once('SIGINT', stop)
|
|
560
|
+
return watcher.done
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Production orchestration with replaceable I/O boundaries for deterministic
|
|
564
|
+
// workspace simulations. Tests run this same routing, queue and delivery code.
|
|
565
|
+
export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }, dependencies = {}) {
|
|
566
|
+
const validBackendId = (value) => value != null && /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
|
567
|
+
const stateDir = dependencies.stateDir || OV_DIR
|
|
568
|
+
const makeRunner = dependencies.createRunner || createCycleRunner
|
|
569
|
+
const autoStart = dependencies.autoStart !== false
|
|
570
|
+
let stopping = false
|
|
571
|
+
const pendingOperations = new Set()
|
|
572
|
+
const trackOperation = (promise) => {
|
|
573
|
+
const tracked = Promise.resolve(promise).finally(() => pendingOperations.delete(tracked))
|
|
574
|
+
pendingOperations.add(tracked)
|
|
575
|
+
return tracked
|
|
576
|
+
}
|
|
577
|
+
const log = dependencies.log || ((m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n'))
|
|
578
|
+
const journal = runtimeJournal({ stateDir, agent: { identifier, slug, provider: agent }, secrets: [apiKey] }, dependencies.journal)
|
|
579
|
+
const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, ...data })
|
|
580
|
+
let cycleSequence = 0
|
|
581
|
+
let mcpSequence = 0
|
|
582
|
+
const cycleContext = new AsyncLocalStorage()
|
|
583
|
+
emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
|
|
507
584
|
let handle = null
|
|
508
585
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
509
586
|
// not clear or overwrite each other's status targets.
|
|
@@ -518,20 +595,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
518
595
|
const authRule = agent !== 'claude'
|
|
519
596
|
? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
520
597
|
: `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. The credentials are given here; do NOT hunt for them.`
|
|
521
|
-
const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional:
|
|
598
|
+
const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: discover an advertised ticket-comment action and follow its input schema; never invent a tool name or call an unadvertised action. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
522
599
|
// Keep the static charter stable so provider prompt caching can reuse it.
|
|
523
600
|
// Actual cache hits and billing depend on the runtime and model provider.
|
|
524
601
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
525
602
|
const runnerOptions = {
|
|
526
603
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
527
|
-
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
604
|
+
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
|
|
528
605
|
}
|
|
529
606
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
530
|
-
// runtimes. Replies
|
|
531
|
-
//
|
|
607
|
+
// runtimes. Replies serialize within a thread while independent threads run
|
|
608
|
+
// concurrently behind the same delivery guards.
|
|
532
609
|
const MAX_CONCURRENT_WORKERS = 3
|
|
533
610
|
const activeWorkRunners = new Set()
|
|
534
|
-
const createWorkRunner = (control, workerWorkdir = '') =>
|
|
611
|
+
const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
|
|
535
612
|
...runnerOptions,
|
|
536
613
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
537
614
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
@@ -543,7 +620,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
543
620
|
const activeReplyRunners = new Set()
|
|
544
621
|
const replyRunners = createRunnerPool({
|
|
545
622
|
capacity: 8,
|
|
546
|
-
create: ({ key, canCoordinate }) =>
|
|
623
|
+
create: ({ key, canCoordinate }) => makeRunner({
|
|
547
624
|
...runnerOptions, workdir: '', canCoordinate,
|
|
548
625
|
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
549
626
|
cfgKey: identifier + '-reply-' + slugify(key),
|
|
@@ -552,7 +629,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
552
629
|
const codexPushGuide = agent === 'codex' && canCode
|
|
553
630
|
? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper 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. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
|
|
554
631
|
: ''
|
|
555
|
-
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable.
|
|
632
|
+
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. For ticket comments, discover an advertised action and follow its input schema; never invent a tool name or call an unadvertised action.'
|
|
556
633
|
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
|
|
557
634
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
558
635
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
@@ -572,7 +649,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
572
649
|
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
573
650
|
item.control.runner = runner
|
|
574
651
|
activeWorkRunners.add(runner)
|
|
575
|
-
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
652
|
+
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir)) }
|
|
576
653
|
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
577
654
|
},
|
|
578
655
|
onError: (error, item) => {
|
|
@@ -589,7 +666,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
589
666
|
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
590
667
|
activeReplyRunners.add(runner)
|
|
591
668
|
item.control.runner = runner
|
|
592
|
-
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
669
|
+
try { return await cycleContext.run({ cycleId: item.control.cycleId }, () => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '')) }
|
|
593
670
|
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
594
671
|
},
|
|
595
672
|
onError: (error, item) => {
|
|
@@ -606,16 +683,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
606
683
|
// Mentions we've already reacted to (by message id) — the backend can re-deliver
|
|
607
684
|
// an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
|
|
608
685
|
// agent reply to the SAME message twice.
|
|
609
|
-
const replayPath = join(
|
|
686
|
+
const replayPath = join(stateDir, 'watch-' + slug + '-replay.json')
|
|
610
687
|
let replayState = {}
|
|
611
688
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
612
689
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
613
690
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
614
691
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
615
692
|
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
693
|
+
const pendingReplyDeliveries = new Map(Array.isArray(replayState.pendingReplyDeliveries) ? replayState.pendingReplyDeliveries : [])
|
|
694
|
+
const memory = dependencies.memory || createMastraMemory({
|
|
695
|
+
ledgerPath: join(stateDir, 'watch-' + slug + '-memory.json'),
|
|
696
|
+
databasePath: join(stateDir, 'watch-' + slug + '-mastra.db'),
|
|
619
697
|
resourceId: identifier,
|
|
620
698
|
})
|
|
621
699
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
@@ -628,9 +706,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
628
706
|
// This is distinct from a policy block: any later human ticket change resumes
|
|
629
707
|
// the work, but reconnects and the watcher's own blocker update do not.
|
|
630
708
|
const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
631
|
-
// A policy-blocked task stays paused across reconnects.
|
|
632
|
-
//
|
|
633
|
-
// unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
709
|
+
// A policy-blocked task stays paused across reconnects. Helper blocks are
|
|
710
|
+
// released by verified local configuration, or cleared when completed/unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
634
711
|
// attempting the same rejected egress action.
|
|
635
712
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
636
713
|
const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
|
|
@@ -650,6 +727,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
650
727
|
recentMentionSignatures: [...recentMentionSignatures],
|
|
651
728
|
seenActivities: [...seenActivities],
|
|
652
729
|
deliveredReplies: [...deliveredReplies],
|
|
730
|
+
pendingReplyDeliveries: [...pendingReplyDeliveries],
|
|
653
731
|
failedTaskVersions: [...failedTaskVersions],
|
|
654
732
|
blockedTasks: [...blockedTasks],
|
|
655
733
|
blockedTaskRepos: [...blockedTaskRepos],
|
|
@@ -712,7 +790,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
712
790
|
const markMentionHandled = (message, channelId) => {
|
|
713
791
|
pruneMentionSignatures()
|
|
714
792
|
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
715
|
-
const duplicate =
|
|
793
|
+
const duplicate = idKey ? seenMentions.has(idKey) : !!signatureKey && recentMentionSignatures.has(signatureKey)
|
|
716
794
|
if (duplicate) return true
|
|
717
795
|
if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
|
|
718
796
|
if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
|
|
@@ -724,19 +802,46 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
724
802
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
725
803
|
// source independently through work, recovery, and delivery.
|
|
726
804
|
let backlogProbeBusy = false
|
|
727
|
-
let lastInboxSignature = ''
|
|
728
805
|
let selfAgentId = null
|
|
806
|
+
let selfOrganizationId = null
|
|
729
807
|
let completionRecipientName = ''
|
|
730
808
|
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
731
809
|
const rememberSelfAgent = (self) => {
|
|
732
|
-
if (!self || typeof self !== 'object') return
|
|
810
|
+
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
733
811
|
if (self.id != null) selfAgentId = Number(self.id)
|
|
734
812
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
735
813
|
const addedBy = agentAddedByName(self)
|
|
736
814
|
if (addedBy) completionRecipientName = addedBy
|
|
815
|
+
const organizationId = self.organization_id ?? self.organizationId
|
|
816
|
+
if (validBackendId(organizationId)) {
|
|
817
|
+
selfOrganizationId = Number(organizationId)
|
|
818
|
+
handle?.subscribeOrganization?.(selfOrganizationId)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const mcpClient = dependencies.mcpClient || createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
822
|
+
const callMcpTool = (name, args = {}) => {
|
|
823
|
+
if (stopping) return Promise.reject(new Error('Watcher is stopping'))
|
|
824
|
+
const toolCallId = `${journal.runId}:mcp:${++mcpSequence}`
|
|
825
|
+
const metadata = { cycleId: cycleContext.getStore()?.cycleId || null, toolCallId, name, title: name, source: 'watcher', provider: agent, runtime: 'watcher', locations: [] }
|
|
826
|
+
emit('tool.started', { ...metadata, status: 'in_progress' })
|
|
827
|
+
return trackOperation(Promise.resolve().then(() => mcpClient.callTool(name, args)).then((result) => {
|
|
828
|
+
emit('tool.finished', { ...metadata, status: 'completed' })
|
|
829
|
+
return result
|
|
830
|
+
}, (error) => {
|
|
831
|
+
emit('tool.finished', { ...metadata, status: 'failed' })
|
|
832
|
+
throw error
|
|
833
|
+
}))
|
|
834
|
+
}
|
|
835
|
+
let selfRead = null
|
|
836
|
+
const ensureSelfAgent = async () => {
|
|
837
|
+
if (selfAgentId != null) return
|
|
838
|
+
if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
|
|
839
|
+
const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row.identifier || row.slug || '') === identifier)
|
|
840
|
+
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
841
|
+
rememberSelfAgent(self)
|
|
842
|
+
}).finally(() => { selfRead = null })
|
|
843
|
+
await selfRead
|
|
737
844
|
}
|
|
738
|
-
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
739
|
-
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
740
845
|
let mcpToolNames = null
|
|
741
846
|
let mcpToolDiscoveryPromise = null
|
|
742
847
|
let mcpToolDiscoveryWarned = false
|
|
@@ -755,24 +860,25 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
755
860
|
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
756
861
|
return mcpToolDiscoveryPromise
|
|
757
862
|
}
|
|
758
|
-
const
|
|
759
|
-
|
|
863
|
+
const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
|
|
864
|
+
let names
|
|
865
|
+
try { names = await discoverMcpTools() }
|
|
760
866
|
catch (e) {
|
|
761
867
|
if (!mcpToolDiscoveryWarned) {
|
|
762
868
|
mcpToolDiscoveryWarned = true
|
|
763
869
|
log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
|
|
764
870
|
}
|
|
765
|
-
return
|
|
871
|
+
return { called: false, reason: 'discovery-unavailable' }
|
|
766
872
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
if (
|
|
771
|
-
|
|
873
|
+
// Select once from advertised capabilities. A failed write may already have
|
|
874
|
+
// reached the server, so never try another comment endpoint after an error.
|
|
875
|
+
const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
|
|
876
|
+
if (!name) return { called: false, reason: 'not-advertised' }
|
|
877
|
+
const args = { project_id: projectId, ticket_id: ticketId, ...(name === 'create_task_comment' ? { content } : { text: content }) }
|
|
878
|
+
try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
|
|
772
879
|
catch (e) {
|
|
773
880
|
if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
|
|
774
881
|
try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
|
|
775
|
-
return { called: false, reason: 'not-available' }
|
|
776
882
|
}
|
|
777
883
|
throw e
|
|
778
884
|
}
|
|
@@ -811,9 +917,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
811
917
|
const tasksData = toolData(tasksResult)
|
|
812
918
|
const { done: doneIds, review: reviewIds } = typesResult
|
|
813
919
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
814
|
-
const
|
|
815
|
-
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
816
|
-
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
920
|
+
const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
|
|
817
921
|
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
818
922
|
}).map((task) => ({
|
|
819
923
|
id: task.id,
|
|
@@ -858,9 +962,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
858
962
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
859
963
|
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
860
964
|
const messageDeliveries = new Map()
|
|
861
|
-
const threadControlKey = (channelId, parentId) =>
|
|
965
|
+
const threadControlKey = (channelId, parentId) => validBackendId(channelId) && validBackendId(parentId) ? `thread:${Number(channelId)}:${parentId}` : ''
|
|
862
966
|
const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
|
|
863
967
|
const cancelThread = (channelId, parentId, summary) => {
|
|
968
|
+
// A subsequent explicit mention may reactivate the thread. Remove replies
|
|
969
|
+
// belonging to the canceled request now so reactivation cannot revive them.
|
|
970
|
+
for (const [key, delivery] of pendingReplyDeliveries) {
|
|
971
|
+
if (!sameDeliveryThread(delivery, channelId, parentId)) continue
|
|
972
|
+
pendingReplyDeliveries.delete(key)
|
|
973
|
+
deliveredReplies.add(key)
|
|
974
|
+
memory.remember({ key, kind: 'delivery', state: 'suppressed', summary: 'The source request was cancelled.', refs: { channelId, threadId: parentId } })
|
|
975
|
+
}
|
|
976
|
+
trimSeen(deliveredReplies)
|
|
977
|
+
persistReplay()
|
|
864
978
|
const controlKey = threadControlKey(channelId, parentId)
|
|
865
979
|
if (!controlKey) return
|
|
866
980
|
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
@@ -876,7 +990,27 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
876
990
|
const controlKey = threadControlKey(channelId, parentId)
|
|
877
991
|
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
878
992
|
}
|
|
993
|
+
const saveOwnThread = (channelId, rootId, content) => {
|
|
994
|
+
memory.remember({ key: `own-root:${channelId}:${rootId}`, kind: 'thread', state: 'authored', summary: content, refs: { channelId: Number(channelId), threadId: rootId }, meta: { rootContent: content } })
|
|
995
|
+
if (!memory.has(threadControlKey(channelId, rootId), 'cancelled')) activateThread(channelId, rootId, content)
|
|
996
|
+
}
|
|
997
|
+
const resolveOwnThread = createThreadContextResolver({
|
|
998
|
+
read: (cid, root) => {
|
|
999
|
+
const cached = memory.get(`own-root:${cid}:${root}`)?.meta?.rootContent
|
|
1000
|
+
if (cached) return cached
|
|
1001
|
+
// Previous releases recorded the posted message id but not ownership.
|
|
1002
|
+
// This also recovers roots when the thread API returns only replies.
|
|
1003
|
+
const previous = memory.recall({ channelId: cid }, 1000).find((node) => node.kind === 'delivery' && node.state === 'rendered' && node.refs?.threadId == null && String(node.meta?.messageId) === String(root))
|
|
1004
|
+
if (previous?.summary) saveOwnThread(cid, root, previous.summary)
|
|
1005
|
+
return previous?.summary || ''
|
|
1006
|
+
},
|
|
1007
|
+
save: saveOwnThread,
|
|
1008
|
+
cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
|
|
1009
|
+
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
1010
|
+
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
1011
|
+
})
|
|
879
1012
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1013
|
+
if (stopping) throw new Error('Watcher is stopping')
|
|
880
1014
|
const deliveryKey = String(key || '')
|
|
881
1015
|
if (parentId == null) {
|
|
882
1016
|
channelId = await projectStatusChannel(projectId)
|
|
@@ -884,12 +1018,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
884
1018
|
projectId = undefined // dedicated channels may be organization-scoped
|
|
885
1019
|
}
|
|
886
1020
|
const message = String(content || '').trim()
|
|
887
|
-
if (!deliveryKey || !
|
|
888
|
-
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered'))
|
|
1021
|
+
if (!deliveryKey || !validBackendId(channelId) || (parentId != null && !validBackendId(parentId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1022
|
+
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
|
|
1023
|
+
if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
|
|
1024
|
+
return { posted: false, reason: 'remembered' }
|
|
1025
|
+
}
|
|
889
1026
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
890
1027
|
const controlKey = threadControlKey(channelId, parentId)
|
|
891
1028
|
const suppressCancelled = () => {
|
|
1029
|
+
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
892
1030
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1031
|
+
pendingReplyDeliveries.delete(deliveryKey)
|
|
893
1032
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
894
1033
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'suppressed', summary: 'A newer redirect or stand-down cancelled this reply.', refs: { channelId: Number(channelId), threadId: parentId } })
|
|
895
1034
|
return { posted: false, reason: 'thread-cancelled' }
|
|
@@ -897,6 +1036,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
897
1036
|
const initiallyCancelled = suppressCancelled()
|
|
898
1037
|
if (initiallyCancelled) return initiallyCancelled
|
|
899
1038
|
|
|
1039
|
+
// Store generated threaded replies before delivery. A transport outage must
|
|
1040
|
+
// retry the authoritative delivery check, not pay for another model turn.
|
|
1041
|
+
if (parentId != null) {
|
|
1042
|
+
pendingReplyDeliveries.set(deliveryKey, { key, channelId, parentId, projectId, content: message, skipIfAnyAgentReply, sourceKey, sourceMessageId, allowCancelled })
|
|
1043
|
+
persistReplay()
|
|
1044
|
+
}
|
|
900
1045
|
const run = (async () => {
|
|
901
1046
|
if (parentId != null) {
|
|
902
1047
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
@@ -915,6 +1060,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
915
1060
|
// Re-check immediately before the irreversible post.
|
|
916
1061
|
const newlyCancelled = suppressCancelled()
|
|
917
1062
|
if (newlyCancelled) return newlyCancelled
|
|
1063
|
+
if (stopping) throw new Error('Watcher is stopping')
|
|
918
1064
|
|
|
919
1065
|
sendStatus(Number(channelId), 'typing')
|
|
920
1066
|
const result = toolData(await callMcpTool('post_message', {
|
|
@@ -923,15 +1069,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
923
1069
|
...(parentId != null ? { parent_id: Number(parentId) } : {}),
|
|
924
1070
|
content: message,
|
|
925
1071
|
}))
|
|
1072
|
+
const postedId = result.id ?? result.message?.id ?? result.message_id
|
|
1073
|
+
if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
|
|
926
1074
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
927
1075
|
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 } })
|
|
928
1076
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
929
1077
|
return { posted: true, result }
|
|
930
|
-
})().
|
|
1078
|
+
})().then((result) => {
|
|
1079
|
+
if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
|
|
1080
|
+
return result
|
|
1081
|
+
}).finally(() => messageDeliveries.delete(deliveryKey))
|
|
931
1082
|
messageDeliveries.set(deliveryKey, run)
|
|
932
1083
|
return run
|
|
933
1084
|
}
|
|
934
1085
|
|
|
1086
|
+
let deliveryProbeBusy = false
|
|
1087
|
+
const flushPendingReplies = async () => {
|
|
1088
|
+
if (stopping || deliveryProbeBusy) return
|
|
1089
|
+
deliveryProbeBusy = true
|
|
1090
|
+
try {
|
|
1091
|
+
await mapConcurrent([...pendingReplyDeliveries.values()], 3, async (delivery) => {
|
|
1092
|
+
try { await postMessageOnce(delivery) }
|
|
1093
|
+
catch (error) { log('reply remains pending: ' + (error?.message || error)) }
|
|
1094
|
+
})
|
|
1095
|
+
} finally { deliveryProbeBusy = false }
|
|
1096
|
+
}
|
|
1097
|
+
|
|
935
1098
|
const statusChannelCache = new Map()
|
|
936
1099
|
let dedicatedChannelRead = null
|
|
937
1100
|
const projectStatusChannel = async (projectId) => {
|
|
@@ -982,7 +1145,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
982
1145
|
const taskKey = `${projectId}:${ticketId}`
|
|
983
1146
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
984
1147
|
const ticket = current.ticket ?? current.task ?? current
|
|
985
|
-
const
|
|
1148
|
+
const completionTypes = await taskTypeSets(projectId)
|
|
1149
|
+
const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName, completedTypeIds: completionTypes.done, reviewTypeIds: completionTypes.review })
|
|
986
1150
|
if (!report) return false
|
|
987
1151
|
const memoryKey = `ticket:${projectId}:${ticketId}`
|
|
988
1152
|
memory.remember({
|
|
@@ -998,9 +1162,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
998
1162
|
// handoff or trap the ticket in reconnect retries.
|
|
999
1163
|
if (!reportedTaskComments.has(report.key)) {
|
|
1000
1164
|
try {
|
|
1001
|
-
const comment = await
|
|
1165
|
+
const comment = await callOptionalTicketComment({ projectId, ticketId, content: report.content })
|
|
1002
1166
|
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
1003
|
-
else log('
|
|
1167
|
+
else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
|
|
1004
1168
|
} catch (e) {
|
|
1005
1169
|
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
|
|
1006
1170
|
}
|
|
@@ -1066,10 +1230,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1066
1230
|
refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
|
|
1067
1231
|
})
|
|
1068
1232
|
try {
|
|
1069
|
-
const comment = await
|
|
1233
|
+
const comment = await callOptionalTicketComment({ projectId, ticketId, content: ticketNotice })
|
|
1070
1234
|
if (comment.called) { delivered = true; return }
|
|
1071
|
-
log('
|
|
1072
|
-
} catch (e) {
|
|
1235
|
+
log('no ticket comment tool is exposed; recording the blocker in the ticket description for #' + ticketId)
|
|
1236
|
+
} catch (e) {
|
|
1237
|
+
log('ticket comment outcome is uncertain for #' + ticketId + '; no alternate ticket write will be attempted: ' + (e?.message || e))
|
|
1238
|
+
throw e
|
|
1239
|
+
}
|
|
1073
1240
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1074
1241
|
const ticket = current.ticket ?? current.task ?? current
|
|
1075
1242
|
const description = String(ticket.description || '')
|
|
@@ -1099,17 +1266,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1099
1266
|
}
|
|
1100
1267
|
const command = block?.command || 'the requested external repository action'
|
|
1101
1268
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
1105
|
-
const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
|
|
1106
|
-
const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the dedicated agent channel; I won't retry automatically.`
|
|
1269
|
+
const notice = `The local runtime rejected \`${command}\`${payload ? ` (${payload})` : ''}. Routine agent PR publishing uses the configured \`openvisio-agent push-pr-branch\` helper. This rejection needs a local runtime policy change; a channel reply cannot change that policy. I've paused the ticket and have not published the rejected action.`
|
|
1270
|
+
const ticketNotice = notice
|
|
1107
1271
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1108
1272
|
}
|
|
1109
1273
|
|
|
1110
1274
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
1111
1275
|
// model tokens unless the tools actually report pending work.
|
|
1112
1276
|
const reconcileBacklog = async () => {
|
|
1277
|
+
if (stopping) return
|
|
1113
1278
|
if (!mcpUrl || backlogProbeBusy) return
|
|
1114
1279
|
backlogProbeBusy = true
|
|
1115
1280
|
try {
|
|
@@ -1131,9 +1296,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1131
1296
|
])
|
|
1132
1297
|
const { done: doneIds, review: reviewIds } = typesData
|
|
1133
1298
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
1134
|
-
|
|
1135
|
-
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
1136
|
-
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
1299
|
+
if (!taskBelongsToAgent(task, { id: selfAgentId, identifier })) continue
|
|
1137
1300
|
const taskKey = `${project.id}:${task.id}`
|
|
1138
1301
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1139
1302
|
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
@@ -1185,7 +1348,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1185
1348
|
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1186
1349
|
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1187
1350
|
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1188
|
-
const activityParentId = activityMessage
|
|
1351
|
+
const activityParentId = messageParentId(activityMessage)
|
|
1189
1352
|
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1190
1353
|
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1191
1354
|
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
@@ -1193,7 +1356,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1193
1356
|
// old tagged thread root can otherwise make another agent's new reply
|
|
1194
1357
|
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1195
1358
|
// in a persistently owned thread both enter the guarded per-message path.
|
|
1196
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
|
|
1359
|
+
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1197
1360
|
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1198
1361
|
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1199
1362
|
// The same logical mention may already have arrived over WebSocket.
|
|
@@ -1203,36 +1366,36 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1203
1366
|
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1204
1367
|
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1205
1368
|
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1206
|
-
if (
|
|
1369
|
+
if (memory.has(activitySourceKey, 'received')) continue
|
|
1207
1370
|
}
|
|
1208
1371
|
mentionActivity.push({
|
|
1209
1372
|
projectId: project.id,
|
|
1210
1373
|
project: project.name,
|
|
1211
1374
|
channelId: activityChannelId,
|
|
1212
1375
|
message: activityMessage,
|
|
1213
|
-
activity: item,
|
|
1376
|
+
activity: item, activityKey,
|
|
1214
1377
|
})
|
|
1215
1378
|
}
|
|
1216
1379
|
}
|
|
1217
1380
|
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1218
1381
|
})
|
|
1219
1382
|
if (activityReplayTouched) persistReplay()
|
|
1220
|
-
|
|
1221
|
-
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1222
|
-
lastInboxSignature = inboxSignature
|
|
1383
|
+
if (mentionActivity.length) {
|
|
1223
1384
|
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1224
1385
|
// Replay each real message through the exact same delivery path as a live
|
|
1225
1386
|
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1226
1387
|
// consult the rendered thread before any reply is emitted.
|
|
1227
1388
|
for (const mention of mentionActivity) {
|
|
1228
|
-
onEvent('agent:mention', {
|
|
1389
|
+
void onEvent('agent:mention', {
|
|
1229
1390
|
project_id: mention.projectId,
|
|
1230
1391
|
channel_id: mention.channelId,
|
|
1231
1392
|
message: mention.message,
|
|
1232
1393
|
_mentionAlreadyMarked: true,
|
|
1394
|
+
})?.then((result) => {
|
|
1395
|
+
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1233
1396
|
})
|
|
1234
1397
|
}
|
|
1235
|
-
}
|
|
1398
|
+
}
|
|
1236
1399
|
} catch (e) {
|
|
1237
1400
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1238
1401
|
} finally { backlogProbeBusy = false }
|
|
@@ -1257,22 +1420,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1257
1420
|
}
|
|
1258
1421
|
|
|
1259
1422
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1423
|
+
if (stopping) return Promise.resolve({ status: 'canceled' })
|
|
1260
1424
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1261
1425
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1262
1426
|
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1263
|
-
|
|
1264
|
-
|
|
1427
|
+
if (key != null && queues[laneName].has(key)) return queues[laneName].enqueue(null, key)
|
|
1428
|
+
const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
|
|
1429
|
+
const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1430
|
+
const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
|
|
1431
|
+
emit('cycle.queued', { ...cycleData, status: 'queued' })
|
|
1432
|
+
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
|
|
1433
|
+
const status = control.cancelled || result?.status === 'canceled' ? 'canceled' : result?.status === 'failed' ? 'error' : control.outcome
|
|
1434
|
+
emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
|
|
1435
|
+
return result
|
|
1436
|
+
})
|
|
1265
1437
|
}
|
|
1266
1438
|
|
|
1267
1439
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1268
1440
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1269
1441
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1270
1442
|
const cycleStartedAt = performance.now()
|
|
1443
|
+
cycleControl.startedAt = cycleStartedAt
|
|
1444
|
+
cycleControl.outcome = 'skipped'
|
|
1445
|
+
emit('cycle.started', { cycleId: cycleControl.cycleId, kind, lane: laneName, status: 'preflight', model: kind === 'full' ? codeModel : liteModel, workdir: preparedWorkdir || (kind === 'full' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : {}), ...(delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}), queueMs: Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt)) })
|
|
1271
1446
|
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1272
1447
|
const ctx = context ? [context] : []
|
|
1273
1448
|
const activeTaskRef = taskRef
|
|
1274
1449
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1275
1450
|
const runnerOptions = {
|
|
1451
|
+
cycleId: cycleControl.cycleId, attempt: 1,
|
|
1276
1452
|
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1277
1453
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1278
1454
|
}
|
|
@@ -1302,10 +1478,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1302
1478
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1303
1479
|
const ticket = data.ticket ?? data.task ?? data
|
|
1304
1480
|
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1305
|
-
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1306
|
-
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1307
1481
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1308
|
-
!(
|
|
1482
|
+
!taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
|
|
1309
1483
|
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1310
1484
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1311
1485
|
log('queued ticket no longer actionable; skipped before model start')
|
|
@@ -1313,8 +1487,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1313
1487
|
}
|
|
1314
1488
|
}
|
|
1315
1489
|
if (cycleControl.cancelled) return
|
|
1490
|
+
cycleControl.outcome = 'running'
|
|
1316
1491
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1317
1492
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1493
|
+
cycleControl.outcome = result?.subtype || 'ok'
|
|
1318
1494
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1319
1495
|
if (result?.model && result.model !== useModel) {
|
|
1320
1496
|
// Persist only a successfully selected model and only if the user has
|
|
@@ -1347,6 +1523,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1347
1523
|
}
|
|
1348
1524
|
const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
|
|
1349
1525
|
if (replyMutationErrors.length) {
|
|
1526
|
+
cycleControl.outcome = 'blocked'
|
|
1350
1527
|
const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
|
|
1351
1528
|
log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
|
|
1352
1529
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
|
|
@@ -1362,13 +1539,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1362
1539
|
const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
|
|
1363
1540
|
const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
|
|
1364
1541
|
if (kind === 'full' && cycleSucceeded(result) && missing.length) {
|
|
1542
|
+
cycleControl.outcome = 'recovering'
|
|
1543
|
+
emit('cycle.recovery', { cycleId: cycleControl.cycleId, kind, status: 'recovering', attempt: 2, missing })
|
|
1365
1544
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1366
1545
|
const recoveryPrompt = `CONTINUE THE SAME OPENVISIO REQUEST. Your earlier output did not complete it. Missing runtime evidence: ${missing.join('; ')}. An intent or progress message is not completion. Continue the actual work now, verify it, and then provide one distinct final result or real blocker using the original delivery rule; a final result after an earlier progress message is explicitly allowed and required. Do not repeat the progress message. ${codexPushGuide}\n\nORIGINAL REQUEST AND ROUTING CONTEXT:\n${prompt}`
|
|
1367
|
-
const recovery = await runner.runCycle(recoveryPrompt, codeModel, runnerOptions)
|
|
1546
|
+
const recovery = await runner.runCycle(recoveryPrompt, codeModel, { ...runnerOptions, attempt: 2 })
|
|
1547
|
+
cycleControl.outcome = recovery?.subtype || 'ok'
|
|
1368
1548
|
if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
|
|
1369
1549
|
const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
|
|
1370
1550
|
const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
|
|
1371
1551
|
if (!cycleSucceeded(recovery) || recoveryMissing.length) {
|
|
1552
|
+
cycleControl.outcome = 'blocked'
|
|
1372
1553
|
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
1373
1554
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
|
|
1374
1555
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
@@ -1454,7 +1635,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1454
1635
|
const hinted = taskFromEvent(raw)
|
|
1455
1636
|
const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
|
|
1456
1637
|
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
1457
|
-
if (ticketId
|
|
1638
|
+
if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
1458
1639
|
try {
|
|
1459
1640
|
if (selfAgentId == null) {
|
|
1460
1641
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
@@ -1464,10 +1645,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1464
1645
|
}
|
|
1465
1646
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1466
1647
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1648
|
+
if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) { log('get_ticket returned mismatched identity; ignored'); return }
|
|
1467
1649
|
const typeSets = await taskTypeSets(projectId)
|
|
1468
|
-
const
|
|
1469
|
-
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1470
|
-
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1650
|
+
const belongsToSelf = taskBelongsToAgent(ticket, { id: selfAgentId, identifier })
|
|
1471
1651
|
const key = `${projectId}:${ticketId}`
|
|
1472
1652
|
if (!belongsToSelf) {
|
|
1473
1653
|
for (const [name, queue] of Object.entries(queues)) {
|
|
@@ -1519,10 +1699,47 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1519
1699
|
}
|
|
1520
1700
|
}
|
|
1521
1701
|
|
|
1702
|
+
const mentionIntake = new Map()
|
|
1522
1703
|
function onEvent(k, d) {
|
|
1704
|
+
if (stopping) return
|
|
1705
|
+
if (k !== 'agent:mention' && k !== 'channel:message') return trackOperation(routeEvent(k, d))
|
|
1706
|
+
const cid = d?.channel_id ?? d?.channelId
|
|
1707
|
+
const message = d?.message || {}
|
|
1708
|
+
const parent = messageParentId(message)
|
|
1709
|
+
const messageId = message.message_id ?? message.messageId ?? message.id
|
|
1710
|
+
const malformedParent = parent == null && [message.parent_id, message.parentId, message.thread_id, message.threadId, message.reply_to, message.replyTo].some((value) => value != null)
|
|
1711
|
+
if (malformedParent || !validBackendId(cid) || !validBackendId(messageId) || (parent != null && !validBackendId(parent))) { log('mention missing valid channel/message identity; ignored'); return }
|
|
1712
|
+
const key = `${cid}:${parent ?? message.message_id ?? message.messageId ?? message.id}`
|
|
1713
|
+
// Keep redirects ordered while a legacy root is being verified. Other
|
|
1714
|
+
// conversations can resolve and run independently.
|
|
1715
|
+
const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
|
|
1716
|
+
try { await ensureSelfAgent() }
|
|
1717
|
+
catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
|
|
1718
|
+
if (k === 'channel:message' && selfOrganizationId == null) return { status: 'ignored-unknown-organization' }
|
|
1719
|
+
// Organization fanout includes our own posts. Ignore stable self identity
|
|
1720
|
+
// before recipient checks, replay guards or thread cancellation run.
|
|
1721
|
+
if (messageSenderIsSelf(message, { id: selfAgentId, identifier })) return { status: 'ignored-self' }
|
|
1722
|
+
const preliminary = classifyConversationTarget(message, [...selfAliases])
|
|
1723
|
+
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
1724
|
+
let rootContent = ''
|
|
1725
|
+
if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
|
|
1726
|
+
try { rootContent = await resolveOwnThread(cid, parent) }
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
|
|
1729
|
+
log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
if (!stopping) return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
|
|
1733
|
+
}).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
|
|
1734
|
+
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
1735
|
+
mentionIntake.set(key, next)
|
|
1736
|
+
return next
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function routeEvent(k, d) {
|
|
1523
1740
|
const raw = d && typeof d === 'object' ? d : {}
|
|
1524
1741
|
if (k === 'task:assigned' || k === 'task:updated') {
|
|
1525
|
-
|
|
1742
|
+
return handleTaskSignal(k, raw)
|
|
1526
1743
|
} else if (k === 'agent:mention') {
|
|
1527
1744
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
1528
1745
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -1530,8 +1747,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1530
1747
|
const who = senderName(msg)
|
|
1531
1748
|
// Reply IN THE SAME THREAD: parent is the thread root (the message's own id
|
|
1532
1749
|
// for a top-level mention, or its parent when the mention is itself a reply).
|
|
1533
|
-
const mid = msg.
|
|
1534
|
-
const parent =
|
|
1750
|
+
const mid = msg.message_id ?? msg.messageId ?? msg.id ?? null
|
|
1751
|
+
const parent = messageParentId(msg)
|
|
1535
1752
|
const threadRoot = parent != null ? parent : mid
|
|
1536
1753
|
const controlKey = threadControlKey(cid, threadRoot)
|
|
1537
1754
|
const threadOwned = !!controlKey && memory.has(controlKey, 'active')
|
|
@@ -1629,13 +1846,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1629
1846
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1630
1847
|
// subsequent working/typing heartbeat for its lane.
|
|
1631
1848
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1632
|
-
const codingMention = canCode && conversationNeedsCode(text)
|
|
1849
|
+
const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
1633
1850
|
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1634
|
-
|
|
1851
|
+
let ctx = cid != null
|
|
1635
1852
|
? replyDelivery?.watcherOwned
|
|
1636
1853
|
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
|
|
1637
1854
|
: `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : '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. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
|
|
1638
1855
|
: undefined
|
|
1856
|
+
if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
|
|
1639
1857
|
if (codingMention) {
|
|
1640
1858
|
// Activity indicators make accepted work visible. Do not add a canned
|
|
1641
1859
|
// pickup message; the first channel message is the verified result or a
|
|
@@ -1651,16 +1869,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1651
1869
|
}
|
|
1652
1870
|
|
|
1653
1871
|
// The WebSocket drives real-time reactions (task:created/updated assigned to us,
|
|
1654
|
-
// agent:mention).
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
// picked up even though we don't hot-poll.
|
|
1872
|
+
// agent:mention and channel:message). Verified organization subscriptions
|
|
1873
|
+
// recover channel mentions even when the backend omits targeted dispatch.
|
|
1874
|
+
// Both event types share recipient, ownership and deduplication guards.
|
|
1658
1875
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1659
|
-
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
1876
|
+
handle = (dependencies.connect || connectAgentWs)({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250) }, log })
|
|
1660
1877
|
|
|
1661
1878
|
let authorizationProbeBusy = false
|
|
1662
1879
|
const legacyAuthorizationChecks = new Set()
|
|
1663
1880
|
const resumeReadyAuthorizations = async () => {
|
|
1881
|
+
if (stopping) return
|
|
1664
1882
|
if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
|
|
1665
1883
|
authorizationProbeBusy = true
|
|
1666
1884
|
try {
|
|
@@ -1689,18 +1907,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1689
1907
|
} catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
|
|
1690
1908
|
finally { authorizationProbeBusy = false }
|
|
1691
1909
|
}
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1910
|
+
const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
|
|
1911
|
+
deliveryTimer?.unref?.()
|
|
1912
|
+
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
1913
|
+
authorizationTimer?.unref?.()
|
|
1914
|
+
const heartbeatTimer = autoStart ? setInterval(() => emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, workdir: workdir || '' }), 15_000) : null
|
|
1915
|
+
heartbeatTimer?.unref?.()
|
|
1694
1916
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1695
|
-
if (mcpConfig || mcpUrl) {
|
|
1917
|
+
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
1696
1918
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
1697
|
-
const introMarker = join(
|
|
1919
|
+
const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
|
|
1698
1920
|
if (!existsSync(introMarker)) {
|
|
1699
1921
|
log('first connection — introducing self to the workspace')
|
|
1700
1922
|
introTimer = setTimeout(() => {
|
|
1701
1923
|
void announceIntroduction().then((delivered) => {
|
|
1702
1924
|
if (!delivered) return
|
|
1703
|
-
try { mkdirSync(
|
|
1925
|
+
try { mkdirSync(stateDir, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1704
1926
|
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
1705
1927
|
}, 5000) // let the socket subscribe first
|
|
1706
1928
|
}
|
|
@@ -1713,32 +1935,42 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1713
1935
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
1714
1936
|
}
|
|
1715
1937
|
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1938
|
+
let resolveDone
|
|
1939
|
+
const done = new Promise((resolve) => { resolveDone = resolve })
|
|
1940
|
+
let closePromise
|
|
1941
|
+
const close = () => {
|
|
1942
|
+
if (closePromise) return closePromise
|
|
1943
|
+
stopping = true
|
|
1944
|
+
closePromise = (async () => {
|
|
1723
1945
|
clearInterval(authorizationTimer)
|
|
1946
|
+
clearInterval(deliveryTimer)
|
|
1947
|
+
clearInterval(heartbeatTimer)
|
|
1724
1948
|
if (introTimer) clearTimeout(introTimer)
|
|
1725
1949
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1726
1950
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
1727
|
-
try { handle
|
|
1951
|
+
try { handle?.close() } catch { /* already disconnected */ }
|
|
1728
1952
|
for (const queue of Object.values(queues)) {
|
|
1729
|
-
queue.cancel(() => true, (item) => {
|
|
1730
|
-
item.control.cancelled = true
|
|
1731
|
-
void item.control.runner?.cancelCurrent?.()
|
|
1732
|
-
})
|
|
1953
|
+
queue.cancel(() => true, (item) => { item.control.cancelled = true })
|
|
1733
1954
|
}
|
|
1734
|
-
await Promise.
|
|
1955
|
+
await Promise.allSettled([...activeReplyRunners, ...activeWorkRunners].map((runner) => Promise.resolve().then(() => runner.cancelCurrent?.())))
|
|
1956
|
+
await Promise.allSettled([...mentionIntake.values(), ...Object.values(queues).map((queue) => queue.settled()), ...messageDeliveries.values(), ...pendingOperations])
|
|
1735
1957
|
workRunners.close()
|
|
1736
1958
|
replyRunners.close()
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1959
|
+
try { await memory.settled?.() }
|
|
1960
|
+
catch (error) { log('memory shutdown failed: ' + (error?.message || error)) }
|
|
1961
|
+
finally {
|
|
1962
|
+
emit('watcher.stopped', { status: 'stopped' })
|
|
1963
|
+
await journal.close()
|
|
1964
|
+
resolveDone()
|
|
1965
|
+
}
|
|
1966
|
+
})()
|
|
1967
|
+
return closePromise
|
|
1968
|
+
}
|
|
1969
|
+
return {
|
|
1970
|
+
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
1971
|
+
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
1972
|
+
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
1973
|
+
}
|
|
1742
1974
|
}
|
|
1743
1975
|
|
|
1744
1976
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
@@ -1746,16 +1978,55 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1746
1978
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
1747
1979
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1748
1980
|
const canCode = !!workdir
|
|
1981
|
+
const journal = runtimeJournal({ stateDir: OV_DIR, agent: { identifier: slug, slug, provider: agent }, secrets: [key] })
|
|
1982
|
+
const emit = (type, data = {}) => journal.emit(type, { watcherPid: process.pid, source: 'relay', ...data })
|
|
1983
|
+
let stopping = false, cycleSequence = 0, activeCycle = null
|
|
1984
|
+
let introTimer, sweepTimer, closePromise
|
|
1985
|
+
emit('watcher.started', { status: 'running', model, chatModel, workdir: workdir || '' })
|
|
1749
1986
|
// Static charter as the cached system prompt (this loop's MCP authenticates via a
|
|
1750
1987
|
// Bearer header, so no per-call creds are needed in the prompt).
|
|
1751
1988
|
const systemPrompt = canCode ? CODE_CHARTER : CHAT_CHARTER
|
|
1752
|
-
const
|
|
1989
|
+
const runner = createCycleRunner({ claude, agent, mcpUrl, mcpHeaders: { Authorization: 'Bearer ' + key }, cfgKey: slug, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit })
|
|
1990
|
+
const runCycle = (prompt, selectedModel) => {
|
|
1991
|
+
if (stopping) return Promise.resolve({ subtype: 'canceled' })
|
|
1992
|
+
const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
|
|
1993
|
+
const kind = prompt === fullPrompt ? 'full' : prompt === fastPrompt ? 'fast' : 'sweep'
|
|
1994
|
+
const metadata = { cycleId, kind, lane: canCode && kind === 'full' ? 'work' : 'reply', model: selectedModel, workdir: workdir || '' }
|
|
1995
|
+
const startedAt = performance.now()
|
|
1996
|
+
emit('cycle.queued', { ...metadata, status: 'queued' })
|
|
1997
|
+
emit('cycle.started', { ...metadata, status: 'running', queueMs: 0 })
|
|
1998
|
+
let status = 'error'
|
|
1999
|
+
activeCycle = Promise.resolve().then(() => stopping ? { subtype: 'canceled' } : runner.runCycle(prompt, selectedModel, { cycleId })).then((result) => { status = result?.subtype || 'ok'; return result }).finally(() => {
|
|
2000
|
+
emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...metadata, status, durationMs: Math.round(performance.now() - startedAt) })
|
|
2001
|
+
activeCycle = null
|
|
2002
|
+
})
|
|
2003
|
+
return activeCycle
|
|
2004
|
+
}
|
|
1753
2005
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
1754
2006
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
1755
2007
|
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
1756
2008
|
|
|
1757
2009
|
const seen = new Set()
|
|
1758
2010
|
let busy = false
|
|
2011
|
+
const heartbeatTimer = setInterval(() => emit('watcher.heartbeat', { status: 'running', active: busy ? 1 : 0, model, chatModel, workdir: workdir || '' }), 15_000)
|
|
2012
|
+
heartbeatTimer.unref?.()
|
|
2013
|
+
const close = () => {
|
|
2014
|
+
if (closePromise) return closePromise
|
|
2015
|
+
stopping = true
|
|
2016
|
+
clearInterval(heartbeatTimer)
|
|
2017
|
+
clearTimeout(introTimer)
|
|
2018
|
+
clearInterval(sweepTimer)
|
|
2019
|
+
closePromise = (async () => {
|
|
2020
|
+
await Promise.allSettled([Promise.resolve().then(() => runner.cancelCurrent?.()), activeCycle])
|
|
2021
|
+
try { runner.close?.() } catch { /* already closed */ }
|
|
2022
|
+
emit('watcher.stopped', { status: 'stopped' })
|
|
2023
|
+
await journal.close()
|
|
2024
|
+
})()
|
|
2025
|
+
return closePromise
|
|
2026
|
+
}
|
|
2027
|
+
const stop = () => void close().then(() => process.exit(0))
|
|
2028
|
+
process.once('SIGTERM', stop)
|
|
2029
|
+
process.once('SIGINT', stop)
|
|
1759
2030
|
let firstCheck = true
|
|
1760
2031
|
let lastNewAt = Date.now()
|
|
1761
2032
|
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
@@ -1766,11 +2037,11 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1766
2037
|
if (!existsSync(introMarker)) {
|
|
1767
2038
|
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1768
2039
|
log('first run — introducing self to the workspace')
|
|
1769
|
-
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
2040
|
+
introTimer = setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
1770
2041
|
}
|
|
1771
2042
|
// The first inbox response below decides whether a startup sweep is needed.
|
|
1772
2043
|
// Do not spend a model turn on every service restart when the inbox is empty.
|
|
1773
|
-
setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + COORDINATE }, 24 * 60 * 60 * 1000)
|
|
2044
|
+
sweepTimer = setInterval(() => { log('daily catch-up sweep'); queuedSpecial = SWEEP + '\n\n' + COORDINATE }, 24 * 60 * 60 * 1000)
|
|
1774
2045
|
}
|
|
1775
2046
|
|
|
1776
2047
|
async function check() {
|
|
@@ -1788,7 +2059,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1788
2059
|
|
|
1789
2060
|
async function run() {
|
|
1790
2061
|
log('up — warm watcher on ' + host + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1791
|
-
|
|
2062
|
+
while (!stopping) {
|
|
1792
2063
|
let delay = FAST
|
|
1793
2064
|
try {
|
|
1794
2065
|
// Run a queued one-off (intro / daily sweep) first when free, on the same
|
|
@@ -1801,6 +2072,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1801
2072
|
continue
|
|
1802
2073
|
}
|
|
1803
2074
|
const res = busy ? { items: [], paused: false } : await check()
|
|
2075
|
+
if (stopping) break
|
|
1804
2076
|
const items = Array.isArray(res.items) ? res.items : []
|
|
1805
2077
|
if (firstCheck && Array.isArray(res.items)) {
|
|
1806
2078
|
firstCheck = false
|
|
@@ -1844,7 +2116,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1844
2116
|
}
|
|
1845
2117
|
}
|
|
1846
2118
|
|
|
1847
|
-
return run()
|
|
2119
|
+
return run().finally(close)
|
|
1848
2120
|
}
|
|
1849
2121
|
|
|
1850
2122
|
// ── background service install (launchd / systemd) ───────────────────────────
|