openvisio-agent 0.19.12 → 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 +9 -6
- 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 +89 -38
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +15 -2
- 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 +38 -3
- package/src/watch.mjs +345 -116
- 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'
|
|
@@ -25,6 +26,19 @@ import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './ass
|
|
|
25
26
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
26
27
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
27
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
|
+
}
|
|
28
42
|
|
|
29
43
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
30
44
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -319,13 +333,13 @@ export async function runWatch({ flags }) {
|
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
322
|
-
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 }) {
|
|
323
337
|
const canCode = !!workdir
|
|
324
338
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
325
339
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
326
340
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
327
341
|
// cannot bleed across concurrently assigned tickets.
|
|
328
|
-
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 })
|
|
329
343
|
if (mastraRunner) return mastraRunner
|
|
330
344
|
let child = null
|
|
331
345
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
@@ -347,11 +361,20 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
347
361
|
let turnDidMcpTaskRead = false
|
|
348
362
|
let turnDidMcpTaskUpdate = false
|
|
349
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
|
+
}
|
|
350
371
|
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
351
372
|
const settleTurn = (o) => {
|
|
352
373
|
clearCycleTimer()
|
|
353
374
|
const r = resolveTurn
|
|
354
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' })
|
|
355
378
|
const mcpCalls = [...turnToolCalls]
|
|
356
379
|
if (r) r({
|
|
357
380
|
...o,
|
|
@@ -393,6 +416,9 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
393
416
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
394
417
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
395
418
|
child = c
|
|
419
|
+
turnRuntimePid = c.pid || null
|
|
420
|
+
childCycleIds.set(c, turnCycleId)
|
|
421
|
+
emit('process.started', { status: 'starting' })
|
|
396
422
|
turnsThisSession = 0
|
|
397
423
|
sessionStartedAt = Date.now()
|
|
398
424
|
let localBuf = ''
|
|
@@ -404,6 +430,26 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
404
430
|
const line = localBuf.slice(0, i); localBuf = localBuf.slice(i + 1)
|
|
405
431
|
if (!line.trim()) continue
|
|
406
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
|
+
}
|
|
407
453
|
if (debug) logEvent(o)
|
|
408
454
|
const evidence = claudeEventEvidence(o, turnToolUses)
|
|
409
455
|
if (evidence.outputText) turnOutputText += (turnOutputText ? ' ' : '') + evidence.outputText
|
|
@@ -441,13 +487,15 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
441
487
|
})
|
|
442
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' }) })
|
|
443
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 }))
|
|
444
491
|
log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
|
|
445
492
|
? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
|
|
446
493
|
: ' [CHAT-ONLY mode (--chat-only) — chat/ticket tools only, no code work]'))
|
|
447
494
|
}
|
|
448
495
|
|
|
449
|
-
function runCycle(prompt, cycleModel) {
|
|
496
|
+
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
450
497
|
return new Promise((resolve) => {
|
|
498
|
+
turnCycleId = cycleOptions.cycleId ?? null
|
|
451
499
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
452
500
|
// change means the warm session must be respawned with the new --model.
|
|
453
501
|
const resolvedCycleModel = resolveClaudeModel(cycleModel)
|
|
@@ -462,6 +510,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
462
510
|
child = null
|
|
463
511
|
}
|
|
464
512
|
ensureSession()
|
|
513
|
+
if (child) childCycleIds.set(child, turnCycleId)
|
|
465
514
|
turnsThisSession++
|
|
466
515
|
turnToolCalls = new Set()
|
|
467
516
|
turnToolUses = new Map()
|
|
@@ -503,8 +552,35 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
503
552
|
// One WebSocket identity feeds independent serialized work/reply queues. Each
|
|
504
553
|
// accepted source keeps its own context; direct MCP reconciliation recovers work
|
|
505
554
|
// without model spend while idle.
|
|
506
|
-
function loopBackendWs(
|
|
507
|
-
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 || '' })
|
|
508
584
|
let handle = null
|
|
509
585
|
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
510
586
|
// not clear or overwrite each other's status targets.
|
|
@@ -519,20 +595,20 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
519
595
|
const authRule = agent !== 'claude'
|
|
520
596
|
? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
521
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.`
|
|
522
|
-
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.`
|
|
523
599
|
// Keep the static charter stable so provider prompt caching can reuse it.
|
|
524
600
|
// Actual cache hits and billing depend on the runtime and model provider.
|
|
525
601
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
526
602
|
const runnerOptions = {
|
|
527
603
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
528
|
-
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
604
|
+
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
|
|
529
605
|
}
|
|
530
606
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
531
|
-
// runtimes. Replies
|
|
532
|
-
//
|
|
607
|
+
// runtimes. Replies serialize within a thread while independent threads run
|
|
608
|
+
// concurrently behind the same delivery guards.
|
|
533
609
|
const MAX_CONCURRENT_WORKERS = 3
|
|
534
610
|
const activeWorkRunners = new Set()
|
|
535
|
-
const createWorkRunner = (control, workerWorkdir = '') =>
|
|
611
|
+
const createWorkRunner = (control, workerWorkdir = '') => makeRunner({
|
|
536
612
|
...runnerOptions,
|
|
537
613
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
538
614
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
@@ -544,7 +620,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
544
620
|
const activeReplyRunners = new Set()
|
|
545
621
|
const replyRunners = createRunnerPool({
|
|
546
622
|
capacity: 8,
|
|
547
|
-
create: ({ key, canCoordinate }) =>
|
|
623
|
+
create: ({ key, canCoordinate }) => makeRunner({
|
|
548
624
|
...runnerOptions, workdir: '', canCoordinate,
|
|
549
625
|
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
550
626
|
cfgKey: identifier + '-reply-' + slugify(key),
|
|
@@ -553,7 +629,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
553
629
|
const codexPushGuide = agent === 'codex' && canCode
|
|
554
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.'
|
|
555
631
|
: ''
|
|
556
|
-
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.'
|
|
557
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
|
|
558
634
|
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule
|
|
559
635
|
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule
|
|
@@ -573,7 +649,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
573
649
|
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
574
650
|
item.control.runner = runner
|
|
575
651
|
activeWorkRunners.add(runner)
|
|
576
|
-
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)) }
|
|
577
653
|
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
578
654
|
},
|
|
579
655
|
onError: (error, item) => {
|
|
@@ -590,7 +666,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
590
666
|
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
591
667
|
activeReplyRunners.add(runner)
|
|
592
668
|
item.control.runner = runner
|
|
593
|
-
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, '')) }
|
|
594
670
|
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
595
671
|
},
|
|
596
672
|
onError: (error, item) => {
|
|
@@ -607,16 +683,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
607
683
|
// Mentions we've already reacted to (by message id) — the backend can re-deliver
|
|
608
684
|
// an agent:mention (reconnect replay, dup fan-out), which otherwise makes the
|
|
609
685
|
// agent reply to the SAME message twice.
|
|
610
|
-
const replayPath = join(
|
|
686
|
+
const replayPath = join(stateDir, 'watch-' + slug + '-replay.json')
|
|
611
687
|
let replayState = {}
|
|
612
688
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
613
689
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
614
690
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
615
691
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
616
692
|
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
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'),
|
|
620
697
|
resourceId: identifier,
|
|
621
698
|
})
|
|
622
699
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
@@ -629,9 +706,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
629
706
|
// This is distinct from a policy block: any later human ticket change resumes
|
|
630
707
|
// the work, but reconnects and the watcher's own blocker update do not.
|
|
631
708
|
const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
632
|
-
// A policy-blocked task stays paused across reconnects.
|
|
633
|
-
//
|
|
634
|
-
// 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
|
|
635
711
|
// attempting the same rejected egress action.
|
|
636
712
|
const blockedTasks = new Set(Array.isArray(replayState.blockedTasks) ? replayState.blockedTasks : [])
|
|
637
713
|
const blockedTaskRepos = new Map(Array.isArray(replayState.blockedTaskRepos) ? replayState.blockedTaskRepos : [])
|
|
@@ -651,6 +727,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
651
727
|
recentMentionSignatures: [...recentMentionSignatures],
|
|
652
728
|
seenActivities: [...seenActivities],
|
|
653
729
|
deliveredReplies: [...deliveredReplies],
|
|
730
|
+
pendingReplyDeliveries: [...pendingReplyDeliveries],
|
|
654
731
|
failedTaskVersions: [...failedTaskVersions],
|
|
655
732
|
blockedTasks: [...blockedTasks],
|
|
656
733
|
blockedTaskRepos: [...blockedTaskRepos],
|
|
@@ -713,7 +790,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
713
790
|
const markMentionHandled = (message, channelId) => {
|
|
714
791
|
pruneMentionSignatures()
|
|
715
792
|
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
716
|
-
const duplicate =
|
|
793
|
+
const duplicate = idKey ? seenMentions.has(idKey) : !!signatureKey && recentMentionSignatures.has(signatureKey)
|
|
717
794
|
if (duplicate) return true
|
|
718
795
|
if (idKey) { seenMentions.add(idKey); trimSeen(seenMentions) }
|
|
719
796
|
if (signatureKey) recentMentionSignatures.set(signatureKey, Date.now())
|
|
@@ -725,19 +802,46 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
725
802
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
726
803
|
// source independently through work, recovery, and delivery.
|
|
727
804
|
let backlogProbeBusy = false
|
|
728
|
-
let lastInboxSignature = ''
|
|
729
805
|
let selfAgentId = null
|
|
806
|
+
let selfOrganizationId = null
|
|
730
807
|
let completionRecipientName = ''
|
|
731
808
|
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
732
809
|
const rememberSelfAgent = (self) => {
|
|
733
|
-
if (!self || typeof self !== 'object') return
|
|
810
|
+
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
734
811
|
if (self.id != null) selfAgentId = Number(self.id)
|
|
735
812
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
736
813
|
const addedBy = agentAddedByName(self)
|
|
737
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
|
|
738
844
|
}
|
|
739
|
-
const mcpClient = createMcpHttpClient({ url: mcpUrl, apiKey, identifier, clientVersion: '0.18.5', log })
|
|
740
|
-
const callMcpTool = (name, args = {}) => mcpClient.callTool(name, args)
|
|
741
845
|
let mcpToolNames = null
|
|
742
846
|
let mcpToolDiscoveryPromise = null
|
|
743
847
|
let mcpToolDiscoveryWarned = false
|
|
@@ -756,24 +860,25 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
756
860
|
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
757
861
|
return mcpToolDiscoveryPromise
|
|
758
862
|
}
|
|
759
|
-
const
|
|
760
|
-
|
|
863
|
+
const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
|
|
864
|
+
let names
|
|
865
|
+
try { names = await discoverMcpTools() }
|
|
761
866
|
catch (e) {
|
|
762
867
|
if (!mcpToolDiscoveryWarned) {
|
|
763
868
|
mcpToolDiscoveryWarned = true
|
|
764
869
|
log('MCP tool discovery failed; optional actions will use compatibility fallbacks: ' + (e?.message || e))
|
|
765
870
|
}
|
|
766
|
-
return
|
|
871
|
+
return { called: false, reason: 'discovery-unavailable' }
|
|
767
872
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
if (
|
|
772
|
-
|
|
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) } }
|
|
773
879
|
catch (e) {
|
|
774
880
|
if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
|
|
775
881
|
try { await discoverMcpTools(true) } catch { /* the original error is enough */ }
|
|
776
|
-
return { called: false, reason: 'not-available' }
|
|
777
882
|
}
|
|
778
883
|
throw e
|
|
779
884
|
}
|
|
@@ -812,9 +917,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
812
917
|
const tasksData = toolData(tasksResult)
|
|
813
918
|
const { done: doneIds, review: reviewIds } = typesResult
|
|
814
919
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
815
|
-
const
|
|
816
|
-
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
817
|
-
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
920
|
+
const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
|
|
818
921
|
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
819
922
|
}).map((task) => ({
|
|
820
923
|
id: task.id,
|
|
@@ -859,9 +962,19 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
859
962
|
// rendered as this agent. A persisted delivery key closes the crash/reconnect
|
|
860
963
|
// gap; an in-flight promise closes the two-lane race inside one watcher.
|
|
861
964
|
const messageDeliveries = new Map()
|
|
862
|
-
const threadControlKey = (channelId, parentId) =>
|
|
965
|
+
const threadControlKey = (channelId, parentId) => validBackendId(channelId) && validBackendId(parentId) ? `thread:${Number(channelId)}:${parentId}` : ''
|
|
863
966
|
const sameDeliveryThread = (delivery, channelId, parentId) => delivery && Number(delivery.channelId) === Number(channelId) && String(delivery.parentId ?? '') === String(parentId ?? '')
|
|
864
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()
|
|
865
978
|
const controlKey = threadControlKey(channelId, parentId)
|
|
866
979
|
if (!controlKey) return
|
|
867
980
|
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
@@ -897,6 +1010,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
897
1010
|
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
898
1011
|
})
|
|
899
1012
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1013
|
+
if (stopping) throw new Error('Watcher is stopping')
|
|
900
1014
|
const deliveryKey = String(key || '')
|
|
901
1015
|
if (parentId == null) {
|
|
902
1016
|
channelId = await projectStatusChannel(projectId)
|
|
@@ -904,12 +1018,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
904
1018
|
projectId = undefined // dedicated channels may be organization-scoped
|
|
905
1019
|
}
|
|
906
1020
|
const message = String(content || '').trim()
|
|
907
|
-
if (!deliveryKey || !
|
|
908
|
-
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
|
+
}
|
|
909
1026
|
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
910
1027
|
const controlKey = threadControlKey(channelId, parentId)
|
|
911
1028
|
const suppressCancelled = () => {
|
|
1029
|
+
if (memory.has(deliveryKey, 'suppressed')) return { posted: false, reason: 'source-cancelled' }
|
|
912
1030
|
if (allowCancelled || !controlKey || !memory.has(controlKey, 'cancelled')) return null
|
|
1031
|
+
pendingReplyDeliveries.delete(deliveryKey)
|
|
913
1032
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
914
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 } })
|
|
915
1034
|
return { posted: false, reason: 'thread-cancelled' }
|
|
@@ -917,6 +1036,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
917
1036
|
const initiallyCancelled = suppressCancelled()
|
|
918
1037
|
if (initiallyCancelled) return initiallyCancelled
|
|
919
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
|
+
}
|
|
920
1045
|
const run = (async () => {
|
|
921
1046
|
if (parentId != null) {
|
|
922
1047
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
@@ -935,6 +1060,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
935
1060
|
// Re-check immediately before the irreversible post.
|
|
936
1061
|
const newlyCancelled = suppressCancelled()
|
|
937
1062
|
if (newlyCancelled) return newlyCancelled
|
|
1063
|
+
if (stopping) throw new Error('Watcher is stopping')
|
|
938
1064
|
|
|
939
1065
|
sendStatus(Number(channelId), 'typing')
|
|
940
1066
|
const result = toolData(await callMcpTool('post_message', {
|
|
@@ -949,11 +1075,26 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
949
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 } })
|
|
950
1076
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
951
1077
|
return { posted: true, result }
|
|
952
|
-
})().
|
|
1078
|
+
})().then((result) => {
|
|
1079
|
+
if (pendingReplyDeliveries.delete(deliveryKey)) persistReplay()
|
|
1080
|
+
return result
|
|
1081
|
+
}).finally(() => messageDeliveries.delete(deliveryKey))
|
|
953
1082
|
messageDeliveries.set(deliveryKey, run)
|
|
954
1083
|
return run
|
|
955
1084
|
}
|
|
956
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
|
+
|
|
957
1098
|
const statusChannelCache = new Map()
|
|
958
1099
|
let dedicatedChannelRead = null
|
|
959
1100
|
const projectStatusChannel = async (projectId) => {
|
|
@@ -1004,7 +1145,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1004
1145
|
const taskKey = `${projectId}:${ticketId}`
|
|
1005
1146
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1006
1147
|
const ticket = current.ticket ?? current.task ?? current
|
|
1007
|
-
const
|
|
1148
|
+
const completionTypes = await taskTypeSets(projectId)
|
|
1149
|
+
const report = buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, recipientName: completionRecipientName, completedTypeIds: completionTypes.done, reviewTypeIds: completionTypes.review })
|
|
1008
1150
|
if (!report) return false
|
|
1009
1151
|
const memoryKey = `ticket:${projectId}:${ticketId}`
|
|
1010
1152
|
memory.remember({
|
|
@@ -1020,9 +1162,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1020
1162
|
// handoff or trap the ticket in reconnect retries.
|
|
1021
1163
|
if (!reportedTaskComments.has(report.key)) {
|
|
1022
1164
|
try {
|
|
1023
|
-
const comment = await
|
|
1165
|
+
const comment = await callOptionalTicketComment({ projectId, ticketId, content: report.content })
|
|
1024
1166
|
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
1025
|
-
else log('
|
|
1167
|
+
else log('no ticket comment tool is exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
|
|
1026
1168
|
} catch (e) {
|
|
1027
1169
|
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
|
|
1028
1170
|
}
|
|
@@ -1088,10 +1230,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1088
1230
|
refs: { projectId, ticketId, ...(Number.isFinite(channelId) ? { channelId } : {}) },
|
|
1089
1231
|
})
|
|
1090
1232
|
try {
|
|
1091
|
-
const comment = await
|
|
1233
|
+
const comment = await callOptionalTicketComment({ projectId, ticketId, content: ticketNotice })
|
|
1092
1234
|
if (comment.called) { delivered = true; return }
|
|
1093
|
-
log('
|
|
1094
|
-
} 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
|
+
}
|
|
1095
1240
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1096
1241
|
const ticket = current.ticket ?? current.task ?? current
|
|
1097
1242
|
const description = String(ticket.description || '')
|
|
@@ -1121,17 +1266,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1121
1266
|
}
|
|
1122
1267
|
const command = block?.command || 'the requested external repository action'
|
|
1123
1268
|
const payload = [block?.commit && `commit ${block.commit}`, block?.branch && `branch ${block.branch}`, block?.remote && `remote ${block.remote}`].filter(Boolean).join(', ')
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
1127
|
-
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.`
|
|
1128
|
-
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
|
|
1129
1271
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1130
1272
|
}
|
|
1131
1273
|
|
|
1132
1274
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
1133
1275
|
// model tokens unless the tools actually report pending work.
|
|
1134
1276
|
const reconcileBacklog = async () => {
|
|
1277
|
+
if (stopping) return
|
|
1135
1278
|
if (!mcpUrl || backlogProbeBusy) return
|
|
1136
1279
|
backlogProbeBusy = true
|
|
1137
1280
|
try {
|
|
@@ -1153,9 +1296,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1153
1296
|
])
|
|
1154
1297
|
const { done: doneIds, review: reviewIds } = typesData
|
|
1155
1298
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
1156
|
-
|
|
1157
|
-
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
1158
|
-
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
1299
|
+
if (!taskBelongsToAgent(task, { id: selfAgentId, identifier })) continue
|
|
1159
1300
|
const taskKey = `${project.id}:${task.id}`
|
|
1160
1301
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1161
1302
|
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
@@ -1207,7 +1348,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1207
1348
|
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1208
1349
|
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1209
1350
|
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1210
|
-
const activityParentId = activityMessage
|
|
1351
|
+
const activityParentId = messageParentId(activityMessage)
|
|
1211
1352
|
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1212
1353
|
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1213
1354
|
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
@@ -1232,29 +1373,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1232
1373
|
project: project.name,
|
|
1233
1374
|
channelId: activityChannelId,
|
|
1234
1375
|
message: activityMessage,
|
|
1235
|
-
activity: item,
|
|
1376
|
+
activity: item, activityKey,
|
|
1236
1377
|
})
|
|
1237
1378
|
}
|
|
1238
1379
|
}
|
|
1239
1380
|
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1240
1381
|
})
|
|
1241
1382
|
if (activityReplayTouched) persistReplay()
|
|
1242
|
-
|
|
1243
|
-
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1244
|
-
lastInboxSignature = inboxSignature
|
|
1383
|
+
if (mentionActivity.length) {
|
|
1245
1384
|
log('backlog reconciliation found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1246
1385
|
// Replay each real message through the exact same delivery path as a live
|
|
1247
1386
|
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1248
1387
|
// consult the rendered thread before any reply is emitted.
|
|
1249
1388
|
for (const mention of mentionActivity) {
|
|
1250
|
-
onEvent('agent:mention', {
|
|
1389
|
+
void onEvent('agent:mention', {
|
|
1251
1390
|
project_id: mention.projectId,
|
|
1252
1391
|
channel_id: mention.channelId,
|
|
1253
1392
|
message: mention.message,
|
|
1254
1393
|
_mentionAlreadyMarked: true,
|
|
1394
|
+
})?.then((result) => {
|
|
1395
|
+
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1255
1396
|
})
|
|
1256
1397
|
}
|
|
1257
|
-
}
|
|
1398
|
+
}
|
|
1258
1399
|
} catch (e) {
|
|
1259
1400
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1260
1401
|
} finally { backlogProbeBusy = false }
|
|
@@ -1279,22 +1420,35 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1279
1420
|
}
|
|
1280
1421
|
|
|
1281
1422
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1423
|
+
if (stopping) return Promise.resolve({ status: 'canceled' })
|
|
1282
1424
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1283
1425
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1284
1426
|
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
+
})
|
|
1287
1437
|
}
|
|
1288
1438
|
|
|
1289
1439
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1290
1440
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1291
1441
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1292
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)) })
|
|
1293
1446
|
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1294
1447
|
const ctx = context ? [context] : []
|
|
1295
1448
|
const activeTaskRef = taskRef
|
|
1296
1449
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1297
1450
|
const runnerOptions = {
|
|
1451
|
+
cycleId: cycleControl.cycleId, attempt: 1,
|
|
1298
1452
|
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1299
1453
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1300
1454
|
}
|
|
@@ -1324,10 +1478,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1324
1478
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1325
1479
|
const ticket = data.ticket ?? data.task ?? data
|
|
1326
1480
|
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1327
|
-
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1328
|
-
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1329
1481
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1330
|
-
!(
|
|
1482
|
+
!taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
|
|
1331
1483
|
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1332
1484
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1333
1485
|
log('queued ticket no longer actionable; skipped before model start')
|
|
@@ -1335,8 +1487,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1335
1487
|
}
|
|
1336
1488
|
}
|
|
1337
1489
|
if (cycleControl.cancelled) return
|
|
1490
|
+
cycleControl.outcome = 'running'
|
|
1338
1491
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1339
1492
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1493
|
+
cycleControl.outcome = result?.subtype || 'ok'
|
|
1340
1494
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1341
1495
|
if (result?.model && result.model !== useModel) {
|
|
1342
1496
|
// Persist only a successfully selected model and only if the user has
|
|
@@ -1369,6 +1523,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1369
1523
|
}
|
|
1370
1524
|
const replyMutationErrors = kind !== 'full' ? blockingReplyMcpErrors(result?.mcpErrors) : []
|
|
1371
1525
|
if (replyMutationErrors.length) {
|
|
1526
|
+
cycleControl.outcome = 'blocked'
|
|
1372
1527
|
const notice = `I couldn't complete the requested OpenVisio action: ${replyMutationErrors.join(', ')}. I haven't claimed that change succeeded.`
|
|
1373
1528
|
log('COORDINATION_CYCLE_BLOCKED failed MCP calls; publishing blocker')
|
|
1374
1529
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
|
|
@@ -1384,13 +1539,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1384
1539
|
const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
|
|
1385
1540
|
const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
|
|
1386
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 })
|
|
1387
1544
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1388
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}`
|
|
1389
|
-
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'
|
|
1390
1548
|
if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
|
|
1391
1549
|
const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
|
|
1392
1550
|
const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
|
|
1393
1551
|
if (!cycleSucceeded(recovery) || recoveryMissing.length) {
|
|
1552
|
+
cycleControl.outcome = 'blocked'
|
|
1394
1553
|
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
1395
1554
|
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
|
|
1396
1555
|
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
@@ -1476,7 +1635,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1476
1635
|
const hinted = taskFromEvent(raw)
|
|
1477
1636
|
const ticketId = hinted && (hinted.id ?? hinted.task_id ?? hinted.taskId)
|
|
1478
1637
|
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
1479
|
-
if (ticketId
|
|
1638
|
+
if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
1480
1639
|
try {
|
|
1481
1640
|
if (selfAgentId == null) {
|
|
1482
1641
|
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
@@ -1486,10 +1645,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1486
1645
|
}
|
|
1487
1646
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1488
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 }
|
|
1489
1649
|
const typeSets = await taskTypeSets(projectId)
|
|
1490
|
-
const
|
|
1491
|
-
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1492
|
-
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1650
|
+
const belongsToSelf = taskBelongsToAgent(ticket, { id: selfAgentId, identifier })
|
|
1493
1651
|
const key = `${projectId}:${ticketId}`
|
|
1494
1652
|
if (!belongsToSelf) {
|
|
1495
1653
|
for (const [name, queue] of Object.entries(queues)) {
|
|
@@ -1543,19 +1701,36 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1543
1701
|
|
|
1544
1702
|
const mentionIntake = new Map()
|
|
1545
1703
|
function onEvent(k, d) {
|
|
1546
|
-
if (
|
|
1704
|
+
if (stopping) return
|
|
1705
|
+
if (k !== 'agent:mention' && k !== 'channel:message') return trackOperation(routeEvent(k, d))
|
|
1547
1706
|
const cid = d?.channel_id ?? d?.channelId
|
|
1548
1707
|
const message = d?.message || {}
|
|
1549
|
-
const parent = message
|
|
1550
|
-
const
|
|
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}`
|
|
1551
1713
|
// Keep redirects ordered while a legacy root is being verified. Other
|
|
1552
1714
|
// conversations can resolve and run independently.
|
|
1553
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' }
|
|
1554
1722
|
const preliminary = classifyConversationTarget(message, [...selfAliases])
|
|
1555
1723
|
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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' } })
|
|
1559
1734
|
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
1560
1735
|
mentionIntake.set(key, next)
|
|
1561
1736
|
return next
|
|
@@ -1564,7 +1739,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1564
1739
|
function routeEvent(k, d) {
|
|
1565
1740
|
const raw = d && typeof d === 'object' ? d : {}
|
|
1566
1741
|
if (k === 'task:assigned' || k === 'task:updated') {
|
|
1567
|
-
|
|
1742
|
+
return handleTaskSignal(k, raw)
|
|
1568
1743
|
} else if (k === 'agent:mention') {
|
|
1569
1744
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
1570
1745
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
@@ -1572,8 +1747,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1572
1747
|
const who = senderName(msg)
|
|
1573
1748
|
// Reply IN THE SAME THREAD: parent is the thread root (the message's own id
|
|
1574
1749
|
// for a top-level mention, or its parent when the mention is itself a reply).
|
|
1575
|
-
const mid = msg.
|
|
1576
|
-
const parent =
|
|
1750
|
+
const mid = msg.message_id ?? msg.messageId ?? msg.id ?? null
|
|
1751
|
+
const parent = messageParentId(msg)
|
|
1577
1752
|
const threadRoot = parent != null ? parent : mid
|
|
1578
1753
|
const controlKey = threadControlKey(cid, threadRoot)
|
|
1579
1754
|
const threadOwned = !!controlKey && memory.has(controlKey, 'active')
|
|
@@ -1694,16 +1869,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1694
1869
|
}
|
|
1695
1870
|
|
|
1696
1871
|
// The WebSocket drives real-time reactions (task:created/updated assigned to us,
|
|
1697
|
-
// agent:mention).
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
// 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.
|
|
1701
1875
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1702
|
-
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 })
|
|
1703
1877
|
|
|
1704
1878
|
let authorizationProbeBusy = false
|
|
1705
1879
|
const legacyAuthorizationChecks = new Set()
|
|
1706
1880
|
const resumeReadyAuthorizations = async () => {
|
|
1881
|
+
if (stopping) return
|
|
1707
1882
|
if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
|
|
1708
1883
|
authorizationProbeBusy = true
|
|
1709
1884
|
try {
|
|
@@ -1732,18 +1907,22 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1732
1907
|
} catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
|
|
1733
1908
|
finally { authorizationProbeBusy = false }
|
|
1734
1909
|
}
|
|
1735
|
-
const
|
|
1736
|
-
|
|
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?.()
|
|
1737
1916
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1738
|
-
if (mcpConfig || mcpUrl) {
|
|
1917
|
+
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
1739
1918
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
1740
|
-
const introMarker = join(
|
|
1919
|
+
const introMarker = join(stateDir, 'intro-' + slugify(identifier) + '.done')
|
|
1741
1920
|
if (!existsSync(introMarker)) {
|
|
1742
1921
|
log('first connection — introducing self to the workspace')
|
|
1743
1922
|
introTimer = setTimeout(() => {
|
|
1744
1923
|
void announceIntroduction().then((delivered) => {
|
|
1745
1924
|
if (!delivered) return
|
|
1746
|
-
try { mkdirSync(
|
|
1925
|
+
try { mkdirSync(stateDir, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1747
1926
|
}).catch((e) => log('guarded introduction failed: ' + (e?.message || e)))
|
|
1748
1927
|
}, 5000) // let the socket subscribe first
|
|
1749
1928
|
}
|
|
@@ -1756,32 +1935,42 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1756
1935
|
log('no MCP config — skipping intro + daily sweep (agent has no tools to post/act)')
|
|
1757
1936
|
}
|
|
1758
1937
|
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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 () => {
|
|
1766
1945
|
clearInterval(authorizationTimer)
|
|
1946
|
+
clearInterval(deliveryTimer)
|
|
1947
|
+
clearInterval(heartbeatTimer)
|
|
1767
1948
|
if (introTimer) clearTimeout(introTimer)
|
|
1768
1949
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1769
1950
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
1770
|
-
try { handle
|
|
1951
|
+
try { handle?.close() } catch { /* already disconnected */ }
|
|
1771
1952
|
for (const queue of Object.values(queues)) {
|
|
1772
|
-
queue.cancel(() => true, (item) => {
|
|
1773
|
-
item.control.cancelled = true
|
|
1774
|
-
void item.control.runner?.cancelCurrent?.()
|
|
1775
|
-
})
|
|
1953
|
+
queue.cancel(() => true, (item) => { item.control.cancelled = true })
|
|
1776
1954
|
}
|
|
1777
|
-
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])
|
|
1778
1957
|
workRunners.close()
|
|
1779
1958
|
replyRunners.close()
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
+
}
|
|
1785
1974
|
}
|
|
1786
1975
|
|
|
1787
1976
|
// ── the warm loop ────────────────────────────────────────────────────────────
|
|
@@ -1789,16 +1978,55 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1789
1978
|
const log = (m) => process.stdout.write('[warm ' + new Date().toISOString() + '] ' + m + '\n')
|
|
1790
1979
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1791
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 || '' })
|
|
1792
1986
|
// Static charter as the cached system prompt (this loop's MCP authenticates via a
|
|
1793
1987
|
// Bearer header, so no per-call creds are needed in the prompt).
|
|
1794
1988
|
const systemPrompt = canCode ? CODE_CHARTER : CHAT_CHARTER
|
|
1795
|
-
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
|
+
}
|
|
1796
2005
|
const fullPrompt = canCode ? CODE_FULL : CYCLE
|
|
1797
2006
|
const fastPrompt = canCode ? CODE_FAST : CYCLE_FAST
|
|
1798
2007
|
const liteModel = chatModel || model // cheaper model for chat/quick cycles
|
|
1799
2008
|
|
|
1800
2009
|
const seen = new Set()
|
|
1801
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)
|
|
1802
2030
|
let firstCheck = true
|
|
1803
2031
|
let lastNewAt = Date.now()
|
|
1804
2032
|
// A one-off prompt (intro / daily sweep) the main loop runs the next time it's
|
|
@@ -1809,11 +2037,11 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1809
2037
|
if (!existsSync(introMarker)) {
|
|
1810
2038
|
try { mkdirSync(OV_DIR, { recursive: true }); writeFileSync(introMarker, new Date().toISOString() + '\n') } catch { /* best-effort */ }
|
|
1811
2039
|
log('first run — introducing self to the workspace')
|
|
1812
|
-
setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
2040
|
+
introTimer = setTimeout(() => { queuedSpecial = INTRO }, 5000)
|
|
1813
2041
|
}
|
|
1814
2042
|
// The first inbox response below decides whether a startup sweep is needed.
|
|
1815
2043
|
// Do not spend a model turn on every service restart when the inbox is empty.
|
|
1816
|
-
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)
|
|
1817
2045
|
}
|
|
1818
2046
|
|
|
1819
2047
|
async function check() {
|
|
@@ -1831,7 +2059,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1831
2059
|
|
|
1832
2060
|
async function run() {
|
|
1833
2061
|
log('up — warm watcher on ' + host + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1834
|
-
|
|
2062
|
+
while (!stopping) {
|
|
1835
2063
|
let delay = FAST
|
|
1836
2064
|
try {
|
|
1837
2065
|
// Run a queued one-off (intro / daily sweep) first when free, on the same
|
|
@@ -1844,6 +2072,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1844
2072
|
continue
|
|
1845
2073
|
}
|
|
1846
2074
|
const res = busy ? { items: [], paused: false } : await check()
|
|
2075
|
+
if (stopping) break
|
|
1847
2076
|
const items = Array.isArray(res.items) ? res.items : []
|
|
1848
2077
|
if (firstCheck && Array.isArray(res.items)) {
|
|
1849
2078
|
firstCheck = false
|
|
@@ -1887,7 +2116,7 @@ function loop({ host, key, slug, claude, agent, mcpConfig, mcpUrl, workdir, mode
|
|
|
1887
2116
|
}
|
|
1888
2117
|
}
|
|
1889
2118
|
|
|
1890
|
-
return run()
|
|
2119
|
+
return run().finally(close)
|
|
1891
2120
|
}
|
|
1892
2121
|
|
|
1893
2122
|
// ── background service install (launchd / systemd) ───────────────────────────
|