openvisio-agent 0.19.10 → 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 +22 -17
- 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 +228 -492
package/src/watch.mjs
CHANGED
|
@@ -8,16 +8,18 @@ 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'
|
|
21
23
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
22
24
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
23
25
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
@@ -61,6 +63,7 @@ const REPLY_DISCIPLINE = [
|
|
|
61
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.',
|
|
62
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.',
|
|
63
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.',
|
|
64
67
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
65
68
|
].join('\n')
|
|
66
69
|
|
|
@@ -102,7 +105,7 @@ const CODE_CHARTER = [
|
|
|
102
105
|
'',
|
|
103
106
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
104
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.',
|
|
105
|
-
' 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.',
|
|
106
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.',
|
|
107
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.',
|
|
108
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.',
|
|
@@ -122,7 +125,7 @@ const CODE_FULL = [
|
|
|
122
125
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
123
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.',
|
|
124
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.',
|
|
125
|
-
' 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.',
|
|
126
129
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
127
130
|
].join('\n')
|
|
128
131
|
|
|
@@ -140,7 +143,7 @@ const CODE_FAST = [
|
|
|
140
143
|
// missed — TASKS especially.
|
|
141
144
|
const INTRO = [
|
|
142
145
|
'You have just JOINED this OpenVisio workspace (your first connection). Workspace etiquette: introduce yourself so the team knows you are here and reachable.',
|
|
143
|
-
'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.',
|
|
144
147
|
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
145
148
|
].join('\n')
|
|
146
149
|
const SWEEP = [
|
|
@@ -260,6 +263,7 @@ export async function runWatch({ flags }) {
|
|
|
260
263
|
: flags.workdir ? String(flags.workdir)
|
|
261
264
|
: flags.workspace ? String(flags.workspace) : ''
|
|
262
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 })
|
|
263
267
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
264
268
|
|
|
265
269
|
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
@@ -313,303 +317,15 @@ export async function runWatch({ flags }) {
|
|
|
313
317
|
return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
314
318
|
}
|
|
315
319
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
// so each cycle is a headless `opencode run <prompt> --auto [--model provider/model]`.
|
|
319
|
-
// The openvisio-team MCP is declared in a private per-agent config. The actual
|
|
320
|
-
// code workspace is passed through --dir, so two agents never overwrite each
|
|
321
|
-
// other's MCP identity even when they work in the same repository.
|
|
322
|
-
// `--auto` approves tool use non-interactively.
|
|
323
|
-
// Same { runCycle, canCode } contract as the Claude runner.
|
|
324
|
-
function createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
325
|
-
const { configDir, configPath: opencodeConfigPath, workspace } = opencodeRuntimeLayout({ cfgKey, workdir })
|
|
326
|
-
const bin = onPath('opencode') || 'opencode'
|
|
327
|
-
const redactKey = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
328
|
-
const opencodeConfig = buildOpencodeConfig({ mcpUrl, mcpHeaders, canCode })
|
|
329
|
-
let configured = false
|
|
330
|
-
let cancelActive = null
|
|
331
|
-
const ensureConfig = () => {
|
|
332
|
-
if (configured) return
|
|
333
|
-
configured = true
|
|
334
|
-
try {
|
|
335
|
-
mkdirSync(configDir, { recursive: true })
|
|
336
|
-
mkdirSync(workspace, { recursive: true })
|
|
337
|
-
if (opencodeConfig) {
|
|
338
|
-
writeJson(opencodeConfigPath, opencodeConfig, true)
|
|
339
|
-
} else {
|
|
340
|
-
log('WARNING: no --mcp-url — opencode has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
341
|
-
}
|
|
342
|
-
} catch (e) { log('opencode config write failed: ' + (e && e.message ? e.message : e)) }
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function runCycle(prompt, cycleModel) {
|
|
346
|
-
return new Promise((resolve) => {
|
|
347
|
-
ensureConfig()
|
|
348
|
-
const m = cycleModel || model
|
|
349
|
-
// opencode has no system-prompt flag; each run is a fresh process, so fold the
|
|
350
|
-
// charter/creds into the message (still not re-accumulated across cycles).
|
|
351
|
-
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
352
|
-
// JSON mode is the evidence boundary. Formatted stdout only tells us that
|
|
353
|
-
// OpenCode exited; raw events tell us which tools actually completed.
|
|
354
|
-
const args = ['run', full, '--auto', '--format', 'json', '--dir', workspace, ...(m ? ['--model', m] : [])]
|
|
355
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
356
|
-
let terminationResult = null
|
|
357
|
-
let cancel = null
|
|
358
|
-
let outputText = '', jsonlBuffer = ''
|
|
359
|
-
const mcpCalls = new Set(), mcpErrors = new Set(), runtimeErrors = new Set()
|
|
360
|
-
const finish = (o) => {
|
|
361
|
-
if (done) return
|
|
362
|
-
done = true
|
|
363
|
-
if (cancelActive === cancel) cancelActive = null
|
|
364
|
-
clearTimeout(timer)
|
|
365
|
-
const calls = [...mcpCalls]
|
|
366
|
-
log('opencode MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (mcpErrors.size ? ' (failed: ' + [...mcpErrors].join(', ') + ')' : ''))
|
|
367
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], outputText })
|
|
368
|
-
}
|
|
369
|
-
const terminate = (subtype) => {
|
|
370
|
-
terminationResult ||= { type: 'result', subtype }
|
|
371
|
-
return stopModelProcess(child).then(() => finish(terminationResult))
|
|
372
|
-
}
|
|
373
|
-
const inspectLine = (line) => {
|
|
374
|
-
const value = String(line || '').trim()
|
|
375
|
-
if (!value) return
|
|
376
|
-
let event
|
|
377
|
-
try { event = JSON.parse(value) } catch {
|
|
378
|
-
if (debug) log(' · unparsed opencode output: ' + value.slice(0, 180))
|
|
379
|
-
return
|
|
380
|
-
}
|
|
381
|
-
const evidence = opencodeEventEvidence(event)
|
|
382
|
-
if (evidence.outputText) {
|
|
383
|
-
outputText += ' ' + evidence.outputText
|
|
384
|
-
if (debug) log(' · ' + evidence.outputText.replace(/\s+/g, ' ').slice(0, 180))
|
|
385
|
-
}
|
|
386
|
-
if (evidence.runtimeError) runtimeErrors.add(evidence.runtimeError)
|
|
387
|
-
if (!evidence.tool) return
|
|
388
|
-
if (debug) log(' → tool ' + evidence.tool + (evidence.failed ? ' (failed)' : evidence.completed ? ' (completed)' : ''))
|
|
389
|
-
if (evidence.failed && evidence.toolError) {
|
|
390
|
-
const safeError = (redactKey ? String(evidence.toolError).split(redactKey).join('[redacted]') : String(evidence.toolError)).replace(/\s+/g, ' ').slice(0, 240)
|
|
391
|
-
log(' ✗ opencode tool ' + evidence.tool + ': ' + safeError)
|
|
392
|
-
}
|
|
393
|
-
try { onTool && onTool(evidence.tool) } catch { /* activity is best-effort */ }
|
|
394
|
-
if (evidence.mcpTool) {
|
|
395
|
-
mcpCalls.add(evidence.mcpTool)
|
|
396
|
-
if (evidence.failed) mcpErrors.add(evidence.mcpTool)
|
|
397
|
-
else if (evidence.completed) mcpErrors.delete(evidence.mcpTool)
|
|
398
|
-
}
|
|
399
|
-
const repositoryWasChanged = didRepoMutation
|
|
400
|
-
if (evidence.didChannelMessage && repositoryWasChanged) didResultMessage = true
|
|
401
|
-
didCode ||= !!evidence.didCode
|
|
402
|
-
didRepoMutation ||= !!evidence.didRepoMutation
|
|
403
|
-
didMessage ||= !!evidence.didMessage
|
|
404
|
-
didChannelMessage ||= !!evidence.didChannelMessage
|
|
405
|
-
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
406
|
-
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
407
|
-
}
|
|
408
|
-
const timer = setTimeout(() => {
|
|
409
|
-
log('opencode cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
410
|
-
void terminate('timeout')
|
|
411
|
-
}, maxCycleMs)
|
|
412
|
-
cancel = () => terminate('canceled')
|
|
413
|
-
cancelActive = cancel
|
|
414
|
-
log('running opencode cycle…' + (m ? ' [' + m + ']' : ''))
|
|
415
|
-
try {
|
|
416
|
-
child = spawn(bin, args, {
|
|
417
|
-
...modelProcessOptions,
|
|
418
|
-
cwd: configDir,
|
|
419
|
-
env: {
|
|
420
|
-
...process.env,
|
|
421
|
-
OPENCODE_CONFIG: opencodeConfigPath,
|
|
422
|
-
// Inline config has higher precedence than a project opencode.json.
|
|
423
|
-
// This prevents a stale generated workspace file from replacing this
|
|
424
|
-
// agent's URL or credentials while keeping normal project settings.
|
|
425
|
-
...(opencodeConfig ? { OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig) } : {}),
|
|
426
|
-
},
|
|
427
|
-
stdio: ['ignore', 'pipe', 'inherit'],
|
|
428
|
-
})
|
|
429
|
-
child.stdout?.on('data', (data) => {
|
|
430
|
-
jsonlBuffer += String(data)
|
|
431
|
-
const lines = jsonlBuffer.split('\n')
|
|
432
|
-
jsonlBuffer = lines.pop() ?? ''
|
|
433
|
-
for (const line of lines) inspectLine(line)
|
|
434
|
-
})
|
|
435
|
-
}
|
|
436
|
-
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' }) }
|
|
437
|
-
child.on('close', (code) => {
|
|
438
|
-
inspectLine(jsonlBuffer); jsonlBuffer = ''
|
|
439
|
-
const subtype = code === 0 && runtimeErrors.size === 0 ? 'ok' : 'error'
|
|
440
|
-
log('opencode cycle done (' + (subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
441
|
-
finish(terminationResult || { type: 'result', subtype })
|
|
442
|
-
})
|
|
443
|
-
child.on('error', (e) => { log('opencode error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
444
|
-
})
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
log('opencode runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + workspace + '; isolated cfg ' + configDir + ']' : ' [CHAT-ONLY; isolated cfg ' + configDir + ']'))
|
|
448
|
-
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Codex has a purpose-built non-interactive mode. Run a fresh, ephemeral cycle
|
|
452
|
-
// for each event and inject only this agent's OpenVisio MCP config on the command
|
|
453
|
-
// line. That avoids inheriting unrelated user MCP servers while keeping the
|
|
454
|
-
// user's normal Codex authentication. We deliberately never use Codex's dangerous
|
|
455
|
-
// approval/sandbox bypass flag.
|
|
456
|
-
function createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt }) {
|
|
457
|
-
const bin = onPath('codex') || 'codex'
|
|
458
|
-
const defaultCwd = workdir || OV_DIR
|
|
459
|
-
const proxyPath = fileURLToPath(new URL('./codex-mcp-proxy.mjs', import.meta.url))
|
|
460
|
-
let cancelActive = null
|
|
461
|
-
|
|
462
|
-
function runCycle(prompt, cycleModel, cycleOptions = {}) {
|
|
463
|
-
return new Promise((resolve) => {
|
|
464
|
-
const m = cycleModel || model
|
|
465
|
-
const cycleCwd = cycleOptions.workdir || defaultCwd
|
|
466
|
-
const full = systemPrompt ? systemPrompt + '\n\n' + prompt : prompt
|
|
467
|
-
const disabledMcpTools = Array.isArray(cycleOptions.disabledMcpTools) ? cycleOptions.disabledMcpTools.filter(Boolean) : []
|
|
468
|
-
const mcpOverride = buildCodexMcpOverride({ mcpUrl, proxyCommand: process.execPath, proxyPath, disabledMcpTools })
|
|
469
|
-
const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', '--ephemeral', '--json', '--color', 'never',
|
|
470
|
-
...(canCode ? ['--approve-for-me'] : ['--sandbox', 'read-only']),
|
|
471
|
-
...(m ? ['--model', m] : []),
|
|
472
|
-
...(mcpOverride ? ['-c', mcpOverride] : []),
|
|
473
|
-
full]
|
|
474
|
-
let child = null, done = false, didCode = false, didRepoMutation = false, didMessage = false, didChannelMessage = false, didResultMessage = false, outputText = '', jsonlBuffer = '', stderrBuffer = '', stderrLineBuffer = ''
|
|
475
|
-
let terminationResult = null
|
|
476
|
-
let cancel = null
|
|
477
|
-
let policyBlock = null
|
|
478
|
-
const mcpCalls = new Set(), mcpErrors = new Set(), mcpErrorDetails = new Map()
|
|
479
|
-
let didMcpTaskRead = false, didMcpTaskUpdate = false
|
|
480
|
-
const finish = (o) => {
|
|
481
|
-
if (done) return
|
|
482
|
-
done = true
|
|
483
|
-
if (cancelActive === cancel) cancelActive = null
|
|
484
|
-
clearTimeout(timer)
|
|
485
|
-
const calls = [...mcpCalls]
|
|
486
|
-
const failedSummary = [...mcpErrors].map((name) => `${name}${mcpErrorDetails.get(name) ? ': ' + mcpErrorDetails.get(name) : ''}`).join('; ')
|
|
487
|
-
log('codex MCP calls: ' + (calls.length ? calls.join(', ') : 'none') + (failedSummary ? ' (failed: ' + failedSummary + ')' : ''))
|
|
488
|
-
resolve({ ...o, didCode, didRepoMutation, didMessage, didChannelMessage, didResultMessage, didMcpTaskRead, didMcpTaskUpdate, mcpCalls: calls, mcpErrors: [...mcpErrors], mcpErrorDetails: Object.fromEntries(mcpErrorDetails), outputText, policyBlock })
|
|
489
|
-
}
|
|
490
|
-
const terminate = (subtype) => {
|
|
491
|
-
terminationResult ||= { type: 'result', subtype }
|
|
492
|
-
return stopModelProcess(child).then(() => finish(terminationResult))
|
|
493
|
-
}
|
|
494
|
-
const inspectDiagnostic = (value) => {
|
|
495
|
-
const s = String(value || '')
|
|
496
|
-
stderrBuffer = (stderrBuffer + s).slice(-24_000)
|
|
497
|
-
policyBlock = codexPolicyBlock(stderrBuffer) || policyBlock
|
|
498
|
-
}
|
|
499
|
-
const forwardDiagnostic = (value, flush = false) => {
|
|
500
|
-
const incoming = String(value || '')
|
|
501
|
-
inspectDiagnostic(incoming)
|
|
502
|
-
stderrLineBuffer += incoming
|
|
503
|
-
const lines = stderrLineBuffer.split('\n')
|
|
504
|
-
stderrLineBuffer = lines.pop() ?? ''
|
|
505
|
-
for (const line of lines) {
|
|
506
|
-
if (!shouldSuppressCodexDiagnostic(line)) process.stderr.write(line + '\n')
|
|
507
|
-
}
|
|
508
|
-
if (flush && stderrLineBuffer) {
|
|
509
|
-
if (!shouldSuppressCodexDiagnostic(stderrLineBuffer)) process.stderr.write(stderrLineBuffer)
|
|
510
|
-
stderrLineBuffer = ''
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
const inspectLine = (line) => {
|
|
514
|
-
const s = line.trim()
|
|
515
|
-
if (!s) return
|
|
516
|
-
policyBlock = codexPolicyBlock(s) || policyBlock
|
|
517
|
-
try {
|
|
518
|
-
const event = JSON.parse(s)
|
|
519
|
-
const evidence = codexEventEvidence(event)
|
|
520
|
-
if (evidence.outputText) outputText += ' ' + evidence.outputText
|
|
521
|
-
if (evidence.mcpTool) {
|
|
522
|
-
mcpCalls.add(evidence.mcpTool)
|
|
523
|
-
try { onTool && onTool(evidence.mcpTool) } catch { /* activity is best-effort */ }
|
|
524
|
-
if (evidence.failed) {
|
|
525
|
-
mcpErrors.add(evidence.mcpTool)
|
|
526
|
-
if (evidence.toolError) {
|
|
527
|
-
const secret = String(mcpHeaders?.['x-agent-api-key'] || '')
|
|
528
|
-
const detail = String(evidence.toolError)
|
|
529
|
-
const safe = (secret ? detail.split(secret).join('[redacted]') : detail).slice(0, 300)
|
|
530
|
-
mcpErrorDetails.set(evidence.mcpTool, safe)
|
|
531
|
-
}
|
|
532
|
-
} else if (evidence.completed) {
|
|
533
|
-
mcpErrors.delete(evidence.mcpTool)
|
|
534
|
-
mcpErrorDetails.delete(evidence.mcpTool)
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
if (evidence.didChannelMessage && didRepoMutation) didResultMessage = true
|
|
538
|
-
didCode ||= !!evidence.didCode
|
|
539
|
-
didRepoMutation ||= !!evidence.didRepoMutation
|
|
540
|
-
didMessage ||= !!evidence.didMessage
|
|
541
|
-
didChannelMessage ||= !!evidence.didChannelMessage
|
|
542
|
-
didMcpTaskRead ||= !!evidence.didMcpTaskRead
|
|
543
|
-
didMcpTaskUpdate ||= !!evidence.didMcpTaskUpdate
|
|
544
|
-
} catch { /* non-JSON diagnostic */ }
|
|
545
|
-
if (debug) log(' · ' + s.slice(0, 220))
|
|
546
|
-
}
|
|
547
|
-
const timer = setTimeout(() => {
|
|
548
|
-
log('codex cycle TIMED OUT after ' + Math.round(maxCycleMs / 1000) + 's — killing')
|
|
549
|
-
void terminate('timeout')
|
|
550
|
-
}, maxCycleMs)
|
|
551
|
-
cancel = () => terminate('canceled')
|
|
552
|
-
cancelActive = cancel
|
|
553
|
-
log('running codex cycle…' + (m ? ' [' + m + ']' : ''))
|
|
554
|
-
try {
|
|
555
|
-
// Always inspect Codex JSONL so a successful process exit cannot be
|
|
556
|
-
// mistaken for completed work. Keep it out of normal logs unless debug.
|
|
557
|
-
child = spawn(bin, args, {
|
|
558
|
-
...modelProcessOptions,
|
|
559
|
-
cwd: cycleCwd,
|
|
560
|
-
env: {
|
|
561
|
-
...process.env,
|
|
562
|
-
...(mcpUrl ? {
|
|
563
|
-
OPENVISIO_CODEX_MCP_URL: mcpUrl,
|
|
564
|
-
OPENVISIO_CODEX_API_KEY: mcpHeaders?.['x-agent-api-key'] || '',
|
|
565
|
-
OPENVISIO_CODEX_IDENTIFIER: mcpHeaders?.['x-agent-identifier'] || '',
|
|
566
|
-
} : {}),
|
|
567
|
-
},
|
|
568
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
569
|
-
})
|
|
570
|
-
if (child.stdout) child.stdout.on('data', (d) => {
|
|
571
|
-
jsonlBuffer += String(d)
|
|
572
|
-
const lines = jsonlBuffer.split('\n')
|
|
573
|
-
jsonlBuffer = lines.pop() ?? ''
|
|
574
|
-
for (const line of lines) inspectLine(line)
|
|
575
|
-
})
|
|
576
|
-
if (child.stderr) child.stderr.on('data', (d) => {
|
|
577
|
-
forwardDiagnostic(d)
|
|
578
|
-
})
|
|
579
|
-
} catch (e) {
|
|
580
|
-
log('codex spawn failed: ' + (e && e.message ? e.message : e) + ' — is Codex installed and signed in? (`npm i -g @openai/codex`, then `codex login`)')
|
|
581
|
-
return finish({ type: 'result', subtype: 'spawn-failed' })
|
|
582
|
-
}
|
|
583
|
-
child.on('close', (code) => {
|
|
584
|
-
inspectLine(jsonlBuffer); jsonlBuffer = ''; forwardDiagnostic('', true)
|
|
585
|
-
const subtype = policyBlock ? 'blocked' : code === 0 ? 'ok' : 'error'
|
|
586
|
-
log('codex cycle done (' + (subtype === 'blocked' ? 'BLOCKED: user authorization required' : subtype === 'ok' ? 'ok' : 'exit ' + code) + ')')
|
|
587
|
-
finish(terminationResult || { type: 'result', subtype })
|
|
588
|
-
})
|
|
589
|
-
child.on('error', (e) => { log('codex error: ' + (e && e.message ? e.message : e)); finish({ type: 'result', subtype: 'error' }) })
|
|
590
|
-
})
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (!mcpUrl) log('WARNING: no --mcp-url — Codex has no openvisio-team tools to act with. Re-connect with --mcp-url.')
|
|
594
|
-
log('codex runner ready' + (model ? ' [model ' + model + ']' : '') + (canCode ? ' [CODE workspace ' + defaultCwd + ']' : ' [CHAT-ONLY]'))
|
|
595
|
-
return { runCycle, canCode, cancelCurrent: () => cancelActive?.() }
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// ── Claude Code warm-session cycle runner (shared by the REST + WS loops) ─────
|
|
599
|
-
// One persistent stream-json session, poked with a prompt per cycle. Recycled
|
|
600
|
-
// after MAX_TURNS or SESSION_IDLE_MS. Returns { runCycle, canCode }.
|
|
601
|
-
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 }) {
|
|
602
322
|
const canCode = !!workdir
|
|
603
323
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
604
324
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
605
325
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
606
326
|
// cannot bleed across concurrently assigned tickets.
|
|
607
|
-
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 })
|
|
608
328
|
if (mastraRunner) return mastraRunner
|
|
609
|
-
// opencode drives cycles differently — a headless `opencode run` per cycle rather
|
|
610
|
-
// than a persistent stream-json session. Same { runCycle, canCode } contract.
|
|
611
|
-
if (agent === 'opencode') return createOpencodeRunner({ mcpUrl, mcpHeaders, cfgKey, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
612
|
-
if (agent === 'codex') return createCodexRunner({ mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
613
329
|
let child = null
|
|
614
330
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
615
331
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -779,7 +495,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
779
495
|
child = null
|
|
780
496
|
return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
|
|
781
497
|
}
|
|
782
|
-
return { runCycle, canCode, cancelCurrent }
|
|
498
|
+
return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
|
|
783
499
|
}
|
|
784
500
|
|
|
785
501
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -799,12 +515,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
799
515
|
const canCode = !!workdir
|
|
800
516
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
801
517
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
802
|
-
const authRule = agent
|
|
518
|
+
const authRule = agent !== 'claude'
|
|
803
519
|
? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
804
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.`
|
|
805
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.`
|
|
806
|
-
//
|
|
807
|
-
//
|
|
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.
|
|
808
524
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
809
525
|
const runnerOptions = {
|
|
810
526
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
@@ -820,30 +536,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
820
536
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
821
537
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
822
538
|
})
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
replyRunners.set(key, runner)
|
|
837
|
-
while (replyRunners.size > 8) {
|
|
838
|
-
const oldestKey = replyRunners.keys().next().value
|
|
839
|
-
const oldest = replyRunners.get(oldestKey)
|
|
840
|
-
replyRunners.delete(oldestKey)
|
|
841
|
-
oldest?.close?.()
|
|
842
|
-
}
|
|
843
|
-
return runner
|
|
844
|
-
}
|
|
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
|
+
})
|
|
845
552
|
const codexPushGuide = agent === 'codex' && canCode
|
|
846
|
-
? '\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.'
|
|
847
554
|
: ''
|
|
848
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.'
|
|
849
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
|
|
@@ -861,11 +568,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
861
568
|
concurrency: MAX_CONCURRENT_WORKERS,
|
|
862
569
|
groupKey: (item) => item.workdir || workdir || 'shared-workspace',
|
|
863
570
|
run: async (item) => {
|
|
864
|
-
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 })
|
|
865
573
|
item.control.runner = runner
|
|
866
574
|
activeWorkRunners.add(runner)
|
|
867
575
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
868
|
-
finally {
|
|
576
|
+
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
869
577
|
},
|
|
870
578
|
onError: (error, item) => {
|
|
871
579
|
pauseFailedTask(item.taskRef)
|
|
@@ -874,11 +582,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
874
582
|
},
|
|
875
583
|
}),
|
|
876
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}`,
|
|
877
587
|
run: async (item) => {
|
|
878
|
-
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)
|
|
879
591
|
item.control.runner = runner
|
|
880
592
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
881
|
-
finally { item.control.runner = null }
|
|
593
|
+
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
882
594
|
},
|
|
883
595
|
onError: (error, item) => {
|
|
884
596
|
pauseFailedTask(item.taskRef)
|
|
@@ -958,7 +670,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
958
670
|
trimMap(failedTaskVersions)
|
|
959
671
|
seenTasks.add(key)
|
|
960
672
|
trimSeen(seenTasks)
|
|
961
|
-
lastTaskSignature = ''
|
|
962
673
|
persistReplay()
|
|
963
674
|
}
|
|
964
675
|
const finalizeFailedTaskPause = async (taskRef) => {
|
|
@@ -990,6 +701,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
990
701
|
persistReplay()
|
|
991
702
|
return false
|
|
992
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
|
+
}
|
|
993
712
|
const markMentionHandled = (message, channelId) => {
|
|
994
713
|
pruneMentionSignatures()
|
|
995
714
|
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
@@ -1005,8 +724,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1005
724
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
1006
725
|
// source independently through work, recovery, and delivery.
|
|
1007
726
|
let backlogProbeBusy = false
|
|
1008
|
-
let lastTaskSignature = ''
|
|
1009
|
-
let lastTaskTriggeredAt = 0
|
|
1010
727
|
let lastInboxSignature = ''
|
|
1011
728
|
let selfAgentId = null
|
|
1012
729
|
let completionRecipientName = ''
|
|
@@ -1067,6 +784,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1067
784
|
try { return JSON.parse(text) } catch { return { text } }
|
|
1068
785
|
}
|
|
1069
786
|
|
|
787
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => callMcpTool('list_task_types', { project_id: projectId }).then(toolData) })
|
|
788
|
+
|
|
1070
789
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
1071
790
|
try { return await callMcpTool(name, args) }
|
|
1072
791
|
catch (first) {
|
|
@@ -1084,19 +803,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1084
803
|
}
|
|
1085
804
|
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1086
805
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1087
|
-
const groups = await
|
|
806
|
+
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
1088
807
|
const [tasksResult, typesResult] = await Promise.all([
|
|
1089
808
|
callMcpReadWithRetry('list_tasks', { project_id: project.id }),
|
|
1090
|
-
|
|
1091
|
-
log(`list_task_types read unavailable for project ${project.id}: ${error?.message || error}`)
|
|
1092
|
-
return null
|
|
1093
|
-
}),
|
|
809
|
+
taskTypeSets(project.id),
|
|
1094
810
|
])
|
|
1095
811
|
const tasksData = toolData(tasksResult)
|
|
1096
|
-
const
|
|
1097
|
-
const types = Array.isArray(typesData.types) ? typesData.types : Array.isArray(typesData.task_types) ? typesData.task_types : Array.isArray(typesData.taskTypes) ? typesData.taskTypes : []
|
|
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)))
|
|
812
|
+
const { done: doneIds, review: reviewIds } = typesResult
|
|
1100
813
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1101
814
|
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1102
815
|
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
@@ -1111,7 +824,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1111
824
|
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
1112
825
|
project: String(project.name || ''),
|
|
1113
826
|
}))
|
|
1114
|
-
})
|
|
827
|
+
})
|
|
1115
828
|
return groups.flat()
|
|
1116
829
|
}
|
|
1117
830
|
|
|
@@ -1163,8 +876,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1163
876
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1164
877
|
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
1165
878
|
}
|
|
1166
|
-
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 }) => {
|
|
1167
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
|
+
}
|
|
1168
886
|
const message = String(content || '').trim()
|
|
1169
887
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1170
888
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
@@ -1182,7 +900,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1182
900
|
const run = (async () => {
|
|
1183
901
|
if (parentId != null) {
|
|
1184
902
|
const live = toolData(await callMcpTool('list_message_thread', { channel_id: Number(channelId), message_id: Number(parentId) }))
|
|
1185
|
-
const rendered = renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug })
|
|
903
|
+
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
|
|
1186
904
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
1187
905
|
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
1188
906
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
@@ -1215,24 +933,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1215
933
|
}
|
|
1216
934
|
|
|
1217
935
|
const statusChannelCache = new Map()
|
|
936
|
+
let dedicatedChannelRead = null
|
|
1218
937
|
const projectStatusChannel = async (projectId) => {
|
|
1219
|
-
const
|
|
1220
|
-
const cached = statusChannelCache.get(key)
|
|
938
|
+
const cached = statusChannelCache.get('self')
|
|
1221
939
|
if (cached && cached.expiresAt > Date.now()) return cached.channelId
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
|
1236
960
|
}
|
|
1237
961
|
|
|
1238
962
|
const announceIntroduction = async () => {
|
|
@@ -1241,7 +965,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1241
965
|
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
1242
966
|
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
1243
967
|
const channelId = await projectStatusChannel(project.id)
|
|
1244
|
-
if (!Number.isFinite(Number(channelId))) return false
|
|
968
|
+
if (channelId == null || !Number.isFinite(Number(channelId))) return false
|
|
1245
969
|
await postMessageOnce({
|
|
1246
970
|
key: `intro:${identifier}:${project.id}`,
|
|
1247
971
|
projectId: Number(project.id),
|
|
@@ -1276,9 +1000,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1276
1000
|
try {
|
|
1277
1001
|
const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
1278
1002
|
if (comment.called) log('posted verified ticket comment for #' + ticketId)
|
|
1279
|
-
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')
|
|
1280
1004
|
} catch (e) {
|
|
1281
|
-
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')
|
|
1282
1006
|
}
|
|
1283
1007
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
1284
1008
|
}
|
|
@@ -1296,8 +1020,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1296
1020
|
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
1297
1021
|
return true
|
|
1298
1022
|
}
|
|
1299
|
-
const channelId =
|
|
1300
|
-
if (!Number.isFinite(channelId)) return false
|
|
1023
|
+
const channelId = await projectStatusChannel(projectId)
|
|
1024
|
+
if (channelId == null || !Number.isFinite(channelId)) return false
|
|
1301
1025
|
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
1302
1026
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1303
1027
|
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
@@ -1379,7 +1103,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1379
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}.”`
|
|
1380
1104
|
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
1381
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.`
|
|
1382
|
-
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.`
|
|
1383
1107
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1384
1108
|
}
|
|
1385
1109
|
|
|
@@ -1396,122 +1120,103 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1396
1120
|
rememberSelfAgent(self)
|
|
1397
1121
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1398
1122
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1399
|
-
const assigned = []
|
|
1400
1123
|
const mentionActivity = []
|
|
1401
1124
|
let activityReplayTouched = false
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
|
1427
1152
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
seenTasks.delete(taskKey)
|
|
1434
|
-
continue
|
|
1435
|
-
}
|
|
1436
|
-
if (failedTaskIsPaused(taskKey, task)) {
|
|
1437
|
-
seenTasks.add(taskKey)
|
|
1438
|
-
log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
|
|
1439
|
-
continue
|
|
1440
|
-
}
|
|
1441
|
-
if (blockedTasks.has(taskKey)) {
|
|
1442
|
-
const blockedRepo = blockedTaskRepos.get(taskKey)
|
|
1443
|
-
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1444
|
-
if (helperAuthorized) {
|
|
1445
|
-
blockedTasks.delete(taskKey); blockedTaskRepos.delete(taskKey); seenTasks.delete(taskKey); persistReplay()
|
|
1446
|
-
log('backlog ticket #' + task.id + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' now contains explicit push authorization') + ' — resuming')
|
|
1447
|
-
} else continue
|
|
1448
|
-
}
|
|
1449
|
-
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 } })
|
|
1450
|
-
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 })
|
|
1451
|
-
}
|
|
1452
|
-
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1453
|
-
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1454
|
-
for (const item of activities) {
|
|
1455
|
-
const text = JSON.stringify(item)
|
|
1456
|
-
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1457
|
-
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1458
|
-
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1459
|
-
? item.message
|
|
1460
|
-
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1461
|
-
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1462
|
-
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1463
|
-
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1464
|
-
const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
|
|
1465
|
-
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1466
|
-
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1467
|
-
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1468
|
-
// Search only the source message, never the whole activity envelope: an
|
|
1469
|
-
// old tagged thread root can otherwise make another agent's new reply
|
|
1470
|
-
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1471
|
-
// in a persistently owned thread both enter the guarded per-message path.
|
|
1472
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity
|
|
1473
|
-
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1474
|
-
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1475
|
-
// The same logical mention may already have arrived over WebSocket.
|
|
1476
|
-
// Share the id/signature guard instead of starting a second model turn.
|
|
1477
|
-
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1478
|
-
// when an owned-thread source has no received ledger entry.
|
|
1479
|
-
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1480
|
-
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1481
|
-
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1482
|
-
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
|
|
1483
1158
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
channelId: activityChannelId,
|
|
1488
|
-
message: activityMessage,
|
|
1489
|
-
activity: item,
|
|
1490
|
-
})
|
|
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 })
|
|
1491
1162
|
}
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
if (activityReplayTouched) persistReplay()
|
|
1495
|
-
if (!assigned.length) lastTaskSignature = ''
|
|
1496
|
-
else {
|
|
1497
|
-
const signature = JSON.stringify(assigned)
|
|
1498
|
-
const retryDue = Date.now() - lastTaskTriggeredAt > 30 * 60 * 1000
|
|
1499
|
-
if (signature !== lastTaskSignature || retryDue) {
|
|
1500
|
-
lastTaskSignature = signature
|
|
1501
|
-
lastTaskTriggeredAt = Date.now()
|
|
1502
1163
|
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
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) {
|
|
1506
1166
|
const key = `${next.projectId}:${next.id}`
|
|
1507
1167
|
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
// live events. Every ticket keeps its own context and result.
|
|
1168
|
+
// Live ownership/state verification remains inside handleTaskSignal.
|
|
1169
|
+
seenTasks.delete(key)
|
|
1511
1170
|
await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
|
|
1512
1171
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
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()
|
|
1515
1220
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
1516
1221
|
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1517
1222
|
lastInboxSignature = inboxSignature
|
|
@@ -1549,20 +1254,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1549
1254
|
}
|
|
1550
1255
|
// The next task update or periodic reconciliation must be allowed to queue
|
|
1551
1256
|
// this work again. An acknowledgement is not a terminal task signature.
|
|
1552
|
-
lastTaskSignature = ''
|
|
1553
1257
|
}
|
|
1554
1258
|
|
|
1555
1259
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1556
1260
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1557
1261
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1558
1262
|
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1559
|
-
const control = { cancelled: false, runner: null, statusTargets: new Set() }
|
|
1263
|
+
const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1560
1264
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
|
|
1561
1265
|
}
|
|
1562
1266
|
|
|
1563
1267
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1564
1268
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1565
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}`)
|
|
1566
1272
|
const ctx = context ? [context] : []
|
|
1567
1273
|
const activeTaskRef = taskRef
|
|
1568
1274
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
@@ -1595,18 +1301,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1595
1301
|
if (activeTaskRef) {
|
|
1596
1302
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1597
1303
|
const ticket = data.ticket ?? data.task ?? data
|
|
1304
|
+
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1598
1305
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1599
1306
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1600
1307
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1601
1308
|
!((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
|
|
1602
|
-
taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1309
|
+
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1603
1310
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1604
1311
|
log('queued ticket no longer actionable; skipped before model start')
|
|
1605
1312
|
return
|
|
1606
1313
|
}
|
|
1607
1314
|
}
|
|
1608
1315
|
if (cycleControl.cancelled) return
|
|
1316
|
+
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1609
1317
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1318
|
+
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1610
1319
|
if (result?.model && result.model !== useModel) {
|
|
1611
1320
|
// Persist only a successfully selected model and only if the user has
|
|
1612
1321
|
// not changed that tier while this cycle was running.
|
|
@@ -1755,6 +1464,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1755
1464
|
}
|
|
1756
1465
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1757
1466
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1467
|
+
const typeSets = await taskTypeSets(projectId)
|
|
1758
1468
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1759
1469
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1760
1470
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
@@ -1769,7 +1479,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1769
1479
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1770
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
|
|
1771
1481
|
}
|
|
1772
|
-
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1482
|
+
if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1773
1483
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1774
1484
|
if (pendingCompletionReports.has(key)) {
|
|
1775
1485
|
const activityChannel = await projectStatusChannel(projectId)
|
|
@@ -1780,6 +1490,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1780
1490
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1781
1491
|
return
|
|
1782
1492
|
}
|
|
1493
|
+
resumeAuthorized(key)
|
|
1783
1494
|
const hadFailedRevision = failedTaskVersions.has(key)
|
|
1784
1495
|
if (failedTaskIsPaused(key, ticket)) {
|
|
1785
1496
|
seenTasks.add(key)
|
|
@@ -1787,17 +1498,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1787
1498
|
return
|
|
1788
1499
|
}
|
|
1789
1500
|
if (hadFailedRevision) log(kind + ' ticket #' + ticketId + ' changed since its failed cycle — resuming once')
|
|
1790
|
-
if (blockedTasks.has(key)) {
|
|
1791
|
-
const blockedRepo = blockedTaskRepos.get(key)
|
|
1792
|
-
const helperAuthorized = blockedRepo && repositoryHasPrPushAuthorization({ cwd: blockedRepo })
|
|
1793
|
-
if (helperAuthorized) {
|
|
1794
|
-
blockedTasks.delete(key); blockedTaskRepos.delete(key); seenTasks.delete(key); persistReplay()
|
|
1795
|
-
log(kind + ' ticket #' + ticketId + (helperAuthorized ? ' has repository-scoped PR push authorization' : ' contains explicit push authorization') + ' — resuming')
|
|
1796
|
-
} else {
|
|
1797
|
-
log(kind + ' ticket #' + ticketId + ' is paused for explicit repository push authorization — ignored')
|
|
1798
|
-
return
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1501
|
+
if (blockedTasks.has(key)) { log(kind + ' ticket authorization is still unavailable'); return }
|
|
1801
1502
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1802
1503
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1803
1504
|
const title = String(ticket.title || hinted.title || '')
|
|
@@ -1812,7 +1513,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1812
1513
|
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1813
1514
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1814
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.` : ''
|
|
1815
|
-
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 })
|
|
1816
1517
|
} catch (e) {
|
|
1817
1518
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1818
1519
|
}
|
|
@@ -1872,7 +1573,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1872
1573
|
// redirect can cancel its process. Codex additionally delegates the final
|
|
1873
1574
|
// post to the watcher for an authoritative last-moment thread check.
|
|
1874
1575
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1875
|
-
? { 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' }
|
|
1876
1577
|
: null
|
|
1877
1578
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1878
1579
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1957,6 +1658,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1957
1658
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1958
1659
|
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
1959
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?.()
|
|
1960
1694
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1961
1695
|
if (mcpConfig || mcpUrl) {
|
|
1962
1696
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
@@ -1986,6 +1720,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1986
1720
|
const bye = async () => {
|
|
1987
1721
|
if (stopping) return
|
|
1988
1722
|
stopping = true
|
|
1723
|
+
clearInterval(authorizationTimer)
|
|
1989
1724
|
if (introTimer) clearTimeout(introTimer)
|
|
1990
1725
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1991
1726
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
@@ -1996,8 +1731,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1996
1731
|
void item.control.runner?.cancelCurrent?.()
|
|
1997
1732
|
})
|
|
1998
1733
|
}
|
|
1999
|
-
await Promise.all([...
|
|
2000
|
-
|
|
1734
|
+
await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
|
|
1735
|
+
workRunners.close()
|
|
1736
|
+
replyRunners.close()
|
|
2001
1737
|
process.exit(0)
|
|
2002
1738
|
}
|
|
2003
1739
|
process.on('SIGTERM', bye)
|