openvisio-agent 0.19.9 → 0.19.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/bin/cli.mjs +15 -8
- package/package.json +2 -2
- package/scripts/certify.mjs +23 -18
- package/src/assignment-routing.mjs +32 -0
- package/src/authorization-resume.mjs +10 -0
- package/src/channel-routing.mjs +26 -0
- package/src/codex-mcp-proxy.mjs +3 -1
- package/src/concurrency.mjs +12 -0
- package/src/cycle-queue.mjs +5 -1
- package/src/mastra-harness.mjs +68 -32
- package/src/memory.mjs +15 -7
- package/src/opencode-config.mjs +1 -0
- package/src/pr-push.mjs +56 -17
- package/src/runner-pool.mjs +38 -0
- package/src/task-types.mjs +24 -0
- package/src/watch.mjs +268 -488
package/src/watch.mjs
CHANGED
|
@@ -8,16 +8,19 @@ import { spawn, spawnSync } from 'node:child_process'
|
|
|
8
8
|
import { closeSync, writeFileSync, mkdirSync, existsSync, openSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
|
-
import { fileURLToPath } from 'node:url'
|
|
12
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
13
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
14
|
-
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget,
|
|
13
|
+
import { agentAddedByName, blockingReplyMcpErrors, buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, combineRuntimeWorkEvidence, conversationAsksPendingTickets, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, missingRuntimeWorkEvidence, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
15
14
|
import { createMastraMemory } from './memory.mjs'
|
|
16
|
-
import { repositoryHasPrPushAuthorization } from './pr-push.mjs'
|
|
15
|
+
import { repositoryHasPrPushAuthorization, configurePrPublishing } from './pr-push.mjs'
|
|
17
16
|
import { createMcpHttpClient } from './mcp-http.mjs'
|
|
18
|
-
import { buildOpencodeConfig, opencodeRuntimeLayout } from './opencode-config.mjs'
|
|
19
|
-
import { buildCodexMcpOverride } from './codex-config.mjs'
|
|
20
17
|
import { createCycleQueue } from './cycle-queue.mjs'
|
|
18
|
+
import { createRunnerPool } from './runner-pool.mjs'
|
|
19
|
+
import { mapConcurrent } from './concurrency.mjs'
|
|
20
|
+
import { releaseAuthorizedPause } from './authorization-resume.mjs'
|
|
21
|
+
import { dedicatedChannel, repliesAfterSource } from './channel-routing.mjs'
|
|
22
|
+
import { createTaskTypeLookup } from './task-types.mjs'
|
|
23
|
+
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
21
24
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
22
25
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
23
26
|
import { claudeFallbackModels, resolveClaudeModel } from './model-selection.mjs'
|
|
@@ -60,6 +63,7 @@ const REPLY_DISCIPLINE = [
|
|
|
60
63
|
' • LOOK UP ASSIGNED WORK. If someone says they assigned you a task, asks which task is yours, or asks for its status, check the live board yourself with list_agents + list_projects + list_tasks and then get_ticket as needed. Match assignments to your authenticated agent identity. Do not ask the teammate for a project slug, ticket slug, or numeric id before trying those MCP tools; ask only if the live lookup fails or returns genuinely ambiguous matches.',
|
|
61
64
|
' • TICKET SLUGS, NEVER DATABASE IDS. In every human-facing channel message, ticket comment, PR description, summary, blocker, and result, reference a ticket by the exact project-scoped slug returned by get_ticket/list_tasks (for example, `OVS-57`). Numeric project_id and ticket_id values are internal MCP arguments only: never write `#57`, `ticket 57`, or expose a database id to teammates. If the backend omits the slug, use the ticket title or say “the ticket”; do not invent a slug.',
|
|
62
65
|
' • CLOSE CONCERNS. Never leave a concern, direct question, correction, or blocker addressed to you without a clear response. Acknowledge the concern, act if you can, then report the verified result. If blocked, name the blocker and the exact next action or owner in one message.',
|
|
66
|
+
' • NEW THREADS BELONG IN YOUR DEDICATED CHANNEL. Reply to an existing request in its original thread. For any fresh top-level thread (results, blockers, or an introduction), use only your own dedicated agent channel, resolved from live list_channels by your exact identity/name. Never open a fresh thread in general, a project channel, or another agent’s channel. If no unique dedicated channel exists, keep results on the ticket and report the missing channel in an existing source thread. Never invent a channel or tool.',
|
|
63
67
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
64
68
|
].join('\n')
|
|
65
69
|
|
|
@@ -101,7 +105,7 @@ const CODE_CHARTER = [
|
|
|
101
105
|
'',
|
|
102
106
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
103
107
|
' 1. CLOSE THE LOOP in THIS cycle. Never say "I\'ll do X" and stop. If you commit to something, do it NOW — the human must never have to remind you to circle back. Sending an intent or progress message is not a stop condition: continue using tools and send the verified result or blocker afterward.',
|
|
104
|
-
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the
|
|
108
|
+
' 2. FINISH, then REPORT. Always update/move the ticket with update_ticket. Reply in a supplied human source thread when one exists. For backlog-assigned work, do not call post_message yourself: the watcher publishes one evidence-verified result in the dedicated agent channel after the PR and ticket handoff are confirmed.',
|
|
105
109
|
' 3. Be honest and specific. Never invent progress. If you are genuinely blocked (missing repo, unclear spec, a failing tool), say exactly what you need in one message — that IS closing the loop.',
|
|
106
110
|
' 4. One final reply per request; answer several nudges together. A single concrete progress update is allowed during longer work, but it must be followed by the final result or blocker in the same cycle.',
|
|
107
111
|
' 5. RECOVER DEAD COMMAND SESSIONS. If write_stdin reports “Unknown process id”, that command session has already exited. Never poll the same process id again. Start a fresh exec_command when more work is required, then continue the task and verify the final state.',
|
|
@@ -121,7 +125,7 @@ const CODE_FULL = [
|
|
|
121
125
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
122
126
|
' 4. COMMIT + PUSH YOUR BRANCH: stage only the specific paths or hunks changed for this task; inspect git diff --cached before committing. If unrelated changes are already staged, use an isolated worktree. Commit with a clear message, then publish only your own agent/* branch using the runtime-specific authorized push flow. Never --force, never push to main/master, never merge.',
|
|
123
127
|
' 5. RAISE A PR: gh pr create --fill --base <default-branch> --head agent/<slug> (a clear title + a body summarizing the change and how you verified it). Never gh pr merge.',
|
|
124
|
-
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified
|
|
128
|
+
' 6. CLOSE THE LOOP: move/update the ticket with update_ticket. Use a ticket-comment tool for a blocker or clarification only when that tool actually appears; otherwise keep the blocker in the ticket update and let the watcher deliver the visible channel result. When a source thread is supplied, follow its explicit delivery rule: either post once or return final text for watcher delivery. For backlog-only tickets, do not call post_message yourself; the watcher sends one verified dedicated-channel completion message and deduplicates it across reconnects.',
|
|
125
129
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
126
130
|
].join('\n')
|
|
127
131
|
|
|
@@ -139,7 +143,7 @@ const CODE_FAST = [
|
|
|
139
143
|
// missed — TASKS especially.
|
|
140
144
|
const INTRO = [
|
|
141
145
|
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
142
|
-
'Find
|
|
146
|
+
'Find your own dedicated agent channel using list_channels and your exact identity/name, then post_message there ONCE: say in first person that you are here, pick up assigned tasks, and answer @mentions. Never use general or another agent’s channel for a fresh thread. If no unique dedicated channel exists, do not invent one. Do not state your name; the app already displays it. Keep it to 1-2 sentences.',
|
|
143
147
|
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
144
148
|
].join('\n')
|
|
145
149
|
const SWEEP = [
|
|
@@ -259,6 +263,7 @@ export async function runWatch({ flags }) {
|
|
|
259
263
|
: flags.workdir ? String(flags.workdir)
|
|
260
264
|
: flags.workspace ? String(flags.workspace) : ''
|
|
261
265
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
266
|
+
if (workdir) configurePrPublishing({ workspace: workdir, enabled: !flags['no-pr-push'] && saved?.prPublishing !== false })
|
|
262
267
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
263
268
|
|
|
264
269
|
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
@@ -312,303 +317,15 @@ export async function runWatch({ flags }) {
|
|
|
312
317
|
return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
313
318
|
}
|
|
314
319
|
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
// so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
|
|
318
|
-
// The openvisio-team MCP is declared in a private per-agent config. The actual
|
|
319
|
-
// code workspace is passed through --dir, so two agents never overwrite each
|
|
320
|
-
// other's MCP identity even when they work in the same repository.
|
|
321
|
-
// `--auto` approves tool use non-interactively.
|
|
322
|
-
// Same { runCycle, canCode } contract as the Claude runner.
|
|
323
|
-
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
324
|
-
const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
|
|
325
|
-
const bin = onPath('opencode') || 'opencode'
|
|
326
|
-
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
327
|
-
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode })
|
|
328
|
-
let configured = false
|
|
329
|
-
let cancelActive = null
|
|
330
|
-
const ensureConfig = () => {
|
|
331
|
-
if (configured) return
|
|
332
|
-
configured = true
|
|
333
|
-
try {
|
|
334
|
-
mkdirSync(configDir, { recursive: true })
|
|
335
|
-
mkdirSync(workspace, { recursive: true })
|
|
336
|
-
if (opencodeConfig) {
|
|
337
|
-
writeJson(opencodeConfigPath, opencodeConfig, true)
|
|
338
|
-
} else {
|
|
339
|
-
log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
340
|
-
}
|
|
341
|
-
} catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function runCycle(prompt, cycleModel) {
|
|
345
|
-
return new Promise((resolve) => {
|
|
346
|
-
ensureConfig()
|
|
347
|
-
const m = cycleModel || model
|
|
348
|
-
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
349
|
-
// charter/creds into the message (still not re-accumulated across cycles).
|
|
350
|
-
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
351
|
-
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
352
|
-
// OpenCode exited; raw events tell us which tools actually completed.
|
|
353
|
-
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
354
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
355
|
-
let terminationResult = null
|
|
356
|
-
let cancel = null
|
|
357
|
-
let outputText = '', jsonlBuffer = ''
|
|
358
|
-
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
359
|
-
const finish = (o) => {
|
|
360
|
-
if (done) return
|
|
361
|
-
done = true
|
|
362
|
-
if (cancelActive === cancel) cancelActive = null
|
|
363
|
-
clearTimeout(timer)
|
|
364
|
-
const calls = [...mcpCalls]
|
|
365
|
-
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
366
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
367
|
-
}
|
|
368
|
-
const terminate = (subtype) => {
|
|
369
|
-
terminationResult ||= { type: 'result', subtype }
|
|
370
|
-
return stopModelProcess(child).then(() => finish(terminationResult))
|
|
371
|
-
}
|
|
372
|
-
const inspectLine = (line) => {
|
|
373
|
-
const value = String(line || '').trim()
|
|
374
|
-
if (!value) return
|
|
375
|
-
let event
|
|
376
|
-
try { event = JSON.parse(value) } catch {
|
|
377
|
-
if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
const evidence = opencodeEventEvidence(event)
|
|
381
|
-
if (evidence.outputText) {
|
|
382
|
-
outputText += ' ' + evidence.outputText
|
|
383
|
-
if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
|
|
384
|
-
}
|
|
385
|
-
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
386
|
-
if (!evidence.tool) return
|
|
387
|
-
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
388
|
-
if (evidence.failed && evidence.toolError) {
|
|
389
|
-
const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
|
|
390
|
-
log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
|
|
391
|
-
}
|
|
392
|
-
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
393
|
-
if (evidence.mcpTool) {
|
|
394
|
-
mcpCalls.add(evidence.mcpTool)
|
|
395
|
-
if (evidence.failed) mcpErrors.add(evidence.mcpTool)
|
|
396
|
-
else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
|
|
397
|
-
}
|
|
398
|
-
const repositoryWasChanged = didRepoMutation
|
|
399
|
-
if (evidence.didChannelMessage && repositoryWasChanged) didResultMessage = true
|
|
400
|
-
didCode ||= !!evidence.didCode
|
|
401
|
-
didRepoMutation ||= !!evidence.didRepoMutation
|
|
402
|
-
didMessage ||= !!evidence.didMessage
|
|
403
|
-
didChannelMessage ||= !!evidence.didChannelMessage
|
|
404
|
-
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
405
|
-
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
406
|
-
}
|
|
407
|
-
const timer = setTimeout(() => {
|
|
408
|
-
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
409
|
-
void terminate('timeout')
|
|
410
|
-
}, maxCycleMs)
|
|
411
|
-
cancel = () => terminate('canceled')
|
|
412
|
-
cancelActive = cancel
|
|
413
|
-
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
414
|
-
try {
|
|
415
|
-
child = spawn(bin, args, {
|
|
416
|
-
...modelProcessOptions,
|
|
417
|
-
cwd: configDir,
|
|
418
|
-
env: {
|
|
419
|
-
...process.env,
|
|
420
|
-
OPENCODE_CONFIG: opencodeConfigPath,
|
|
421
|
-
// Inline config has higher precedence than a project opencode.json.
|
|
422
|
-
// This prevents a stale generated workspace file from replacing this
|
|
423
|
-
// agent's URL or credentials while keeping normal project settings.
|
|
424
|
-
...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
|
|
425
|
-
},
|
|
426
|
-
stdio: ['ignore', 'pipe', 'inherit'],
|
|
427
|
-
})
|
|
428
|
-
child.stdout?.on('data', (data) => {
|
|
429
|
-
jsonlBuffer += String(data)
|
|
430
|
-
const lines = jsonlBuffer.split('\n')
|
|
431
|
-
jsonlBuffer = lines.pop() ?? ''
|
|
432
|
-
for (const line of lines) inspectLine(line)
|
|
433
|
-
})
|
|
434
|
-
}
|
|
435
|
-
catch (e) { log('opencode spawn failed: ' + (e && e.message ? e.message : e) + ' — is opencode installed? (npm i -g opencode-ai, then `opencode auth login`)'); return finish({ type: 'result', subtype: 'spawn-failed' }) }
|
|
436
|
-
child.on('close', (code) => {
|
|
437
|
-
inspectLine(jsonlBuffer); jsonlBuffer = ''
|
|
438
|
-
const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
|
|
439
|
-
log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
440
|
-
finish(terminationResult || { type: 'result', subtype })
|
|
441
|
-
})
|
|
442
|
-
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
443
|
-
})
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
447
|
-
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
451
|
-
// for each event and inject only this agent's OpenVisio MCP config on the command
|
|
452
|
-
// line. That avoids inheriting unrelated user MCP servers while keeping the
|
|
453
|
-
// user's normal Codex authentication. We deliberately never use Codex's dangerous
|
|
454
|
-
// approval/sandbox bypass flag.
|
|
455
|
-
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
456
|
-
const bin = onPath('codex') || 'codex'
|
|
457
|
-
const defaultCwd = workdir || OV_DIR
|
|
458
|
-
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
459
|
-
let cancelActive = null
|
|
460
|
-
|
|
461
|
-
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
462
|
-
return new Promise((resolve) => {
|
|
463
|
-
const m = cycleModel || model
|
|
464
|
-
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
465
|
-
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
466
|
-
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
467
|
-
const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
|
|
468
|
-
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
469
|
-
...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
|
|
470
|
-
...(m ? ['--model', m] : []),
|
|
471
|
-
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
472
|
-
full]
|
|
473
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
474
|
-
let terminationResult = null
|
|
475
|
-
let cancel = null
|
|
476
|
-
let policyBlock = null
|
|
477
|
-
const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
|
|
478
|
-
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
479
|
-
const finish = (o) => {
|
|
480
|
-
if (done) return
|
|
481
|
-
done = true
|
|
482
|
-
if (cancelActive === cancel) cancelActive = null
|
|
483
|
-
clearTimeout(timer)
|
|
484
|
-
const calls = [...mcpCalls]
|
|
485
|
-
const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
|
|
486
|
-
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
|
|
487
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
|
|
488
|
-
}
|
|
489
|
-
const terminate = (subtype) => {
|
|
490
|
-
terminationResult ||= { type: 'result', subtype }
|
|
491
|
-
return stopModelProcess(child).then(() => finish(terminationResult))
|
|
492
|
-
}
|
|
493
|
-
const inspectDiagnostic = (value) => {
|
|
494
|
-
const s = String(value || '')
|
|
495
|
-
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
496
|
-
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
497
|
-
}
|
|
498
|
-
const forwardDiagnostic = (value, flush = false) => {
|
|
499
|
-
const incoming = String(value || '')
|
|
500
|
-
inspectDiagnostic(incoming)
|
|
501
|
-
stderrLineBuffer += incoming
|
|
502
|
-
const lines = stderrLineBuffer.split('\n')
|
|
503
|
-
stderrLineBuffer = lines.pop() ?? ''
|
|
504
|
-
for (const line of lines) {
|
|
505
|
-
if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
|
|
506
|
-
}
|
|
507
|
-
if (flush && stderrLineBuffer) {
|
|
508
|
-
if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
|
|
509
|
-
stderrLineBuffer = ''
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
const inspectLine = (line) => {
|
|
513
|
-
const s = line.trim()
|
|
514
|
-
if (!s) return
|
|
515
|
-
policyBlock = codexPolicyBlock(s) || policyBlock
|
|
516
|
-
try {
|
|
517
|
-
const event = JSON.parse(s)
|
|
518
|
-
const evidence = codexEventEvidence(event)
|
|
519
|
-
if (evidence.outputText) outputText += ' ' + evidence.outputText
|
|
520
|
-
if (evidence.mcpTool) {
|
|
521
|
-
mcpCalls.add(evidence.mcpTool)
|
|
522
|
-
try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
|
|
523
|
-
if (evidence.failed) {
|
|
524
|
-
mcpErrors.add(evidence.mcpTool)
|
|
525
|
-
if (evidence.toolError) {
|
|
526
|
-
const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
527
|
-
const detail = String(evidence.toolError)
|
|
528
|
-
const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
|
|
529
|
-
mcpErrorDetails.set(evidence.mcpTool, safe)
|
|
530
|
-
}
|
|
531
|
-
} else if (evidence.completed) {
|
|
532
|
-
mcpErrors.delete(evidence.mcpTool)
|
|
533
|
-
mcpErrorDetails.delete(evidence.mcpTool)
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
|
|
537
|
-
didCode ||= !!evidence.didCode
|
|
538
|
-
didRepoMutation ||= !!evidence.didRepoMutation
|
|
539
|
-
didMessage ||= !!evidence.didMessage
|
|
540
|
-
didChannelMessage ||= !!evidence.didChannelMessage
|
|
541
|
-
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
542
|
-
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
543
|
-
} catch { /* non-JSON diagnostic */ }
|
|
544
|
-
if (debug) log(' · ' + s.slice(0, 220))
|
|
545
|
-
}
|
|
546
|
-
const timer = setTimeout(() => {
|
|
547
|
-
log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
548
|
-
void terminate('timeout')
|
|
549
|
-
}, maxCycleMs)
|
|
550
|
-
cancel = () => terminate('canceled')
|
|
551
|
-
cancelActive = cancel
|
|
552
|
-
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
553
|
-
try {
|
|
554
|
-
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
555
|
-
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
556
|
-
child = spawn(bin, args, {
|
|
557
|
-
...modelProcessOptions,
|
|
558
|
-
cwd: cycleCwd,
|
|
559
|
-
env: {
|
|
560
|
-
...process.env,
|
|
561
|
-
...(mcpUrl ? {
|
|
562
|
-
OPENVISIO_CODEX_MCP_URL: mcpUrl,
|
|
563
|
-
OPENVISIO_CODEX_API_KEY: mcpHeaders?.['x-agent-api-key'] || '',
|
|
564
|
-
OPENVISIO_CODEX_IDENTIFIER: mcpHeaders?.['x-agent-identifier'] || '',
|
|
565
|
-
} : {}),
|
|
566
|
-
},
|
|
567
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
568
|
-
})
|
|
569
|
-
if (child.stdout) child.stdout.on('data', (d) => {
|
|
570
|
-
jsonlBuffer += String(d)
|
|
571
|
-
const lines = jsonlBuffer.split('\n')
|
|
572
|
-
jsonlBuffer = lines.pop() ?? ''
|
|
573
|
-
for (const line of lines) inspectLine(line)
|
|
574
|
-
})
|
|
575
|
-
if (child.stderr) child.stderr.on('data', (d) => {
|
|
576
|
-
forwardDiagnostic(d)
|
|
577
|
-
})
|
|
578
|
-
} catch (e) {
|
|
579
|
-
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
580
|
-
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
581
|
-
}
|
|
582
|
-
child.on('close', (code) => {
|
|
583
|
-
inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
|
|
584
|
-
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
585
|
-
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
586
|
-
finish(terminationResult || { type: 'result', subtype })
|
|
587
|
-
})
|
|
588
|
-
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
589
|
-
})
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
593
|
-
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
|
|
594
|
-
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
598
|
-
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
599
|
-
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
600
|
-
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt }) {
|
|
320
|
+
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
321
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt, canCoordinate = false }) {
|
|
601
322
|
const canCode = !!workdir
|
|
602
323
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
603
324
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
604
325
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
605
326
|
// cannot bleed across concurrently assigned tickets.
|
|
606
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
327
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
607
328
|
if (mastraRunner) return mastraRunner
|
|
608
|
-
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
609
|
-
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
610
|
-
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
611
|
-
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
612
329
|
let child = null
|
|
613
330
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
614
331
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -778,7 +495,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
778
495
|
child = null
|
|
779
496
|
return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
|
|
780
497
|
}
|
|
781
|
-
return { runCycle, canCode, cancelCurrent }
|
|
498
|
+
return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
|
|
782
499
|
}
|
|
783
500
|
|
|
784
501
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -798,12 +515,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
798
515
|
const canCode = !!workdir
|
|
799
516
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
800
517
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
801
|
-
const authRule = agent
|
|
518
|
+
const authRule = agent !== 'claude'
|
|
802
519
|
? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
803
520
|
: `AUTH: the openvisio-team tools REQUIRE two arguments on EVERY call — agent_identifier: "${identifier}" and agent_api_key: "${apiKey}". Include BOTH on every openvisio-team tool call. The credentials are given here; do NOT hunt for them.`
|
|
804
521
|
const credNote = `${authRule} Codex may defer MCP actions instead of placing them in the initial tool list. If a native tool_search is available and an expected openvisio-team action is not initially visible, use tool_search to find that exact action, then call it; an initial-list miss is NOT evidence that the server is disconnected. Do not ask a teammate to reconnect or provide a slug until tool_search and the applicable live lookup have actually failed. On backend MCP, discover and update work with list_agents, list_projects, list_tasks, list_task_types, get_ticket, update_ticket, list_channels, list_message_thread, and list_activity as applicable. Ticket comments are optional: never invent or call comment_ticket unless that exact tool appears or tool_search finds it. get_marching_orders, poll_inbox, get_resource, list_mcp_resources, and list_mcp_resource_templates are NOT team-action tools here; never call them. Tools may be namespaced — call whichever names actually appear. Bash/git/gh ARE for code work; this rule only forbids searching for keys.`
|
|
805
|
-
//
|
|
806
|
-
//
|
|
522
|
+
// Keep the static charter stable so provider prompt caching can reuse it.
|
|
523
|
+
// Actual cache hits and billing depend on the runtime and model provider.
|
|
807
524
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
808
525
|
const runnerOptions = {
|
|
809
526
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
@@ -819,30 +536,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
819
536
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
820
537
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
821
538
|
})
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
replyRunners.set(key, runner)
|
|
836
|
-
while (replyRunners.size > 8) {
|
|
837
|
-
const oldestKey = replyRunners.keys().next().value
|
|
838
|
-
const oldest = replyRunners.get(oldestKey)
|
|
839
|
-
replyRunners.delete(oldestKey)
|
|
840
|
-
oldest?.close?.()
|
|
841
|
-
}
|
|
842
|
-
return runner
|
|
843
|
-
}
|
|
539
|
+
const workRunners = createRunnerPool({
|
|
540
|
+
create: ({ control, workdir }) => createWorkRunner(control, workdir),
|
|
541
|
+
capacity: 6,
|
|
542
|
+
})
|
|
543
|
+
const activeReplyRunners = new Set()
|
|
544
|
+
const replyRunners = createRunnerPool({
|
|
545
|
+
capacity: 8,
|
|
546
|
+
create: ({ key, canCoordinate }) => createCycleRunner({
|
|
547
|
+
...runnerOptions, workdir: '', canCoordinate,
|
|
548
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
549
|
+
cfgKey: identifier + '-reply-' + slugify(key),
|
|
550
|
+
}),
|
|
551
|
+
})
|
|
844
552
|
const codexPushGuide = agent === 'codex' && canCode
|
|
845
|
-
? '\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.
|
|
553
|
+
? '\n\nCODEX PR DELIVERY: when the repository exists in the local workspace, use that clone for branch creation, edits, tests, and commits; do not inspect or mutate it through linked-codebase MCP tools. To publish the local agent/* branch, run `openvisio-agent push-pr-branch` from the repository, then open the PR with `gh pr create`. The helper can only push HEAD to the matching agent/* branch on the exact authorized origin. If it reports OPENVISIO_PR_PUSH_AUTH_REQUIRED, do not retry or route around it. PR publishing is enabled during connection setup for the coding workspace and its worktrees. If authorization is missing outside that workspace, report the exact repository configuration issue once; never tell the user to restart the watcher. Use list_codebases/create_codebase_branch/create_codebase_commit/create_pull_request only as a fallback when the repository cannot be obtained locally.'
|
|
846
554
|
: ''
|
|
847
555
|
const backendToolRule = 'BACKEND MCP RULE: the event and watcher already provide the work source. Never call get_marching_orders, poll_inbox, get_resource, list_mcp_resources, or list_mcp_resource_templates; they are not backend team actions. Codex can defer MCP actions: native tool_search is allowed and must be used to find an expected openvisio-team action that is absent from the initial list. An initial-list miss does not mean the MCP server is unavailable. For live discovery use list_agents, list_projects, list_tasks, list_task_types, list_activity, get_ticket, and list_channels as applicable. Never call comment_ticket unless that exact optional tool is present or tool_search finds it.'
|
|
848
556
|
const fullPrompt = (canCode ? CODE_FULL + codexPushGuide : 'Handle the supplied verified backend ticket with the available OpenVisio tools. Update or comment on the ticket as requested, do not claim repository work in chat-only mode, and stop after the verified action.') + '\n\n' + backendToolRule
|
|
@@ -860,11 +568,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
860
568
|
concurrency: MAX_CONCURRENT_WORKERS,
|
|
861
569
|
groupKey: (item) => item.workdir || workdir || 'shared-workspace',
|
|
862
570
|
run: async (item) => {
|
|
863
|
-
const
|
|
571
|
+
const sessionKey = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.workdir || workdir])
|
|
572
|
+
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
864
573
|
item.control.runner = runner
|
|
865
574
|
activeWorkRunners.add(runner)
|
|
866
575
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
867
|
-
finally {
|
|
576
|
+
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
868
577
|
},
|
|
869
578
|
onError: (error, item) => {
|
|
870
579
|
pauseFailedTask(item.taskRef)
|
|
@@ -873,11 +582,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
873
582
|
},
|
|
874
583
|
}),
|
|
875
584
|
reply: createCycleQueue({
|
|
585
|
+
concurrency: 3,
|
|
586
|
+
groupKey: (item) => item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
|
|
876
587
|
run: async (item) => {
|
|
877
|
-
const
|
|
588
|
+
const key = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.kind === 'coord'])
|
|
589
|
+
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
590
|
+
activeReplyRunners.add(runner)
|
|
878
591
|
item.control.runner = runner
|
|
879
592
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
880
|
-
finally { item.control.runner = null }
|
|
593
|
+
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
881
594
|
},
|
|
882
595
|
onError: (error, item) => {
|
|
883
596
|
pauseFailedTask(item.taskRef)
|
|
@@ -957,7 +670,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
957
670
|
trimMap(failedTaskVersions)
|
|
958
671
|
seenTasks.add(key)
|
|
959
672
|
trimSeen(seenTasks)
|
|
960
|
-
lastTaskSignature = ''
|
|
961
673
|
persistReplay()
|
|
962
674
|
}
|
|
963
675
|
const finalizeFailedTaskPause = async (taskRef) => {
|
|
@@ -989,6 +701,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
989
701
|
persistReplay()
|
|
990
702
|
return false
|
|
991
703
|
}
|
|
704
|
+
const resumeAuthorized = (key) => {
|
|
705
|
+
const resumed = releaseAuthorizedPause(key, {
|
|
706
|
+
blocked: blockedTasks, repos: blockedTaskRepos, failed: failedTaskVersions, seen: seenTasks,
|
|
707
|
+
authorized: (cwd) => repositoryHasPrPushAuthorization({ cwd }),
|
|
708
|
+
})
|
|
709
|
+
if (resumed) { persistReplay(); log('PR authorization ready; resuming ' + key) }
|
|
710
|
+
return resumed
|
|
711
|
+
}
|
|
992
712
|
const markMentionHandled = (message, channelId) => {
|
|
993
713
|
pruneMentionSignatures()
|
|
994
714
|
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
@@ -1004,8 +724,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1004
724
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
1005
725
|
// source independently through work, recovery, and delivery.
|
|
1006
726
|
let backlogProbeBusy = false
|
|
1007
|
-
let lastTaskSignature = ''
|
|
1008
|
-
let lastTaskTriggeredAt = 0
|
|
1009
727
|
let lastInboxSignature = ''
|
|
1010
728
|
let selfAgentId = null
|
|
1011
729
|
let completionRecipientName = ''
|
|
@@ -1066,6 +784,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1066
784
|
try { return JSON.parse(text) } catch { return { text } }
|
|
1067
785
|
}
|
|
1068
786
|
|
|
787
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => callMcpTool('list_task_types', { project_id: projectId }).then(toolData) })
|
|
788
|
+
|
|
1069
789
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
1070
790
|
try { return await callMcpTool(name, args) }
|
|
1071
791
|
catch (first) {
|
|
@@ -1075,38 +795,50 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1075
795
|
}
|
|
1076
796
|
|
|
1077
797
|
const loadPendingTickets = async () => {
|
|
798
|
+
if (selfAgentId == null) {
|
|
799
|
+
const agentsData = toolData(await callMcpReadWithRetry('list_agents'))
|
|
800
|
+
const self = (Array.isArray(agentsData.agents) ? agentsData.agents : []).find((a) => String(a.identifier || a.slug || '') === identifier)
|
|
801
|
+
if (!self?.id) throw new Error('list_agents did not return this BYO agent')
|
|
802
|
+
rememberSelfAgent(self)
|
|
803
|
+
}
|
|
1078
804
|
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1079
805
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1080
|
-
const groups = await
|
|
806
|
+
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
1081
807
|
const [tasksResult, typesResult] = await Promise.all([
|
|
1082
808
|
callMcpReadWithRetry('list_tasks', { project_id: project.id }),
|
|
1083
|
-
|
|
1084
|
-
log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
|
|
1085
|
-
return null
|
|
1086
|
-
}),
|
|
809
|
+
taskTypeSets(project.id),
|
|
1087
810
|
])
|
|
1088
811
|
const tasksData = toolData(tasksResult)
|
|
1089
|
-
const
|
|
1090
|
-
const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
1091
|
-
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)))
|
|
812
|
+
const { done: doneIds, review: reviewIds } = typesResult
|
|
1092
813
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1093
814
|
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1094
815
|
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
1095
816
|
const assignedHere = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
1096
817
|
return assignedHere && !taskIsCompleted(task, doneIds)
|
|
1097
818
|
}).map((task) => ({
|
|
819
|
+
id: task.id,
|
|
820
|
+
projectId: project.id,
|
|
821
|
+
awaitingReview: taskIsAwaitingReview(task, reviewIds),
|
|
1098
822
|
slug: ticketDisplaySlug(task),
|
|
1099
823
|
title: String(task.title || 'Untitled ticket'),
|
|
1100
824
|
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
1101
825
|
project: String(project.name || ''),
|
|
1102
826
|
}))
|
|
1103
|
-
})
|
|
827
|
+
})
|
|
1104
828
|
return groups.flat()
|
|
1105
829
|
}
|
|
1106
830
|
|
|
1107
|
-
const answerPendingTickets = async ({ delivery, who }) => {
|
|
831
|
+
const answerPendingTickets = async ({ delivery, who, text }) => {
|
|
1108
832
|
try {
|
|
1109
833
|
const tickets = await loadPendingTickets()
|
|
834
|
+
// Assignments already authorize pickup. A board lookup should not leave
|
|
835
|
+
// discovered work idle until a second human nudge arrives.
|
|
836
|
+
if (canCode && !assignmentStatusOnly(text)) {
|
|
837
|
+
await routeAssignments({
|
|
838
|
+
request: { slugs: [] }, loadTickets: async () => tickets, handleTask: handleTaskSignal,
|
|
839
|
+
isCancelled: () => memory.has(threadControlKey(delivery?.channelId, delivery?.parentId), 'cancelled'),
|
|
840
|
+
})
|
|
841
|
+
}
|
|
1110
842
|
const prefix = who ? `@${who} ` : ''
|
|
1111
843
|
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}` : ''}`)
|
|
1112
844
|
const extra = tickets.length > details.length ? `, plus ${tickets.length - details.length} more` : ''
|
|
@@ -1144,8 +876,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1144
876
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1145
877
|
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
1146
878
|
}
|
|
1147
|
-
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', allowCancelled = false }) => {
|
|
879
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1148
880
|
const deliveryKey = String(key || '')
|
|
881
|
+
if (parentId == null) {
|
|
882
|
+
channelId = await projectStatusChannel(projectId)
|
|
883
|
+
if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
|
|
884
|
+
projectId = undefined // dedicated channels may be organization-scoped
|
|
885
|
+
}
|
|
1149
886
|
const message = String(content || '').trim()
|
|
1150
887
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1151
888
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
@@ -1163,7 +900,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1163
900
|
const run = (async () => {
|
|
1164
901
|
if (parentId != null) {
|
|
1165
902
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1166
|
-
const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
|
|
903
|
+
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
|
|
1167
904
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
1168
905
|
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
1169
906
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
@@ -1196,24 +933,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1196
933
|
}
|
|
1197
934
|
|
|
1198
935
|
const statusChannelCache = new Map()
|
|
936
|
+
let dedicatedChannelRead = null
|
|
1199
937
|
const projectStatusChannel = async (projectId) => {
|
|
1200
|
-
const
|
|
1201
|
-
const cached = statusChannelCache.get(key)
|
|
938
|
+
const cached = statusChannelCache.get('self')
|
|
1202
939
|
if (cached && cached.expiresAt > Date.now()) return cached.channelId
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
940
|
+
if (dedicatedChannelRead) return dedicatedChannelRead
|
|
941
|
+
dedicatedChannelRead = (async () => {
|
|
942
|
+
try {
|
|
943
|
+
if (selfAgentId == null) {
|
|
944
|
+
const agents = toolData(await callMcpTool('list_agents')).agents || []
|
|
945
|
+
rememberSelfAgent(agents.find((entry) => String(entry.identifier || entry.slug || '') === identifier))
|
|
946
|
+
}
|
|
947
|
+
const data = toolData(await callMcpTool('list_channels'))
|
|
948
|
+
const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
|
|
949
|
+
const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
|
|
950
|
+
const channelId = channel ? Number(channel.id) : null
|
|
951
|
+
statusChannelCache.set('self', { channelId, expiresAt: Date.now() + (channelId ? 300_000 : 10_000) })
|
|
952
|
+
if (channelId == null) log('No unique dedicated agent channel; top-level delivery stays pending.')
|
|
953
|
+
return channelId
|
|
954
|
+
} catch (error) {
|
|
955
|
+
log('Dedicated channel lookup failed: ' + (error?.message || error))
|
|
956
|
+
return null
|
|
957
|
+
}
|
|
958
|
+
})().finally(() => { dedicatedChannelRead = null })
|
|
959
|
+
return dedicatedChannelRead
|
|
1217
960
|
}
|
|
1218
961
|
|
|
1219
962
|
const announceIntroduction = async () => {
|
|
@@ -1222,7 +965,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1222
965
|
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
1223
966
|
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
1224
967
|
const channelId = await projectStatusChannel(project.id)
|
|
1225
|
-
if (!Number.isFinite(Number(channelId))) return false
|
|
968
|
+
if (channelId == null || !Number.isFinite(Number(channelId))) return false
|
|
1226
969
|
await postMessageOnce({
|
|
1227
970
|
key: `intro:${identifier}:${project.id}`,
|
|
1228
971
|
projectId: Number(project.id),
|
|
@@ -1257,9 +1000,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1257
1000
|
try {
|
|
1258
1001
|
const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
1259
1002
|
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
1260
|
-
else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and
|
|
1003
|
+
else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
|
|
1261
1004
|
} catch (e) {
|
|
1262
|
-
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified
|
|
1005
|
+
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
|
|
1263
1006
|
}
|
|
1264
1007
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
1265
1008
|
}
|
|
@@ -1277,8 +1020,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1277
1020
|
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
1278
1021
|
return true
|
|
1279
1022
|
}
|
|
1280
|
-
const channelId =
|
|
1281
|
-
if (!Number.isFinite(channelId)) return false
|
|
1023
|
+
const channelId = await projectStatusChannel(projectId)
|
|
1024
|
+
if (channelId == null || !Number.isFinite(channelId)) return false
|
|
1282
1025
|
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
1283
1026
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1284
1027
|
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
@@ -1360,7 +1103,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1360
1103
|
? `Reply with: “I explicitly authorize you to push commit ${block.commit} to the exact repository URL for ${block.remote || 'the configured remote'}, on branch ${block.branch}.”`
|
|
1361
1104
|
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
1362
1105
|
const notice = `Action required: I'm blocked at \`${command}\`${payload ? ` (${payload})` : ''}. Codex requires confirmation before exporting private repository code. ${approval} I've paused the ticket until that approval is recorded.`
|
|
1363
|
-
const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the
|
|
1106
|
+
const ticketNotice = `I'm paused at \`${command}\`${payload ? ` (${payload})` : ''}. Repository push confirmation is required in the dedicated agent channel; I won't retry automatically.`
|
|
1364
1107
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1365
1108
|
}
|
|
1366
1109
|
|
|
@@ -1377,117 +1120,103 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1377
1120
|
rememberSelfAgent(self)
|
|
1378
1121
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1379
1122
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1380
|
-
const assigned = []
|
|
1381
1123
|
const mentionActivity = []
|
|
1382
1124
|
let activityReplayTouched = false
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1125
|
+
await mapConcurrent(projects, 3, async (project) => {
|
|
1126
|
+
try {
|
|
1127
|
+
const assigned = []
|
|
1128
|
+
const [tasksData, typesData] = await Promise.all([
|
|
1129
|
+
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
1130
|
+
taskTypeSets(project.id),
|
|
1131
|
+
])
|
|
1132
|
+
const { done: doneIds, review: reviewIds } = typesData
|
|
1133
|
+
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
1134
|
+
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
1135
|
+
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
1136
|
+
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
1137
|
+
const taskKey = `${project.id}:${task.id}`
|
|
1138
|
+
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1139
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
1140
|
+
if (pendingCompletionReports.has(taskKey)) {
|
|
1141
|
+
const activityChannel = await projectStatusChannel(project.id)
|
|
1142
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
1143
|
+
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
1144
|
+
}
|
|
1145
|
+
if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
|
|
1146
|
+
clearFailedTask(taskKey)
|
|
1147
|
+
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
1148
|
+
// the ticket back to an actionable column, that update must start a
|
|
1149
|
+
// fresh work cycle.
|
|
1150
|
+
seenTasks.delete(taskKey)
|
|
1151
|
+
continue
|
|
1403
1152
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
seenTasks.delete(taskKey)
|
|
1410
|
-
continue
|
|
1411
|
-
}
|
|
1412
|
-
if (failedTaskIsPaused(taskKey, task)) {
|
|
1413
|
-
seenTasks.add(taskKey)
|
|
1414
|
-
log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
|
|
1415
|
-
continue
|
|
1416
|
-
}
|
|
1417
|
-
if (blockedTasks.has(taskKey)) {
|
|
1418
|
-
const blockedRepo = blockedTaskRepos.get(taskKey)
|
|
1419
|
-
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1420
|
-
if (helperAuthorized) {
|
|
1421
|
-
blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
1422
|
-
log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
|
|
1423
|
-
} else continue
|
|
1424
|
-
}
|
|
1425
|
-
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
|
|
1426
|
-
assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
1427
|
-
}
|
|
1428
|
-
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1429
|
-
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1430
|
-
for (const item of activities) {
|
|
1431
|
-
const text = JSON.stringify(item)
|
|
1432
|
-
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1433
|
-
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1434
|
-
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1435
|
-
? item.message
|
|
1436
|
-
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1437
|
-
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1438
|
-
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1439
|
-
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1440
|
-
const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
|
|
1441
|
-
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1442
|
-
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1443
|
-
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1444
|
-
// Search only the source message, never the whole activity envelope: an
|
|
1445
|
-
// old tagged thread root can otherwise make another agent's new reply
|
|
1446
|
-
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1447
|
-
// in a persistently owned thread both enter the guarded per-message path.
|
|
1448
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
|
|
1449
|
-
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1450
|
-
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1451
|
-
// The same logical mention may already have arrived over WebSocket.
|
|
1452
|
-
// Share the id/signature guard instead of starting a second model turn.
|
|
1453
|
-
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1454
|
-
// when an owned-thread source has no received ledger entry.
|
|
1455
|
-
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1456
|
-
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1457
|
-
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1458
|
-
if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
|
|
1153
|
+
resumeAuthorized(taskKey)
|
|
1154
|
+
if (failedTaskIsPaused(taskKey, task)) {
|
|
1155
|
+
seenTasks.add(taskKey)
|
|
1156
|
+
log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
|
|
1157
|
+
continue
|
|
1459
1158
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
channelId: activityChannelId,
|
|
1464
|
-
message: activityMessage,
|
|
1465
|
-
activity: item,
|
|
1466
|
-
})
|
|
1159
|
+
if (blockedTasks.has(taskKey)) continue
|
|
1160
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'assigned', summary: task.title, refs: { projectId: project.id, ticketId: task.id }, meta: { updatedAt: task.updated_at ?? task.updatedAt } })
|
|
1161
|
+
assigned.push({ id: task.id, slug: ticketDisplaySlug(task), projectId: project.id, project: project.name, title: task.title, priority: task.priority, typeId: task.type_id ?? task.typeId, updatedAt: task.updated_at ?? task.updatedAt })
|
|
1467
1162
|
}
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
if (activityReplayTouched) persistReplay()
|
|
1471
|
-
if (!assigned.length) lastTaskSignature = ''
|
|
1472
|
-
else {
|
|
1473
|
-
const signature = JSON.stringify(assigned)
|
|
1474
|
-
const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
|
|
1475
|
-
if (signature !== lastTaskSignature || retryDue) {
|
|
1476
|
-
lastTaskSignature = signature
|
|
1477
|
-
lastTaskTriggeredAt = Date.now()
|
|
1478
1163
|
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
for (const next of candidates) {
|
|
1164
|
+
assigned.sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))
|
|
1165
|
+
for (const next of assigned) {
|
|
1482
1166
|
const key = `${next.projectId}:${next.id}`
|
|
1483
1167
|
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
// live events. Every ticket keeps its own context and result.
|
|
1168
|
+
// Live ownership/state verification remains inside handleTaskSignal.
|
|
1169
|
+
seenTasks.delete(key)
|
|
1487
1170
|
await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
|
|
1488
1171
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1172
|
+
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1173
|
+
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1174
|
+
return {}
|
|
1175
|
+
})
|
|
1176
|
+
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1177
|
+
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1178
|
+
for (const item of activities) {
|
|
1179
|
+
const text = JSON.stringify(item)
|
|
1180
|
+
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1181
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1182
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1183
|
+
? item.message
|
|
1184
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1185
|
+
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1186
|
+
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1187
|
+
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1188
|
+
const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
|
|
1189
|
+
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1190
|
+
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1191
|
+
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1192
|
+
// Search only the source message, never the whole activity envelope: an
|
|
1193
|
+
// old tagged thread root can otherwise make another agent's new reply
|
|
1194
|
+
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1195
|
+
// in a persistently owned thread both enter the guarded per-message path.
|
|
1196
|
+
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
|
|
1197
|
+
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1198
|
+
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1199
|
+
// The same logical mention may already have arrived over WebSocket.
|
|
1200
|
+
// Share the id/signature guard instead of starting a second model turn.
|
|
1201
|
+
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1202
|
+
// when an owned-thread source has no received ledger entry.
|
|
1203
|
+
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1204
|
+
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1205
|
+
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1206
|
+
if (!ownedActivity || memory.has(activitySourceKey, 'received')) continue
|
|
1207
|
+
}
|
|
1208
|
+
mentionActivity.push({
|
|
1209
|
+
projectId: project.id,
|
|
1210
|
+
project: project.name,
|
|
1211
|
+
channelId: activityChannelId,
|
|
1212
|
+
message: activityMessage,
|
|
1213
|
+
activity: item,
|
|
1214
|
+
})
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1218
|
+
})
|
|
1219
|
+
if (activityReplayTouched) persistReplay()
|
|
1491
1220
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
1492
1221
|
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1493
1222
|
lastInboxSignature = inboxSignature
|
|
@@ -1525,20 +1254,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1525
1254
|
}
|
|
1526
1255
|
// The next task update or periodic reconciliation must be allowed to queue
|
|
1527
1256
|
// this work again. An acknowledgement is not a terminal task signature.
|
|
1528
|
-
lastTaskSignature = ''
|
|
1529
1257
|
}
|
|
1530
1258
|
|
|
1531
1259
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1532
1260
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1533
1261
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1534
1262
|
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1535
|
-
const control = { cancelled: false, runner: null, statusTargets: new Set() }
|
|
1263
|
+
const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1536
1264
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
|
|
1537
1265
|
}
|
|
1538
1266
|
|
|
1539
1267
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1540
1268
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1541
1269
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1270
|
+
const cycleStartedAt = performance.now()
|
|
1271
|
+
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1542
1272
|
const ctx = context ? [context] : []
|
|
1543
1273
|
const activeTaskRef = taskRef
|
|
1544
1274
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
@@ -1571,18 +1301,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1571
1301
|
if (activeTaskRef) {
|
|
1572
1302
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1573
1303
|
const ticket = data.ticket ?? data.task ?? data
|
|
1304
|
+
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1574
1305
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1575
1306
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1576
1307
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1577
1308
|
!((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
|
|
1578
|
-
taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1309
|
+
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1579
1310
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1580
1311
|
log('queued ticket no longer actionable; skipped before model start')
|
|
1581
1312
|
return
|
|
1582
1313
|
}
|
|
1583
1314
|
}
|
|
1584
1315
|
if (cycleControl.cancelled) return
|
|
1316
|
+
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1585
1317
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1318
|
+
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1586
1319
|
if (result?.model && result.model !== useModel) {
|
|
1587
1320
|
// Persist only a successfully selected model and only if the user has
|
|
1588
1321
|
// not changed that tier while this cycle was running.
|
|
@@ -1731,6 +1464,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1731
1464
|
}
|
|
1732
1465
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1733
1466
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1467
|
+
const typeSets = await taskTypeSets(projectId)
|
|
1734
1468
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1735
1469
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1736
1470
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
@@ -1745,7 +1479,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1745
1479
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1746
1480
|
blockedTasks.delete(key); blockedTaskRepos.delete(key); pendingCompletionReports.delete(key); failedTaskVersions.delete(key); persistReplay(); seenTasks.delete(key); log(kind + ' ticket #' + ticketId + ' is not assigned to this agent — ignored'); return
|
|
1747
1481
|
}
|
|
1748
|
-
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1482
|
+
if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1749
1483
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1750
1484
|
if (pendingCompletionReports.has(key)) {
|
|
1751
1485
|
const activityChannel = await projectStatusChannel(projectId)
|
|
@@ -1756,6 +1490,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1756
1490
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1757
1491
|
return
|
|
1758
1492
|
}
|
|
1493
|
+
resumeAuthorized(key)
|
|
1759
1494
|
const hadFailedRevision = failedTaskVersions.has(key)
|
|
1760
1495
|
if (failedTaskIsPaused(key, ticket)) {
|
|
1761
1496
|
seenTasks.add(key)
|
|
@@ -1763,17 +1498,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1763
1498
|
return
|
|
1764
1499
|
}
|
|
1765
1500
|
if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
|
|
1766
|
-
if (blockedTasks.has(key)) {
|
|
1767
|
-
const blockedRepo = blockedTaskRepos.get(key)
|
|
1768
|
-
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1769
|
-
if (helperAuthorized) {
|
|
1770
|
-
blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
|
|
1771
|
-
log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
|
|
1772
|
-
} else {
|
|
1773
|
-
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
1774
|
-
return
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1501
|
+
if (blockedTasks.has(key)) { log(kind + ' ticket authorization is still unavailable'); return }
|
|
1777
1502
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1778
1503
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1779
1504
|
const title = String(ticket.title || hinted.title || '')
|
|
@@ -1788,7 +1513,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1788
1513
|
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1789
1514
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1790
1515
|
const workspaceDirective = preparedWorktree ? ` A prepared local git worktree for this ticket exists at ${JSON.stringify(preparedWorktree)}. Start repository inspection and edits there. Do not call list_codebases, codebase_tree, or get_codebase while this local worktree is available.` : ''
|
|
1791
|
-
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the
|
|
1516
|
+
void drain(cycleKind, `Authoritative get_ticket verification confirms ${humanTicketRef} is open and assigned to YOU. Internal tool identity: project_id ${projectId}, ticket_id ${ticketId}. For every get_ticket call pass exactly { project_id: ${projectId}, ticket_id: ${ticketId} }; for list_task_types pass exactly { project_id: ${projectId} }. Numeric ids are MCP arguments only and must never appear in human-facing text; use ${ticketSlug || 'the ticket title'} instead. Ticket details: ${JSON.stringify({ slug: ticketSlug, title, description: ticket.description, priority: ticket.priority, typeId: ticket.type_id ?? ticket.typeId })}.${workspaceDirective} This assignment has no source thread: do not call post_message yourself. Use list_task_types and update_ticket to move it active, complete and verify the work, open the PR when applicable, then update/move the ticket with evidence. For coding work, the watcher will publish exactly one verified completion result in the dedicated agent channel.`, activityChannel == null ? [] : [activityChannel], { projectId, ticketId, channelId: activityChannel })
|
|
1792
1517
|
} catch (e) {
|
|
1793
1518
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1794
1519
|
}
|
|
@@ -1848,7 +1573,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1848
1573
|
// redirect can cancel its process. Codex additionally delegates the final
|
|
1849
1574
|
// post to the watcher for an authoritative last-moment thread check.
|
|
1850
1575
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1851
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
|
|
1576
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: agent === 'codex' }
|
|
1852
1577
|
: null
|
|
1853
1578
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1854
1579
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1875,9 +1600,29 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1875
1600
|
}
|
|
1876
1601
|
return
|
|
1877
1602
|
}
|
|
1603
|
+
const assignment = assignmentRequest(text)
|
|
1604
|
+
if (assignment) {
|
|
1605
|
+
const delivery = conversationDelivery('assignment-routing')
|
|
1606
|
+
const isCancelled = () => !!controlKey && memory.has(controlKey, 'cancelled')
|
|
1607
|
+
// Enqueue each verified ticket separately, using the same ownership,
|
|
1608
|
+
// revision and queue dedupe gates as live assignment events.
|
|
1609
|
+
void (async () => {
|
|
1610
|
+
try {
|
|
1611
|
+
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.')
|
|
1612
|
+
const count = await routeAssignments({ request: assignment, loadTickets: loadPendingTickets, handleTask: handleTaskSignal, isCancelled })
|
|
1613
|
+
if (count === 0 && delivery && !isCancelled()) {
|
|
1614
|
+
await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I have no open assignments ready for implementation; tickets awaiting review or testing stay in their current stage.` })
|
|
1615
|
+
}
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
log('assignment routing failed: ' + (error?.message || error))
|
|
1618
|
+
if (delivery && !isCancelled()) await postMessageOnce({ ...delivery, content: `${who ? `@${who} ` : ''}I couldn't route the assigned work: ${error?.message || error}` })
|
|
1619
|
+
}
|
|
1620
|
+
})().catch((error) => log('assignment routing delivery failed: ' + (error?.message || error)))
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1878
1623
|
if (cid != null && conversationAsksPendingTickets(text)) {
|
|
1879
1624
|
log('pending-ticket question -> watcher-owned MCP lookup')
|
|
1880
|
-
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who })
|
|
1625
|
+
void answerPendingTickets({ delivery: conversationDelivery('pending-tickets'), who, text })
|
|
1881
1626
|
return
|
|
1882
1627
|
}
|
|
1883
1628
|
log('agent:mention in channel ' + (cid != null ? cid : '?') + (threadRoot != null ? ' (thread ' + threadRoot + ')' : ''))
|
|
@@ -1913,6 +1658,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1913
1658
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1914
1659
|
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
1915
1660
|
|
|
1661
|
+
let authorizationProbeBusy = false
|
|
1662
|
+
const legacyAuthorizationChecks = new Set()
|
|
1663
|
+
const resumeReadyAuthorizations = async () => {
|
|
1664
|
+
if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
|
|
1665
|
+
authorizationProbeBusy = true
|
|
1666
|
+
try {
|
|
1667
|
+
// Older ACP adapters recorded a helper denial as a generic failed cycle.
|
|
1668
|
+
// Recover that specific legacy state once, without reviving other failures.
|
|
1669
|
+
for (const key of failedTaskVersions.keys()) {
|
|
1670
|
+
const migrationKey = `pr-setup-migration:${key}`
|
|
1671
|
+
if (blockedTasks.has(key) || legacyAuthorizationChecks.has(key) || memory.has(migrationKey)) continue
|
|
1672
|
+
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1673
|
+
const [projectId, ticketId] = key.split(':').map(Number)
|
|
1674
|
+
const cwd = findTicketWorktree(workdir, ticketId)
|
|
1675
|
+
if (!cwd || !repositoryHasPrPushAuthorization({ cwd })) continue
|
|
1676
|
+
const data = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1677
|
+
const ticket = data.ticket ?? data.task ?? data
|
|
1678
|
+
legacyAuthorizationChecks.add(key)
|
|
1679
|
+
if (!/OPENVISIO_PR_PUSH_AUTH_REQUIRED|openvisio-agent authorize-pr-push/.test(String(ticket.description || ''))) continue
|
|
1680
|
+
memory.remember({ key: migrationKey, kind: 'migration', state: 'checked', summary: 'Recovered legacy PR setup block', refs: { projectId, ticketId } })
|
|
1681
|
+
blockedTasks.add(key); blockedTaskRepos.set(key, cwd)
|
|
1682
|
+
}
|
|
1683
|
+
for (const key of [...blockedTasks]) {
|
|
1684
|
+
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1685
|
+
if (!resumeAuthorized(key)) continue
|
|
1686
|
+
const [projectId, id] = key.split(':').map(Number)
|
|
1687
|
+
await handleTaskSignal('task:assigned', { task: { id, project_id: projectId } })
|
|
1688
|
+
}
|
|
1689
|
+
} catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
|
|
1690
|
+
finally { authorizationProbeBusy = false }
|
|
1691
|
+
}
|
|
1692
|
+
const authorizationTimer = setInterval(() => void resumeReadyAuthorizations(), 2_000)
|
|
1693
|
+
authorizationTimer.unref?.()
|
|
1916
1694
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1917
1695
|
if (mcpConfig || mcpUrl) {
|
|
1918
1696
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
@@ -1942,6 +1720,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1942
1720
|
const bye = async () => {
|
|
1943
1721
|
if (stopping) return
|
|
1944
1722
|
stopping = true
|
|
1723
|
+
clearInterval(authorizationTimer)
|
|
1945
1724
|
if (introTimer) clearTimeout(introTimer)
|
|
1946
1725
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1947
1726
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
@@ -1952,8 +1731,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1952
1731
|
void item.control.runner?.cancelCurrent?.()
|
|
1953
1732
|
})
|
|
1954
1733
|
}
|
|
1955
|
-
await Promise.all([...
|
|
1956
|
-
|
|
1734
|
+
await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
|
|
1735
|
+
workRunners.close()
|
|
1736
|
+
replyRunners.close()
|
|
1957
1737
|
process.exit(0)
|
|
1958
1738
|
}
|
|
1959
1739
|
process.on('SIGTERM', bye)
|