openvisio-agent 0.20.0 → 0.22.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/CHANGELOG.md +797 -0
- package/README.md +8 -2
- package/USER_GUIDE.md +15 -6
- package/package.json +3 -2
- package/scenarios/runtime.scenarios.mjs +6 -11
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/scripts/certify.mjs +13 -13
- package/src/codex-mcp-proxy.mjs +12 -1
- package/src/events.mjs +23 -3
- package/src/mastra-harness.mjs +51 -22
- package/src/memory.mjs +4 -3
- package/src/model-settings.mjs +57 -0
- package/src/opencode-config.mjs +13 -16
- package/src/runtime-control.mjs +50 -0
- package/src/studio-cli.mjs +11 -0
- package/src/studio-server.mjs +43 -7
- package/src/watch.mjs +239 -168
- package/studio/app.mjs +234 -47
- package/studio/guide.html +15 -3
- package/studio/index.html +45 -9
- package/studio/openvisio.svg +1 -0
- package/studio/satoshi-400.woff2 +0 -0
- package/studio/satoshi-500.woff2 +0 -0
- package/studio/style.css +240 -70
package/src/watch.mjs
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { spawn, spawnSync } from 'node:child_process'
|
|
8
8
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
9
|
-
import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
10
|
-
import { homedir } from 'node:os'
|
|
9
|
+
import { closeSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
10
|
+
import { homedir, tmpdir } from 'node:os'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
11
12
|
import { join, dirname } from 'node:path'
|
|
12
13
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
13
14
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
14
|
-
import {
|
|
15
|
+
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
16
|
+
import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
|
|
15
17
|
import { createMastraMemory } from './memory.mjs'
|
|
16
18
|
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
17
19
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
@@ -27,6 +29,8 @@ import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
|
27
29
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
28
30
|
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
29
31
|
import { createAgentJournal } from './agent-journal.mjs'
|
|
32
|
+
import { readModelSettings } from './model-settings.mjs'
|
|
33
|
+
import { startStudioInBackground } from './studio-cli.mjs'
|
|
30
34
|
|
|
31
35
|
function runtimeJournal(options, supplied) {
|
|
32
36
|
let journal
|
|
@@ -73,9 +77,9 @@ const REPLY_DISCIPLINE = [
|
|
|
73
77
|
' • IS IT FOR YOU? Act ONLY on messages addressed to YOU — an @mention of your exact name, a direct question to you, or a reply to something YOU said or did. If a DIFFERENT agent or person was @mentioned or asked to do something, STAY OUT: do not answer for them and do not pick up their task. When it is not yours, posting nothing is the correct move.',
|
|
74
78
|
' • EVENT NAMES ARE NOT OWNERSHIP. A transport may wake you for activity in a thread you once joined. Trust only the watcher\'s verified recipient decision for the current source message; never infer that every thread update is yours.',
|
|
75
79
|
' • NO DUPLICATES OR PICKUP NOISE. Before you post, scan the recent thread/channel for what YOU already said. If you already replied to this exact request, do NOT repeat the same message. Do not send a generic pickup acknowledgement; activity shows that work is underway. For longer code work, you may send at most one concrete progress update after work has actually begun, but that update NEVER completes the cycle: keep using tools, then send one distinct verified result or real blocker. One final answer per question.',
|
|
76
|
-
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state.
|
|
80
|
+
' • BE SURE BEFORE YOU SPEAK. Do not claim something is possible, done, or broken until you have actually verified it — call the tool, read the code, check the real state. Be willing to disagree or revise a conclusion when evidence changes. If you are unsure, investigate and distinguish a finding from an assumption.',
|
|
77
81
|
' • USE RECALL, NEVER INVENT IT. Before answering a context-dependent question, search the visible thread and use any available history, search, docs, or recall tools. Reuse verified context instead of asking the user to repeat it. If no record exists, say plainly "I don\'t have a record of that". Never fabricate past events, conversations, results, links, PR numbers, deploy URLs, or figures.',
|
|
78
|
-
' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with
|
|
82
|
+
' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
|
|
79
83
|
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
|
|
80
84
|
' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
|
|
81
85
|
' • NEW THREADS BELONG IN YOUR DEDICATED CHANNEL. Reply to an existing request in its original thread. For any fresh top-level thread (results, blockers, or an introduction), use only your own dedicated agent channel, resolved from live list_channels by your exact identity/name. Never open a fresh thread in general, a project channel, or another agent’s channel. If no unique dedicated channel exists, keep results on the ticket and report the missing channel in an existing source thread. Never invent a channel or tool.',
|
|
@@ -84,9 +88,9 @@ const REPLY_DISCIPLINE = [
|
|
|
84
88
|
|
|
85
89
|
// ── CHAT-ONLY agents (no --workdir): chat/ticket tools, no code surface. ──────
|
|
86
90
|
const CHAT_CHARTER = [
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'',
|
|
91
|
+
'You are the same connected OpenVisio teammate in every session. Your role, voice, current request, and available capabilities are supplied with the context.',
|
|
92
|
+
'Discover tools, retrieve relevant context, explore, plan, and choose your next steps. Use native context management. If local execution or edits are needed and openvisio_request_work_session is advertised, use it to continue the same request in your own workspace. A reply session is an internal scheduling choice, not a reason to ask the teammate to reassign your work.',
|
|
93
|
+
'Stay honest about what you have checked and what remains uncertain. Do the requested work within its authority, and give a useful final response when you choose to end your turn.',
|
|
90
94
|
REPLY_DISCIPLINE,
|
|
91
95
|
].join('\n')
|
|
92
96
|
|
|
@@ -98,13 +102,13 @@ const CYCLE_FAST = [
|
|
|
98
102
|
'New chat activity. Do EXACTLY ONE of these:',
|
|
99
103
|
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — you already have the message; polling would make you re-answer it and double-post.',
|
|
100
104
|
' • IF NO specific mention is given above: call poll_inbox and reply only to items truly directed at YOU (a question to you, or a reply to your own message) — SKIP chatter aimed at someone else / another agent, ignore .tasks/.claimable, at most one reply per channel.',
|
|
101
|
-
'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket.
|
|
105
|
+
'Post ONE message total for the thing you are answering — compose it fully, then send once. Never send a reply and then a "better" version; never repeat a reply you already sent. Be sure of your answer before sending. If asked for work you have no tool for, say so plainly and offer to file a ticket. Use enough detail to answer the request, then end your turn.',
|
|
102
106
|
].join('\n')
|
|
103
107
|
|
|
104
108
|
const COORDINATE = [
|
|
105
109
|
'COORDINATION-ONLY cycle. Use the lightweight lane for messaging, triage, ticket comments, assignment, and board movement.',
|
|
106
110
|
'Call get_marching_orders or poll_inbox only when the event context does not already contain enough detail. Use update_ticket to move a ticket to the correct board column when requested or when non-code work is complete.',
|
|
107
|
-
'
|
|
111
|
+
'Explore the context and available tools as needed; this session has no writable coding workspace. If local work is needed, use openvisio_request_work_session when advertised to continue in your coding workspace. Preserve the current request and recipient.',
|
|
108
112
|
'Respond to every direct concern assigned to you, but post only once per item and never duplicate an existing answer.',
|
|
109
113
|
].join('\n')
|
|
110
114
|
|
|
@@ -120,7 +124,7 @@ const CODE_CHARTER = [
|
|
|
120
124
|
'',
|
|
121
125
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
122
126
|
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
|
|
123
|
-
' 2. FINISH, then REPORT.
|
|
127
|
+
' 2. FINISH, then REPORT. Update the ticket when the actual work calls for a state change. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes your final response in the dedicated agent channel.',
|
|
124
128
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
125
129
|
' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
|
|
126
130
|
' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
|
|
@@ -132,24 +136,20 @@ const CODE_CHARTER = [
|
|
|
132
136
|
].join('\n')
|
|
133
137
|
|
|
134
138
|
const CODE_FULL = [
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
139
|
-
'
|
|
140
|
-
'
|
|
141
|
-
' 4. COMMIT + PUSH YOUR BRANCH: stage only the specific paths or hunks changed for this task; inspect git diff --cached before committing. If unrelated changes are already staged, use an isolated worktree. Commit with a clear message, then publish only your own agent/* branch using the runtime-specific authorized push flow. Never --force, never push to main/master, never merge.',
|
|
142
|
-
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
143
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified dedicated-channel completion message and deduplicates it across reconnects.',
|
|
144
|
-
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
139
|
+
'Handle the supplied verified ticket or source request. You decide how to investigate, which tools to use, what context to retain or compact, and when the work is ready for a final response.',
|
|
140
|
+
'There is no required sequence of tools, code edits, ticket updates, or PR creation. Research, audits, explanations, coordination, and already-satisfied requests may finish without repository changes. Choose validation and delivery appropriate to the actual request.',
|
|
141
|
+
'Use available tool discovery, resources, history, search, skills, and planning tools when useful. Prefer the repository knowledge graph when available. Recover from optional tool failures using alternatives; report only a limitation that actually prevents the requested outcome.',
|
|
142
|
+
'A usable local clone is your primary code surface. Preserve unrelated work and use an isolated worktree when needed. Remote codebase tools are a fallback only when the repository cannot be obtained locally. For code changes, verify the result and use the authorized branch/PR flow when the request calls for it.',
|
|
143
|
+
'You own ticket state: use update_ticket when its state should change, including review, completion, or a real blocker. Ending your turn does not mark the ticket done. Your native final response ends this cycle; it can report a result, ask a necessary question, or explain a blocker. Do not describe unperformed work as complete.',
|
|
144
|
+
'Follow the supplied source delivery rule. For backlog-only tickets, return your final response; the watcher delivers it once to your dedicated channel. Do not call post_message for that same result.',
|
|
145
145
|
].join('\n')
|
|
146
146
|
|
|
147
147
|
const CODE_FAST = [
|
|
148
148
|
'New chat activity. Do EXACTLY ONE of these:',
|
|
149
149
|
' • IF a specific mention/message FOR YOU is given above: reply to THAT ONE message exactly once with post_message, then STOP. Do NOT call poll_inbox and do NOT answer anything else this cycle — polling would re-surface the same message and make you double-post.',
|
|
150
150
|
' • IF NO specific mention is given above: call poll_inbox and reply only to items directed at YOU (asks you something, or responds to your own message) — SKIP chatter aimed at someone else / another agent; at most one reply per channel.',
|
|
151
|
-
'
|
|
152
|
-
'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message.
|
|
151
|
+
'Explore the request and its context. If local execution or edits are needed, use openvisio_request_work_session when advertised to continue in your own coding workspace. Do not turn an internal scheduling choice into a reassignment request.',
|
|
152
|
+
'For a non-code question, post ONE answer and stop. For code work, do not post a generic pickup message. You may post one concrete progress update after work starts, but keep working after it; then post one distinct final result with the PR/test evidence or a real blocker. Never repeat the same message. Let the request determine how much detail the final response needs.',
|
|
153
153
|
].join('\n')
|
|
154
154
|
|
|
155
155
|
// ── Workspace-ethics cycles (both chat-only + code agents) ───────────────────
|
|
@@ -170,7 +170,8 @@ const SWEEP = [
|
|
|
170
170
|
].join('\n')
|
|
171
171
|
|
|
172
172
|
// Bash covers git/gh/clone/tests; the deny list is where the guardrails live.
|
|
173
|
-
const
|
|
173
|
+
const CONTEXT_TOOLS = ['Read', 'Grep', 'Glob', 'TodoWrite', 'ToolSearch', 'WebSearch', 'WebFetch', 'Task', 'Agent', 'Skill']
|
|
174
|
+
const CODE_TOOLS = [...CONTEXT_TOOLS, 'Edit', 'Write', 'MultiEdit', 'Bash', 'mcp__openvisio-team__*']
|
|
174
175
|
// Push + PR creation ARE allowed (agents raise PRs), but main/master, force-pushes,
|
|
175
176
|
// merges and destructive/publishing/repo-deleting commands stay blocked.
|
|
176
177
|
const DENY_TOOLS = [
|
|
@@ -188,14 +189,9 @@ const DENY_TOOLS = [
|
|
|
188
189
|
const FAST = 5000
|
|
189
190
|
const SLOW = 60000
|
|
190
191
|
const IDLE_AFTER = 30000
|
|
191
|
-
//
|
|
192
|
-
// mention → a reply, a task → its work), so keeping a session across cycles just
|
|
193
|
-
// re-bills the entire prior history on every new cycle — the #1 token sink. Fresh
|
|
194
|
-
// per cycle keeps each cycle's cost to its own work (the static charter/creds ride
|
|
195
|
-
// in the cached system prompt, so a fresh spawn is cheap).
|
|
196
|
-
const MAX_TURNS = 1
|
|
192
|
+
// Keep source-scoped sessions warm; the native agent owns compaction.
|
|
197
193
|
const SESSION_IDLE_MS = 1200000
|
|
198
|
-
// A
|
|
194
|
+
// A silent runtime is stopped after this inactivity interval — otherwise a hung
|
|
199
195
|
// cycle (e.g. an MCP tool stalling on a down bridge) would leave `busy` stuck
|
|
200
196
|
// true forever and silently queue every later mention. Code cycles get a much
|
|
201
197
|
// longer budget: a real clone → branch → test → push → PR flow legitimately takes
|
|
@@ -320,6 +316,7 @@ export async function runWatch({ flags }) {
|
|
|
320
316
|
if (!apiKey || !identifier) fail('No saved backend credentials for that agent.\n Run `openvisio-agent connect --backend …` first, or pass --key and --id.')
|
|
321
317
|
assertWebSocket(fail)
|
|
322
318
|
if (flags.install) return installService({ slug: slug || 'openvisio', claude, mcpConfig, workdir })
|
|
319
|
+
startStudioInBackground()
|
|
323
320
|
return loopBackendWs({ backend, wsUrl, apiKey, identifier, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
324
321
|
}
|
|
325
322
|
|
|
@@ -333,20 +330,20 @@ export async function runWatch({ flags }) {
|
|
|
333
330
|
}
|
|
334
331
|
|
|
335
332
|
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
336
|
-
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false }) {
|
|
333
|
+
export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, onEvent, systemPrompt, canCoordinate = false, workspaceAvailable = !!workdir }) {
|
|
337
334
|
const canCode = !!workdir
|
|
338
335
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
339
336
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
340
337
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
341
338
|
// cannot bleed across concurrently assigned tickets.
|
|
342
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
|
|
339
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, workspaceAvailable, maxCycleMs, log, debug, model, onTool, onEvent, systemPrompt })
|
|
343
340
|
if (mastraRunner) return mastraRunner
|
|
344
341
|
let child = null
|
|
342
|
+
let privateMcpDir = ''
|
|
345
343
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
346
344
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
347
345
|
// session so the new model takes effect.
|
|
348
346
|
let sessionModel = resolveClaudeModel(model) || null
|
|
349
|
-
let turnsThisSession = 0
|
|
350
347
|
let sessionStartedAt = 0
|
|
351
348
|
let resolveTurn = null
|
|
352
349
|
let cycleTimer = null
|
|
@@ -360,6 +357,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
360
357
|
let turnDidResultMessage = false
|
|
361
358
|
let turnDidMcpTaskRead = false
|
|
362
359
|
let turnDidMcpTaskUpdate = false
|
|
360
|
+
let turnWorkRequest = null
|
|
363
361
|
let turnOutputText = ''
|
|
364
362
|
let turnCycleId = null
|
|
365
363
|
let turnRuntimePid = null
|
|
@@ -371,10 +369,11 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
371
369
|
const clearCycleTimer = () => { if (cycleTimer) { clearTimeout(cycleTimer); cycleTimer = null } }
|
|
372
370
|
const settleTurn = (o) => {
|
|
373
371
|
clearCycleTimer()
|
|
372
|
+
sessionStartedAt = Date.now()
|
|
374
373
|
const r = resolveTurn
|
|
375
374
|
resolveTurn = null
|
|
376
375
|
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 || '
|
|
376
|
+
if (r && finalText) emit('output.final', { text: safeText(finalText, 8000), status: o.is_error ? 'error' : o.subtype || 'error' })
|
|
378
377
|
const mcpCalls = [...turnToolCalls]
|
|
379
378
|
if (r) r({
|
|
380
379
|
...o,
|
|
@@ -387,7 +386,8 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
387
386
|
didResultMessage: turnDidResultMessage,
|
|
388
387
|
didMcpTaskRead: turnDidMcpTaskRead,
|
|
389
388
|
didMcpTaskUpdate: turnDidMcpTaskUpdate,
|
|
390
|
-
outputText:
|
|
389
|
+
outputText: finalText,
|
|
390
|
+
workRequest: turnWorkRequest,
|
|
391
391
|
})
|
|
392
392
|
}
|
|
393
393
|
|
|
@@ -412,18 +412,33 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
412
412
|
// The charter + creds ride in the system prompt (cacheable → not re-billed each
|
|
413
413
|
// cycle), leaving only the small per-cycle instruction in the user message.
|
|
414
414
|
const fallbackModels = claudeFallbackModels(sessionModel)
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
let sessionMcpConfig = mcpConfig
|
|
416
|
+
if (mcpUrl && mcpHeaders?.['x-agent-api-key'] && mcpHeaders?.['x-agent-identifier']) {
|
|
417
|
+
if (!privateMcpDir) privateMcpDir = mkdtempSync(join(tmpdir(), 'openvisio-claude-mcp-'))
|
|
418
|
+
sessionMcpConfig = join(privateMcpDir, 'mcp.json')
|
|
419
|
+
writeJson(sessionMcpConfig, { mcpServers: { 'openvisio-team': {
|
|
420
|
+
command: process.execPath, args: [fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))],
|
|
421
|
+
env: {
|
|
422
|
+
OPENVISIO_CODEX_MCP_URL: mcpUrl,
|
|
423
|
+
OPENVISIO_CODEX_API_KEY: mcpHeaders['x-agent-api-key'],
|
|
424
|
+
OPENVISIO_CODEX_IDENTIFIER: mcpHeaders['x-agent-identifier'],
|
|
425
|
+
OPENVISIO_CAN_CODE: String(canCode),
|
|
426
|
+
OPENVISIO_WORKSPACE_AVAILABLE: String(workspaceAvailable),
|
|
427
|
+
},
|
|
428
|
+
} } }, true)
|
|
429
|
+
}
|
|
430
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--mcp-config', sessionMcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
|
|
431
|
+
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', ...CONTEXT_TOOLS, 'mcp__openvisio-team__*', '--disallowedTools', 'Edit', 'Write', 'MultiEdit', 'Bash']
|
|
417
432
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
418
433
|
child = c
|
|
419
434
|
turnRuntimePid = c.pid || null
|
|
420
435
|
childCycleIds.set(c, turnCycleId)
|
|
421
436
|
emit('process.started', { status: 'starting' })
|
|
422
|
-
turnsThisSession = 0
|
|
423
437
|
sessionStartedAt = Date.now()
|
|
424
438
|
let localBuf = ''
|
|
425
439
|
c.stdout.on('data', (d) => {
|
|
426
440
|
if (c !== child) return
|
|
441
|
+
cycleTimer?.refresh()
|
|
427
442
|
localBuf += d
|
|
428
443
|
let i
|
|
429
444
|
while ((i = localBuf.indexOf('\n')) >= 0) {
|
|
@@ -445,6 +460,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
445
460
|
if (o.type === 'user' && Array.isArray(o.message?.content)) {
|
|
446
461
|
for (const block of o.message.content) if (block.type === 'tool_result') {
|
|
447
462
|
const use = turnToolUses.get(block.tool_use_id)
|
|
463
|
+
if (!block.is_error && use?.name?.endsWith('__' + WORK_SESSION_TOOL)) turnWorkRequest = workSessionRequest(block.content) || turnWorkRequest
|
|
448
464
|
const title = use?.name === 'Bash' && typeof use.input?.command === 'string' ? use.input.command : use?.name || 'tool'
|
|
449
465
|
const filePath = ['Read', 'Write', 'Edit', 'MultiEdit', 'Grep', 'Glob'].includes(use?.name) ? use.input?.file_path || use.input?.path : ''
|
|
450
466
|
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) }] : [] })
|
|
@@ -474,13 +490,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
474
490
|
if (o.type === 'result') {
|
|
475
491
|
log('cycle done (' + (o.subtype || 'ok') + (o.is_error ? ' · ERROR' : '') + ')')
|
|
476
492
|
clearCycleTimer()
|
|
477
|
-
|
|
478
|
-
// full Claude runtime resident until the next event; release its CPU,
|
|
479
|
-
// memory and file watchers as soon as the result has been received.
|
|
480
|
-
if (MAX_TURNS === 1 && c === child) {
|
|
481
|
-
child = null
|
|
482
|
-
void stopModelProcess(c).then(() => settleTurn(o))
|
|
483
|
-
} else settleTurn(o)
|
|
493
|
+
settleTurn(o)
|
|
484
494
|
return
|
|
485
495
|
}
|
|
486
496
|
}
|
|
@@ -490,7 +500,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
490
500
|
c.on('close', (code, signal) => emit('process.stopped', { cycleId: childCycleIds.get(c) ?? null, runtimePid: c.pid || null, status: 'exited', exitCode: code, signal }))
|
|
491
501
|
log('warm session started' + (sessionModel ? ' [model ' + sessionModel + ']' : '') + (canCode
|
|
492
502
|
? ' [CODE mode — workspace ' + workdir + ' — finds/clones the org\'s repos here, branches, pushes, opens PRs]'
|
|
493
|
-
: ' [
|
|
503
|
+
: ' [CONTEXT session — advertised tools, reading, search, and planning]'))
|
|
494
504
|
}
|
|
495
505
|
|
|
496
506
|
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
@@ -504,14 +514,13 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
504
514
|
if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
|
|
505
515
|
sessionModel = resolvedCycleModel
|
|
506
516
|
}
|
|
507
|
-
if (child && (
|
|
508
|
-
log('recycling session
|
|
517
|
+
if (child && (Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
|
|
518
|
+
log('recycling idle session')
|
|
509
519
|
try { child.kill() } catch { /* already gone */ }
|
|
510
520
|
child = null
|
|
511
521
|
}
|
|
512
522
|
ensureSession()
|
|
513
523
|
if (child) childCycleIds.set(child, turnCycleId)
|
|
514
|
-
turnsThisSession++
|
|
515
524
|
turnToolCalls = new Set()
|
|
516
525
|
turnToolUses = new Map()
|
|
517
526
|
turnMcpErrors = new Set()
|
|
@@ -522,6 +531,7 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
522
531
|
turnDidResultMessage = false
|
|
523
532
|
turnDidMcpTaskRead = false
|
|
524
533
|
turnDidMcpTaskUpdate = false
|
|
534
|
+
turnWorkRequest = null
|
|
525
535
|
turnOutputText = ''
|
|
526
536
|
resolveTurn = resolve
|
|
527
537
|
// Backstop: abandon a cycle that never returns a result so `busy` is released
|
|
@@ -545,7 +555,10 @@ export function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, m
|
|
|
545
555
|
child = null
|
|
546
556
|
return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
|
|
547
557
|
}
|
|
548
|
-
return { runCycle, canCode, cancelCurrent, close: () => {
|
|
558
|
+
return { runCycle, canCode, cancelCurrent, close: async () => {
|
|
559
|
+
await cancelCurrent()
|
|
560
|
+
if (privateMcpDir) { rmSync(privateMcpDir, { recursive: true, force: true }); privateMcpDir = '' }
|
|
561
|
+
} }
|
|
549
562
|
}
|
|
550
563
|
|
|
551
564
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -592,16 +605,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
592
605
|
const canCode = !!workdir
|
|
593
606
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
594
607
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
595
|
-
const authRule = agent
|
|
596
|
-
|
|
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.`
|
|
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.`
|
|
608
|
+
const authRule = 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Use openvisio-team-watcher for Codex/OpenCode and openvisio-team for Claude; these are the authenticated servers for this session. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
609
|
+
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. Use tool and resource discovery when helpful, and call only advertised capabilities. Tools may be namespaced; use advertised actions on the authenticated session server only. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
599
610
|
// Keep the static charter stable so provider prompt caching can reuse it.
|
|
600
611
|
// Actual cache hits and billing depend on the runtime and model provider.
|
|
601
612
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
602
613
|
const runnerOptions = {
|
|
603
614
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
604
|
-
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt, onEvent: emit,
|
|
615
|
+
cfgKey: identifier, mcpConfig, workdir, workspaceAvailable: canCode, log, debug, model, systemPrompt, onEvent: emit,
|
|
605
616
|
}
|
|
606
617
|
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
607
618
|
// runtimes. Replies serialize within a thread while independent threads run
|
|
@@ -629,16 +640,30 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
629
640
|
const codexPushGuide = agent === 'codex' && canCode
|
|
630
641
|
? '\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.'
|
|
631
642
|
: ''
|
|
632
|
-
const backendToolRule = '
|
|
633
|
-
const
|
|
634
|
-
const
|
|
635
|
-
const
|
|
636
|
-
const
|
|
643
|
+
const backendToolRule = 'TOOL DISCOVERY: use the tools and resources actually advertised by your connected runtime. Native tool_search, MCP resources, history, search, planning, skills, and context compaction are available when the runtime supports them. Discover an absent action before deciding it is unavailable; never invent a tool or its schema. The supplied event identifies the request, so further discovery is your choice when it helps the task.'
|
|
644
|
+
const studioGuide = `\n\nAGENT STUDIO: When a teammate asks to inspect agent activity or change model settings, offer [Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1). The app renders this link as a button. Studio manages agents on the teammate’s own computer; it does not connect to another teammate’s machine. Prefer this UI over asking people to run model-setting CLI commands.`
|
|
645
|
+
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 + studioGuide
|
|
646
|
+
const fastPrompt = (canCode ? CODE_FAST : CYCLE_FAST) + '\n\n' + backendToolRule + studioGuide
|
|
647
|
+
const coordinatePrompt = COORDINATE + '\n\n' + backendToolRule + studioGuide
|
|
648
|
+
const guardedReplyPrompt = `WATCHER-DELIVERED REPLY: the watcher has already verified that the current source message is addressed to you. Your current OpenVisio agent identifier is "${identifier}"; do not call list_agents merely to rediscover yourself. Return the final reply for watcher delivery instead of calling post_message for the same result. Discover tools and retrieve additional context as needed. OpenVisio team-state tools are allowed: when the answer depends on live projects, assignments, tickets, or status, use list_projects/list_tasks/get_ticket as needed and never ask the teammate for a slug that those tools can resolve. A failed read-only discovery call is not a completed action and must not be described as an intervention-level blocker; continue with available live data or state the narrow fact you could not verify. Return one natural, context-specific final response with enough detail to address the request. Do not echo the request, announce a plan, or add a generic acknowledgement. If the supplied context says the work was completed, lead with the verified result; if it is blocked, name only the real blocker and next action.` + '\n\n' + backendToolRule + studioGuide
|
|
637
649
|
// Live model state — changeable at runtime by the in-chat `/model` command.
|
|
638
650
|
// codeModel drives full/sweep cycles; chatModel (if set) the lighter fast/intro
|
|
639
651
|
// ones, so routine chatter can run cheaper than real code work.
|
|
640
652
|
let codeModel = model
|
|
641
653
|
let liteModel = chatModel || model
|
|
654
|
+
let modelSettingsRevision = ''
|
|
655
|
+
const refreshModelSettings = () => {
|
|
656
|
+
try {
|
|
657
|
+
const settings = readModelSettings(stateDir, slug)
|
|
658
|
+
if (!settings.revision || settings.revision === modelSettingsRevision) return
|
|
659
|
+
codeModel = settings.model
|
|
660
|
+
liteModel = settings.chatModel || settings.model
|
|
661
|
+
modelSettingsRevision = settings.revision
|
|
662
|
+
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
663
|
+
log('Model settings applied from Agent Studio; new requests will use the selected models')
|
|
664
|
+
} catch { /* absent, legacy, or concurrently replaced configuration */ }
|
|
665
|
+
}
|
|
666
|
+
refreshModelSettings()
|
|
642
667
|
|
|
643
668
|
const queues = {
|
|
644
669
|
work: createCycleQueue({
|
|
@@ -700,12 +725,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
700
725
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
701
726
|
// without re-running the model or posting the same result twice.
|
|
702
727
|
const pendingCompletionReports = new Set(Array.isArray(replayState.pendingCompletionReports) ? replayState.pendingCompletionReports : [])
|
|
728
|
+
const pendingAgentResults = new Map(Array.isArray(replayState.pendingAgentResults) ? replayState.pendingAgentResults : [])
|
|
729
|
+
const finishedTaskVersions = new Map(Array.isArray(replayState.finishedTaskVersions) ? replayState.finishedTaskVersions : [])
|
|
703
730
|
const reportedCompletions = new Set(Array.isArray(replayState.reportedCompletions) ? replayState.reportedCompletions : [])
|
|
704
731
|
const reportedTaskComments = new Set(Array.isArray(replayState.reportedTaskComments) ? replayState.reportedTaskComments : [])
|
|
705
|
-
//
|
|
732
|
+
// Transport/runtime failures are held at the ticket revision that produced them.
|
|
706
733
|
// This is distinct from a policy block: any later human ticket change resumes
|
|
707
734
|
// the work, but reconnects and the watcher's own blocker update do not.
|
|
708
|
-
const failedTaskVersions = new Map(Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
735
|
+
const failedTaskVersions = new Map(replayState.completionPolicy === 'agent' && Array.isArray(replayState.failedTaskVersions) ? replayState.failedTaskVersions : [])
|
|
709
736
|
// A policy-blocked task stays paused across reconnects. Helper blocks are
|
|
710
737
|
// released by verified local configuration, or cleared when completed/unassigned. This prevents a 30-minute reconciliation retry from repeatedly
|
|
711
738
|
// attempting the same rejected egress action.
|
|
@@ -732,6 +759,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
732
759
|
blockedTasks: [...blockedTasks],
|
|
733
760
|
blockedTaskRepos: [...blockedTaskRepos],
|
|
734
761
|
pendingCompletionReports: [...pendingCompletionReports],
|
|
762
|
+
pendingAgentResults: [...pendingAgentResults],
|
|
763
|
+
finishedTaskVersions: [...finishedTaskVersions],
|
|
764
|
+
completionPolicy: 'agent',
|
|
735
765
|
reportedCompletions: [...reportedCompletions],
|
|
736
766
|
reportedTaskComments: [...reportedTaskComments],
|
|
737
767
|
}, true)
|
|
@@ -800,18 +830,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
800
830
|
// Context lines from the events themselves (the WS payload already carries the
|
|
801
831
|
// channel + message / task), so the agent acts on THEM directly instead of
|
|
802
832
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
803
|
-
// source independently through work,
|
|
833
|
+
// source independently through work, continuation, and delivery.
|
|
804
834
|
let backlogProbeBusy = false
|
|
805
835
|
let selfAgentId = null
|
|
806
836
|
let selfOrganizationId = null
|
|
807
|
-
let
|
|
837
|
+
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
808
838
|
const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
|
|
809
839
|
const rememberSelfAgent = (self) => {
|
|
810
840
|
if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
|
|
811
|
-
if (self.id
|
|
841
|
+
if (validBackendId(self.id)) selfAgentId = Number(self.id)
|
|
812
842
|
for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
|
|
813
|
-
|
|
814
|
-
|
|
843
|
+
identityContext = agentProfileContext(self, identifier)
|
|
844
|
+
memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext } })
|
|
815
845
|
const organizationId = self.organization_id ?? self.organizationId
|
|
816
846
|
if (validBackendId(organizationId)) {
|
|
817
847
|
selfOrganizationId = Number(organizationId)
|
|
@@ -833,15 +863,24 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
833
863
|
}))
|
|
834
864
|
}
|
|
835
865
|
let selfRead = null
|
|
866
|
+
let selfReadAfter = 0
|
|
836
867
|
const ensureSelfAgent = async () => {
|
|
837
868
|
if (selfAgentId != null) return
|
|
869
|
+
if (!selfRead && Date.now() < selfReadAfter) return
|
|
838
870
|
if (!selfRead) selfRead = callMcpTool('list_agents').then(toolData).then((data) => {
|
|
839
|
-
const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row
|
|
840
|
-
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
871
|
+
const self = (Array.isArray(data.agents) ? data.agents : []).find((row) => String(row?.identifier || row?.slug || '') === identifier)
|
|
841
872
|
rememberSelfAgent(self)
|
|
842
|
-
}).
|
|
873
|
+
}).catch((error) => {
|
|
874
|
+
log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
|
|
875
|
+
}).finally(() => { selfRead = null; selfReadAfter = Date.now() + 60_000 })
|
|
843
876
|
await selfRead
|
|
844
877
|
}
|
|
878
|
+
const resolveTaskOwner = async (task) => {
|
|
879
|
+
// An identifier on the authoritative task is sufficient. Older backends
|
|
880
|
+
// emit only a numeric assignee, which needs the optional profile lookup.
|
|
881
|
+
if (selfAgentId == null && !taskAgentIdentifier(task) && taskAgentId(task) != null) await ensureSelfAgent()
|
|
882
|
+
return taskBelongsToAgent(task, { id: selfAgentId, identifier })
|
|
883
|
+
}
|
|
845
884
|
let mcpToolNames = null
|
|
846
885
|
let mcpToolDiscoveryPromise = null
|
|
847
886
|
let mcpToolDiscoveryWarned = false
|
|
@@ -901,12 +940,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
901
940
|
}
|
|
902
941
|
|
|
903
942
|
const loadPendingTickets = async () => {
|
|
904
|
-
if (selfAgentId == null) {
|
|
905
|
-
const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
|
|
906
|
-
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
907
|
-
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
908
|
-
rememberSelfAgent(self)
|
|
909
|
-
}
|
|
910
943
|
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
911
944
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
912
945
|
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
@@ -916,6 +949,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
916
949
|
])
|
|
917
950
|
const tasksData = toolData(tasksResult)
|
|
918
951
|
const { done: doneIds, review: reviewIds } = typesResult
|
|
952
|
+
await Promise.all((Array.isArray(tasksData.tasks) ? tasksData.tasks : []).map(resolveTaskOwner))
|
|
919
953
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
920
954
|
const assignedHere = taskBelongsToAgent(task, { id: selfAgentId, identifier })
|
|
921
955
|
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
@@ -1103,10 +1137,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1103
1137
|
if (dedicatedChannelRead) return dedicatedChannelRead
|
|
1104
1138
|
dedicatedChannelRead = (async () => {
|
|
1105
1139
|
try {
|
|
1106
|
-
|
|
1107
|
-
const agents = toolData(await callMcpTool('list_agents')).agents || []
|
|
1108
|
-
rememberSelfAgent(agents.find((entry) => String(entry.identifier || entry.slug || '') === identifier))
|
|
1109
|
-
}
|
|
1140
|
+
await ensureSelfAgent()
|
|
1110
1141
|
const data = toolData(await callMcpTool('list_channels'))
|
|
1111
1142
|
const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
|
|
1112
1143
|
const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
|
|
@@ -1138,21 +1169,34 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1138
1169
|
return true
|
|
1139
1170
|
}
|
|
1140
1171
|
|
|
1141
|
-
const announceTaskCompletion = async (taskRef, result = {}) => {
|
|
1172
|
+
const announceTaskCompletion = async (taskRef, result = {}, isCancelled = () => false) => {
|
|
1142
1173
|
const projectId = Number(taskRef?.projectId)
|
|
1143
1174
|
const ticketId = Number(taskRef?.ticketId)
|
|
1144
1175
|
if (!Number.isFinite(projectId) || !Number.isFinite(ticketId)) return false
|
|
1145
1176
|
const taskKey = `${projectId}:${ticketId}`
|
|
1177
|
+
if (String(result.outputText || '').trim()) {
|
|
1178
|
+
pendingAgentResults.set(taskKey, { outputText: result.outputText, didChannelMessage: !!result.didChannelMessage })
|
|
1179
|
+
pendingCompletionReports.add(taskKey)
|
|
1180
|
+
trimMap(pendingAgentResults); trimSeen(pendingCompletionReports); persistReplay()
|
|
1181
|
+
}
|
|
1182
|
+
result = pendingAgentResults.get(taskKey)
|
|
1183
|
+
if (!result) return false
|
|
1146
1184
|
const current = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1147
1185
|
const ticket = current.ticket ?? current.task ?? current
|
|
1148
|
-
|
|
1149
|
-
|
|
1186
|
+
if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId)) || !taskBelongsToAgent(ticket, { id: selfAgentId, identifier })) {
|
|
1187
|
+
pendingAgentResults.delete(taskKey); pendingCompletionReports.delete(taskKey); persistReplay()
|
|
1188
|
+
return false
|
|
1189
|
+
}
|
|
1190
|
+
if (isCancelled()) return false
|
|
1191
|
+
const report = result.report || buildTaskCompletionReport(ticket, { projectId, fallbackText: result.outputText, agentFinal: true })
|
|
1192
|
+
if (report && !result.report) { result.report = report; persistReplay() }
|
|
1193
|
+
finishedTaskVersions.set(taskKey, taskRevision(ticket)); trimMap(finishedTaskVersions); persistReplay()
|
|
1150
1194
|
if (!report) return false
|
|
1151
1195
|
const memoryKey = `ticket:${projectId}:${ticketId}`
|
|
1152
1196
|
memory.remember({
|
|
1153
1197
|
key: memoryKey,
|
|
1154
1198
|
kind: 'ticket',
|
|
1155
|
-
state: '
|
|
1199
|
+
state: 'responded',
|
|
1156
1200
|
summary: report.content,
|
|
1157
1201
|
refs: { projectId, ticketId },
|
|
1158
1202
|
meta: { reportKey: report.key, prUrl: report.prUrl },
|
|
@@ -1171,7 +1215,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1171
1215
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
1172
1216
|
}
|
|
1173
1217
|
if (reportedCompletions.has(report.key)) {
|
|
1174
|
-
pendingCompletionReports.delete(taskKey)
|
|
1218
|
+
pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey)
|
|
1175
1219
|
persistReplay()
|
|
1176
1220
|
return true
|
|
1177
1221
|
}
|
|
@@ -1180,15 +1224,16 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1180
1224
|
// second top-level copy.
|
|
1181
1225
|
if (result.didChannelMessage) {
|
|
1182
1226
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1183
|
-
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
1227
|
+
pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
|
|
1184
1228
|
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
1185
1229
|
return true
|
|
1186
1230
|
}
|
|
1187
1231
|
const channelId = await projectStatusChannel(projectId)
|
|
1188
1232
|
if (channelId == null || !Number.isFinite(channelId)) return false
|
|
1233
|
+
if (isCancelled()) return false
|
|
1189
1234
|
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
1190
1235
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1191
|
-
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
1236
|
+
pendingCompletionReports.delete(taskKey); pendingAgentResults.delete(taskKey); persistReplay()
|
|
1192
1237
|
memory.remember({ key: memoryKey, kind: 'ticket', state: 'reported', summary: report.content, refs: { projectId, ticketId, channelId }, meta: { reportKey: report.key, prUrl: report.prUrl } })
|
|
1193
1238
|
log('posted verified completion for ticket #' + ticketId + ' in channel ' + channelId)
|
|
1194
1239
|
return true
|
|
@@ -1278,11 +1323,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1278
1323
|
if (!mcpUrl || backlogProbeBusy) return
|
|
1279
1324
|
backlogProbeBusy = true
|
|
1280
1325
|
try {
|
|
1281
|
-
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1282
|
-
const agents = Array.isArray(agentsData.agents) ? agentsData.agents : []
|
|
1283
|
-
const self = agents.find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1284
|
-
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
1285
|
-
rememberSelfAgent(self)
|
|
1286
1326
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1287
1327
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1288
1328
|
const mentionActivity = []
|
|
@@ -1296,8 +1336,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1296
1336
|
])
|
|
1297
1337
|
const { done: doneIds, review: reviewIds } = typesData
|
|
1298
1338
|
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
1299
|
-
if (!
|
|
1339
|
+
if (!await resolveTaskOwner(task)) continue
|
|
1300
1340
|
const taskKey = `${project.id}:${task.id}`
|
|
1341
|
+
if (pendingAgentResults.has(taskKey)) {
|
|
1342
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id }) }
|
|
1343
|
+
catch (error) { log('agent result delivery pending: ' + (error?.message || error)) }
|
|
1344
|
+
}
|
|
1301
1345
|
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1302
1346
|
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
1303
1347
|
if (pendingCompletionReports.has(taskKey)) {
|
|
@@ -1337,7 +1381,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1337
1381
|
return {}
|
|
1338
1382
|
})
|
|
1339
1383
|
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1340
|
-
|
|
1384
|
+
await ensureSelfAgent()
|
|
1385
|
+
const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
|
|
1341
1386
|
for (const item of activities) {
|
|
1342
1387
|
const text = JSON.stringify(item)
|
|
1343
1388
|
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
@@ -1404,9 +1449,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1404
1449
|
const baseFor = (kind, delivery) => delivery?.watcherOwned
|
|
1405
1450
|
? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
|
|
1406
1451
|
: kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
const cycleSucceeded = (result) => !!result && !result.is_error && [
|
|
1452
|
+
// A native final turn ends a cycle. Ticket completion is a separate board
|
|
1453
|
+
// action chosen by the agent; tool counts never determine either decision.
|
|
1454
|
+
const cycleSucceeded = (result) => !!result && !result.is_error && ['ok', 'success'].includes(result.subtype)
|
|
1410
1455
|
const releaseTaskForRetry = (taskRef, prompt) => {
|
|
1411
1456
|
const directProject = Number(taskRef?.projectId)
|
|
1412
1457
|
const directTicket = Number(taskRef?.ticketId)
|
|
@@ -1431,12 +1476,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1431
1476
|
emit('cycle.queued', { ...cycleData, status: 'queued' })
|
|
1432
1477
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
|
|
1433
1478
|
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)) })
|
|
1479
|
+
emit(status === 'canceled' ? 'cycle.cancelled' : 'cycle.finished', { ...cycleData, status, finishedBy: control.finishedBy || 'runtime', durationMs: Math.round(performance.now() - (control.startedAt ?? control.enqueuedAt)) })
|
|
1435
1480
|
return result
|
|
1436
1481
|
})
|
|
1437
1482
|
}
|
|
1438
1483
|
|
|
1439
1484
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1485
|
+
refreshModelSettings()
|
|
1440
1486
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1441
1487
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1442
1488
|
const cycleStartedAt = performance.now()
|
|
@@ -1446,6 +1492,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1446
1492
|
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1447
1493
|
const ctx = context ? [context] : []
|
|
1448
1494
|
const activeTaskRef = taskRef
|
|
1495
|
+
let verifiedTaskRevision = ''
|
|
1449
1496
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1450
1497
|
const runnerOptions = {
|
|
1451
1498
|
cycleId: cycleControl.cycleId, attempt: 1,
|
|
@@ -1463,10 +1510,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1463
1510
|
const memoryRefs = delivery
|
|
1464
1511
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1465
1512
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1466
|
-
|
|
1467
|
-
|
|
1513
|
+
let recalled = ''
|
|
1514
|
+
try { recalled = await memory.context(memoryRefs) }
|
|
1515
|
+
catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
|
|
1516
|
+
const capabilityContext = kind !== 'full' && canCode
|
|
1517
|
+
? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the findings and context to carry forward, then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
|
|
1518
|
+
: canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
|
|
1519
|
+
const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1468
1520
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1469
1521
|
// work (full/sweep) uses the main model.
|
|
1522
|
+
refreshModelSettings()
|
|
1523
|
+
const cycleModelRevision = modelSettingsRevision
|
|
1470
1524
|
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1471
1525
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1472
1526
|
// Presence notifications are intentionally disabled; durable messages and
|
|
@@ -1477,6 +1531,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1477
1531
|
if (activeTaskRef) {
|
|
1478
1532
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1479
1533
|
const ticket = data.ticket ?? data.task ?? data
|
|
1534
|
+
verifiedTaskRevision = taskRevision(ticket)
|
|
1480
1535
|
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1481
1536
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1482
1537
|
!taskBelongsToAgent(ticket, { id: selfAgentId, identifier }) ||
|
|
@@ -1490,9 +1545,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1490
1545
|
cycleControl.outcome = 'running'
|
|
1491
1546
|
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1492
1547
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1493
|
-
cycleControl.outcome = result?.subtype || '
|
|
1548
|
+
cycleControl.outcome = result?.is_error ? 'error' : result?.subtype || 'error'
|
|
1494
1549
|
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1495
|
-
|
|
1550
|
+
refreshModelSettings()
|
|
1551
|
+
if (cycleModelRevision === modelSettingsRevision && result?.model && result.model !== useModel) {
|
|
1496
1552
|
// Persist only a successfully selected model and only if the user has
|
|
1497
1553
|
// not changed that tier while this cycle was running.
|
|
1498
1554
|
let changed = false
|
|
@@ -1500,7 +1556,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1500
1556
|
if (liteModel === useModel) { liteModel = result.model; changed = true }
|
|
1501
1557
|
if (changed) persistModel()
|
|
1502
1558
|
}
|
|
1503
|
-
|
|
1559
|
+
const completionResult = result
|
|
1504
1560
|
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1505
1561
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1506
1562
|
return
|
|
@@ -1513,7 +1569,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1513
1569
|
}
|
|
1514
1570
|
if (!cycleSucceeded(result)) {
|
|
1515
1571
|
const outcome = result?.subtype || 'an unknown runtime error'
|
|
1516
|
-
const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}. I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
|
|
1572
|
+
const notice = `I'm blocked because the ${kind === 'full' ? 'coding' : 'reply'} cycle ended with ${outcome}.${result?.subtype === 'rate_limited' ? ` The model provider is rate-limiting this request. Choose a model with available quota in Agent Studio, or retry once the limit resets.\n\n[Open Agent Studio](http://127.0.0.1:4317/#agent=${encodeURIComponent(identifier)}&settings=1)\n\n` : ' '}I'm not claiming completion.${activeTaskRef ? " I've paused this revision until the ticket changes." : ''}`
|
|
1517
1573
|
log('WORK_CYCLE_BLOCKED ' + outcome + '; publishing blocker')
|
|
1518
1574
|
if (activeTaskRef) pauseFailedTask(activeTaskRef)
|
|
1519
1575
|
try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
|
|
@@ -1521,56 +1577,36 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1521
1577
|
finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
|
|
1522
1578
|
return
|
|
1523
1579
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1580
|
+
if (result?.workRequest && kind !== 'full' && canCode) {
|
|
1581
|
+
// An agent-selected continuation, not a heuristic retry or a new task.
|
|
1582
|
+
// Preserve the authoritative recipient and cancellation key; the tool
|
|
1583
|
+
// cannot select another task, channel, identity, or permission scope.
|
|
1584
|
+
cycleControl.outcome = 'continued'
|
|
1585
|
+
cycleControl.finishedBy = 'agent'
|
|
1586
|
+
const continuation = String(result.workRequest.context || '').slice(0, 12000)
|
|
1587
|
+
const handoffKey = `continuation:${cycleControl.cycleId}`
|
|
1588
|
+
memory.remember({ key: handoffKey, kind: 'continuation', state: 'requested', summary: continuation, refs: memoryRefs, meta: { context: continuation, requestedBy: 'agent' } })
|
|
1589
|
+
const sourceKey = delivery?.sourceKey || (activeTaskRef ? `ticket:${activeTaskRef.projectId}:${activeTaskRef.ticketId}` : '')
|
|
1590
|
+
if (sourceKey) memory.connect(sourceKey, handoffKey, 'continued_with')
|
|
1591
|
+
emit('cycle.continued', { cycleId: cycleControl.cycleId, status: 'continued', reason: 'agent requested coding workspace' })
|
|
1592
|
+
void drain('full', `${context || ''}\n\nYOUR CONTINUATION CONTEXT:\n${continuation}\nContinue the original request using your configured workspace. Preserve the same role, voice, recipient, and authorization.`, targetChannels, activeTaskRef, delivery)
|
|
1531
1593
|
return
|
|
1532
1594
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1595
|
+
// The native runtime's successful final turn is the agent's decision to
|
|
1596
|
+
// yield. Tool activity is telemetry, not a universal completion checklist.
|
|
1597
|
+
// Optional failures and alternative workflows remain the agent's concern.
|
|
1598
|
+
cycleControl.outcome = 'ok'
|
|
1599
|
+
cycleControl.finishedBy = 'agent'
|
|
1600
|
+
if (result?.mcpErrors?.length) log('agent ended its turn with tool diagnostics: ' + result.mcpErrors.join(', '))
|
|
1601
|
+
if (activeTaskRef && !String(completionResult?.outputText || '').trim()) {
|
|
1602
|
+
finishedTaskVersions.set(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`, verifiedTaskRevision)
|
|
1603
|
+
trimMap(finishedTaskVersions); persistReplay()
|
|
1535
1604
|
}
|
|
1536
|
-
|
|
1537
|
-
// runtime-observed ticket reads, repository evidence, and ticket updates.
|
|
1538
|
-
const ticketCycle = !!activeTaskRef || /ticket\s+#?\d+.*?project\s+\d+/i.test(prompt)
|
|
1539
|
-
const resultMessageRequired = kind === 'full' && !!delivery && !delivery.watcherOwned
|
|
1540
|
-
const missing = missingRuntimeWorkEvidence(result, { ticketCycle, resultMessageRequired })
|
|
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 })
|
|
1544
|
-
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
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}`
|
|
1546
|
-
const recovery = await runner.runCycle(recoveryPrompt, codeModel, { ...runnerOptions, attempt: 2 })
|
|
1547
|
-
cycleControl.outcome = recovery?.subtype || 'ok'
|
|
1548
|
-
if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
|
|
1549
|
-
const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
|
|
1550
|
-
const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
|
|
1551
|
-
if (!cycleSucceeded(recovery) || recoveryMissing.length) {
|
|
1552
|
-
cycleControl.outcome = 'blocked'
|
|
1553
|
-
if (recovery?.subtype === 'blocked' && recovery?.policyBlock) {
|
|
1554
|
-
try { await reportPolicyBlock(prompt, activeTaskRef, recovery.policyBlock, delivery) }
|
|
1555
|
-
catch (e) { log('failed to publish recovery policy blocker: ' + (e?.message || e)) }
|
|
1556
|
-
} else {
|
|
1557
|
-
const unresolved = recoveryMissing.length ? recoveryMissing : [`the recovery cycle ended with ${recovery?.subtype || 'an unknown error'}`]
|
|
1558
|
-
const notice = `I'm blocked after one recovery attempt. Missing required evidence: ${unresolved.join('; ')}. I've paused this revision until the ticket changes, and I'm not claiming completion.`
|
|
1559
|
-
if (activeTaskRef) pauseFailedTask(activeTaskRef)
|
|
1560
|
-
try { await publishBlocker({ prompt, taskRef: activeTaskRef, delivery, notice }) }
|
|
1561
|
-
catch (e) { log('failed to publish recovery blocker: ' + (e?.message || e)) }
|
|
1562
|
-
finally { if (activeTaskRef) await finalizeFailedTaskPause(activeTaskRef) }
|
|
1563
|
-
}
|
|
1564
|
-
log('WORK_CYCLE_FAILED evidence gate still incomplete after one recovery; ticket paused until its revision changes')
|
|
1565
|
-
return
|
|
1566
|
-
}
|
|
1567
|
-
completionResult = recoveredResult
|
|
1568
|
-
}
|
|
1569
|
-
if (kind === 'full' && activeTaskRef) {
|
|
1605
|
+
if (activeTaskRef) {
|
|
1570
1606
|
try {
|
|
1571
|
-
const delivered = await announceTaskCompletion(activeTaskRef, completionResult)
|
|
1607
|
+
const delivered = await announceTaskCompletion(activeTaskRef, completionResult, () => cycleControl.cancelled)
|
|
1572
1608
|
if (!delivered) {
|
|
1573
|
-
log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for
|
|
1609
|
+
log('completion report deferred for ticket #' + activeTaskRef.ticketId + '; waiting for result delivery without rerunning the agent')
|
|
1574
1610
|
}
|
|
1575
1611
|
} catch (e) {
|
|
1576
1612
|
log('completion report failed for ticket #' + activeTaskRef.ticketId + ': ' + (e?.message || e) + '; retained for delivery retry without rerunning repository work')
|
|
@@ -1581,8 +1617,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1581
1617
|
if (!reply) {
|
|
1582
1618
|
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
1583
1619
|
} else {
|
|
1620
|
+
const deliveryStartedAt = performance.now()
|
|
1584
1621
|
try { await postMessageOnce({ ...delivery, content: reply }) }
|
|
1585
1622
|
catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
|
|
1623
|
+
finally { log(`cycle timing delivery=${Math.round(performance.now() - deliveryStartedAt)}ms kind=${kind}`) }
|
|
1586
1624
|
}
|
|
1587
1625
|
}
|
|
1588
1626
|
} finally {
|
|
@@ -1637,19 +1675,14 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1637
1675
|
const projectId = hinted && (hinted.project_id ?? hinted.projectId ?? raw.project_id ?? raw.projectId)
|
|
1638
1676
|
if (!validBackendId(ticketId) || !validBackendId(projectId)) { log(kind + ' missing ticket/project identity — ignored'); return }
|
|
1639
1677
|
try {
|
|
1640
|
-
if (selfAgentId == null) {
|
|
1641
|
-
const agentsData = toolData(await callMcpTool('list_agents'))
|
|
1642
|
-
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1643
|
-
selfAgentId = null
|
|
1644
|
-
rememberSelfAgent(self)
|
|
1645
|
-
}
|
|
1646
1678
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1647
1679
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1648
1680
|
if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) { log('get_ticket returned mismatched identity; ignored'); return }
|
|
1649
1681
|
const typeSets = await taskTypeSets(projectId)
|
|
1650
|
-
const belongsToSelf =
|
|
1682
|
+
const belongsToSelf = await resolveTaskOwner(ticket)
|
|
1651
1683
|
const key = `${projectId}:${ticketId}`
|
|
1652
1684
|
if (!belongsToSelf) {
|
|
1685
|
+
finishedTaskVersions.delete(key); pendingAgentResults.delete(key)
|
|
1653
1686
|
for (const [name, queue] of Object.entries(queues)) {
|
|
1654
1687
|
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
|
|
1655
1688
|
item.control.cancelled = true
|
|
@@ -1659,6 +1692,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1659
1692
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1660
1693
|
blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
|
|
1661
1694
|
}
|
|
1695
|
+
if (finishedTaskVersions.has(key)) {
|
|
1696
|
+
if (finishedTaskVersions.get(key) === taskRevision(ticket)) {
|
|
1697
|
+
if (pendingAgentResults.has(key)) await announceTaskCompletion({ projectId, ticketId })
|
|
1698
|
+
return
|
|
1699
|
+
}
|
|
1700
|
+
finishedTaskVersions.delete(key); seenTasks.delete(key); persistReplay()
|
|
1701
|
+
}
|
|
1662
1702
|
if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1663
1703
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1664
1704
|
if (pendingCompletionReports.has(key)) {
|
|
@@ -1690,10 +1730,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1690
1730
|
log(kind + ' verified ticket #' + ticketId + ' “' + title + '” -> ' + cycleKind + ' lane')
|
|
1691
1731
|
const activityChannel = await projectStatusChannel(projectId)
|
|
1692
1732
|
if (activityChannel != null) sendStatus(activityChannel, 'thinking')
|
|
1693
|
-
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1694
1733
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1695
1734
|
const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
|
|
1696
|
-
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself.
|
|
1735
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Choose the tools, context, validation, and ticket state appropriate to this request. Return your final response when you decide this turn is finished; the watcher will publish it once in the dedicated agent channel without changing ticket status.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
1697
1736
|
} catch (e) {
|
|
1698
1737
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1699
1738
|
}
|
|
@@ -1713,6 +1752,15 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1713
1752
|
// Keep redirects ordered while a legacy root is being verified. Other
|
|
1714
1753
|
// conversations can resolve and run independently.
|
|
1715
1754
|
const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
|
|
1755
|
+
const { idKey } = mentionDedupeKeys(message, cid)
|
|
1756
|
+
const sourceKey = `mention:${cid}:${idKey}`
|
|
1757
|
+
// A confirmed received ledger entry needs no identity/root network read.
|
|
1758
|
+
// Preserve the legacy replay recovery path when only the seen key exists.
|
|
1759
|
+
if (idKey && seenMentions.has(idKey) && memory.has(sourceKey, 'received')) {
|
|
1760
|
+
log(`${k} duplicate channel=${cid} message=${messageId} — original already accepted; no new cycle`)
|
|
1761
|
+
return { status: 'duplicate' }
|
|
1762
|
+
}
|
|
1763
|
+
const intakeStartedAt = performance.now()
|
|
1716
1764
|
try { await ensureSelfAgent() }
|
|
1717
1765
|
catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
|
|
1718
1766
|
if (k === 'channel:message' && selfOrganizationId == null) return { status: 'ignored-unknown-organization' }
|
|
@@ -1723,13 +1771,30 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1723
1771
|
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
1724
1772
|
let rootContent = ''
|
|
1725
1773
|
if (parent != null && !irrelevant && preliminary.action !== 'stand_down') {
|
|
1726
|
-
|
|
1774
|
+
const optional = preliminary.explicitSelf || memory.has(threadControlKey(cid, parent), 'active')
|
|
1775
|
+
let contextTimer
|
|
1776
|
+
try {
|
|
1777
|
+
const context = resolveOwnThread(cid, parent)
|
|
1778
|
+
// An explicit recipient or durable ownership already authorizes intake.
|
|
1779
|
+
// Slow optional context must not consume the transport's 20s deadline
|
|
1780
|
+
// before a model starts. Delivery still re-reads the authoritative thread.
|
|
1781
|
+
rootContent = optional ? await Promise.race([context, new Promise((resolve) => {
|
|
1782
|
+
contextTimer = setTimeout(() => {
|
|
1783
|
+
log(`mention optional context pending channel=${cid} message=${messageId} — continuing with verified recipient`)
|
|
1784
|
+
resolve('')
|
|
1785
|
+
}, 1000)
|
|
1786
|
+
})]) : await context
|
|
1787
|
+
}
|
|
1727
1788
|
catch (error) {
|
|
1728
1789
|
if (!preliminary.explicitSelf && !memory.has(threadControlKey(cid, parent), 'active')) throw error
|
|
1729
1790
|
log('optional root context unavailable; preserving verified recipient: ' + (error?.message || error))
|
|
1730
1791
|
}
|
|
1792
|
+
finally { clearTimeout(contextTimer) }
|
|
1793
|
+
}
|
|
1794
|
+
if (!stopping) {
|
|
1795
|
+
if (!irrelevant) log(`mention timing intake=${Math.round(performance.now() - intakeStartedAt)}ms source=${k} channel=${cid} message=${messageId}`)
|
|
1796
|
+
return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
|
|
1731
1797
|
}
|
|
1732
|
-
if (!stopping) return routeEvent('agent:mention', { ...d, _threadRootContent: rootContent })
|
|
1733
1798
|
}).catch((error) => { log('mention thread verification failed: ' + (error?.message || error)); return { status: 'retry' } })
|
|
1734
1799
|
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
1735
1800
|
mentionIntake.set(key, next)
|
|
@@ -1817,7 +1882,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1817
1882
|
}
|
|
1818
1883
|
return
|
|
1819
1884
|
}
|
|
1820
|
-
const
|
|
1885
|
+
const creatingTicket = conversationCreatesTicket(text)
|
|
1886
|
+
const assignment = creatingTicket ? null : assignmentRequest(text)
|
|
1821
1887
|
if (assignment) {
|
|
1822
1888
|
const delivery = conversationDelivery('assignment-routing')
|
|
1823
1889
|
const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
|
|
@@ -1837,7 +1903,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1837
1903
|
})().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
|
|
1838
1904
|
return
|
|
1839
1905
|
}
|
|
1840
|
-
if (cid != null && conversationAsksPendingTickets(text)) {
|
|
1906
|
+
if (cid != null && !creatingTicket && conversationAsksPendingTickets(text)) {
|
|
1841
1907
|
log('pending-ticket question -> watcher-owned MCP lookup')
|
|
1842
1908
|
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
|
|
1843
1909
|
return
|
|
@@ -1846,20 +1912,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1846
1912
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1847
1913
|
// subsequent working/typing heartbeat for its lane.
|
|
1848
1914
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1849
|
-
const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
1915
|
+
const codingMention = !creatingTicket && canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
1850
1916
|
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1851
1917
|
let ctx = cid != null
|
|
1852
1918
|
? replyDelivery?.watcherOwned
|
|
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}".` : ''}
|
|
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)` : ''},
|
|
1919
|
+
? `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}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
1920
|
+
: `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)` : ''}, using the authenticated session bridge. 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.` : ''} Use additional discovery, resources, and history when they help answer this request.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
|
|
1855
1921
|
: undefined
|
|
1922
|
+
if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project and valid type using list_projects and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
|
|
1856
1923
|
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.`
|
|
1857
1924
|
if (codingMention) {
|
|
1858
1925
|
// Activity indicators make accepted work visible. Do not add a canned
|
|
1859
1926
|
// pickup message; the first channel message is the verified result or a
|
|
1860
1927
|
// concrete blocker from the work lane.
|
|
1861
1928
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1862
|
-
} else void drain('fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1929
|
+
} else void drain(creatingTicket ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
1863
1930
|
} else if (k === 'error') {
|
|
1864
1931
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
1865
1932
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
@@ -1911,7 +1978,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1911
1978
|
deliveryTimer?.unref?.()
|
|
1912
1979
|
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
1913
1980
|
authorizationTimer?.unref?.()
|
|
1914
|
-
const
|
|
1981
|
+
const modelSettingsTimer = autoStart ? setInterval(refreshModelSettings, 2_000) : null
|
|
1982
|
+
modelSettingsTimer?.unref?.()
|
|
1983
|
+
emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
|
|
1984
|
+
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, modelSettingsRevision, workdir: workdir || '' }), 15_000) : null
|
|
1915
1985
|
heartbeatTimer?.unref?.()
|
|
1916
1986
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1917
1987
|
if (autoStart && (mcpConfig || mcpUrl)) {
|
|
@@ -1945,6 +2015,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1945
2015
|
clearInterval(authorizationTimer)
|
|
1946
2016
|
clearInterval(deliveryTimer)
|
|
1947
2017
|
clearInterval(heartbeatTimer)
|
|
2018
|
+
clearInterval(modelSettingsTimer)
|
|
1948
2019
|
if (introTimer) clearTimeout(introTimer)
|
|
1949
2020
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1950
2021
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|