openvisio-agent 0.18.15 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/package.json +9 -3
- package/scripts/certify.mjs +13 -8
- 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 +199 -0
- package/src/memory.mjs +70 -0
- package/src/model-selection.mjs +75 -0
- package/src/watch.mjs +120 -86
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,15 @@ 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.
|
|
60
|
+
|
|
61
|
+
Model names are negotiated at session start. Codex and OpenCode match the configured name against the models advertised by ACP, preferring the same family and reasoning effort (`medium` for an unsuffixed family); provider-qualified and nearby generation names are resolved automatically. Claude versioned names are normalized to its stable `sonnet`, `opus`, `haiku`, or `fable` aliases and use the CLI's native ordered fallback chain.
|
|
58
62
|
|
|
59
63
|
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
64
|
|
|
@@ -62,7 +66,7 @@ The backend MCP may be stateful or stateless. A successful initialize response w
|
|
|
62
66
|
|
|
63
67
|
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
68
|
|
|
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.
|
|
69
|
+
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
70
|
|
|
67
71
|
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
72
|
|
|
@@ -75,7 +79,7 @@ openvisio-agent watch --name ada --workdir ~/repo # allow REAL work on a git br
|
|
|
75
79
|
openvisio-agent stop --name ada # stop service + every ada watcher
|
|
76
80
|
```
|
|
77
81
|
|
|
78
|
-
The
|
|
82
|
+
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
83
|
|
|
80
84
|
Publishing a local branch uses an explicit, one-time authorization per repository:
|
|
81
85
|
|
|
@@ -86,7 +90,7 @@ openvisio-agent authorize-pr-push
|
|
|
86
90
|
|
|
87
91
|
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
92
|
|
|
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.
|
|
93
|
+
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
94
|
|
|
91
95
|
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
96
|
|
|
@@ -103,7 +107,7 @@ Do not chase auto-changing watcher PIDs. `openvisio-agent stop --name <agent>` u
|
|
|
103
107
|
|
|
104
108
|
## Requirements
|
|
105
109
|
|
|
106
|
-
- Node.js ≥
|
|
110
|
+
- Node.js ≥ 22.13
|
|
107
111
|
- Claude Code, Codex (`@openai/codex`), or OpenCode (the selected CLI is auto-installed if missing)
|
|
108
112
|
|
|
109
113
|
## 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.1",
|
|
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,8 @@ 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')
|
|
35
|
+
const modelSelection = readFileSync(join(root, 'src', 'model-selection.mjs'), 'utf8')
|
|
34
36
|
const activityHook = readFileSync(join(repo, 'frontend', 'hooks', 'useAgentActivity.ts'), 'utf8')
|
|
35
37
|
const taskHook = readFileSync(join(repo, 'frontend', 'hooks', 'useBackendTasks.ts'), 'utf8')
|
|
36
38
|
const liveTasks = readFileSync(join(repo, 'frontend', 'lib', 'collab', 'liveTasks.ts'), 'utf8')
|
|
@@ -56,7 +58,7 @@ const assertions = [
|
|
|
56
58
|
['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
|
|
57
59
|
['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
60
|
['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
|
|
61
|
+
['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
62
|
['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
|
|
61
63
|
['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
|
|
62
64
|
['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 +68,19 @@ const assertions = [
|
|
|
66
68
|
['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
69
|
['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
70
|
['reply delivery keys survive reconnects', watcher.includes('deliveredReplies: [...deliveredReplies]') && watcher.includes('deliveredReplies.has(deliveryKey)')],
|
|
69
|
-
['tickets and guarded replies use the behavior-tested
|
|
71
|
+
['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
72
|
['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
|
|
71
73
|
['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: ''
|
|
74
|
+
['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
|
|
73
75
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
74
|
-
['
|
|
75
|
-
['
|
|
76
|
-
['
|
|
76
|
+
['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:')],
|
|
77
|
+
['Codex and OpenCode use isolated Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: false') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
78
|
+
['runtime models are resolved before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(selectedModel)') && modelSelection.includes("const DEFAULT_EFFORT = 'medium'") && watcher.includes('resolveClaudeModel') && watcher.includes("'--fallback-model'" )],
|
|
79
|
+
['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')],
|
|
80
|
+
['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)')],
|
|
81
|
+
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
77
82
|
['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(')],
|
|
83
|
+
['presence notification requests are disabled', watcher.includes('const sendStatus = () => {}') && !watcher.includes('agentStateRequest(') && !watcher.includes("setInterval(() => emitStatusTargets")],
|
|
80
84
|
['websocket client cannot emit legacy agent_status', !websocket.includes('agent_status')],
|
|
81
85
|
['frontend consumes thinking event', activityHook.includes("'channel:agent:thinking'")],
|
|
82
86
|
['frontend consumes working event', activityHook.includes("'channel:agent:working'")],
|
|
@@ -101,6 +105,7 @@ const assertions = [
|
|
|
101
105
|
['OpenCode tool failures retain sanitized diagnostics', events.includes('toolError: toolError.replace') && watcher.includes("opencode tool '") && watcher.includes("split(redactKey).join('[redacted]')")],
|
|
102
106
|
['all runtime acknowledgements cannot satisfy coding completion', watcher.includes('missingRuntimeWorkEvidence(result') && watcher.includes('claudeEventEvidence(o, turnToolUses)') && watcher.includes('releaseTaskForRetry(activeTaskRef, prompt)')],
|
|
103
107
|
['Codex prose cannot masquerade as repository evidence', watcher.includes('codexEventEvidence(event)') && events.includes("item.type === 'agent_message'") && events.includes("event.type === 'item.completed'")],
|
|
108
|
+
['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
109
|
['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
110
|
['backend MCP accepts stateless initialize responses', mcpHttp.includes("mode = () => !initialized ? 'uninitialized' : sessionId ? 'stateful' : 'stateless'") && !watcher.includes('MCP initialize returned no session id')],
|
|
106
111
|
['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,199 @@
|
|
|
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
|
+
import { resolveAvailableModel } from './model-selection.mjs'
|
|
6
|
+
|
|
7
|
+
const MCP_TOOL_NAMES = [
|
|
8
|
+
'list_agents', 'list_projects', 'list_tasks', 'list_task_types', 'get_ticket',
|
|
9
|
+
'update_ticket', 'list_channels', 'list_message_thread', 'list_activity',
|
|
10
|
+
'post_message', 'react_message', 'list_codebases', 'get_codebase',
|
|
11
|
+
'codebase_tree', 'create_codebase_branch', 'create_codebase_commit',
|
|
12
|
+
'create_pull_request',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const clean = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max)
|
|
16
|
+
const json = (value) => { try { return JSON.stringify(value) } catch { return String(value ?? '') } }
|
|
17
|
+
|
|
18
|
+
function commandFor(agent) {
|
|
19
|
+
if (agent === 'opencode') return { command: onPath('opencode') || 'opencode', args: ['acp'] }
|
|
20
|
+
if (agent === 'codex') {
|
|
21
|
+
const filename = process.platform === 'win32' ? 'codex-acp.cmd' : 'codex-acp'
|
|
22
|
+
return { command: fileURLToPath(new URL(`../node_modules/.bin/${filename}`, import.meta.url)), args: [] }
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
28
|
+
|
|
29
|
+
function repositorySnapshot(cwd) {
|
|
30
|
+
if (!cwd) return ''
|
|
31
|
+
const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
|
|
32
|
+
cwd, encoding: 'utf8', timeout: 10_000,
|
|
33
|
+
})
|
|
34
|
+
return result.status === 0 ? String(result.stdout || '') : ''
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolName(update) {
|
|
38
|
+
const haystack = `${update?.title || ''} ${json(update?.rawInput)}`
|
|
39
|
+
return MCP_TOOL_NAMES.find((name) => new RegExp(`(?:^|[^a-z0-9_])${name}(?:$|[^a-z0-9_])`, 'i').test(haystack)) || ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function contentText(content) {
|
|
43
|
+
if (!content || typeof content !== 'object') return ''
|
|
44
|
+
if (content.type === 'text') return String(content.text || '')
|
|
45
|
+
if (content.type === 'content') return contentText(content.content)
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createMastraAcpRunner({
|
|
50
|
+
agent, mcpUrl, mcpHeaders = {}, workdir, canCode = !!workdir, maxCycleMs,
|
|
51
|
+
log = () => {}, debug = false, model, onTool, systemPrompt = '', AcpAgentClass = AcpAgent,
|
|
52
|
+
}) {
|
|
53
|
+
const runtime = commandFor(agent)
|
|
54
|
+
if (!runtime) return null
|
|
55
|
+
let active = null
|
|
56
|
+
const defaultCwd = workdir || process.cwd()
|
|
57
|
+
|
|
58
|
+
async function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
59
|
+
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
60
|
+
const controller = new AbortController()
|
|
61
|
+
let timedOut = false
|
|
62
|
+
const before = repositorySnapshot(canCode ? cycleCwd : '')
|
|
63
|
+
const calls = new Set()
|
|
64
|
+
const errors = new Map()
|
|
65
|
+
const tools = new Map()
|
|
66
|
+
let outputText = ''
|
|
67
|
+
let didCode = false
|
|
68
|
+
let didRepoMutation = false
|
|
69
|
+
let didMessage = false
|
|
70
|
+
let didChannelMessage = false
|
|
71
|
+
let didResultMessage = false
|
|
72
|
+
let didMcpTaskRead = false
|
|
73
|
+
let didMcpTaskUpdate = false
|
|
74
|
+
|
|
75
|
+
const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
|
|
76
|
+
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
77
|
+
const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
|
|
78
|
+
name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
|
|
79
|
+
{ name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
|
|
80
|
+
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
81
|
+
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
82
|
+
{ name: 'OPENVISIO_CODEX_DISABLED_TOOLS', value: JSON.stringify(disabledMcpTools) },
|
|
83
|
+
],
|
|
84
|
+
}] : [{ type: 'http', name: 'openvisio-team', url: mcpUrl, headers }]
|
|
85
|
+
const acp = new AcpAgentClass({
|
|
86
|
+
id: `openvisio-${agent}-${Date.now()}`,
|
|
87
|
+
name: `OpenVisio ${agent}`,
|
|
88
|
+
description: 'An isolated OpenVisio coding-agent run.',
|
|
89
|
+
command: runtime.command,
|
|
90
|
+
args: runtime.args,
|
|
91
|
+
cwd: cycleCwd,
|
|
92
|
+
env: agent === 'codex' ? {
|
|
93
|
+
INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
|
|
94
|
+
NO_BROWSER: '1',
|
|
95
|
+
} : {},
|
|
96
|
+
session: {
|
|
97
|
+
cwd: cycleCwd,
|
|
98
|
+
mcpServers,
|
|
99
|
+
},
|
|
100
|
+
persistSession: false,
|
|
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(); acp.connection.disconnect() }, maxCycleMs)
|
|
118
|
+
const requestedModel = cycleModel || model || ''
|
|
119
|
+
let selectedModel = ''
|
|
120
|
+
try {
|
|
121
|
+
if (requestedModel) {
|
|
122
|
+
const availableModels = await acp.getAvailableModels()
|
|
123
|
+
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
124
|
+
selectedModel = resolution.selected
|
|
125
|
+
if (selectedModel) {
|
|
126
|
+
await acp.setModel(selectedModel)
|
|
127
|
+
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
128
|
+
} else {
|
|
129
|
+
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
log(`running ${agent} cycle via Mastra ACP…${selectedModel || requestedModel ? ` [${selectedModel || requestedModel}]` : ' [runtime default]'}`)
|
|
133
|
+
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt
|
|
134
|
+
for await (const event of acp.connection.promptStream(fullPrompt, controller.signal)) {
|
|
135
|
+
if (event.type === 'text') { outputText += event.text; continue }
|
|
136
|
+
const update = event.update || {}
|
|
137
|
+
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
138
|
+
outputText += contentText(update.content)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (!['tool_call', 'tool_call_update'].includes(update.sessionUpdate)) continue
|
|
142
|
+
const prior = tools.get(update.toolCallId) || {}
|
|
143
|
+
const merged = { ...prior, ...update }
|
|
144
|
+
tools.set(update.toolCallId, merged)
|
|
145
|
+
const name = toolName(merged)
|
|
146
|
+
const label = name || clean(merged.title, 100) || 'tool'
|
|
147
|
+
try { onTool?.(label) } catch { /* status is best-effort */ }
|
|
148
|
+
if (debug && update.sessionUpdate === 'tool_call') log(` → tool ${label}`)
|
|
149
|
+
if (name) {
|
|
150
|
+
calls.add(name)
|
|
151
|
+
if (merged.status === 'failed') errors.set(name, clean(json(merged.rawOutput) || merged.title))
|
|
152
|
+
else if (merged.status === 'completed') errors.delete(name)
|
|
153
|
+
didMessage ||= name === 'post_message'
|
|
154
|
+
didChannelMessage ||= name === 'post_message'
|
|
155
|
+
didMcpTaskRead ||= ['list_tasks', 'list_task_types', 'get_ticket'].includes(name)
|
|
156
|
+
didMcpTaskUpdate ||= name === 'update_ticket'
|
|
157
|
+
}
|
|
158
|
+
const title = clean(merged.title, 200).toLowerCase()
|
|
159
|
+
const looksLikeMutation = merged.kind === 'edit' || /\b(edit|write|patch|create|delete|commit)\b/.test(title)
|
|
160
|
+
didCode ||= ['edit', 'execute'].includes(merged.kind) || looksLikeMutation
|
|
161
|
+
didRepoMutation ||= looksLikeMutation
|
|
162
|
+
}
|
|
163
|
+
const after = repositorySnapshot(canCode ? cycleCwd : '')
|
|
164
|
+
if (before !== after) { didCode = true; didRepoMutation = true }
|
|
165
|
+
didResultMessage = didChannelMessage && didRepoMutation
|
|
166
|
+
log(`${agent} cycle done via Mastra ACP (ok)`)
|
|
167
|
+
return {
|
|
168
|
+
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
169
|
+
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
170
|
+
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
171
|
+
didMcpTaskRead, didMcpTaskUpdate,
|
|
172
|
+
}
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const canceled = controller.signal.aborted
|
|
175
|
+
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
176
|
+
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && error?.message ? ': ' + clean(error.message) : ''})`)
|
|
177
|
+
return {
|
|
178
|
+
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
179
|
+
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
180
|
+
didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage,
|
|
181
|
+
didMcpTaskRead, didMcpTaskUpdate,
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
acp.connection.disconnect()
|
|
186
|
+
if (active?.acp === acp) active = null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cancelCurrent = async () => {
|
|
191
|
+
if (!active) return
|
|
192
|
+
active.controller.abort()
|
|
193
|
+
try { await active.acp.connection.cancel() } catch { /* already stopped */ }
|
|
194
|
+
active.acp.connection.disconnect()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
log(`${agent} Mastra ACP runner ready${canCode ? ` [CODE workspace ${defaultCwd}]` : ' [CHAT-ONLY]'}`)
|
|
198
|
+
return { runCycle, canCode, cancelCurrent, harness: 'mastra-acp' }
|
|
199
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
|
|
2
|
+
const DEFAULT_EFFORT = 'medium'
|
|
3
|
+
|
|
4
|
+
const text = (value) => String(value ?? '').trim()
|
|
5
|
+
const lower = (value) => text(value).toLowerCase()
|
|
6
|
+
|
|
7
|
+
function parts(value) {
|
|
8
|
+
const raw = text(value)
|
|
9
|
+
const effortMatch = /\[([^\]]+)\]$/.exec(raw)
|
|
10
|
+
const withoutEffort = effortMatch ? raw.slice(0, effortMatch.index) : raw
|
|
11
|
+
const path = lower(withoutEffort).split('/').filter(Boolean)
|
|
12
|
+
const leaf = path.at(-1) || ''
|
|
13
|
+
const tokens = leaf.split(/[^a-z0-9]+/).filter(Boolean)
|
|
14
|
+
return { raw, id: lower(raw), base: lower(withoutEffort), leaf, tokens, provider: path.slice(0, -1).join('/'), effort: lower(effortMatch?.[1]) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const effortDistance = (actual, preferred) => {
|
|
18
|
+
const actualIndex = EFFORTS.indexOf(actual)
|
|
19
|
+
const preferredIndex = EFFORTS.indexOf(preferred)
|
|
20
|
+
if (actualIndex < 0) return 20
|
|
21
|
+
return Math.abs(actualIndex - (preferredIndex < 0 ? EFFORTS.indexOf(DEFAULT_EFFORT) : preferredIndex))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function similarity(requested, candidate, preferredEffort) {
|
|
25
|
+
if (candidate.id === requested.id) return 100_000
|
|
26
|
+
let score = 0
|
|
27
|
+
if (candidate.base === requested.base || candidate.leaf === requested.leaf) score += 50_000
|
|
28
|
+
if (requested.provider && candidate.provider === requested.provider) score += 2_000
|
|
29
|
+
const requestedTokens = new Set(requested.tokens)
|
|
30
|
+
const shared = candidate.tokens.filter((token) => requestedTokens.has(token)).length
|
|
31
|
+
score += shared * 1_000
|
|
32
|
+
if (requested.leaf && (candidate.leaf.includes(requested.leaf) || requested.leaf.includes(candidate.leaf))) score += 10_000
|
|
33
|
+
let prefix = 0
|
|
34
|
+
while (prefix < requested.leaf.length && prefix < candidate.leaf.length && requested.leaf[prefix] === candidate.leaf[prefix]) prefix++
|
|
35
|
+
score += prefix * 10
|
|
36
|
+
score -= effortDistance(candidate.effort, requested.effort || preferredEffort) * 100
|
|
37
|
+
return score
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
|
+
const requested = parts(requestedValue)
|
|
42
|
+
const choices = availableModels.map((entry) => typeof entry === 'string' ? entry : entry?.modelId).map(text).filter(Boolean)
|
|
43
|
+
if (!choices.length) return { requested: requested.raw, selected: '', reason: 'runtime-default', available: [] }
|
|
44
|
+
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
|
+
|
|
46
|
+
const ranked = choices.map((id, index) => {
|
|
47
|
+
const candidate = parts(id)
|
|
48
|
+
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
|
+
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
50
|
+
const winner = ranked[0]
|
|
51
|
+
return {
|
|
52
|
+
requested: requested.raw,
|
|
53
|
+
selected: winner.id,
|
|
54
|
+
reason: lower(winner.id) === requested.id ? 'exact' : parts(winner.id).base === requested.base || parts(winner.id).leaf === requested.leaf ? 'family-effort' : 'nearest',
|
|
55
|
+
available: choices,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
|
+
|
|
61
|
+
export function resolveClaudeModel(requestedValue) {
|
|
62
|
+
const requested = lower(requestedValue)
|
|
63
|
+
if (!requested) return ''
|
|
64
|
+
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
+
return family || text(requestedValue)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function claudeFallbackModels(selectedValue) {
|
|
69
|
+
const selected = lower(selectedValue)
|
|
70
|
+
const preferred = selected === 'opus' ? ['sonnet', 'haiku']
|
|
71
|
+
: selected === 'haiku' ? ['sonnet', 'opus']
|
|
72
|
+
: selected === 'fable' ? ['sonnet', 'opus'] : ['opus', 'haiku']
|
|
73
|
+
return preferred.filter((model) => model !== selected)
|
|
74
|
+
}
|
|
75
|
+
|
package/src/watch.mjs
CHANGED
|
@@ -11,14 +11,16 @@ 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'
|
|
23
|
+
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
22
24
|
|
|
23
25
|
function findTicketWorktree(workspaceRoot, ticketId) {
|
|
24
26
|
if (!workspaceRoot || ticketId == null) return ''
|
|
@@ -473,7 +475,7 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
473
475
|
let terminationResult = null
|
|
474
476
|
let cancel = null
|
|
475
477
|
let policyBlock = null
|
|
476
|
-
const mcpCalls = new Set(), mcpErrors = new Set()
|
|
478
|
+
const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
|
|
477
479
|
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
478
480
|
const finish = (o) => {
|
|
479
481
|
if (done) return
|
|
@@ -481,8 +483,9 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
481
483
|
if (cancelActive === cancel) cancelActive = null
|
|
482
484
|
clearTimeout(timer)
|
|
483
485
|
const calls = [...mcpCalls]
|
|
484
|
-
|
|
485
|
-
|
|
486
|
+
const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
|
|
487
|
+
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
|
|
488
|
+
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
|
|
486
489
|
}
|
|
487
490
|
const terminate = (subtype) => {
|
|
488
491
|
terminationResult ||= { type: 'result', subtype }
|
|
@@ -518,8 +521,18 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
518
521
|
if (evidence.mcpTool) {
|
|
519
522
|
mcpCalls.add(evidence.mcpTool)
|
|
520
523
|
try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
|
|
521
|
-
if (evidence.failed)
|
|
522
|
-
|
|
524
|
+
if (evidence.failed) {
|
|
525
|
+
mcpErrors.add(evidence.mcpTool)
|
|
526
|
+
if (evidence.toolError) {
|
|
527
|
+
const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
528
|
+
const detail = String(evidence.toolError)
|
|
529
|
+
const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
|
|
530
|
+
mcpErrorDetails.set(evidence.mcpTool, safe)
|
|
531
|
+
}
|
|
532
|
+
} else if (evidence.completed) {
|
|
533
|
+
mcpErrors.delete(evidence.mcpTool)
|
|
534
|
+
mcpErrorDetails.delete(evidence.mcpTool)
|
|
535
|
+
}
|
|
523
536
|
}
|
|
524
537
|
if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
|
|
525
538
|
didCode ||= !!evidence.didCode
|
|
@@ -588,6 +601,11 @@ function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, l
|
|
|
588
601
|
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
|
|
589
602
|
const canCode = !!workdir
|
|
590
603
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
604
|
+
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
605
|
+
// its own ACP session and worktree, so cancellation, permissions and context
|
|
606
|
+
// cannot bleed across concurrently assigned tickets.
|
|
607
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
608
|
+
if (mastraRunner) return mastraRunner
|
|
591
609
|
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
592
610
|
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
593
611
|
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
@@ -596,7 +614,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
596
614
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
597
615
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
598
616
|
// session so the new model takes effect.
|
|
599
|
-
let sessionModel = model || null
|
|
617
|
+
let sessionModel = resolveClaudeModel(model) || null
|
|
600
618
|
let turnsThisSession = 0
|
|
601
619
|
let sessionStartedAt = 0
|
|
602
620
|
let resolveTurn = null
|
|
@@ -653,7 +671,8 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
653
671
|
if (!mcpConfig) { log('WARNING: no MCP config — the agent can react to events but has no tools to act. Re-connect with --mcp-url.') }
|
|
654
672
|
// The charter + creds ride in the system prompt (cacheable → not re-billed each
|
|
655
673
|
// cycle), leaving only the small per-cycle instruction in the user message.
|
|
656
|
-
const
|
|
674
|
+
const fallbackModels = claudeFallbackModels(sessionModel)
|
|
675
|
+
const base = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--strict-mcp-config', '--mcp-config', mcpConfig, ...(sessionModel ? ['--model', sessionModel, ...(fallbackModels.length ? ['--fallback-model', fallbackModels.join(',')] : [])] : []), ...(systemPrompt ? ['--append-system-prompt', systemPrompt] : [])]
|
|
657
676
|
const args = canCode ? [...base, '--allowedTools', ...CODE_TOOLS, '--disallowedTools', ...DENY_TOOLS] : [...base, '--allowedTools', 'mcp__openvisio-team__*']
|
|
658
677
|
const c = spawn(claude, args, { ...modelProcessOptions, cwd: workdir || undefined, stdio: ['pipe', 'pipe', 'inherit'] })
|
|
659
678
|
child = c
|
|
@@ -714,9 +733,11 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
714
733
|
return new Promise((resolve) => {
|
|
715
734
|
// A per-cycle model override (e.g. chat on a cheaper model than code) — a
|
|
716
735
|
// change means the warm session must be respawned with the new --model.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
sessionModel =
|
|
736
|
+
const resolvedCycleModel = resolveClaudeModel(cycleModel)
|
|
737
|
+
if (resolvedCycleModel && resolvedCycleModel !== sessionModel) {
|
|
738
|
+
if (child) { log('model change ' + (sessionModel || 'default') + ' → ' + resolvedCycleModel + ' — recycling session'); try { child.kill() } catch { /* gone */ } child = null }
|
|
739
|
+
if (resolvedCycleModel !== cycleModel) log('model ' + cycleModel + ' resolved to Claude family alias ' + resolvedCycleModel)
|
|
740
|
+
sessionModel = resolvedCycleModel
|
|
720
741
|
}
|
|
721
742
|
if (child && (turnsThisSession >= MAX_TURNS || Date.now() - sessionStartedAt > SESSION_IDLE_MS)) {
|
|
722
743
|
log('recycling session (turns=' + turnsThisSession + ')')
|
|
@@ -768,29 +789,13 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
768
789
|
function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug }) {
|
|
769
790
|
const log = (m) => process.stdout.write('[ws ' + new Date().toISOString() + '] ' + m + '\n')
|
|
770
791
|
let handle = null
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
const sendStatus = (
|
|
777
|
-
|
|
778
|
-
const key = Number(channelId)
|
|
779
|
-
if (statusInFlight.has(key) || (statusBackoff.get(key) || 0) > Date.now()) return
|
|
780
|
-
let request
|
|
781
|
-
try { request = agentStateRequest(backend, key, state, apiKey, identifier) } catch { return }
|
|
782
|
-
statusInFlight.add(key)
|
|
783
|
-
void fetch(request.url, { ...request.init, signal: AbortSignal.timeout(10_000) }).then(async (res) => {
|
|
784
|
-
if (res.ok) { statusBackoff.delete(key); return }
|
|
785
|
-
const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160)
|
|
786
|
-
statusBackoff.set(key, Date.now() + 30_000)
|
|
787
|
-
log(`agent state HTTP ${res.status}${body ? ': ' + body : ''}; backing off 30s`)
|
|
788
|
-
}).catch((e) => {
|
|
789
|
-
statusBackoff.set(key, Date.now() + 30_000)
|
|
790
|
-
log('agent state request failed: ' + (e?.message || e) + '; backing off 30s')
|
|
791
|
-
}).finally(() => statusInFlight.delete(key))
|
|
792
|
-
}
|
|
793
|
-
const emitLaneStatus = (lane, state) => { for (const c of laneStatusTargets[lane]) sendStatus(c, state) }
|
|
792
|
+
// Activity belongs to an individual cycle. Independent ticket workers must
|
|
793
|
+
// not clear or overwrite each other's status targets.
|
|
794
|
+
const replyStatusTargets = new Set()
|
|
795
|
+
// Working/thinking/typing presence updates are intentionally silent. They
|
|
796
|
+
// generated notification noise without adding durable progress evidence.
|
|
797
|
+
const sendStatus = () => {}
|
|
798
|
+
const emitStatusTargets = (targets, state) => { for (const c of targets) sendStatus(c, state) }
|
|
794
799
|
const canCode = !!workdir
|
|
795
800
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
796
801
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
@@ -805,12 +810,23 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
805
810
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
806
811
|
cfgKey: identifier, mcpConfig, workdir, log, debug, model, systemPrompt,
|
|
807
812
|
}
|
|
808
|
-
// One watcher and one WS subscription,
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
813
|
+
// One watcher and one WS subscription, with a bounded pool of isolated coding
|
|
814
|
+
// runtimes. Replies remain serialized so one agent cannot answer two messages
|
|
815
|
+
// at once and race duplicate-delivery guards.
|
|
816
|
+
const MAX_CONCURRENT_WORKERS = 3
|
|
817
|
+
const activeWorkRunners = new Set()
|
|
818
|
+
const createWorkRunner = (control, workerWorkdir = '') => createCycleRunner({
|
|
819
|
+
...runnerOptions,
|
|
820
|
+
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
821
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
822
|
+
})
|
|
823
|
+
const replyRunner = createCycleRunner({
|
|
824
|
+
...runnerOptions,
|
|
825
|
+
workdir: '',
|
|
826
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
827
|
+
cfgKey: identifier + '-reply',
|
|
828
|
+
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(replyStatusTargets, 'typing') },
|
|
829
|
+
})
|
|
814
830
|
const codexPushGuide = agent === 'codex' && canCode
|
|
815
831
|
? '\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.'
|
|
816
832
|
: ''
|
|
@@ -825,18 +841,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
825
841
|
let codeModel = model
|
|
826
842
|
let liteModel = chatModel || model
|
|
827
843
|
|
|
828
|
-
const
|
|
829
|
-
work: {
|
|
830
|
-
|
|
844
|
+
const queues = {
|
|
845
|
+
work: createCycleQueue({
|
|
846
|
+
concurrency: MAX_CONCURRENT_WORKERS,
|
|
847
|
+
groupKey: (item) => item.workdir || workdir || 'shared-workspace',
|
|
848
|
+
run: async (item) => {
|
|
849
|
+
const runner = createWorkRunner(item.control, item.workdir)
|
|
850
|
+
item.control.runner = runner
|
|
851
|
+
activeWorkRunners.add(runner)
|
|
852
|
+
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
853
|
+
finally { activeWorkRunners.delete(runner); item.control.runner = null }
|
|
854
|
+
},
|
|
855
|
+
onError: (error, item) => {
|
|
856
|
+
pauseFailedTask(item.taskRef)
|
|
857
|
+
void finalizeFailedTaskPause(item.taskRef)
|
|
858
|
+
log('work cycle failed: ' + (error?.message || error))
|
|
859
|
+
},
|
|
860
|
+
}),
|
|
861
|
+
reply: createCycleQueue({
|
|
862
|
+
run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, replyRunner, item.control, ''),
|
|
863
|
+
onError: (error, item) => {
|
|
864
|
+
pauseFailedTask(item.taskRef)
|
|
865
|
+
void finalizeFailedTaskPause(item.taskRef)
|
|
866
|
+
log('reply cycle failed: ' + (error?.message || error))
|
|
867
|
+
},
|
|
868
|
+
}),
|
|
831
869
|
}
|
|
832
|
-
const queues = Object.fromEntries(['work', 'reply'].map((laneName) => [laneName, createCycleQueue({
|
|
833
|
-
run: (item) => executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery),
|
|
834
|
-
onError: (error, item) => {
|
|
835
|
-
pauseFailedTask(item.taskRef)
|
|
836
|
-
void finalizeFailedTaskPause(item.taskRef)
|
|
837
|
-
log(laneName + ' cycle failed: ' + (error?.message || error))
|
|
838
|
-
},
|
|
839
|
-
})]))
|
|
840
870
|
// Tasks we've already reacted to (keyed task-id:agent — so a REASSIGNMENT to a
|
|
841
871
|
// different agent re-triggers), so a noisy stream of task:updated events doesn't
|
|
842
872
|
// re-acknowledge the same assignment.
|
|
@@ -851,7 +881,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
851
881
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
852
882
|
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
853
883
|
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
854
|
-
const memory =
|
|
884
|
+
const memory = createMastraMemory({
|
|
885
|
+
ledgerPath: join(OV_DIR, 'watch-' + slug + '-memory.json'),
|
|
886
|
+
databasePath: join(OV_DIR, 'watch-' + slug + '-mastra.db'),
|
|
887
|
+
resourceId: identifier,
|
|
888
|
+
})
|
|
855
889
|
// Completion delivery is runtime-owned for assigned coding work. Persist both
|
|
856
890
|
// pending and delivered keys so a reconnect can finish a missed notification
|
|
857
891
|
// without re-running the model or posting the same result twice.
|
|
@@ -1024,11 +1058,11 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1024
1058
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1025
1059
|
if (!controlKey) return
|
|
1026
1060
|
memory.remember({ key: controlKey, kind: 'thread', state: 'cancelled', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
1027
|
-
for (const [laneName,
|
|
1028
|
-
|
|
1029
|
-
|
|
1061
|
+
for (const [laneName, queue] of Object.entries(queues)) {
|
|
1062
|
+
queue.cancel((item) => sameDeliveryThread(item.delivery, channelId, parentId), (item) => {
|
|
1063
|
+
item.control.cancelled = true
|
|
1030
1064
|
log(`${laneName} lane cancelled by a newer redirect/stand-down in thread ${parentId}`)
|
|
1031
|
-
|
|
1065
|
+
void item.control.runner?.cancelCurrent?.('thread-cancelled')
|
|
1032
1066
|
})
|
|
1033
1067
|
}
|
|
1034
1068
|
}
|
|
@@ -1410,40 +1444,40 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1410
1444
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1411
1445
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1412
1446
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1413
|
-
|
|
1447
|
+
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1448
|
+
const control = { cancelled: false, runner: null, statusTargets: new Set() }
|
|
1449
|
+
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
|
|
1414
1450
|
}
|
|
1415
1451
|
|
|
1416
|
-
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1452
|
+
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1417
1453
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1418
|
-
const
|
|
1419
|
-
lane.cancelled = false
|
|
1420
|
-
lane.activeDelivery = delivery
|
|
1454
|
+
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1421
1455
|
const ctx = context ? [context] : []
|
|
1422
1456
|
const activeTaskRef = taskRef
|
|
1423
|
-
const ticketWorktree = kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : ''
|
|
1457
|
+
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1424
1458
|
const runnerOptions = {
|
|
1425
1459
|
...(delivery?.watcherOwned ? { disabledMcpTools: ['post_message'] } : {}),
|
|
1426
1460
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1427
1461
|
}
|
|
1428
1462
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
1429
|
-
|
|
1463
|
+
cycleControl.statusTargets = laneName === 'reply' ? replyStatusTargets : new Set(targets)
|
|
1464
|
+
if (laneName === 'reply') {
|
|
1465
|
+
replyStatusTargets.clear()
|
|
1466
|
+
for (const target of targets) replyStatusTargets.add(target)
|
|
1467
|
+
}
|
|
1430
1468
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
1431
1469
|
// message is just the event context + the small base instruction.
|
|
1432
1470
|
const memoryRefs = delivery
|
|
1433
1471
|
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1434
1472
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1435
|
-
const recalled = memory.context(memoryRefs)
|
|
1473
|
+
const recalled = await memory.context(memoryRefs)
|
|
1436
1474
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1437
1475
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1438
1476
|
// work (full/sweep) uses the main model.
|
|
1439
1477
|
const useModel = agent === 'codex' ? codeModel : kind === 'full' ? codeModel : liteModel
|
|
1440
1478
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
emitLaneStatus(laneName, 'working')
|
|
1444
|
-
// Backend activity TTL is refreshed well before expiry, but only once every
|
|
1445
|
-
// 20 seconds so long coding runs do not create needless network/battery load.
|
|
1446
|
-
const heartbeat = targets.length ? setInterval(() => emitLaneStatus(laneName, 'working'), 20_000) : null
|
|
1479
|
+
// Presence notifications are intentionally disabled; durable messages and
|
|
1480
|
+
// ticket transitions are the only user-visible progress signals.
|
|
1447
1481
|
try {
|
|
1448
1482
|
// The ticket may have been reassigned or handed to review while waiting.
|
|
1449
1483
|
// Re-check at dequeue, before any model can edit the repository.
|
|
@@ -1460,10 +1494,10 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1460
1494
|
return
|
|
1461
1495
|
}
|
|
1462
1496
|
}
|
|
1463
|
-
if (
|
|
1464
|
-
const result = await
|
|
1497
|
+
if (cycleControl.cancelled) return
|
|
1498
|
+
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1465
1499
|
let completionResult = result
|
|
1466
|
-
if (
|
|
1500
|
+
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1467
1501
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
1468
1502
|
return
|
|
1469
1503
|
}
|
|
@@ -1498,8 +1532,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1498
1532
|
if (kind === 'full' && cycleSucceeded(result) && missing.length) {
|
|
1499
1533
|
log(agent + ' coding cycle incomplete; recovery requires: ' + missing.join(', '))
|
|
1500
1534
|
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}`
|
|
1501
|
-
const recovery = await
|
|
1502
|
-
if (
|
|
1535
|
+
const recovery = await runner.runCycle(recoveryPrompt, codeModel, runnerOptions)
|
|
1536
|
+
if (cycleControl.cancelled || recovery?.subtype === 'canceled') return
|
|
1503
1537
|
const recoveredResult = combineRuntimeWorkEvidence(result, recovery)
|
|
1504
1538
|
const recoveryMissing = missingRuntimeWorkEvidence(recoveredResult, { ticketCycle, resultMessageRequired })
|
|
1505
1539
|
if (!cycleSucceeded(recovery) || recoveryMissing.length) {
|
|
@@ -1539,9 +1573,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1539
1573
|
}
|
|
1540
1574
|
}
|
|
1541
1575
|
} finally {
|
|
1542
|
-
|
|
1543
|
-
laneStatusTargets[laneName].clear()
|
|
1544
|
-
lane.activeDelivery = null
|
|
1576
|
+
cycleControl.statusTargets.clear()
|
|
1545
1577
|
}
|
|
1546
1578
|
}
|
|
1547
1579
|
|
|
@@ -1606,9 +1638,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1606
1638
|
const key = `${projectId}:${ticketId}`
|
|
1607
1639
|
if (!belongsToSelf) {
|
|
1608
1640
|
for (const [name, queue] of Object.entries(queues)) {
|
|
1609
|
-
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), () => {
|
|
1610
|
-
|
|
1611
|
-
void
|
|
1641
|
+
queue.cancel((item) => String(item.taskRef?.projectId) === String(projectId) && String(item.taskRef?.ticketId) === String(ticketId), (item) => {
|
|
1642
|
+
item.control.cancelled = true
|
|
1643
|
+
void item.control.runner?.cancelCurrent?.()
|
|
1612
1644
|
})
|
|
1613
1645
|
}
|
|
1614
1646
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
@@ -1802,11 +1834,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1802
1834
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1803
1835
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
1804
1836
|
try { handle && handle.close() } catch { /* noop */ }
|
|
1805
|
-
for (const
|
|
1806
|
-
|
|
1807
|
-
|
|
1837
|
+
for (const queue of Object.values(queues)) {
|
|
1838
|
+
queue.cancel(() => true, (item) => {
|
|
1839
|
+
item.control.cancelled = true
|
|
1840
|
+
void item.control.runner?.cancelCurrent?.()
|
|
1841
|
+
})
|
|
1808
1842
|
}
|
|
1809
|
-
await Promise.all(
|
|
1843
|
+
await Promise.all([replyRunner.cancelCurrent?.(), ...[...activeWorkRunners].map((runner) => runner.cancelCurrent?.())])
|
|
1810
1844
|
process.exit(0)
|
|
1811
1845
|
}
|
|
1812
1846
|
process.on('SIGTERM', bye)
|