openvisio-agent 0.19.8 → 0.19.10
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 +1 -1
- package/package.json +1 -1
- package/scripts/certify.mjs +2 -2
- package/src/assignment-routing.mjs +32 -0
- package/src/mastra-harness.mjs +14 -6
- package/src/model-selection.mjs +12 -12
- package/src/watch.mjs +61 -10
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Runs the **autonomy loop** — the agent replies to @mentions and picks up ticke
|
|
|
58
58
|
|
|
59
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
60
|
|
|
61
|
-
Model
|
|
61
|
+
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
62
62
|
|
|
63
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.
|
|
64
64
|
|
package/package.json
CHANGED
package/scripts/certify.mjs
CHANGED
|
@@ -77,7 +77,7 @@ const assertions = [
|
|
|
77
77
|
['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
|
|
78
78
|
['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:')],
|
|
79
79
|
['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('const replyRunners = new Map()') && watcher.includes('replyRunnerFor(item.delivery)') && mastraHarness.includes('new AcpAgentClass') && mastraHarness.includes('persistSession: true') && mastraHarness.includes("runtime: 'mastra-acp'")],
|
|
80
|
-
['runtime models are
|
|
80
|
+
['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
|
|
81
81
|
['one watcher owns isolated concurrent work and thread-scoped serial reply runtimes', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('createWorkRunner(item.control, item.workdir)') && watcher.includes('const replyRunners = new Map()') && watcher.includes('while (replyRunners.size > 8)')],
|
|
82
82
|
['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)')],
|
|
83
83
|
['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
|
|
@@ -128,7 +128,7 @@ const assertions = [
|
|
|
128
128
|
['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
|
|
129
129
|
['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
|
|
130
130
|
['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
|
|
131
|
-
['pending-ticket questions use watcher-owned MCP reads without a model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") &&
|
|
131
|
+
['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
|
|
132
132
|
['chat ACP allows opaque MCP approvals behind a strict read-only proxy', mastraHarness.includes('export function acpPermissionResponse') && mastraHarness.includes('opaqueMcpApproval') && mastraHarness.includes('OPENVISIO_CODEX_ALLOWED_TOOLS') && codexProxy.includes('export function toolAllowed') && codexProxy.includes('allowedTools.has(name)')],
|
|
133
133
|
['all runtime blockers have a delivery path', watcher.includes('publishBlocker') && watcher.includes('WORK_CYCLE_BLOCKED') && watcher.includes('COORDINATION_CYCLE_BLOCKED')],
|
|
134
134
|
['ticket blocker cannot self-authorize', watcher.includes("ticketNotice = `I'm paused") && watcher.includes('publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })') && !watcher.includes('test(approvalText)')],
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Assignment requests are routed by the watcher, before the restricted chat
|
|
2
|
+
// runtime can mistake its own tool limits for the coding worker's capabilities.
|
|
3
|
+
export function assignmentStatusOnly(value) {
|
|
4
|
+
return /\b(?:do not|don't|dont|stop|cancel|stand down|status only|just (?:list|check|tell|show)|only (?:list|check|tell|show))\b/i.test(String(value || ''))
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function assignmentRequest(value) {
|
|
8
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
|
9
|
+
if (assignmentStatusOnly(text)) return null
|
|
10
|
+
const slugs = [...new Set((text.match(/\b[a-z][a-z0-9]*-\d+\b/gi) || []).map((slug) => slug.toUpperCase()))]
|
|
11
|
+
const tickets = /\b(?:tickets?|tasks?|assignments?|backlog)\b/i.test(text) || slugs.length > 0
|
|
12
|
+
const action = /\b(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick\s+up|work\s+(?:on|through))\b/i.test(text)
|
|
13
|
+
const assigned = /\b(?:assigned|gave)\s+(?:\S+\s+){0,3}you\b|\byou\s+have\b.*\b(?:pending|assigned)\b/i.test(text)
|
|
14
|
+
return tickets && (action || assigned) ? { slugs } : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function routeAssignments({ request, loadTickets, handleTask, isCancelled = () => false }) {
|
|
18
|
+
const tickets = await loadTickets()
|
|
19
|
+
const selected = tickets.filter((ticket) => !request.slugs.length || request.slugs.includes(ticket.slug))
|
|
20
|
+
// A project-scoped slug must resolve uniquely before any work is started.
|
|
21
|
+
for (const slug of request.slugs) {
|
|
22
|
+
if (selected.filter((ticket) => ticket.slug === slug).length !== 1) {
|
|
23
|
+
throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const actionable = selected.filter((ticket) => !ticket.awaitingReview)
|
|
27
|
+
for (const ticket of actionable) {
|
|
28
|
+
if (isCancelled()) return
|
|
29
|
+
await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId } })
|
|
30
|
+
}
|
|
31
|
+
return actionable.length
|
|
32
|
+
}
|
package/src/mastra-harness.mjs
CHANGED
|
@@ -98,7 +98,7 @@ export function createMastraAcpRunner({
|
|
|
98
98
|
const headers = Object.entries(mcpHeaders).filter(([, value]) => value != null && value !== '').map(([name, value]) => ({ name, value: String(value) }))
|
|
99
99
|
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
100
100
|
const mcpServers = !mcpUrl ? [] : agent === 'codex' ? [{
|
|
101
|
-
name: 'openvisio-team', command: process.execPath, args: [proxyPath], env: [
|
|
101
|
+
name: 'openvisio-team-watcher', command: process.execPath, args: [proxyPath], env: [
|
|
102
102
|
{ name: 'OPENVISIO_CODEX_MCP_URL', value: mcpUrl },
|
|
103
103
|
{ name: 'OPENVISIO_CODEX_API_KEY', value: String(mcpHeaders['x-agent-api-key'] || '') },
|
|
104
104
|
{ name: 'OPENVISIO_CODEX_IDENTIFIER', value: String(mcpHeaders['x-agent-identifier'] || '') },
|
|
@@ -119,6 +119,10 @@ export function createMastraAcpRunner({
|
|
|
119
119
|
env: agent === 'codex' ? {
|
|
120
120
|
INITIAL_AGENT_MODE: canCode ? 'agent' : 'read-only',
|
|
121
121
|
NO_BROWSER: '1',
|
|
122
|
+
// codex-acp otherwise keeps a pre-existing openvisio-team server and
|
|
123
|
+
// silently discards this session's authenticated stdio bridge.
|
|
124
|
+
DISABLE_MCP_CONFIG_FILTERING: 'true',
|
|
125
|
+
CODEX_CONFIG: JSON.stringify({ 'mcp_servers.openvisio-team.enabled': false }),
|
|
122
126
|
} : {},
|
|
123
127
|
session: {
|
|
124
128
|
cwd: cycleCwd,
|
|
@@ -161,9 +165,9 @@ export function createMastraAcpRunner({
|
|
|
161
165
|
if (requestedModel !== negotiatedRequested) {
|
|
162
166
|
const availableModels = await acp.getAvailableModels()
|
|
163
167
|
const resolution = resolveAvailableModel(requestedModel, availableModels)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
if (resolution.selected) {
|
|
169
|
+
await acp.setModel(resolution.selected)
|
|
170
|
+
selectedModel = resolution.selected
|
|
167
171
|
if (selectedModel !== requestedModel) log(`model ${requestedModel} resolved to available ${selectedModel} [${resolution.reason}]`)
|
|
168
172
|
} else {
|
|
169
173
|
log(`model ${requestedModel} could not be negotiated because ${agent} did not advertise selectable models; using its session default`)
|
|
@@ -209,7 +213,9 @@ export function createMastraAcpRunner({
|
|
|
209
213
|
const after = repositorySnapshot(canCode ? cycleCwd : '')
|
|
210
214
|
if (before !== after) { didCode = true; didRepoMutation = true }
|
|
211
215
|
didResultMessage = didChannelMessage && didRepoMutation
|
|
212
|
-
|
|
216
|
+
const redact = (value) => Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), String(value))
|
|
217
|
+
for (const [name, detail] of errors) log(`MCP ${name} failed: ${redact(detail)}`)
|
|
218
|
+
log(`${agent} cycle done via Mastra ACP (${errors.size ? 'tool-errors' : 'ok'})`)
|
|
213
219
|
return {
|
|
214
220
|
type: 'result', subtype: 'ok', runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
|
215
221
|
mcpCalls: [...calls], mcpErrors: [...errors.keys()], mcpErrorDetails: Object.fromEntries(errors),
|
|
@@ -219,7 +225,9 @@ export function createMastraAcpRunner({
|
|
|
219
225
|
} catch (error) {
|
|
220
226
|
const canceled = controller.signal.aborted
|
|
221
227
|
const subtype = timedOut ? 'timeout' : canceled ? 'canceled' : 'error'
|
|
222
|
-
|
|
228
|
+
const errorDetail = [error?.message, error?.data ? json(error.data) : ''].filter(Boolean).join(': ')
|
|
229
|
+
const safeError = Object.values(mcpHeaders).filter(Boolean).reduce((safe, secret) => safe.split(String(secret)).join('[redacted]'), errorDetail)
|
|
230
|
+
log(`${agent} cycle done via Mastra ACP (${subtype}${!canceled && safeError ? ': ' + clean(safeError, 1200) : ''})`)
|
|
223
231
|
invalidate()
|
|
224
232
|
return {
|
|
225
233
|
type: 'result', subtype, runtime: 'mastra-acp', model: selectedModel || null, requestedModel: requestedModel || null, outputText: outputText.trim(),
|
package/src/model-selection.mjs
CHANGED
|
@@ -40,10 +40,18 @@ function similarity(requested, candidate, preferredEffort) {
|
|
|
40
40
|
export function resolveAvailableModel(requestedValue, availableModels = [], { preferredEffort = DEFAULT_EFFORT } = {}) {
|
|
41
41
|
const requested = parts(requestedValue)
|
|
42
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:
|
|
43
|
+
if (!choices.length && requested.raw) return { requested: requested.raw, selected: requested.raw, reason: 'explicit-unadvertised', available: [] }
|
|
44
|
+
if (!choices.length) return { requested: '', selected: '', reason: 'runtime-default', available: [] }
|
|
44
45
|
if (!requested.raw) return { requested: '', selected: '', reason: 'runtime-default', available: choices }
|
|
45
46
|
|
|
46
|
-
const
|
|
47
|
+
const compatible = choices.filter((id) => {
|
|
48
|
+
const candidate = parts(id)
|
|
49
|
+
return candidate.id === requested.id || (!requested.effort &&
|
|
50
|
+
candidate.leaf === requested.leaf && (!requested.provider || candidate.provider === requested.provider) &&
|
|
51
|
+
(!candidate.effort || candidate.effort === preferredEffort))
|
|
52
|
+
})
|
|
53
|
+
if (!compatible.length) throw new Error(`Configured model "${requested.raw}" is unavailable; automatic model substitution is disabled.`)
|
|
54
|
+
const ranked = compatible.map((id, index) => {
|
|
47
55
|
const candidate = parts(id)
|
|
48
56
|
return { id, index, score: similarity(requested, candidate, preferredEffort) }
|
|
49
57
|
}).sort((a, b) => b.score - a.score || a.index - b.index)
|
|
@@ -59,17 +67,9 @@ export function resolveAvailableModel(requestedValue, availableModels = [], { pr
|
|
|
59
67
|
const CLAUDE_FAMILIES = ['sonnet', 'opus', 'haiku', 'fable']
|
|
60
68
|
|
|
61
69
|
export function resolveClaudeModel(requestedValue) {
|
|
62
|
-
|
|
63
|
-
if (!requested) return ''
|
|
64
|
-
const family = CLAUDE_FAMILIES.find((name) => requested === name || requested.startsWith('claude-') && requested.includes(name))
|
|
65
|
-
return family || text(requestedValue)
|
|
70
|
+
return text(requestedValue)
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
export function claudeFallbackModels(selectedValue) {
|
|
69
|
-
|
|
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
|
+
return []
|
|
74
75
|
}
|
|
75
|
-
|
package/src/watch.mjs
CHANGED
|
@@ -18,6 +18,7 @@ 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
|
+
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
21
22
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
22
23
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
23
24
|
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
@@ -261,10 +262,9 @@ export async function runWatch({ flags }) {
|
|
|
261
262
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
262
263
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
263
264
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol' : ''
|
|
265
|
+
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
266
|
+
const defaultModel = agent === 'claude' ? 'sonnet' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
267
|
+
const defaultChatModel = agent === 'claude' ? 'haiku' : agent === 'codex' ? 'gpt-5.6-sol[medium]' : ''
|
|
268
268
|
const model = String(flags.model || (saved && saved.model) || defaultModel)
|
|
269
269
|
const chatModel = String(flags['chat-model'] || (saved && saved.chatModel) || defaultChatModel)
|
|
270
270
|
|
|
@@ -1076,6 +1076,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1076
1076
|
}
|
|
1077
1077
|
|
|
1078
1078
|
const loadPendingTickets = async () => {
|
|
1079
|
+
if (selfAgentId == null) {
|
|
1080
|
+
const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
|
|
1081
|
+
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
1082
|
+
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
1083
|
+
rememberSelfAgent(self)
|
|
1084
|
+
}
|
|
1079
1085
|
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1080
1086
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1081
1087
|
const groups = await Promise.all(projects.filter((project) => project?.id != null).map(async (project) => {
|
|
@@ -1090,12 +1096,16 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1090
1096
|
const typesData = typesResult ? toolData(typesResult) : {}
|
|
1091
1097
|
const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
1092
1098
|
const doneIds = new Set(types.filter((type) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
1099
|
+
const reviewIds = new Set(types.filter((type) => /\b(?:review|test|testing|qa|quality\s+assurance|verification|approval)\b/i.test(String(type.name || ''))).map((type) => Number(type.id)))
|
|
1093
1100
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1094
1101
|
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1095
1102
|
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
1096
1103
|
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1097
1104
|
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
1098
1105
|
}).map((task) => ({
|
|
1106
|
+
id: task.id,
|
|
1107
|
+
projectId: project.id,
|
|
1108
|
+
awaitingReview: taskIsAwaitingReview(task, reviewIds),
|
|
1099
1109
|
slug: ticketDisplaySlug(task),
|
|
1100
1110
|
title: String(task.title || 'Untitled ticket'),
|
|
1101
1111
|
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
@@ -1105,9 +1115,17 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1105
1115
|
return groups.flat()
|
|
1106
1116
|
}
|
|
1107
1117
|
|
|
1108
|
-
const answerPendingTickets = async ({ delivery, who }) => {
|
|
1118
|
+
const answerPendingTickets = async ({ delivery, who, text }) => {
|
|
1109
1119
|
try {
|
|
1110
1120
|
const tickets = await loadPendingTickets()
|
|
1121
|
+
// Assignments already authorize pickup. A board lookup should not leave
|
|
1122
|
+
// discovered work idle until a second human nudge arrives.
|
|
1123
|
+
if (canCode && !assignmentStatusOnly(text)) {
|
|
1124
|
+
await routeAssignments({
|
|
1125
|
+
request: { slugs: [] }, loadTickets: async () => tickets, handleTask: handleTaskSignal,
|
|
1126
|
+
isCancelled: () => memory.has(threadControlKey(delivery?.channelId, delivery?.parentId), 'cancelled'),
|
|
1127
|
+
})
|
|
1128
|
+
}
|
|
1111
1129
|
const prefix = who ? `@${who} ` : ''
|
|
1112
1130
|
const details = tickets.slice(0, 8).map((ticket) => `${ticket.slug || ticket.title}${ticket.slug ? ` “${ticket.title}”` : ''}${ticket.state ? ` (${ticket.state})` : ''}${ticket.project ? ` in ${ticket.project}` : ''}`)
|
|
1113
1131
|
const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
|
|
@@ -1385,7 +1403,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1385
1403
|
const [tasksData, typesData, activityData] = await Promise.all([
|
|
1386
1404
|
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
1387
1405
|
callMcpTool('list_task_types', { project_id: project.id }).then(toolData),
|
|
1388
|
-
|
|
1406
|
+
// Chat history is optional for assignment pickup. An unavailable
|
|
1407
|
+
// activity feed must not strand otherwise verified pending tickets.
|
|
1408
|
+
callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1409
|
+
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1410
|
+
return {}
|
|
1411
|
+
}),
|
|
1389
1412
|
])
|
|
1390
1413
|
const taskTypes = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
1391
1414
|
const doneIds = new Set(taskTypes.filter((t) => /\b(?:done|complete|completed|closed|cancelled|canceled|archived|resolved)\b/i.test(String(t.name || ''))).map((t) => Number(t.id)))
|
|
@@ -1562,7 +1585,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1562
1585
|
const prompt = (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
|
|
1563
1586
|
// Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
|
|
1564
1587
|
// work (full/sweep) uses the main model.
|
|
1565
|
-
const useModel =
|
|
1588
|
+
const useModel = kind === 'full' ? codeModel : liteModel
|
|
1566
1589
|
log('running ' + kind + ' cycle…' + (ctx.length ? ' (' + ctx.length + ' event' + (ctx.length === 1 ? '' : 's') + ')' : '') + (useModel ? ' [' + useModel + ']' : ''))
|
|
1567
1590
|
// Presence notifications are intentionally disabled; durable messages and
|
|
1568
1591
|
// ticket transitions are the only user-visible progress signals.
|
|
@@ -1584,6 +1607,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1584
1607
|
}
|
|
1585
1608
|
if (cycleControl.cancelled) return
|
|
1586
1609
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1610
|
+
if (result?.model && result.model !== useModel) {
|
|
1611
|
+
// Persist only a successfully selected model and only if the user has
|
|
1612
|
+
// not changed that tier while this cycle was running.
|
|
1613
|
+
let changed = false
|
|
1614
|
+
if (codeModel === useModel) { codeModel = result.model; changed = true }
|
|
1615
|
+
if (liteModel === useModel) { liteModel = result.model; changed = true }
|
|
1616
|
+
if (changed) persistModel()
|
|
1617
|
+
}
|
|
1587
1618
|
let completionResult = result
|
|
1588
1619
|
if (cycleControl.cancelled || result?.subtype === 'canceled') {
|
|
1589
1620
|
log(laneName + ' cycle cancelled; no blocker or reply will be published')
|
|
@@ -1686,9 +1717,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1686
1717
|
const x = String(s || '').toLowerCase().replace(/[.,!?]+$/, '')
|
|
1687
1718
|
if (x === 'opus' || x === 'sonnet' || x === 'haiku') return x
|
|
1688
1719
|
if (/^claude-[a-z0-9.\-\[\]]+$/i.test(x)) return x
|
|
1689
|
-
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]
|
|
1720
|
+
if (/^(?:gpt|codex|o[1-9])[a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x)) return x
|
|
1690
1721
|
// opencode models are provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o).
|
|
1691
|
-
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]
|
|
1722
|
+
return /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9.\-:]*(?:\[(?:low|medium|high|xhigh|max|ultra)\])?$/i.test(x) ? x : null
|
|
1692
1723
|
}
|
|
1693
1724
|
// Optional target tier: "chat"/"lite" → chat cycles only, "code"/"full" → code
|
|
1694
1725
|
// cycles only, absent → both.
|
|
@@ -1868,9 +1899,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1868
1899
|
}
|
|
1869
1900
|
return
|
|
1870
1901
|
}
|
|
1902
|
+
const assignment = assignmentRequest(text)
|
|
1903
|
+
if (assignment) {
|
|
1904
|
+
const delivery = conversationDelivery('assignment-routing')
|
|
1905
|
+
const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
|
|
1906
|
+
// Enqueue each verified ticket separately, using the same ownership,
|
|
1907
|
+
// revision and queue dedupe gates as live assignment events.
|
|
1908
|
+
void (async () => {
|
|
1909
|
+
try {
|
|
1910
|
+
if (!canCode) throw new Error('This watcher is configured with --chat-only and has no coding worker. Enable its coding workspace to execute assigned tickets.')
|
|
1911
|
+
const count = await routeAssignments({ request: assignment, loadTickets: loadPendingTickets, handleTask: handleTaskSignal, isCancelled })
|
|
1912
|
+
if (count === 0 && delivery && !isCancelled()) {
|
|
1913
|
+
await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I have no open assignments ready for implementation; tickets awaiting review or testing stay in their current stage.` })
|
|
1914
|
+
}
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
log('assignment routing failed: ' + (error?.message || error))
|
|
1917
|
+
if (delivery && !isCancelled()) await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I couldn't route the assigned work: ${error?.message || error}` })
|
|
1918
|
+
}
|
|
1919
|
+
})().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
|
|
1920
|
+
return
|
|
1921
|
+
}
|
|
1871
1922
|
if (cid != null && conversationAsksPendingTickets(text)) {
|
|
1872
1923
|
log('pending-ticket question -> watcher-owned MCP lookup')
|
|
1873
|
-
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
|
|
1924
|
+
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
|
|
1874
1925
|
return
|
|
1875
1926
|
}
|
|
1876
1927
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|