openvisio-agent 0.18.14 → 0.19.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 +9 -7
- package/package.json +9 -3
- package/scripts/certify.mjs +12 -9
- package/src/codex-mcp-proxy.mjs +5 -1
- package/src/cycle-queue.mjs +33 -20
- package/src/events.mjs +4 -0
- package/src/mastra-harness.mjs +185 -0
- package/src/memory.mjs +70 -0
- package/src/watch.mjs +119 -83
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ That's it. Your selected runtime now has the team's tools (channels, tickets, do
|
|
|
17
17
|
|
|
18
18
|
## What it does
|
|
19
19
|
|
|
20
|
-
`openvisio-agent` is a
|
|
20
|
+
`openvisio-agent` is a local CLI and autonomy harness. Nothing is fetched-and-executed outside its declared npm dependencies; the source is here and on npm.
|
|
21
21
|
|
|
22
22
|
### `connect <ovs_code> --host <url>`
|
|
23
23
|
|
|
@@ -50,11 +50,13 @@ npx -y openvisio-agent@latest connect --backend https://api.your-org.example/dev
|
|
|
50
50
|
- `--ws <wss-url>` — the org's API-Gateway WebSocket base (the same value the frontend uses as `NEXT_PUBLIC_BACKEND_WS_URL`).
|
|
51
51
|
- `--mcp-url <url>` — registers the `openvisio-team` MCP (authed with the agent header pair) so the agent has tools to **act** on the events.
|
|
52
52
|
|
|
53
|
-
Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it.
|
|
53
|
+
Then `watch --name ada` auto-detects the backend agent and runs a WebSocket loop instead of polling: it connects with `?api_key=&identifier=`, keeps the connection warm with keepalives, reconnects with backoff, and queues each accepted assignment or mention independently. A work cycle can contain many assistant, tool, and message actions; emitting a progress message does not end it. The Mastra runtime requires **Node ≥ 22.13**.
|
|
54
54
|
|
|
55
55
|
### `watch --name <agent>`
|
|
56
56
|
|
|
57
|
-
Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and
|
|
57
|
+
Runs the **autonomy loop** — the agent replies to @mentions and picks up tickets on its own. It cheaply polls an inbox endpoint (no model spend when idle) and starts isolated runtime sessions only when something new arrives.
|
|
58
|
+
|
|
59
|
+
Codex and OpenCode cycles run through Mastra's ACP harness. Codex uses the packaged `codex-acp` adapter and reuses the machine's existing ChatGPT/Codex login; OpenCode uses its native `opencode acp` server. Each accepted ticket gets its own ACP session and worktree. Mastra Memory stores ticket/thread context in local libSQL under `~/.openvisio/`, while a compact JSON ledger retains only exact replay, cancellation, and delivery keys.
|
|
58
60
|
|
|
59
61
|
Backend/BYO watchers reconcile immediately whenever the process starts or the WebSocket connects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, `list_task_types`, and `list_activity` to recover assigned tasks and recent mention activity missed while offline. Optional actions such as ticket comments are used only when advertised; their absence cannot strand a completed ticket in a retry loop. The same zero-model check runs every five minutes as a safety net; a model starts only when pending work exists.
|
|
60
62
|
|
|
@@ -62,7 +64,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
|
|
|
62
64
|
|
|
63
65
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent Sol reply/work lanes, authoritative `get_ticket` verification for assignments, REST-backed in-app activity, persistent replay suppression, and runtime evidence gates before completion. Maintainers must run `npm run certify` before publishing.
|
|
64
66
|
|
|
65
|
-
An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread.
|
|
67
|
+
An `agent:mention` event only wakes the watcher; it does not grant ownership of the conversation. The actual source message is checked before any model starts. Messages redirected to another agent and unaddressed agent chatter stay silent, while a direct stand-down cancels queued/running work for that thread. The watcher does not broadcast working, thinking, or typing presence updates. Claude and OpenCode may add one concrete progress update after work begins, but must continue and post a distinct verified result or blocker afterward; Codex keeps cancellation-safe delivery watcher-owned and renders the verified final answer once.
|
|
66
68
|
|
|
67
69
|
Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
|
|
68
70
|
|
|
@@ -75,7 +77,7 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
75
77
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
The
|
|
80
|
+
The watcher can run up to three assigned coding tasks concurrently. Each ticket gets an isolated model runtime and ticket-specific worktree; tasks sharing the same mutable workspace remain serialized to prevent branch and file conflicts. Chat replies stay serialized, and cancellation targets only the matching ticket or source thread. The work pool reuses local clones and is instructed to preserve dirty/staged work, create unique branches, and stage only task-owned changes. The independent reply lane receives a chat charter and no coding workspace. Codex publishes local branches through the constrained helper, then opens a PR with `gh pr create`; linked-codebase MCP operations are a fallback when the repository cannot be obtained locally.
|
|
79
81
|
|
|
80
82
|
Publishing a local branch uses an explicit, one-time authorization per repository:
|
|
81
83
|
|
|
@@ -86,7 +88,7 @@ openvisio-agent authorize-pr-push
|
|
|
86
88
|
|
|
87
89
|
That command installs a narrow Codex rule for `openvisio-agent push-pr-branch` and records the repository's exact root and `origin`. The helper accepts no arguments and can only push `HEAD` to the same `agent/*` branch on that authorized origin. It disables repository hooks and rejects main/master, other branch namespaces, changed remotes, force pushes, local/file remotes, and credential-bearing URLs. Revoke it from the repository with `openvisio-agent revoke-pr-push`.
|
|
88
90
|
|
|
89
|
-
Assignments use independent FIFO entries with stable-key duplicate suppression. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated.
|
|
91
|
+
Assignments use independent FIFO entries with stable-key duplicate suppression and bounded, worktree-aware concurrency. Backlog recovery uses the same ticket verification path as WebSocket events, and each queued ticket is checked again before a model starts. MCP calls have a 20-second deadline including response bodies; tool discovery is cached and paginated. Codex MCP failures retain a bounded, credential-redacted reason in the watcher log. Cancellation targets only its ACP session. Credentials and replay state use atomic file replacement; Mastra persists contextual memory in libSQL.
|
|
90
92
|
|
|
91
93
|
See `docs/BYO_SYSTEM_AUDIT.md` in the repository for the audit results and remaining live-validation limits. Local certification does not certify a deployed agent's behavior.
|
|
92
94
|
|
|
@@ -103,7 +105,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
103
105
|
|
|
104
106
|
## Requirements
|
|
105
107
|
|
|
106
|
-
- Node.js ≥
|
|
108
|
+
- Node.js ≥ 22.13
|
|
107
109
|
- Claude Code, Codex (`@openai/codex`), or OpenCode (the selected CLI is auto-installed if missing)
|
|
108
110
|
|
|
109
111
|
## Getting a setup code
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team — MCP tools + optional autonomy — in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"README.md"
|
|
18
18
|
],
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=22.13"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"openvisio",
|
|
@@ -34,5 +34,11 @@
|
|
|
34
34
|
"url": "git+https://github.com/syntaxPriest/OpenVisio.git",
|
|
35
35
|
"directory": "packages/openvisio-agent"
|
|
36
36
|
},
|
|
37
|
-
"dependencies": {
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@agentclientprotocol/codex-acp": "1.10.0",
|
|
39
|
+
"@mastra/acp": "0.4.1",
|
|
40
|
+
"@mastra/core": "1.64.0",
|
|
41
|
+
"@mastra/libsql": "1.22.3",
|
|
42
|
+
"@mastra/memory": "1.28.2"
|
|
43
|
+
}
|
|
38
44
|
}
|
package/scripts/certify.mjs
CHANGED
|
@@ -31,6 +31,7 @@ const prPush = readFileSync(join(root, 'src', 'pr-push.mjs'), 'utf8')
|
|
|
31
31
|
const cli = readFileSync(join(root, 'bin', 'cli.mjs'), 'utf8')
|
|
32
32
|
const websocket = readFileSync(join(root, 'src', 'ws.mjs'), 'utf8')
|
|
33
33
|
const cycleQueue = readFileSync(join(root, 'src', 'cycle-queue.mjs'), 'utf8')
|
|
34
|
+
const mastraHarness = readFileSync(join(root, 'src', 'mastra-harness.mjs'), 'utf8')
|
|
34
35
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
35
36
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
36
37
|
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
@@ -56,7 +57,7 @@ const assertions = [
|
|
|
56
57
|
['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
|
|
57
58
|
['conversation wake events are recipient-filtered before model start', watcher.includes('classifyConversationTarget(msg, [...selfAliases])') && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
|
|
58
59
|
['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
|
|
59
|
-
['stand-down and redirects cancel source-thread
|
|
60
|
+
['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
|
|
60
61
|
['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
|
|
61
62
|
['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
|
|
62
63
|
['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
|
|
@@ -66,17 +67,18 @@ const assertions = [
|
|
|
66
67
|
['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
|
|
67
68
|
['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
|
|
68
69
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
69
|
-
['tickets and guarded replies use the behavior-tested
|
|
70
|
+
['tickets and guarded replies use the behavior-tested bounded queue', watcher.includes('createCycleQueue({') && watcher.includes('queues[laneName].enqueue') && cycleQueue.includes('const pending = new Map()') && cycleQueue.includes('activeGroups')],
|
|
70
71
|
['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
|
|
71
72
|
['feature titles cannot be mistaken for coordination commands', watcher.includes('taskIsCoordinationOnly(taskText)') && events.includes('Uploading files as messages') && events.includes('explicitCoordination')],
|
|
72
|
-
['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''
|
|
73
|
+
['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
|
|
73
74
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
74
|
-
['
|
|
75
|
-
['
|
|
76
|
-
['work and reply
|
|
75
|
+
['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
|
|
76
|
+
['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
77
|
+
['one watcher owns isolated concurrent work and serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunner = createCycleRunner')],
|
|
78
|
+
['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(entry.group)')],
|
|
79
|
+
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
77
80
|
['assigned task activity resolves a project channel', watcher.includes("callMcpTool('list_channels'") && watcher.includes('projectStatusChannel(projectId)')],
|
|
78
|
-
['
|
|
79
|
-
['activity uses REST endpoint', watcher.includes('agentStateRequest(')],
|
|
81
|
+
['presence notification requests are disabled', watcher.includes('const sendStatus = () => {}') && !watcher.includes('agentStateRequest(') && !watcher.includes("setInterval(() => emitStatusTargets")],
|
|
80
82
|
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
81
83
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
82
84
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
@@ -93,7 +95,7 @@ const assertions = [
|
|
|
93
95
|
['quick replies preserve distinct top-level conversations', quickReply.includes('m.parentId ?? m.messageId') && quickReply.includes('...(parentId ? { parentId } : {})')],
|
|
94
96
|
['completion has an evidence failure gate', watcher.includes('WORK_CYCLE_FAILED')],
|
|
95
97
|
['failed evidence revisions persist and suppress self-triggered retries', watcher.includes('failedTaskVersions: [...failedTaskVersions]') && watcher.includes("failedTaskVersions.set(key, 'pending')") && watcher.includes('failedTaskRevisionIsCurrent(failedRevision, ticket)') && watcher.includes('ticket paused until its revision changes')],
|
|
96
|
-
['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && watcher.includes('Do not call list_codebases, codebase_tree, or get_codebase')],
|
|
98
|
+
['assigned coding tickets prefer their prepared local worktree', watcher.includes('findTicketWorktree(workdir, ticketId)') && watcher.includes('A prepared local git worktree for this ticket exists') && watcher.includes('Do not call list_codebases, codebase_tree, or get_codebase') && watcher.includes('cwd: cycleCwd') && watcher.includes('workdir: ticketWorktree')],
|
|
97
99
|
['OpenCode emits structured events for runtime evidence', watcher.includes("'--format', 'json'") && watcher.includes('opencodeEventEvidence(event)')],
|
|
98
100
|
['OpenCode API-key MCP disables OAuth probing', opencodeConfig.includes("oauth: false") && opencodeConfig.includes('timeout: 15_000')],
|
|
99
101
|
['OpenCode identities use isolated configs outside the code workspace', watcher.includes('opencodeRuntimeLayout({ cfgKey, workdir })') && watcher.includes("'--dir', workspace") && watcher.includes('OPENCODE_CONFIG: opencodeConfigPath') && watcher.includes('OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig)')],
|
|
@@ -101,6 +103,7 @@ const assertions = [
|
|
|
101
103
|
['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
|
|
102
104
|
['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
103
105
|
['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
|
|
106
|
+
['Codex MCP failures retain sanitized per-call diagnostics', events.includes('rawToolError = item.error') && watcher.includes('mcpErrorDetails.set(evidence.mcpTool, safe)') && watcher.includes("join('[redacted]')")],
|
|
104
107
|
['coding recovery retains source context and requires a final result', watcher.includes('ORIGINAL REQUEST AND ROUTING CONTEXT') && events.includes('resultMessageRequired && !result?.didResultMessage') && events.includes("outputText: second?.outputText || first?.outputText || ''")],
|
|
105
108
|
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
106
109
|
['MCP initialize is shared across concurrent startup probes', mcpHttp.includes('if (initializePromise) return initializePromise')],
|
package/src/codex-mcp-proxy.mjs
CHANGED
|
@@ -21,6 +21,9 @@ export function runCodexMcpProxy() {
|
|
|
21
21
|
const url = process.env.OPENVISIO_CODEX_MCP_URL
|
|
22
22
|
const apiKey = process.env.OPENVISIO_CODEX_API_KEY
|
|
23
23
|
const identifier = process.env.OPENVISIO_CODEX_IDENTIFIER
|
|
24
|
+
let disabledTools = []
|
|
25
|
+
try { disabledTools = JSON.parse(process.env.OPENVISIO_CODEX_DISABLED_TOOLS || '[]') } catch { /* no disabled tools */ }
|
|
26
|
+
const disabled = new Set(Array.isArray(disabledTools) ? disabledTools.map(String) : [])
|
|
24
27
|
if (!url || !apiKey || !identifier) throw new Error('OpenVisio Codex MCP bridge is missing its watcher environment.')
|
|
25
28
|
const client = createMcpHttpClient({ url, apiKey, identifier, clientVersion: 'openvisio-agent-codex-proxy' })
|
|
26
29
|
const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n')
|
|
@@ -52,8 +55,9 @@ export function runCodexMcpProxy() {
|
|
|
52
55
|
reply(id, {})
|
|
53
56
|
} else if (request.method === 'tools/list') {
|
|
54
57
|
const tools = await client.listTools()
|
|
55
|
-
reply(id, { tools: tools.map(toolWithoutCredentialInputs) })
|
|
58
|
+
reply(id, { tools: tools.filter((tool) => !disabled.has(String(tool.name))).map(toolWithoutCredentialInputs) })
|
|
56
59
|
} else if (request.method === 'tools/call') {
|
|
60
|
+
if (disabled.has(String(request.params?.name || ''))) throw new Error('This tool is disabled for the current delivery lane.')
|
|
57
61
|
reply(id, await client.callTool(request.params?.name, request.params?.arguments || {}))
|
|
58
62
|
} else if (id != null) {
|
|
59
63
|
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }) + '\n')
|
package/src/cycle-queue.mjs
CHANGED
|
@@ -1,33 +1,45 @@
|
|
|
1
1
|
// One immutable source per cycle. A Map provides FIFO ordering and constant-time
|
|
2
2
|
// replay checks without merging ticket identities or starving older assignments.
|
|
3
|
-
|
|
3
|
+
// Bounded concurrency is opt-in. `groupKey` keeps items sharing a repository or
|
|
4
|
+
// another mutable resource serialized while independent work can run in parallel.
|
|
5
|
+
export function createCycleQueue({ run, onError = () => {}, concurrency = 1, groupKey = () => '' }) {
|
|
6
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('cycle queue concurrency must be a positive integer')
|
|
4
7
|
const pending = new Map()
|
|
5
|
-
|
|
8
|
+
const active = new Map()
|
|
9
|
+
const activeGroups = new Set()
|
|
6
10
|
let sequence = 0
|
|
7
11
|
|
|
8
|
-
const pump =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
const pump = () => {
|
|
13
|
+
while (active.size < concurrency && pending.size) {
|
|
14
|
+
const available = [...pending].find(([, entry]) => !entry.group || !activeGroups.has(entry.group))
|
|
15
|
+
if (!available) return
|
|
16
|
+
const [key, entry] = available
|
|
17
|
+
pending.delete(key)
|
|
18
|
+
active.set(key, entry)
|
|
19
|
+
if (entry.group) activeGroups.add(entry.group)
|
|
20
|
+
void (async () => {
|
|
21
|
+
try { entry.resolve(await run(entry.item)) }
|
|
22
|
+
catch (error) {
|
|
23
|
+
try { onError(error, entry.item) } catch { /* logging cannot strand the queue */ }
|
|
24
|
+
entry.resolve({ status: 'failed' })
|
|
25
|
+
} finally {
|
|
26
|
+
active.delete(key)
|
|
27
|
+
if (entry.group) activeGroups.delete(entry.group)
|
|
28
|
+
pump()
|
|
29
|
+
}
|
|
30
|
+
})()
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
return {
|
|
24
35
|
enqueue(item, key = `cycle:${++sequence}`) {
|
|
25
|
-
if (active
|
|
36
|
+
if (active.has(key)) return active.get(key).promise
|
|
26
37
|
if (pending.has(key)) return pending.get(key).promise
|
|
27
38
|
let resolve
|
|
28
39
|
const promise = new Promise((done) => { resolve = done })
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
const group = String(groupKey(item) || '')
|
|
41
|
+
pending.set(key, { item, promise, resolve, group })
|
|
42
|
+
pump()
|
|
31
43
|
return promise
|
|
32
44
|
},
|
|
33
45
|
cancel(predicate, cancelActive = () => {}) {
|
|
@@ -36,9 +48,10 @@ export function createCycleQueue({ run, onError = () => {} }) {
|
|
|
36
48
|
pending.delete(key)
|
|
37
49
|
entry.resolve({ status: 'canceled' })
|
|
38
50
|
}
|
|
39
|
-
|
|
51
|
+
for (const entry of active.values()) if (predicate(entry.item)) cancelActive(entry.item)
|
|
40
52
|
},
|
|
41
|
-
has(key) { return active
|
|
42
|
-
get size() { return pending.size +
|
|
53
|
+
has(key) { return active.has(key) || pending.has(key) },
|
|
54
|
+
get size() { return pending.size + active.size },
|
|
55
|
+
get activeSize() { return active.size },
|
|
43
56
|
}
|
|
44
57
|
}
|
package/src/events.mjs
CHANGED
|
@@ -189,10 +189,14 @@ export function codexEventEvidence(event) {
|
|
|
189
189
|
const prefixed = /^(?:mcp__)?openvisio(?:-team|_team)(?:__|[_.:/-])(.+)$/i.exec(rawTool)
|
|
190
190
|
const mcpTool = String(prefixed?.[1] ?? rawTool).replace(/[-.]/g, '_')
|
|
191
191
|
const succeeded = completed && !failed
|
|
192
|
+
const resultText = item.result?.content?.find?.((part) => part?.type === 'text')?.text
|
|
193
|
+
const rawToolError = item.error ?? item.result?.error ?? resultText
|
|
194
|
+
const toolError = rawToolError == null ? '' : (typeof rawToolError === 'string' ? rawToolError : JSON.stringify(rawToolError))
|
|
192
195
|
return {
|
|
193
196
|
mcpTool,
|
|
194
197
|
failed,
|
|
195
198
|
completed: succeeded,
|
|
199
|
+
...(failed && toolError ? { toolError: toolError.replace(/\s+/g, ' ').slice(0, 500) } : {}),
|
|
196
200
|
didCode: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
|
|
197
201
|
didRepoMutation: succeeded && /^(?:create_codebase_branch|create_codebase_commit|write_codebase_file|create_pull_request)$/.test(mcpTool),
|
|
198
202
|
didMcpTaskRead: succeeded && /^(?:get_ticket|list_tasks)$/.test(mcpTool),
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { AcpAgent } from '@mastra/acp'
|
|
4
|
+
import { onPath } from './lib.mjs'
|
|
5
|
+
|
|
6
|
+
const MCP_TOOL_NAMES = [
|
|
7
|
+
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
8
|
+
'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
|
|
9
|
+
'post_message', 'react_message', 'list_codebases', 'get_codebase',
|
|
10
|
+
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
|
11
|
+
'create_pull_request',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
15
|
+
const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
|
|
16
|
+
|
|
17
|
+
function commandFor(agent) {
|
|
18
|
+
if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
|
|
19
|
+
if (agent === 'codex') {
|
|
20
|
+
const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
|
|
21
|
+
return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
27
|
+
|
|
28
|
+
function repositorySnapshot(cwd) {
|
|
29
|
+
if (!cwd) return ''
|
|
30
|
+
const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
|
|
31
|
+
cwd, encoding: 'utf8', timeout: 10_000,
|
|
32
|
+
})
|
|
33
|
+
return result.status === 0 ? String(result.stdout || '') : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toolName(update) {
|
|
37
|
+
const haystack = `${update?.title || ''} ${json(update?.rawInput)}`
|
|
38
|
+
return MCP_TOOL_NAMES.find((name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(haystack)) || ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contentText(content) {
|
|
42
|
+
if (!content || typeof content !== 'object') return ''
|
|
43
|
+
if (content.type === 'text') return String(content.text || '')
|
|
44
|
+
if (content.type === 'content') return contentText(content.content)
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMastraAcpRunner({
|
|
49
|
+
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, maxCycleMs,
|
|
50
|
+
log = () => {}, debug = false, model, onTool, systemPrompt = '', AcpAgentClass = AcpAgent,
|
|
51
|
+
}) {
|
|
52
|
+
const runtime = commandFor(agent)
|
|
53
|
+
if (!runtime) return null
|
|
54
|
+
let active = null
|
|
55
|
+
const defaultCwd = workdir || process.cwd()
|
|
56
|
+
|
|
57
|
+
async function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
58
|
+
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
let timedOut = false
|
|
61
|
+
const before = repositorySnapshot(canCode ? cycleCwd : '')
|
|
62
|
+
const calls = new Set()
|
|
63
|
+
const errors = new Map()
|
|
64
|
+
const tools = new Map()
|
|
65
|
+
let outputText = ''
|
|
66
|
+
let didCode = false
|
|
67
|
+
let didRepoMutation = false
|
|
68
|
+
let didMessage = false
|
|
69
|
+
let didChannelMessage = false
|
|
70
|
+
let didResultMessage = false
|
|
71
|
+
let didMcpTaskRead = false
|
|
72
|
+
let didMcpTaskUpdate = false
|
|
73
|
+
|
|
74
|
+
const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
|
|
75
|
+
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
76
|
+
const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
|
|
77
|
+
name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
|
|
78
|
+
{ name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
|
|
79
|
+
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
80
|
+
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
81
|
+
{ name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
|
|
82
|
+
],
|
|
83
|
+
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
84
|
+
const acp = new AcpAgentClass({
|
|
85
|
+
id: `openvisio-${agent}-${Date.now()}`,
|
|
86
|
+
name: `OpenVisio ${agent}`,
|
|
87
|
+
description: 'An isolated OpenVisio coding-agent run.',
|
|
88
|
+
command: runtime.command,
|
|
89
|
+
args: runtime.args,
|
|
90
|
+
cwd: cycleCwd,
|
|
91
|
+
env: agent === 'codex' ? {
|
|
92
|
+
INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
|
|
93
|
+
NO_BROWSER: '1',
|
|
94
|
+
} : {},
|
|
95
|
+
session: {
|
|
96
|
+
cwd: cycleCwd,
|
|
97
|
+
mcpServers,
|
|
98
|
+
},
|
|
99
|
+
persistSession: false,
|
|
100
|
+
...(cycleModel || model ? { model: cycleModel || model } : {}),
|
|
101
|
+
onPermissionRequest: async ({ options }) => {
|
|
102
|
+
const kind = canCode ? 'allow_once' : 'reject_once'
|
|
103
|
+
const selected = options.find((option) => option.kind === kind) || options.find((option) => option.kind.startsWith(canCode ? 'allow' : 'reject'))
|
|
104
|
+
return selected ? { outcome: { outcome: 'selected', optionId: selected.optionId } } : { outcome: { outcome: 'cancelled' } }
|
|
105
|
+
},
|
|
106
|
+
// codex-acp publishes account-status extension notifications. They are
|
|
107
|
+
// useful to interactive clients but should be silent in a background
|
|
108
|
+
// watcher, and the stock Mastra ACP client intentionally knows only ACP.
|
|
109
|
+
createClient: agent === 'codex' ? (client) => new Proxy(client, {
|
|
110
|
+
get(target, property, receiver) {
|
|
111
|
+
if (property === 'extNotification') return async () => {}
|
|
112
|
+
return Reflect.get(target, property, receiver)
|
|
113
|
+
},
|
|
114
|
+
}) : undefined,
|
|
115
|
+
})
|
|
116
|
+
active = { acp, controller }
|
|
117
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, maxCycleMs)
|
|
118
|
+
log(`running ${agent} cycle via Mastra ACP…${cycleModel || model ? ` [${cycleModel || model}]` : ''}`)
|
|
119
|
+
try {
|
|
120
|
+
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
121
|
+
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
122
|
+
if (event.type === 'text') { outputText += event.text; continue }
|
|
123
|
+
const update = event.update || {}
|
|
124
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
125
|
+
outputText += contentText(update.content)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (!['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) continue
|
|
129
|
+
const prior = tools.get(update.toolCallId) || {}
|
|
130
|
+
const merged = { ...prior, ...update }
|
|
131
|
+
tools.set(update.toolCallId, merged)
|
|
132
|
+
const name = toolName(merged)
|
|
133
|
+
const label = name || clean(merged.title, 100) || 'tool'
|
|
134
|
+
try { onTool?.(label) } catch { /* status is best-effort */ }
|
|
135
|
+
if (debug && update.sessionUpdate === 'tool_call') log(` → tool ${label}`)
|
|
136
|
+
if (name) {
|
|
137
|
+
calls.add(name)
|
|
138
|
+
if (merged.status === 'failed') errors.set(name, clean(json(merged.rawOutput) || merged.title))
|
|
139
|
+
else if (merged.status === 'completed') errors.delete(name)
|
|
140
|
+
didMessage ||= name === 'post_message'
|
|
141
|
+
didChannelMessage ||= name === 'post_message'
|
|
142
|
+
didMcpTaskRead ||= ['list_tasks', 'list_task_types', 'get_ticket'].includes(name)
|
|
143
|
+
didMcpTaskUpdate ||= name === 'update_ticket'
|
|
144
|
+
}
|
|
145
|
+
const title = clean(merged.title, 200).toLowerCase()
|
|
146
|
+
const looksLikeMutation = merged.kind === 'edit' || /\b(edit|write|patch|create|delete|commit)\b/.test(title)
|
|
147
|
+
didCode ||= ['edit', 'execute'].includes(merged.kind) || looksLikeMutation
|
|
148
|
+
didRepoMutation ||= looksLikeMutation
|
|
149
|
+
}
|
|
150
|
+
const after = repositorySnapshot(canCode ? cycleCwd : '')
|
|
151
|
+
if (before !== after) { didCode = true; didRepoMutation = true }
|
|
152
|
+
didResultMessage = didChannelMessage && didRepoMutation
|
|
153
|
+
log(`${agent} cycle done via Mastra ACP (ok)`)
|
|
154
|
+
return {
|
|
155
|
+
type: 'result', subtype: 'ok', runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
156
|
+
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
157
|
+
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
158
|
+
didMcpTaskRead, didMcpTaskUpdate,
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const canceled = controller.signal.aborted
|
|
162
|
+
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
163
|
+
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
|
|
164
|
+
return {
|
|
165
|
+
type: 'result', subtype, runtime: 'mastra-acp', outputText: outputText.trim(),
|
|
166
|
+
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
167
|
+
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
168
|
+
didMcpTaskRead, didMcpTaskUpdate,
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
acp.connection.disconnect()
|
|
173
|
+
if (active?.acp === acp) active = null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cancelCurrent = async () => {
|
|
178
|
+
if (!active) return
|
|
179
|
+
active.controller.abort()
|
|
180
|
+
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
184
|
+
return { runCycle, canCode, cancelCurrent, harness: 'mastra-acp' }
|
|
185
|
+
}
|
package/src/memory.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { Memory } from '@mastra/memory'
|
|
4
|
+
import { LibSQLStore } from '@mastra/libsql'
|
|
2
5
|
import { writeJson } from './lib.mjs'
|
|
3
6
|
|
|
4
7
|
const clean = (value, max = 320) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
@@ -79,3 +82,70 @@ export function createByoMemoryGraph({ path, maxNodes = 1000, now = () => Date.n
|
|
|
79
82
|
|
|
80
83
|
return { remember, connect, recall, context, has, persist }
|
|
81
84
|
}
|
|
85
|
+
|
|
86
|
+
const memoryThreadId = (refs = {}) => {
|
|
87
|
+
if (refs.projectId != null && refs.ticketId != null) return `ticket:${refs.projectId}:${refs.ticketId}`
|
|
88
|
+
if (refs.channelId != null) return `channel:${refs.channelId}:thread:${refs.threadId ?? 'root'}`
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messageText = (message) => {
|
|
93
|
+
const parts = message?.content?.parts
|
|
94
|
+
if (!Array.isArray(parts)) return clean(message?.content?.content, 2000)
|
|
95
|
+
return clean(parts.filter((part) => part?.type === 'text').map((part) => part.text).join(' '), 2000)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Mastra owns durable task/thread context. The JSON graph remains a deliberately
|
|
99
|
+
// small coordination ledger for exact replay and delivery keys; it is not used as
|
|
100
|
+
// the primary transcript once the libSQL thread has messages.
|
|
101
|
+
export function createMastraMemory({ ledgerPath, databasePath, resourceId, maxNodes = 1000, now = () => Date.now() }) {
|
|
102
|
+
const ledger = createByoMemoryGraph({ path: ledgerPath, maxNodes, now })
|
|
103
|
+
const storage = new LibSQLStore({ id: `openvisio-memory-${resourceId}`, url: `file:${databasePath}` })
|
|
104
|
+
const memory = new Memory({ storage, options: { lastMessages: 12, semanticRecall: false, workingMemory: { enabled: false } } })
|
|
105
|
+
const threads = new Map()
|
|
106
|
+
let pending = Promise.resolve()
|
|
107
|
+
|
|
108
|
+
const ensureThread = (threadId) => {
|
|
109
|
+
if (!threads.has(threadId)) {
|
|
110
|
+
threads.set(threadId, (async () => {
|
|
111
|
+
const existing = await memory.getThreadById({ threadId, resourceId })
|
|
112
|
+
return existing || memory.createThread({ threadId, resourceId, title: threadId })
|
|
113
|
+
})())
|
|
114
|
+
}
|
|
115
|
+
return threads.get(threadId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const queueMessage = (entry) => {
|
|
119
|
+
const threadId = memoryThreadId(entry?.refs)
|
|
120
|
+
if (!threadId) return
|
|
121
|
+
pending = pending.then(async () => {
|
|
122
|
+
await ensureThread(threadId)
|
|
123
|
+
const text = `${entry.kind || 'event'} ${entry.state || 'observed'}: ${entry.summary || entry.key}`
|
|
124
|
+
await memory.saveMessages({ messages: [{
|
|
125
|
+
id: randomUUID(), role: 'assistant', createdAt: new Date(now()), threadId, resourceId,
|
|
126
|
+
content: { format: 2, parts: [{ type: 'text', text }], metadata: { openvisioKey: entry.key } },
|
|
127
|
+
}] })
|
|
128
|
+
}).catch(() => { /* memory is best-effort; live backend checks stay authoritative */ })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const remember = (entry) => {
|
|
132
|
+
const node = ledger.remember(entry)
|
|
133
|
+
if (node) queueMessage(node)
|
|
134
|
+
return node
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const context = async (refs = {}, limit = 8) => {
|
|
138
|
+
const threadId = memoryThreadId(refs)
|
|
139
|
+
if (!threadId) return ''
|
|
140
|
+
await pending
|
|
141
|
+
try {
|
|
142
|
+
const recalled = await memory.recall({ threadId, resourceId, perPage: limit })
|
|
143
|
+
const items = recalled.messages.map(messageText).filter(Boolean).slice(-limit)
|
|
144
|
+
if (items.length) return ['RELEVANT VERIFIED MEMORY (do not repeat completed/delivered actions):', ...items.map((item) => `- ${item}`)].join('\n')
|
|
145
|
+
} catch { /* fall through to the migration-safe ledger */ }
|
|
146
|
+
return ledger.context(refs, limit)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const settled = async () => { await pending; await memory.settled() }
|
|
150
|
+
return { remember, connect: ledger.connect, recall: ledger.recall, context, has: ledger.has, persist: ledger.persist, settled, provider: 'mastra-libsql' }
|
|
151
|
+
}
|
package/src/watch.mjs
CHANGED
|
@@ -11,14 +11,15 @@ import { join, dirname } from 'node:path'
|
|
|
11
11
|
import { fileURLToPath } from 'node:url'
|
|
12
12
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
13
13
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
14
|
-
import { agentAddedByName,
|
|
15
|
-
import {
|
|
14
|
+
import { agentAddedByName, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, codexEventEvidence, codexPolicyBlock, combineRuntimeWorkEvidence, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, opencodeEventEvidence, renderedAgentMessages, shouldSuppressCodexDiagnostic, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
15
|
+
import { createMastraMemory } from './memory.mjs'
|
|
16
16
|
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
17
17
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
18
18
|
import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
|
|
19
19
|
import { buildCodexMcpOverride } from './codex-config.mjs'
|
|
20
20
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
21
21
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
22
|
+
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
22
23
|
|
|
23
24
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
24
25
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -453,13 +454,14 @@ function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, ma
|
|
|
453
454
|
// approval/sandbox bypass flag.
|
|
454
455
|
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
455
456
|
const bin = onPath('codex') || 'codex'
|
|
456
|
-
const
|
|
457
|
+
const defaultCwd = workdir || OV_DIR
|
|
457
458
|
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
458
459
|
let cancelActive = null
|
|
459
460
|
|
|
460
461
|
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
461
462
|
return new Promise((resolve) => {
|
|
462
463
|
const m = cycleModel || model
|
|
464
|
+
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
463
465
|
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
464
466
|
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
465
467
|
const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
|
|
@@ -472,7 +474,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
472
474
|
let terminationResult = null
|
|
473
475
|
let cancel = null
|
|
474
476
|
let policyBlock = null
|
|
475
|
-
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
477
|
+
const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
|
|
476
478
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
477
479
|
const finish = (o) => {
|
|
478
480
|
if (done) return
|
|
@@ -480,8 +482,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
480
482
|
if (cancelActive === cancel) cancelActive = null
|
|
481
483
|
clearTimeout(timer)
|
|
482
484
|
const calls = [...mcpCalls]
|
|
483
|
-
|
|
484
|
-
|
|
485
|
+
const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
|
|
486
|
+
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
|
|
487
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
|
|
485
488
|
}
|
|
486
489
|
const terminate = (subtype) => {
|
|
487
490
|
terminationResult ||= { type: 'result', subtype }
|
|
@@ -517,8 +520,18 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
517
520
|
if (evidence.mcpTool) {
|
|
518
521
|
mcpCalls.add(evidence.mcpTool)
|
|
519
522
|
try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
|
|
520
|
-
if (evidence.failed)
|
|
521
|
-
|
|
523
|
+
if (evidence.failed) {
|
|
524
|
+
mcpErrors.add(evidence.mcpTool)
|
|
525
|
+
if (evidence.toolError) {
|
|
526
|
+
const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
527
|
+
const detail = String(evidence.toolError)
|
|
528
|
+
const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
|
|
529
|
+
mcpErrorDetails.set(evidence.mcpTool, safe)
|
|
530
|
+
}
|
|
531
|
+
} else if (evidence.completed) {
|
|
532
|
+
mcpErrors.delete(evidence.mcpTool)
|
|
533
|
+
mcpErrorDetails.delete(evidence.mcpTool)
|
|
534
|
+
}
|
|
522
535
|
}
|
|
523
536
|
if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
|
|
524
537
|
didCode ||= !!evidence.didCode
|
|
@@ -542,7 +555,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
542
555
|
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
543
556
|
child = spawn(bin, args, {
|
|
544
557
|
...modelProcessOptions,
|
|
545
|
-
cwd,
|
|
558
|
+
cwd: cycleCwd,
|
|
546
559
|
env: {
|
|
547
560
|
...process.env,
|
|
548
561
|
...(mcpUrl ? {
|
|
@@ -577,7 +590,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
577
590
|
}
|
|
578
591
|
|
|
579
592
|
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
580
|
-
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' +
|
|
593
|
+
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
|
|
581
594
|
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
582
595
|
}
|
|
583
596
|
|
|
@@ -587,6 +600,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
587
600
|
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
|
|
588
601
|
const canCode = !!workdir
|
|
589
602
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
603
|
+
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
604
|
+
// its own ACP session and worktree, so cancellation, permissions and context
|
|
605
|
+
// cannot bleed across concurrently assigned tickets.
|
|
606
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
607
|
+
if (mastraRunner) return mastraRunner
|
|
590
608
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
591
609
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
592
610
|
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
@@ -767,29 +785,13 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
767
785
|
function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
768
786
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
769
787
|
let handle = null
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
//
|
|
774
|
-
|
|
775
|
-
const sendStatus = (
|
|
776
|
-
|
|
777
|
-
const key = Number(channelId)
|
|
778
|
-
if (statusInFlight.has(key) || (statusBackoff.get(key) || 0) > Date.now()) return
|
|
779
|
-
let request
|
|
780
|
-
try { request = agentStateRequest(backend, key, state, apiKey, identifier) } catch { return }
|
|
781
|
-
statusInFlight.add(key)
|
|
782
|
-
void fetch(request.url, { ...request.init, signal: AbortSignal.timeout(10_000) }).then(async (res) => {
|
|
783
|
-
if (res.ok) { statusBackoff.delete(key); return }
|
|
784
|
-
const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160)
|
|
785
|
-
statusBackoff.set(key, Date.now() + 30_000)
|
|
786
|
-
log(`agent state HTTP ${res.status}${body ? ': ' + body : ''}; backing off 30s`)
|
|
787
|
-
}).catch((e) => {
|
|
788
|
-
statusBackoff.set(key, Date.now() + 30_000)
|
|
789
|
-
log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
|
|
790
|
-
}).finally(() => statusInFlight.delete(key))
|
|
791
|
-
}
|
|
792
|
-
const emitLaneStatus = (lane, state) => { for (const c of laneStatusTargets[lane]) sendStatus(c, state) }
|
|
788
|
+
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
789
|
+
// not clear or overwrite each other's status targets.
|
|
790
|
+
const replyStatusTargets = new Set()
|
|
791
|
+
// Working/thinking/typing presence updates are intentionally silent. They
|
|
792
|
+
// generated notification noise without adding durable progress evidence.
|
|
793
|
+
const sendStatus = () => {}
|
|
794
|
+
const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
|
|
793
795
|
const canCode = !!workdir
|
|
794
796
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
795
797
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -804,12 +806,23 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
804
806
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
805
807
|
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
806
808
|
}
|
|
807
|
-
// One watcher and one WS subscription,
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
809
|
+
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
810
|
+
// runtimes. Replies remain serialized so one agent cannot answer two messages
|
|
811
|
+
// at once and race duplicate-delivery guards.
|
|
812
|
+
const MAX_CONCURRENT_WORKERS = 3
|
|
813
|
+
const activeWorkRunners = new Set()
|
|
814
|
+
const createWorkRunner = (control, workerWorkdir = '') => createCycleRunner({
|
|
815
|
+
...runnerOptions,
|
|
816
|
+
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
817
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
818
|
+
})
|
|
819
|
+
const replyRunner = createCycleRunner({
|
|
820
|
+
...runnerOptions,
|
|
821
|
+
workdir: '',
|
|
822
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
823
|
+
cfgKey: identifier + '-reply',
|
|
824
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
|
|
825
|
+
})
|
|
813
826
|
const codexPushGuide = agent === 'codex' && canCode
|
|
814
827
|
? '\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. Report the one-time command `openvisio-agent authorize-pr-push` as the blocker. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
|
|
815
828
|
: ''
|
|
@@ -824,18 +837,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
824
837
|
let codeModel = model
|
|
825
838
|
let liteModel = chatModel || model
|
|
826
839
|
|
|
827
|
-
const
|
|
828
|
-
work: {
|
|
829
|
-
|
|
840
|
+
const queues = {
|
|
841
|
+
work: createCycleQueue({
|
|
842
|
+
concurrency: MAX_CONCURRENT_WORKERS,
|
|
843
|
+
groupKey: (item) => item.workdir || workdir || 'shared-workspace',
|
|
844
|
+
run: async (item) => {
|
|
845
|
+
const runner = createWorkRunner(item.control, item.workdir)
|
|
846
|
+
item.control.runner = runner
|
|
847
|
+
activeWorkRunners.add(runner)
|
|
848
|
+
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
849
|
+
finally { activeWorkRunners.delete(runner); item.control.runner = null }
|
|
850
|
+
},
|
|
851
|
+
onError: (error, item) => {
|
|
852
|
+
pauseFailedTask(item.taskRef)
|
|
853
|
+
void finalizeFailedTaskPause(item.taskRef)
|
|
854
|
+
log('work cycle failed: ' + (error?.message || error))
|
|
855
|
+
},
|
|
856
|
+
}),
|
|
857
|
+
reply: createCycleQueue({
|
|
858
|
+
run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, replyRunner, item.control, ''),
|
|
859
|
+
onError: (error, item) => {
|
|
860
|
+
pauseFailedTask(item.taskRef)
|
|
861
|
+
void finalizeFailedTaskPause(item.taskRef)
|
|
862
|
+
log('reply cycle failed: ' + (error?.message || error))
|
|
863
|
+
},
|
|
864
|
+
}),
|
|
830
865
|
}
|
|
831
|
-
const queues = Object.fromEntries(['work', 'reply'].map((laneName) => [laneName, createCycleQueue({
|
|
832
|
-
run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery),
|
|
833
|
-
onError: (error, item) => {
|
|
834
|
-
pauseFailedTask(item.taskRef)
|
|
835
|
-
void finalizeFailedTaskPause(item.taskRef)
|
|
836
|
-
log(laneName + ' cycle failed: ' + (error?.message || error))
|
|
837
|
-
},
|
|
838
|
-
})]))
|
|
839
866
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
840
867
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
841
868
|
// re-acknowledge the same assignment.
|
|
@@ -850,7 +877,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
850
877
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
851
878
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
852
879
|
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
853
|
-
const memory =
|
|
880
|
+
const memory = createMastraMemory({
|
|
881
|
+
ledgerPath: join(OV_DIR, 'watch-' + slug + '-memory.json'),
|
|
882
|
+
databasePath: join(OV_DIR, 'watch-' + slug + '-mastra.db'),
|
|
883
|
+
resourceId: identifier,
|
|
884
|
+
})
|
|
854
885
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
855
886
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
856
887
|
// without re-running the model or posting the same result twice.
|
|
@@ -1023,11 +1054,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1023
1054
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1024
1055
|
if (!controlKey) return
|
|
1025
1056
|
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
1026
|
-
for (const [laneName,
|
|
1027
|
-
|
|
1028
|
-
|
|
1057
|
+
for (const [laneName, queue] of Object.entries(queues)) {
|
|
1058
|
+
queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
|
|
1059
|
+
item.control.cancelled = true
|
|
1029
1060
|
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
1030
|
-
|
|
1061
|
+
void item.control.runner?.cancelCurrent?.('thread-cancelled')
|
|
1031
1062
|
})
|
|
1032
1063
|
}
|
|
1033
1064
|
}
|
|
@@ -1409,35 +1440,40 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1409
1440
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1410
1441
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1411
1442
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1412
|
-
|
|
1443
|
+
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1444
|
+
const control = { cancelled: false, runner: null, statusTargets: new Set() }
|
|
1445
|
+
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
|
|
1413
1446
|
}
|
|
1414
1447
|
|
|
1415
|
-
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1448
|
+
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1416
1449
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1417
|
-
const
|
|
1418
|
-
lane.cancelled = false
|
|
1419
|
-
lane.activeDelivery = delivery
|
|
1450
|
+
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1420
1451
|
const ctx = context ? [context] : []
|
|
1421
1452
|
const activeTaskRef = taskRef
|
|
1453
|
+
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1454
|
+
const runnerOptions = {
|
|
1455
|
+
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1456
|
+
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1457
|
+
}
|
|
1422
1458
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
1423
|
-
|
|
1459
|
+
cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
|
|
1460
|
+
if (laneName === 'reply') {
|
|
1461
|
+
replyStatusTargets.clear()
|
|
1462
|
+
for (const target of targets) replyStatusTargets.add(target)
|
|
1463
|
+
}
|
|
1424
1464
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
1425
1465
|
// message is just the event context + the small base instruction.
|
|
1426
1466
|
const memoryRefs = delivery
|
|
1427
1467
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1428
1468
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1429
|
-
const recalled = memory.context(memoryRefs)
|
|
1469
|
+
const recalled = await memory.context(memoryRefs)
|
|
1430
1470
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1431
1471
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1432
1472
|
// work (full/sweep) uses the main model.
|
|
1433
1473
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
1434
1474
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
emitLaneStatus(laneName, 'working')
|
|
1438
|
-
// Backend activity TTL is refreshed well before expiry, but only once every
|
|
1439
|
-
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1440
|
-
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1475
|
+
// Presence notifications are intentionally disabled; durable messages and
|
|
1476
|
+
// ticket transitions are the only user-visible progress signals.
|
|
1441
1477
|
try {
|
|
1442
1478
|
// The ticket may have been reassigned or handed to review while waiting.
|
|
1443
1479
|
// Re-check at dequeue, before any model can edit the repository.
|
|
@@ -1454,10 +1490,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1454
1490
|
return
|
|
1455
1491
|
}
|
|
1456
1492
|
}
|
|
1457
|
-
if (
|
|
1458
|
-
const result = await
|
|
1493
|
+
if (cycleControl.cancelled) return
|
|
1494
|
+
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1459
1495
|
let completionResult = result
|
|
1460
|
-
if (
|
|
1496
|
+
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1461
1497
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1462
1498
|
return
|
|
1463
1499
|
}
|
|
@@ -1492,8 +1528,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1492
1528
|
if (kind === 'full' && cycleSucceeded(result) && missing.length) {
|
|
1493
1529
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1494
1530
|
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}`
|
|
1495
|
-
const recovery = await
|
|
1496
|
-
if (
|
|
1531
|
+
const recovery = await runner.runCycle(recoveryPrompt, codeModel, runnerOptions)
|
|
1532
|
+
if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
|
|
1497
1533
|
const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
|
|
1498
1534
|
const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
|
|
1499
1535
|
if (!cycleSucceeded(recovery) || recoveryMissing.length) {
|
|
@@ -1533,9 +1569,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1533
1569
|
}
|
|
1534
1570
|
}
|
|
1535
1571
|
} finally {
|
|
1536
|
-
|
|
1537
|
-
laneStatusTargets[laneName].clear()
|
|
1538
|
-
lane.activeDelivery = null
|
|
1572
|
+
cycleControl.statusTargets.clear()
|
|
1539
1573
|
}
|
|
1540
1574
|
}
|
|
1541
1575
|
|
|
@@ -1600,9 +1634,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1600
1634
|
const key = `${projectId}:${ticketId}`
|
|
1601
1635
|
if (!belongsToSelf) {
|
|
1602
1636
|
for (const [name, queue] of Object.entries(queues)) {
|
|
1603
|
-
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), () => {
|
|
1604
|
-
|
|
1605
|
-
void
|
|
1637
|
+
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
|
|
1638
|
+
item.control.cancelled = true
|
|
1639
|
+
void item.control.runner?.cancelCurrent?.()
|
|
1606
1640
|
})
|
|
1607
1641
|
}
|
|
1608
1642
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
@@ -1796,11 +1830,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1796
1830
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1797
1831
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
1798
1832
|
try { handle && handle.close() } catch { /* noop */ }
|
|
1799
|
-
for (const
|
|
1800
|
-
|
|
1801
|
-
|
|
1833
|
+
for (const queue of Object.values(queues)) {
|
|
1834
|
+
queue.cancel(() => true, (item) => {
|
|
1835
|
+
item.control.cancelled = true
|
|
1836
|
+
void item.control.runner?.cancelCurrent?.()
|
|
1837
|
+
})
|
|
1802
1838
|
}
|
|
1803
|
-
await Promise.all(
|
|
1839
|
+
await Promise.all([replyRunner.cancelCurrent?.(), ...[...activeWorkRunners].map((runner) => runner.cancelCurrent?.())])
|
|
1804
1840
|
process.exit(0)
|
|
1805
1841
|
}
|
|
1806
1842
|
process.on('SIGTERM', bye)
|