openvisio-agent 0.19.10 → 0.19.12
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 +25 -19
- 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/events.mjs +5 -4
- package/src/mastra-harness.mjs +68 -32
- package/src/memory.mjs +16 -8
- 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/thread-context.mjs +27 -0
- package/src/watch.mjs +273 -494
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 { createThreadContextResolver } from './thread-context.mjs'
|
|
21
24
|
import { assignmentRequest, assignmentStatusOnly, routeAssignments } from './assignment-routing.mjs'
|
|
22
25
|
import { modelProcessOptions, stopModelProcess } from './process-lifecycle.mjs'
|
|
23
26
|
import { createMastraAcpRunner } from './mastra-harness.mjs'
|
|
@@ -61,6 +64,7 @@ const REPLY_DISCIPLINE = [
|
|
|
61
64
|
' • 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
65
|
' • 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
66
|
' • 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.',
|
|
67
|
+
' • 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
68
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
65
69
|
].join('\n')
|
|
66
70
|
|
|
@@ -102,7 +106,7 @@ const CODE_CHARTER = [
|
|
|
102
106
|
'',
|
|
103
107
|
'WORK ETHIC — how a reliable teammate behaves (this is the difference between useful and ignored):',
|
|
104
108
|
' 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
|
|
109
|
+
' 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
110
|
' 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
111
|
' 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
112
|
' 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 +126,7 @@ const CODE_FULL = [
|
|
|
122
126
|
' 3. CHANGE + VERIFY: Read/Edit/Write the files; run the tests or build if the repo has them.',
|
|
123
127
|
' 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
128
|
' 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
|
|
129
|
+
' 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
130
|
'Bash is for git / gh / tests / clone ONLY — never to hunt for credentials (they are given to you above).',
|
|
127
131
|
].join('\n')
|
|
128
132
|
|
|
@@ -140,7 +144,7 @@ const CODE_FAST = [
|
|
|
140
144
|
// missed — TASKS especially.
|
|
141
145
|
const INTRO = [
|
|
142
146
|
'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
|
|
147
|
+
'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
148
|
'Post it EXACTLY ONCE, then stop. Do NOT do any other work this cycle.',
|
|
145
149
|
].join('\n')
|
|
146
150
|
const SWEEP = [
|
|
@@ -260,6 +264,7 @@ export async function runWatch({ flags }) {
|
|
|
260
264
|
: flags.workdir ? String(flags.workdir)
|
|
261
265
|
: flags.workspace ? String(flags.workspace) : ''
|
|
262
266
|
const workdir = chatOnly ? '' : (explicitWorkdir || (saved && (saved.workspace || saved.workdir)) || DEFAULT_WORKSPACE)
|
|
267
|
+
if (workdir) configurePrPublishing({ workspace: workdir, enabled: !flags['no-pr-push'] && saved?.prPublishing !== false })
|
|
263
268
|
if (workdir) { try { mkdirSync(workdir, { recursive: true }) } catch { /* best-effort; spawn will surface a real problem */ } }
|
|
264
269
|
|
|
265
270
|
// Use a specific Codex ID; configured chat and code tiers remain independent.
|
|
@@ -313,303 +318,15 @@ export async function runWatch({ flags }) {
|
|
|
313
318
|
return loop({ host, key, slug: slug || 'openvisio', claude, agent, mcpConfig, mcpUrl, workdir, model, chatModel, debug: !!flags.debug })
|
|
314
319
|
}
|
|
315
320
|
|
|
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 }) {
|
|
321
|
+
// Claude keeps its native stream; Codex and OpenCode use Mastra ACP.
|
|
322
|
+
function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfig, workdir, log, debug, model, onTool, systemPrompt, canCoordinate = false }) {
|
|
602
323
|
const canCode = !!workdir
|
|
603
324
|
const maxCycleMs = canCode ? MAX_CODE_CYCLE_MS : MAX_CYCLE_MS
|
|
604
325
|
// Codex and OpenCode run through Mastra's ACP harness. Each queue worker gets
|
|
605
326
|
// its own ACP session and worktree, so cancellation, permissions and context
|
|
606
327
|
// cannot bleed across concurrently assigned tickets.
|
|
607
|
-
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
328
|
+
const mastraRunner = createMastraAcpRunner({ agent, mcpUrl, mcpHeaders, workdir, canCode, canCoordinate, maxCycleMs, log, debug, model, onTool, systemPrompt })
|
|
608
329
|
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
330
|
let child = null
|
|
614
331
|
// The model the CURRENT session was spawned with. runCycle can pass a different
|
|
615
332
|
// model per cycle (cheap for chat, stronger for code) — a change recycles the
|
|
@@ -779,7 +496,7 @@ function createCycleRunner({ claude, agent, mcpUrl, mcpHeaders, cfgKey, mcpConfi
|
|
|
779
496
|
child = null
|
|
780
497
|
return stopModelProcess(active).then(() => settleTurn({ type: 'result', subtype: 'canceled' }))
|
|
781
498
|
}
|
|
782
|
-
return { runCycle, canCode, cancelCurrent }
|
|
499
|
+
return { runCycle, canCode, cancelCurrent, close: () => { void cancelCurrent() } }
|
|
783
500
|
}
|
|
784
501
|
|
|
785
502
|
// ── the backend WS loop ──────────────────────────────────────────────────────
|
|
@@ -799,12 +516,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
799
516
|
const canCode = !!workdir
|
|
800
517
|
// The Mastra bridge authenticates per-CALL: every openvisio-team tool needs
|
|
801
518
|
// agent_identifier + agent_api_key as arguments. Hand them over up front.
|
|
802
|
-
const authRule = agent
|
|
519
|
+
const authRule = agent !== 'claude'
|
|
803
520
|
? 'AUTH: the local OpenVisio MCP bridge injects authentication outside the model. Never add, request, print, or search for agent credentials in tool arguments.'
|
|
804
521
|
: `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
522
|
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
|
-
//
|
|
523
|
+
// Keep the static charter stable so provider prompt caching can reuse it.
|
|
524
|
+
// Actual cache hits and billing depend on the runtime and model provider.
|
|
808
525
|
const systemPrompt = (canCode ? CODE_CHARTER : CHAT_CHARTER) + '\n\n' + credNote
|
|
809
526
|
const runnerOptions = {
|
|
810
527
|
claude, agent, mcpUrl, mcpHeaders: { 'x-agent-api-key': apiKey, 'x-agent-identifier': identifier },
|
|
@@ -820,30 +537,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
820
537
|
...(workerWorkdir ? { workdir: workerWorkdir } : {}),
|
|
821
538
|
onTool: (name) => { if (/post_message/.test(name)) emitStatusTargets(control.statusTargets, 'typing') },
|
|
822
539
|
})
|
|
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
|
-
}
|
|
540
|
+
const workRunners = createRunnerPool({
|
|
541
|
+
create: ({ control, workdir }) => createWorkRunner(control, workdir),
|
|
542
|
+
capacity: 6,
|
|
543
|
+
})
|
|
544
|
+
const activeReplyRunners = new Set()
|
|
545
|
+
const replyRunners = createRunnerPool({
|
|
546
|
+
capacity: 8,
|
|
547
|
+
create: ({ key, canCoordinate }) => createCycleRunner({
|
|
548
|
+
...runnerOptions, workdir: '', canCoordinate,
|
|
549
|
+
systemPrompt: CHAT_CHARTER + '\n\n' + credNote,
|
|
550
|
+
cfgKey: identifier + '-reply-' + slugify(key),
|
|
551
|
+
}),
|
|
552
|
+
})
|
|
845
553
|
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.
|
|
554
|
+
? '\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
555
|
: ''
|
|
848
556
|
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
557
|
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 +569,12 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
861
569
|
concurrency: MAX_CONCURRENT_WORKERS,
|
|
862
570
|
groupKey: (item) => item.workdir || workdir || 'shared-workspace',
|
|
863
571
|
run: async (item) => {
|
|
864
|
-
const
|
|
572
|
+
const sessionKey = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.workdir || workdir])
|
|
573
|
+
const runner = workRunners.acquire(sessionKey, { control: item.control, workdir: item.workdir })
|
|
865
574
|
item.control.runner = runner
|
|
866
575
|
activeWorkRunners.add(runner)
|
|
867
576
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, item.workdir) }
|
|
868
|
-
finally {
|
|
577
|
+
finally { workRunners.release(sessionKey); activeWorkRunners.delete(runner); item.control.runner = null }
|
|
869
578
|
},
|
|
870
579
|
onError: (error, item) => {
|
|
871
580
|
pauseFailedTask(item.taskRef)
|
|
@@ -874,11 +583,15 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
874
583
|
},
|
|
875
584
|
}),
|
|
876
585
|
reply: createCycleQueue({
|
|
586
|
+
concurrency: 3,
|
|
587
|
+
groupKey: (item) => item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
|
|
877
588
|
run: async (item) => {
|
|
878
|
-
const
|
|
589
|
+
const key = JSON.stringify([item.taskRef ? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}` : `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`, item.kind === 'coord'])
|
|
590
|
+
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
591
|
+
activeReplyRunners.add(runner)
|
|
879
592
|
item.control.runner = runner
|
|
880
593
|
try { return await executeCycle(item.kind, item.context, item.targetChannels, item.taskRef, item.delivery, runner, item.control, '') }
|
|
881
|
-
finally { item.control.runner = null }
|
|
594
|
+
finally { replyRunners.release(key); activeReplyRunners.delete(runner); item.control.runner = null }
|
|
882
595
|
},
|
|
883
596
|
onError: (error, item) => {
|
|
884
597
|
pauseFailedTask(item.taskRef)
|
|
@@ -958,7 +671,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
958
671
|
trimMap(failedTaskVersions)
|
|
959
672
|
seenTasks.add(key)
|
|
960
673
|
trimSeen(seenTasks)
|
|
961
|
-
lastTaskSignature = ''
|
|
962
674
|
persistReplay()
|
|
963
675
|
}
|
|
964
676
|
const finalizeFailedTaskPause = async (taskRef) => {
|
|
@@ -990,6 +702,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
990
702
|
persistReplay()
|
|
991
703
|
return false
|
|
992
704
|
}
|
|
705
|
+
const resumeAuthorized = (key) => {
|
|
706
|
+
const resumed = releaseAuthorizedPause(key, {
|
|
707
|
+
blocked: blockedTasks, repos: blockedTaskRepos, failed: failedTaskVersions, seen: seenTasks,
|
|
708
|
+
authorized: (cwd) => repositoryHasPrPushAuthorization({ cwd }),
|
|
709
|
+
})
|
|
710
|
+
if (resumed) { persistReplay(); log('PR authorization ready; resuming ' + key) }
|
|
711
|
+
return resumed
|
|
712
|
+
}
|
|
993
713
|
const markMentionHandled = (message, channelId) => {
|
|
994
714
|
pruneMentionSignatures()
|
|
995
715
|
const { idKey, signatureKey } = mentionDedupeKeys(message, channelId)
|
|
@@ -1005,8 +725,6 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1005
725
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
1006
726
|
// source independently through work, recovery, and delivery.
|
|
1007
727
|
let backlogProbeBusy = false
|
|
1008
|
-
let lastTaskSignature = ''
|
|
1009
|
-
let lastTaskTriggeredAt = 0
|
|
1010
728
|
let lastInboxSignature = ''
|
|
1011
729
|
let selfAgentId = null
|
|
1012
730
|
let completionRecipientName = ''
|
|
@@ -1067,6 +785,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1067
785
|
try { return JSON.parse(text) } catch { return { text } }
|
|
1068
786
|
}
|
|
1069
787
|
|
|
788
|
+
const taskTypeSets = createTaskTypeLookup({ load: (projectId) => callMcpTool('list_task_types', { project_id: projectId }).then(toolData) })
|
|
789
|
+
|
|
1070
790
|
const callMcpReadWithRetry = async (name, args = {}) => {
|
|
1071
791
|
try { return await callMcpTool(name, args) }
|
|
1072
792
|
catch (first) {
|
|
@@ -1084,19 +804,13 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1084
804
|
}
|
|
1085
805
|
const projectsData = toolData(await callMcpReadWithRetry('list_projects'))
|
|
1086
806
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1087
|
-
const groups = await
|
|
807
|
+
const groups = await mapConcurrent(projects.filter((project) => project?.id != null), 3, async (project) => {
|
|
1088
808
|
const [tasksResult, typesResult] = await Promise.all([
|
|
1089
809
|
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
|
-
}),
|
|
810
|
+
taskTypeSets(project.id),
|
|
1094
811
|
])
|
|
1095
812
|
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)))
|
|
813
|
+
const { done: doneIds, review: reviewIds } = typesResult
|
|
1100
814
|
return (Array.isArray(tasksData.tasks) ? tasksData.tasks : []).filter((task) => {
|
|
1101
815
|
const assignedId = Number(taskAgentId(task) ?? task.agent?.id ?? task.assigned_agent?.id)
|
|
1102
816
|
const assignedIdentifier = String(task.agent?.identifier ?? task.agent?.slug ?? task.assigned_agent?.identifier ?? task.assigned_agent?.slug ?? '')
|
|
@@ -1111,7 +825,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1111
825
|
state: String(task.type?.name ?? task.task_type?.name ?? task.status ?? '').trim(),
|
|
1112
826
|
project: String(project.name || ''),
|
|
1113
827
|
}))
|
|
1114
|
-
})
|
|
828
|
+
})
|
|
1115
829
|
return groups.flat()
|
|
1116
830
|
}
|
|
1117
831
|
|
|
@@ -1163,8 +877,32 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1163
877
|
const controlKey = threadControlKey(channelId, parentId)
|
|
1164
878
|
if (controlKey) memory.remember({ key: controlKey, kind: 'thread', state: 'active', summary, refs: { channelId: Number(channelId), threadId: parentId } })
|
|
1165
879
|
}
|
|
1166
|
-
const
|
|
880
|
+
const saveOwnThread = (channelId, rootId, content) => {
|
|
881
|
+
memory.remember({ key: `own-root:${channelId}:${rootId}`, kind: 'thread', state: 'authored', summary: content, refs: { channelId: Number(channelId), threadId: rootId }, meta: { rootContent: content } })
|
|
882
|
+
if (!memory.has(threadControlKey(channelId, rootId), 'cancelled')) activateThread(channelId, rootId, content)
|
|
883
|
+
}
|
|
884
|
+
const resolveOwnThread = createThreadContextResolver({
|
|
885
|
+
read: (cid, root) => {
|
|
886
|
+
const cached = memory.get(`own-root:${cid}:${root}`)?.meta?.rootContent
|
|
887
|
+
if (cached) return cached
|
|
888
|
+
// Previous releases recorded the posted message id but not ownership.
|
|
889
|
+
// This also recovers roots when the thread API returns only replies.
|
|
890
|
+
const previous = memory.recall({ channelId: cid }, 1000).find((node) => node.kind === 'delivery' && node.state === 'rendered' && node.refs?.threadId == null && String(node.meta?.messageId) === String(root))
|
|
891
|
+
if (previous?.summary) saveOwnThread(cid, root, previous.summary)
|
|
892
|
+
return previous?.summary || ''
|
|
893
|
+
},
|
|
894
|
+
save: saveOwnThread,
|
|
895
|
+
cancelled: (cid, root) => memory.has(threadControlKey(cid, root), 'cancelled'),
|
|
896
|
+
identity: () => ({ id: selfAgentId, identifier, slug, name: slug }),
|
|
897
|
+
load: async (cid, root) => toolData(await callMcpTool('list_message_thread', { channel_id: Number(cid), message_id: Number(root) })),
|
|
898
|
+
})
|
|
899
|
+
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false }) => {
|
|
1167
900
|
const deliveryKey = String(key || '')
|
|
901
|
+
if (parentId == null) {
|
|
902
|
+
channelId = await projectStatusChannel(projectId)
|
|
903
|
+
if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
|
|
904
|
+
projectId = undefined // dedicated channels may be organization-scoped
|
|
905
|
+
}
|
|
1168
906
|
const message = String(content || '').trim()
|
|
1169
907
|
if (!deliveryKey || !Number.isFinite(Number(channelId)) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1170
908
|
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) return { posted: false, reason: 'remembered' }
|
|
@@ -1182,7 +920,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1182
920
|
const run = (async () => {
|
|
1183
921
|
if (parentId != null) {
|
|
1184
922
|
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 })
|
|
923
|
+
const rendered = repliesAfterSource(renderedAgentMessages(live, { id: selfAgentId, identifier, slug, name: slug }), sourceMessageId)
|
|
1186
924
|
const duplicate = rendered.some((row) => normalizeRenderedMessageText(row.content) === normalizeRenderedMessageText(message))
|
|
1187
925
|
if (duplicate || (skipIfAnyAgentReply && rendered.length)) {
|
|
1188
926
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
@@ -1205,6 +943,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1205
943
|
...(parentId != null ? { parent_id: Number(parentId) } : {}),
|
|
1206
944
|
content: message,
|
|
1207
945
|
}))
|
|
946
|
+
const postedId = result.id ?? result.message?.id ?? result.message_id
|
|
947
|
+
if (parentId == null && postedId != null) saveOwnThread(channelId, postedId, message)
|
|
1208
948
|
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies); persistReplay()
|
|
1209
949
|
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { channelId: Number(channelId), ...(parentId != null ? { threadId: Number(parentId) } : {}), ...(Number.isFinite(Number(projectId)) ? { projectId: Number(projectId) } : {}) }, meta: { messageId: result.id ?? result.message?.id ?? null } })
|
|
1210
950
|
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
@@ -1215,24 +955,30 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1215
955
|
}
|
|
1216
956
|
|
|
1217
957
|
const statusChannelCache = new Map()
|
|
958
|
+
let dedicatedChannelRead = null
|
|
1218
959
|
const projectStatusChannel = async (projectId) => {
|
|
1219
|
-
const
|
|
1220
|
-
const cached = statusChannelCache.get(key)
|
|
960
|
+
const cached = statusChannelCache.get('self')
|
|
1221
961
|
if (cached && cached.expiresAt > Date.now()) return cached.channelId
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
962
|
+
if (dedicatedChannelRead) return dedicatedChannelRead
|
|
963
|
+
dedicatedChannelRead = (async () => {
|
|
964
|
+
try {
|
|
965
|
+
if (selfAgentId == null) {
|
|
966
|
+
const agents = toolData(await callMcpTool('list_agents')).agents || []
|
|
967
|
+
rememberSelfAgent(agents.find((entry) => String(entry.identifier || entry.slug || '') === identifier))
|
|
968
|
+
}
|
|
969
|
+
const data = toolData(await callMcpTool('list_channels'))
|
|
970
|
+
const channels = data.channels ?? data.data?.channels ?? data.data ?? data.items ?? []
|
|
971
|
+
const channel = dedicatedChannel(Array.isArray(channels) ? channels : [], { id: selfAgentId, identifier, aliases: [...selfAliases], channelId: readConfig(slug)?.dedicatedChannelId })
|
|
972
|
+
const channelId = channel ? Number(channel.id) : null
|
|
973
|
+
statusChannelCache.set('self', { channelId, expiresAt: Date.now() + (channelId ? 300_000 : 10_000) })
|
|
974
|
+
if (channelId == null) log('No unique dedicated agent channel; top-level delivery stays pending.')
|
|
975
|
+
return channelId
|
|
976
|
+
} catch (error) {
|
|
977
|
+
log('Dedicated channel lookup failed: ' + (error?.message || error))
|
|
978
|
+
return null
|
|
979
|
+
}
|
|
980
|
+
})().finally(() => { dedicatedChannelRead = null })
|
|
981
|
+
return dedicatedChannelRead
|
|
1236
982
|
}
|
|
1237
983
|
|
|
1238
984
|
const announceIntroduction = async () => {
|
|
@@ -1241,7 +987,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1241
987
|
const project = projects.find((item) => Number.isFinite(Number(item.id)))
|
|
1242
988
|
if (!project) { log('no project available for first-connection introduction'); return false }
|
|
1243
989
|
const channelId = await projectStatusChannel(project.id)
|
|
1244
|
-
if (!Number.isFinite(Number(channelId))) return false
|
|
990
|
+
if (channelId == null || !Number.isFinite(Number(channelId))) return false
|
|
1245
991
|
await postMessageOnce({
|
|
1246
992
|
key: `intro:${identifier}:${project.id}`,
|
|
1247
993
|
projectId: Number(project.id),
|
|
@@ -1276,9 +1022,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1276
1022
|
try {
|
|
1277
1023
|
const comment = await callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })
|
|
1278
1024
|
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
|
|
1025
|
+
else log('comment_ticket is not exposed; completing ticket #' + ticketId + ' through its verified state and dedicated-channel report')
|
|
1280
1026
|
} catch (e) {
|
|
1281
|
-
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified
|
|
1027
|
+
log('optional ticket comment failed for #' + ticketId + ': ' + (e?.message || e) + '; continuing with the verified dedicated-channel report')
|
|
1282
1028
|
}
|
|
1283
1029
|
reportedTaskComments.add(report.key); trimSeen(reportedTaskComments); persistReplay()
|
|
1284
1030
|
}
|
|
@@ -1296,8 +1042,8 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1296
1042
|
log('completion for ticket #' + ticketId + ' was already posted by the work cycle')
|
|
1297
1043
|
return true
|
|
1298
1044
|
}
|
|
1299
|
-
const channelId =
|
|
1300
|
-
if (!Number.isFinite(channelId)) return false
|
|
1045
|
+
const channelId = await projectStatusChannel(projectId)
|
|
1046
|
+
if (channelId == null || !Number.isFinite(channelId)) return false
|
|
1301
1047
|
await postMessageOnce({ key: `completion:${report.key}`, projectId, channelId, content: report.content, sourceKey: memoryKey })
|
|
1302
1048
|
reportedCompletions.add(report.key); trimSeen(reportedCompletions)
|
|
1303
1049
|
pendingCompletionReports.delete(taskKey); persistReplay()
|
|
@@ -1379,7 +1125,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1379
1125
|
? `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
1126
|
: 'Explicitly authorize the exact repository URL, commit, and branch in your reply.'
|
|
1381
1127
|
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
|
|
1128
|
+
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
1129
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1384
1130
|
}
|
|
1385
1131
|
|
|
@@ -1396,122 +1142,103 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1396
1142
|
rememberSelfAgent(self)
|
|
1397
1143
|
const projectsData = toolData(await callMcpTool('list_projects'))
|
|
1398
1144
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1399
|
-
const assigned = []
|
|
1400
1145
|
const mentionActivity = []
|
|
1401
1146
|
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
|
-
|
|
1147
|
+
await mapConcurrent(projects, 3, async (project) => {
|
|
1148
|
+
try {
|
|
1149
|
+
const assigned = []
|
|
1150
|
+
const [tasksData, typesData] = await Promise.all([
|
|
1151
|
+
callMcpTool('list_tasks', { project_id: project.id }).then(toolData),
|
|
1152
|
+
taskTypeSets(project.id),
|
|
1153
|
+
])
|
|
1154
|
+
const { done: doneIds, review: reviewIds } = typesData
|
|
1155
|
+
for (const task of Array.isArray(tasksData.tasks) ? tasksData.tasks : []) {
|
|
1156
|
+
const taskAgentId = Number(task.agent_id ?? task.agentId ?? task.agent?.id)
|
|
1157
|
+
const taskIdent = String(task.agent?.identifier ?? task.agent?.slug ?? '')
|
|
1158
|
+
if (taskAgentId !== Number(self.id) && taskIdent !== identifier) continue
|
|
1159
|
+
const taskKey = `${project.id}:${task.id}`
|
|
1160
|
+
if (taskIsCompleted(task, doneIds) || taskIsAwaitingReview(task, reviewIds)) {
|
|
1161
|
+
memory.remember({ key: `ticket:${project.id}:${task.id}`, kind: 'ticket', state: 'handoff', summary: task.title, refs: { projectId: project.id, ticketId: task.id } })
|
|
1162
|
+
if (pendingCompletionReports.has(taskKey)) {
|
|
1163
|
+
const activityChannel = await projectStatusChannel(project.id)
|
|
1164
|
+
try { await announceTaskCompletion({ projectId: project.id, ticketId: task.id, channelId: activityChannel }) }
|
|
1165
|
+
catch (e) { log('completion report retry failed for ticket #' + task.id + ': ' + (e?.message || e)) }
|
|
1166
|
+
}
|
|
1167
|
+
if (blockedTasks.delete(taskKey)) { blockedTaskRepos.delete(taskKey); persistReplay() }
|
|
1168
|
+
clearFailedTask(taskKey)
|
|
1169
|
+
// Release the in-flight de-dupe key at handoff. If a reviewer moves
|
|
1170
|
+
// the ticket back to an actionable column, that update must start a
|
|
1171
|
+
// fresh work cycle.
|
|
1172
|
+
seenTasks.delete(taskKey)
|
|
1173
|
+
continue
|
|
1427
1174
|
}
|
|
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
|
|
1175
|
+
resumeAuthorized(taskKey)
|
|
1176
|
+
if (failedTaskIsPaused(taskKey, task)) {
|
|
1177
|
+
seenTasks.add(taskKey)
|
|
1178
|
+
log('backlog ticket #' + task.id + ' is paused at its failed revision; waiting for a ticket change')
|
|
1179
|
+
continue
|
|
1483
1180
|
}
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
channelId: activityChannelId,
|
|
1488
|
-
message: activityMessage,
|
|
1489
|
-
activity: item,
|
|
1490
|
-
})
|
|
1181
|
+
if (blockedTasks.has(taskKey)) continue
|
|
1182
|
+
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 } })
|
|
1183
|
+
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
1184
|
}
|
|
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
1185
|
const priorityRank = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
for (const next of candidates) {
|
|
1186
|
+
assigned.sort((a, b) => (priorityRank[a.priority] ?? 9) - (priorityRank[b.priority] ?? 9) || String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))
|
|
1187
|
+
for (const next of assigned) {
|
|
1506
1188
|
const key = `${next.projectId}:${next.id}`
|
|
1507
1189
|
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.
|
|
1190
|
+
// Live ownership/state verification remains inside handleTaskSignal.
|
|
1191
|
+
seenTasks.delete(key)
|
|
1511
1192
|
await handleTaskSignal('task:assigned', { task: { id: next.id, project_id: next.projectId } })
|
|
1512
1193
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1194
|
+
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1195
|
+
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1196
|
+
return {}
|
|
1197
|
+
})
|
|
1198
|
+
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1199
|
+
const mentionNeedles = [self.name, self.identifier, self.slug, identifier].filter(Boolean).map((s) => '@' + String(s).toLowerCase())
|
|
1200
|
+
for (const item of activities) {
|
|
1201
|
+
const text = JSON.stringify(item)
|
|
1202
|
+
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1203
|
+
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1204
|
+
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1205
|
+
? item.message
|
|
1206
|
+
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1207
|
+
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1208
|
+
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1209
|
+
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1210
|
+
const activityParentId = activityMessage?.parent_id ?? activityMessage?.parentId
|
|
1211
|
+
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1212
|
+
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1213
|
+
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1214
|
+
// Search only the source message, never the whole activity envelope: an
|
|
1215
|
+
// old tagged thread root can otherwise make another agent's new reply
|
|
1216
|
+
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1217
|
+
// in a persistently owned thread both enter the guarded per-message path.
|
|
1218
|
+
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1219
|
+
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1220
|
+
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1221
|
+
// The same logical mention may already have arrived over WebSocket.
|
|
1222
|
+
// Share the id/signature guard instead of starting a second model turn.
|
|
1223
|
+
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1224
|
+
// when an owned-thread source has no received ledger entry.
|
|
1225
|
+
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1226
|
+
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1227
|
+
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1228
|
+
if (memory.has(activitySourceKey, 'received')) continue
|
|
1229
|
+
}
|
|
1230
|
+
mentionActivity.push({
|
|
1231
|
+
projectId: project.id,
|
|
1232
|
+
project: project.name,
|
|
1233
|
+
channelId: activityChannelId,
|
|
1234
|
+
message: activityMessage,
|
|
1235
|
+
activity: item,
|
|
1236
|
+
})
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1240
|
+
})
|
|
1241
|
+
if (activityReplayTouched) persistReplay()
|
|
1515
1242
|
const inboxSignature = JSON.stringify(mentionActivity.slice(-20)).slice(0, 4000)
|
|
1516
1243
|
if (mentionActivity.length && inboxSignature !== lastInboxSignature) {
|
|
1517
1244
|
lastInboxSignature = inboxSignature
|
|
@@ -1549,20 +1276,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1549
1276
|
}
|
|
1550
1277
|
// The next task update or periodic reconciliation must be allowed to queue
|
|
1551
1278
|
// this work again. An acknowledgement is not a terminal task signature.
|
|
1552
|
-
lastTaskSignature = ''
|
|
1553
1279
|
}
|
|
1554
1280
|
|
|
1555
1281
|
function drain(kind, context, targetChannels = [], taskRef = null, delivery = null) {
|
|
1556
1282
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1557
1283
|
const key = taskRef ? `ticket:${taskRef.projectId}:${taskRef.ticketId}` : delivery?.key
|
|
1558
1284
|
const itemWorkdir = laneName === 'work' && taskRef ? findTicketWorktree(workdir, taskRef.ticketId) : ''
|
|
1559
|
-
const control = { cancelled: false, runner: null, statusTargets: new Set() }
|
|
1285
|
+
const control = { cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1560
1286
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key)
|
|
1561
1287
|
}
|
|
1562
1288
|
|
|
1563
1289
|
async function executeCycle(kind, context, targetChannels = [], taskRef = null, delivery = null, runner, control, preparedWorkdir = '') {
|
|
1564
1290
|
const laneName = kind === 'full' ? 'work' : 'reply'
|
|
1565
1291
|
const cycleControl = control || { cancelled: false, runner, statusTargets: new Set() }
|
|
1292
|
+
const cycleStartedAt = performance.now()
|
|
1293
|
+
log(`cycle timing queue=${Math.round(cycleStartedAt - (cycleControl.enqueuedAt ?? cycleStartedAt))}ms kind=${kind}`)
|
|
1566
1294
|
const ctx = context ? [context] : []
|
|
1567
1295
|
const activeTaskRef = taskRef
|
|
1568
1296
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
@@ -1595,18 +1323,21 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1595
1323
|
if (activeTaskRef) {
|
|
1596
1324
|
const data = toolData(await callMcpTool('get_ticket', { project_id: activeTaskRef.projectId, ticket_id: activeTaskRef.ticketId }))
|
|
1597
1325
|
const ticket = data.ticket ?? data.task ?? data
|
|
1326
|
+
const typeSets = await taskTypeSets(activeTaskRef.projectId)
|
|
1598
1327
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1599
1328
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1600
1329
|
if (blockedTasks.has(`${activeTaskRef.projectId}:${activeTaskRef.ticketId}`) ||
|
|
1601
1330
|
!((selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier) ||
|
|
1602
|
-
taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1331
|
+
taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1603
1332
|
releaseTaskForRetry(activeTaskRef, prompt)
|
|
1604
1333
|
log('queued ticket no longer actionable; skipped before model start')
|
|
1605
1334
|
return
|
|
1606
1335
|
}
|
|
1607
1336
|
}
|
|
1608
1337
|
if (cycleControl.cancelled) return
|
|
1338
|
+
log(`cycle timing preflight=${Math.round(performance.now() - cycleStartedAt)}ms kind=${kind}`)
|
|
1609
1339
|
const result = await runner.runCycle(prompt, useModel, runnerOptions)
|
|
1340
|
+
if (result?.timings) log(`cycle timing runtime=${JSON.stringify(result.timings)} kind=${kind}`)
|
|
1610
1341
|
if (result?.model && result.model !== useModel) {
|
|
1611
1342
|
// Persist only a successfully selected model and only if the user has
|
|
1612
1343
|
// not changed that tier while this cycle was running.
|
|
@@ -1755,6 +1486,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1755
1486
|
}
|
|
1756
1487
|
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1757
1488
|
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1489
|
+
const typeSets = await taskTypeSets(projectId)
|
|
1758
1490
|
const assignedId = Number(taskAgentId(ticket) ?? ticket.agent?.id ?? ticket.assigned_agent?.id)
|
|
1759
1491
|
const assignedIdentifier = String(ticket.agent?.identifier ?? ticket.assigned_agent?.identifier ?? '')
|
|
1760
1492
|
const belongsToSelf = (selfAgentId != null && assignedId === selfAgentId) || assignedIdentifier === identifier
|
|
@@ -1769,7 +1501,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1769
1501
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'unassigned', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1770
1502
|
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
1503
|
}
|
|
1772
|
-
if (taskIsCompleted(ticket) || taskIsAwaitingReview(ticket)) {
|
|
1504
|
+
if (taskIsCompleted(ticket, typeSets.done) || taskIsAwaitingReview(ticket, typeSets.review)) {
|
|
1773
1505
|
memory.remember({ key: `ticket:${projectId}:${ticketId}`, kind: 'ticket', state: 'handoff', summary: ticket.title, refs: { projectId, ticketId } })
|
|
1774
1506
|
if (pendingCompletionReports.has(key)) {
|
|
1775
1507
|
const activityChannel = await projectStatusChannel(projectId)
|
|
@@ -1780,6 +1512,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1780
1512
|
log(kind + ' ticket #' + ticketId + ' is already complete or awaiting review — ignored')
|
|
1781
1513
|
return
|
|
1782
1514
|
}
|
|
1515
|
+
resumeAuthorized(key)
|
|
1783
1516
|
const hadFailedRevision = failedTaskVersions.has(key)
|
|
1784
1517
|
if (failedTaskIsPaused(key, ticket)) {
|
|
1785
1518
|
seenTasks.add(key)
|
|
@@ -1787,17 +1520,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1787
1520
|
return
|
|
1788
1521
|
}
|
|
1789
1522
|
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
|
-
}
|
|
1523
|
+
if (blockedTasks.has(key)) { log(kind + ' ticket authorization is still unavailable'); return }
|
|
1801
1524
|
if (seenTasks.has(key)) { log(kind + ' ticket #' + ticketId + ' already queued/active — ignored'); return }
|
|
1802
1525
|
seenTasks.add(key); trimSeen(seenTasks)
|
|
1803
1526
|
const title = String(ticket.title || hinted.title || '')
|
|
@@ -1812,13 +1535,33 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1812
1535
|
if (cycleKind === 'full') { pendingCompletionReports.add(key); trimSeen(pendingCompletionReports); persistReplay() }
|
|
1813
1536
|
const humanTicketRef = ticketSlug ? `ticket ${ticketSlug}` : `the ticket “${title}”`
|
|
1814
1537
|
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
|
|
1538
|
+
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
1539
|
} catch (e) {
|
|
1817
1540
|
log(kind + ' ticket verification failed for #' + ticketId + ': ' + (e?.message || e))
|
|
1818
1541
|
}
|
|
1819
1542
|
}
|
|
1820
1543
|
|
|
1544
|
+
const mentionIntake = new Map()
|
|
1821
1545
|
function onEvent(k, d) {
|
|
1546
|
+
if (k !== 'agent:mention') return routeEvent(k, d)
|
|
1547
|
+
const cid = d?.channel_id ?? d?.channelId
|
|
1548
|
+
const message = d?.message || {}
|
|
1549
|
+
const parent = message.parent_id ?? message.parentId
|
|
1550
|
+
const key = `${cid}:${parent ?? message.id ?? message.message_id}`
|
|
1551
|
+
// Keep redirects ordered while a legacy root is being verified. Other
|
|
1552
|
+
// conversations can resolve and run independently.
|
|
1553
|
+
const next = (mentionIntake.get(key) || Promise.resolve()).then(async () => {
|
|
1554
|
+
const preliminary = classifyConversationTarget(message, [...selfAliases])
|
|
1555
|
+
const irrelevant = preliminary.action === 'ignore' && preliminary.reason !== 'unaddressed-thread-activity'
|
|
1556
|
+
const rootContent = parent == null || irrelevant || preliminary.action === 'stand_down' ? '' : await resolveOwnThread(cid, parent)
|
|
1557
|
+
routeEvent(k, { ...d, _threadRootContent: rootContent })
|
|
1558
|
+
}).catch((error) => log('mention thread verification failed: ' + (error?.message || error)))
|
|
1559
|
+
.finally(() => { if (mentionIntake.get(key) === next) mentionIntake.delete(key) })
|
|
1560
|
+
mentionIntake.set(key, next)
|
|
1561
|
+
return next
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function routeEvent(k, d) {
|
|
1822
1565
|
const raw = d && typeof d === 'object' ? d : {}
|
|
1823
1566
|
if (k === 'task:assigned' || k === 'task:updated') {
|
|
1824
1567
|
void handleTaskSignal(k, raw)
|
|
@@ -1872,7 +1615,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1872
1615
|
// redirect can cancel its process. Codex additionally delegates the final
|
|
1873
1616
|
// post to the watcher for an authoritative last-moment thread check.
|
|
1874
1617
|
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
1875
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, watcherOwned: agent === 'codex' }
|
|
1618
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: agent === 'codex' }
|
|
1876
1619
|
: null
|
|
1877
1620
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
1878
1621
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -1928,13 +1671,14 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1928
1671
|
// Light up the live status the instant we pick this up; drain owns the
|
|
1929
1672
|
// subsequent working/typing heartbeat for its lane.
|
|
1930
1673
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
1931
|
-
const codingMention = canCode && conversationNeedsCode(text)
|
|
1674
|
+
const codingMention = canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
1932
1675
|
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
1933
|
-
|
|
1676
|
+
let ctx = cid != null
|
|
1934
1677
|
? replyDelivery?.watcherOwned
|
|
1935
1678
|
? `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? 'Complete the concrete repository work and verification first.' : 'Answer the request directly.'} Do NOT call post_message; it is intentionally unavailable. Return only the final reply as your final answer. The watcher will re-check the live thread and render it at most once.${who ? ` To mention the requester, use their exact full name "@${who}".` : ''} The complete message is already here; do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.`
|
|
1936
1679
|
: `The watcher verified this source message is addressed to you in OpenVisio channel ${cid}${who ? ` by "${who}"` : ''}: "${text}". ${codingMention ? `This is concrete repository work. Start the coding flow now. You may send at most one concrete progress update after work begins, but do not stop there: continue the work and then send one distinct final result with evidence or a real blocker. Use post_message for the final result even if you already sent a progress message` : 'Send EXACTLY ONE reply with post_message'}: arguments: channel_id ${cid}${threadRoot != null ? `, parent_id ${threadRoot} (reply IN THAT THREAD, do not start a new top-level message)` : ''}, plus agent_identifier + agent_api_key from the AUTH line above. Never repeat the same message.${who ? ` To @mention them back, write their EXACT full name "@${who}". A mention only links when the name matches exactly.` : ''} The complete message is already here. Do not call get_resource, get_marching_orders, poll_inbox, list_mcp_resources, or list_mcp_resource_templates.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
|
|
1937
1680
|
: undefined
|
|
1681
|
+
if (ctx && raw._threadRootContent) ctx += ` Verified thread root authored by you (context, not a new request): ${JSON.stringify(raw._threadRootContent)}. For requested repairs, inspect the referenced PR and its branch, run the failing verification, and fix that existing work. A review/testing handoff does not prevent an explicit human repair request.`
|
|
1938
1682
|
if (codingMention) {
|
|
1939
1683
|
// Activity indicators make accepted work visible. Do not add a canned
|
|
1940
1684
|
// pickup message; the first channel message is the verified result or a
|
|
@@ -1957,6 +1701,39 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1957
1701
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
1958
1702
|
handle = connectAgentWs({ wsUrl, apiKey, identifier, onEvent, onConnect: () => { setTimeout(() => void reconcileBacklog(), 250) }, log })
|
|
1959
1703
|
|
|
1704
|
+
let authorizationProbeBusy = false
|
|
1705
|
+
const legacyAuthorizationChecks = new Set()
|
|
1706
|
+
const resumeReadyAuthorizations = async () => {
|
|
1707
|
+
if (authorizationProbeBusy || (!blockedTasks.size && !failedTaskVersions.size)) return
|
|
1708
|
+
authorizationProbeBusy = true
|
|
1709
|
+
try {
|
|
1710
|
+
// Older ACP adapters recorded a helper denial as a generic failed cycle.
|
|
1711
|
+
// Recover that specific legacy state once, without reviving other failures.
|
|
1712
|
+
for (const key of failedTaskVersions.keys()) {
|
|
1713
|
+
const migrationKey = `pr-setup-migration:${key}`
|
|
1714
|
+
if (blockedTasks.has(key) || legacyAuthorizationChecks.has(key) || memory.has(migrationKey)) continue
|
|
1715
|
+
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1716
|
+
const [projectId, ticketId] = key.split(':').map(Number)
|
|
1717
|
+
const cwd = findTicketWorktree(workdir, ticketId)
|
|
1718
|
+
if (!cwd || !repositoryHasPrPushAuthorization({ cwd })) continue
|
|
1719
|
+
const data = toolData(await callMcpTool('get_ticket', { project_id: projectId, ticket_id: ticketId }))
|
|
1720
|
+
const ticket = data.ticket ?? data.task ?? data
|
|
1721
|
+
legacyAuthorizationChecks.add(key)
|
|
1722
|
+
if (!/OPENVISIO_PR_PUSH_AUTH_REQUIRED|openvisio-agent authorize-pr-push/.test(String(ticket.description || ''))) continue
|
|
1723
|
+
memory.remember({ key: migrationKey, kind: 'migration', state: 'checked', summary: 'Recovered legacy PR setup block', refs: { projectId, ticketId } })
|
|
1724
|
+
blockedTasks.add(key); blockedTaskRepos.set(key, cwd)
|
|
1725
|
+
}
|
|
1726
|
+
for (const key of [...blockedTasks]) {
|
|
1727
|
+
if (queues.work.has(`ticket:${key}`) || queues.reply.has(`ticket:${key}`)) continue
|
|
1728
|
+
if (!resumeAuthorized(key)) continue
|
|
1729
|
+
const [projectId, id] = key.split(':').map(Number)
|
|
1730
|
+
await handleTaskSignal('task:assigned', { task: { id, project_id: projectId } })
|
|
1731
|
+
}
|
|
1732
|
+
} catch (error) { log('Authorization resume failed: ' + (error?.message || error)) }
|
|
1733
|
+
finally { authorizationProbeBusy = false }
|
|
1734
|
+
}
|
|
1735
|
+
const authorizationTimer = setInterval(() => void resumeReadyAuthorizations(), 2_000)
|
|
1736
|
+
authorizationTimer.unref?.()
|
|
1960
1737
|
let introTimer = null, taskProbeStartTimer = null, taskProbeTimer = null
|
|
1961
1738
|
if (mcpConfig || mcpUrl) {
|
|
1962
1739
|
// Workspace ethics: a one-time hello the FIRST time this agent ever connects.
|
|
@@ -1986,6 +1763,7 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1986
1763
|
const bye = async () => {
|
|
1987
1764
|
if (stopping) return
|
|
1988
1765
|
stopping = true
|
|
1766
|
+
clearInterval(authorizationTimer)
|
|
1989
1767
|
if (introTimer) clearTimeout(introTimer)
|
|
1990
1768
|
if (taskProbeStartTimer) clearTimeout(taskProbeStartTimer)
|
|
1991
1769
|
if (taskProbeTimer) clearInterval(taskProbeTimer)
|
|
@@ -1996,8 +1774,9 @@ function loopBackendWs({ backend, wsUrl, apiKey, identifier, slug, claude, agent
|
|
|
1996
1774
|
void item.control.runner?.cancelCurrent?.()
|
|
1997
1775
|
})
|
|
1998
1776
|
}
|
|
1999
|
-
await Promise.all([...
|
|
2000
|
-
|
|
1777
|
+
await Promise.all([...activeReplyRunners, ...activeWorkRunners].map((runner) => runner.cancelCurrent?.()))
|
|
1778
|
+
workRunners.close()
|
|
1779
|
+
replyRunners.close()
|
|
2001
1780
|
process.exit(0)
|
|
2002
1781
|
}
|
|
2003
1782
|
process.on('SIGTERM', bye)
|