openvisio-agent 0.25.7 → 0.25.9
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/CHANGELOG.md +15 -0
- package/README.md +3 -3
- package/USER_GUIDE.md +3 -1
- package/package.json +1 -1
- package/scenarios/routing.scenarios.mjs +7 -1
- package/scenarios/workspace.scenarios.mjs +7 -2
- package/scripts/certify.mjs +11 -8
- package/src/events.mjs +56 -11
- package/src/watch.mjs +164 -124
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## [0.25.9] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
- Keep pending-ticket detection within one sentence or clause, preventing a peer's ticket-status statement from hijacking a separate onboarding question.
|
|
8
|
+
- Let BYO agents answer concrete peer questions and requests in an owned thread without requiring every turn to repeat an @mention.
|
|
9
|
+
- Keep routine agent chatter silent and instruct peer replies to finish decisively instead of creating automated reply loops.
|
|
10
|
+
- Route new comments on an assigned task through authoritative ownership checks and post one deduplicated reply in the source comment thread, including concrete requests from another BYO agent.
|
|
11
|
+
- Remove the 30-second activity-feed recovery timer and startup `list_activity` pass. Healthy watchers now remain on WebSocket delivery while idle, with MCP recovery limited to connection and outstanding-delivery events.
|
|
12
|
+
|
|
13
|
+
## [0.25.8] — 2026-09-21
|
|
14
|
+
|
|
15
|
+
- Hydrate compact `list_agents` identities through `get_agent` so watchers subscribe to their verified organization and receive mentions from every accessible channel.
|
|
16
|
+
- Route organization `task:created` and `task:updated` events through the same authoritative assignment verification used by targeted assignment events.
|
|
17
|
+
- Allow explicit requests to publish in another named channel while retaining guarded, single-delivery replies in the source thread and dedicated-channel defaults for unsolicited results.
|
|
18
|
+
- Start organization discovery immediately after each WebSocket connection and retain event-driven idle behavior.
|
|
19
|
+
|
|
5
20
|
## [0.25.7] — 2026-09-21
|
|
6
21
|
|
|
7
22
|
- Restore recovery for addressed messages that the backend omits from the watcher WebSocket stream.
|
package/README.md
CHANGED
|
@@ -81,13 +81,13 @@ The integration follows Mastra's [semantic memory](https://mastra.ai/docs/memory
|
|
|
81
81
|
|
|
82
82
|
Model selections are stable. Exact IDs (including reasoning effort) are preserved. An unsuffixed ACP model may resolve once to the same model with `medium` effort; the watcher saves that exact selection. Unavailable selections produce an explicit error rather than switching generations or families. Claude versioned IDs are preserved, with automatic family fallbacks disabled. Codex uses the configured chat model for replies.
|
|
83
83
|
|
|
84
|
-
Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`,
|
|
84
|
+
Backend/BYO watchers reconcile assignments whenever the WebSocket starts or reconnects. A direct MCP session discovers and caches the backend's actual `tools/list` response, then uses available tools such as `list_agents`, `list_projects`, `list_tasks`, and `list_task_types` to recover assigned work after a connection event. The authenticated profile lookup also supplies the verified organization needed for channel, assignment, and task-comment subscriptions. Once connected, the watcher makes no periodic `list_activity`, roster, project, board, task-type, or task calls while idle; incoming WebSocket events start the relevant verification path. Team discovery is shared by every runtime provider: structured MCP results and compact Markdown list envelopes are normalized once before the watcher resolves its project, identity, boards, tickets, or dedicated channel. Adding another model provider therefore reuses the same saved team connection and setup path instead of adding provider-specific project discovery. New comments on a task assigned to the agent are verified with `get_ticket`; human comments and concrete peer-agent requests receive one deduplicated reply in the source comment thread. Optional comment actions are used only when advertised, and their absence cannot strand a completed ticket in a retry loop.
|
|
85
85
|
|
|
86
86
|
The backend MCP may be stateful or stateless. A successful initialize response without `Mcp-Session-Id` is accepted as stateless, so OpenCode agents do not stop with “MCP initialize returned no session id.” Each OpenCode lane keeps its MCP identity in a private per-agent config directory while the repository is supplied separately with `--dir`; stale workspace configuration therefore cannot swap one agent's credentials for another's. The generated remote configuration sends the agent headers directly, disables OAuth probing, and backend cycles never request relay-only inbox calls or MCP resource-discovery tools in place of team actions.
|
|
87
87
|
|
|
88
88
|
Codex BYO agents follow the repository's normative runtime specification in `docs/CODEX_BYO_AGENT_SPEC.md`: one WebSocket identity, independent reply/work queues, authoritative `get_ticket` verification for assignments, silent transient activity, persistent replay suppression, and agent-controlled turn completion. Maintainers must run `npm run certify` before publishing.
|
|
89
89
|
|
|
90
|
-
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher subscribes only to the organization verified from
|
|
90
|
+
Both `agent:mention` and organization `channel:message` events pass through the same source-recipient, ownership, and deduplication checks before any model starts. The watcher hydrates compact roster entries through `get_agent`, subscribes only to the organization verified from that authenticated profile, and restores the subscription after reconnect. Organization `task:created` and `task:updated` events enter the same authoritative assignment verifier as targeted `task:assigned` events, so a task starts only after `get_ticket` confirms it is open and owned by this agent. Self-authored source messages are ignored; embedded old agent replies cannot hide a human follow-up. An explicit tag establishes durable ownership of that thread, so later human follow-ups remain addressed to the agent without another @mention. Another BYO agent can continue that owned thread with a concrete question or request without repeating the mention; routine agent status chatter remains silent to prevent reply loops. Ticket-inventory intent is sentence-scoped, so a peer saying it has no assigned tickets cannot hijack a separate onboarding or coordination question. Messages redirected to another agent stay silent, while a direct stand-down cancels queued/running work and releases ownership for that thread. Concrete roots verified as belonging to someone else have a bounded one-minute cache; missing authors and failed reads remain immediately retryable. The watcher publishes working, thinking, and typing presence from actual runtime activity using the authenticated REST endpoint. It stops refreshing when work finishes or is cancelled; the app clears the indicator through its expiry timer. Accepted coding tasks publish a short native plan first; the watcher sends one acknowledgement with up to three steps to the source thread or default agent channel. Every runtime returns a distinct final answer for guarded watcher delivery. Plans and reconnects cannot duplicate or suppress that final answer.
|
|
91
91
|
|
|
92
92
|
Ticket references follow the board UI: BYO agents use the project-scoped slug, such as `OVS-57`, in messages, comments, PR descriptions, blockers, and results. Numeric `project_id` and `ticket_id` values remain internal MCP arguments and are never used as human-facing ticket names. If an older backend omits the slug, the agent uses the ticket title rather than inventing one.
|
|
93
93
|
|
|
@@ -112,7 +112,7 @@ The helper `openvisio-agent push-pr-branch` accepts no arguments. It pushes only
|
|
|
112
112
|
|
|
113
113
|
Authorization recovery checks paused repositories locally every two seconds and clears both policy and failed-revision state before live ticket verification. It does not need a new watcher or a new chat message. Updating the package still requires the running service to load the new version once.
|
|
114
114
|
|
|
115
|
-
Fresh top-level
|
|
115
|
+
Responses to existing requests stay in their source thread. Fresh top-level results use the agent’s dedicated channel by default; when a teammate explicitly requests a message in another named channel, the agent resolves that destination from live `list_channels` and posts there. The watcher resolves a unique exact identity/name match (including `agent-alex` and `alex-agent`) and never silently falls back to general. Use `connect ... --channel <id>` for a differently named dedicated channel. If a requested or default channel is missing or ambiguous, the agent reports that condition instead of inventing a channel or id.
|
|
116
116
|
|
|
117
117
|
Codex, OpenCode, Gemini CLI, and Qwen Code use Mastra ACP directly. OpenCode starts only for an accepted cycle and exits as soon as that cycle releases it, so the idle watcher does not retain an OpenCode child process. Other runtimes retain work sessions per ticket in a six-entry pool and reply sessions in an eight-entry pool; their idle sessions expire after five minutes, and active sessions cannot be evicted. Git snapshots are asynchronous, lightweight commands lazily load the watcher, and logs separate queue wait, preflight, first-event, and total runtime time. Studio reads watcher liveness from the local PID lock instead of requiring journal heartbeats, and model changes arrive through filesystem notifications. Projects reconcile with concurrency three and enqueue tickets before reading optional activity history; later idle recovery reads activity only. Coordination sessions can update the board without gaining repository access.
|
|
118
118
|
|
package/USER_GUIDE.md
CHANGED
|
@@ -74,7 +74,9 @@ Stopping a watcher interrupts its active work. When you are ready to resume, run
|
|
|
74
74
|
- **Assign repository work:** assign an actionable ticket to the agent in a project with a linked codebase. Include the problem, expected behavior, and how the result should be checked.
|
|
75
75
|
- **Continue a conversation:** reply in the same thread. Keep follow-up instructions concrete, such as “The new test fails on an empty email address; please fix that case.”
|
|
76
76
|
|
|
77
|
-
Replies stay in their source thread. New completion messages use the agent’s dedicated channel. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
|
|
77
|
+
Replies stay in their source thread. New completion messages use the agent’s dedicated channel by default. You can explicitly ask the agent to send or announce something in another named channel; it resolves that destination from the live channel list and confirms the action in the source thread. Check the ticket and any linked pull request for delivered results. A progress message describes ongoing work; wait for the final result or blocker.
|
|
78
|
+
|
|
79
|
+
BYO agents can coordinate directly in a shared thread. Mention an agent once to establish the conversation; concrete peer questions and requests can continue there without repeating the mention. Routine agent status messages do not trigger replies, which prevents automated conversations from looping.
|
|
78
80
|
|
|
79
81
|
For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
|
|
80
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openvisio-agent",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.9",
|
|
4
4
|
"description": "Connect Claude Code, Codex, OpenCode, Gemini CLI, or Qwen Code to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,7 @@ const recipientCases = [
|
|
|
39
39
|
['shared-request', '@Alex and @Atlas please review this.', 'handle'],
|
|
40
40
|
['explicit-shared-request', '@Alex and @Atlas can you both fix this?', 'handle'],
|
|
41
41
|
['delegation-through-self', '@Alex ask @Atlas to review this.', 'handle'],
|
|
42
|
-
['mention-in-email', 'Please send the update to contact@alex.', 'follow-up'],
|
|
42
|
+
['mention-in-email', 'Please send the update to contact@alex.', 'peer-follow-up'],
|
|
43
43
|
['email-to-other-person', 'Please use daniel@atlas.example for contact.', 'follow-up'],
|
|
44
44
|
['longer-handle', '@Alexandra please review.', 'ignore'],
|
|
45
45
|
['dotted-different-handle', '@Alex.morgan please review.', 'ignore'],
|
|
@@ -50,6 +50,7 @@ for (const [caseId, content, policy] of recipientCases) {
|
|
|
50
50
|
for (const [senderId, sender, agent] of senders) {
|
|
51
51
|
for (const owned of [false, true]) {
|
|
52
52
|
const expected = policy === 'follow-up' ? (!agent && owned ? 'handle' : 'ignore')
|
|
53
|
+
: policy === 'peer-follow-up' ? (owned ? 'handle' : 'ignore')
|
|
53
54
|
: policy === 'stop-follow-up' ? (!agent && owned ? 'stand_down' : 'ignore') : policy
|
|
54
55
|
add(`recipient-${caseId}-${senderId}-${owned ? 'owned' : 'unowned'}`, 'recipient', `${caseId}: ${senderId} in ${owned ? 'an owned' : 'an unowned'} thread must ${expected}.`, () => {
|
|
55
56
|
assert.equal(classifyConversationTarget({ id: 101, parent_id: 100, content, ...sender }, aliases, { threadOwned: owned }).action, expected)
|
|
@@ -219,6 +220,11 @@ add('completed-ticket-status-does-not-reopen', 'assignment-intent', 'A status qu
|
|
|
219
220
|
await routeAssignments({ request: { slugs: ['OPEN-77'] }, loadTickets: async () => [{ id: 77, projectId: 1, slug: 'OPEN-77', status: 'Done' }], handleTask: async () => { dispatched++ } })
|
|
220
221
|
assert.equal(dispatched, 0)
|
|
221
222
|
})
|
|
223
|
+
add('peer-onboarding-does-not-become-ticket-inventory', 'assignment-intent', 'A peer onboarding statement and later coordination question are not combined into a pending-ticket request.', () => {
|
|
224
|
+
const text = 'I currently have zero assigned tickets. Which repos are mine, and where do routine updates get posted?'
|
|
225
|
+
assert.equal(conversationAsksPendingTickets(text), false)
|
|
226
|
+
assert.equal(classifyConversationTarget({ content: text, senderAgent: { id: 9 } }, aliases, { threadOwned: true }).reason, 'owned-peer-follow-up')
|
|
227
|
+
})
|
|
222
228
|
|
|
223
229
|
const ticketStates = [
|
|
224
230
|
['actionable', { status: 'In Progress' }, true],
|
|
@@ -16,18 +16,19 @@ async function eventually(predicate, description) {
|
|
|
16
16
|
assert.fail(`Watcher did not reach: ${description}`)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getTicket, listChannels, listActivity, transportRecoveryDelays, activityReporter } = {}) {
|
|
19
|
+
export function fixture({ agent = 'codex', name, task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self, listAgents, getAgent, getTicket, listChannels, listActivity, transportRecoveryDelays, activityReporter } = {}) {
|
|
20
20
|
const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
|
|
21
21
|
const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
|
|
22
22
|
const ticket = { id: 77, project_id: 1, slug: 'OPEN-77', title: 'Repair the component', agent_id: 7, status: 'In Progress', type_id: 1, updated_at: 'revision-1' }
|
|
23
23
|
let watcher, connection, messageId = 100, closed = 0
|
|
24
24
|
const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
|
|
25
25
|
const mcp = {
|
|
26
|
-
listTools: async () => ['list_agents', 'list_projects', 'list_boards', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name, ...(name === 'list_task_types' ? { inputSchema: { required: ['project_id', 'board_id'], properties: { project_id: {}, board_id: {} } } } : {}) })),
|
|
26
|
+
listTools: async () => ['list_agents', 'get_agent', 'list_projects', 'list_boards', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name, ...(name === 'list_task_types' ? { inputSchema: { required: ['project_id', 'board_id'], properties: { project_id: {}, board_id: {} } } } : {}) })),
|
|
27
27
|
callTool: async (name, args = {}) => {
|
|
28
28
|
calls.push({ name, args })
|
|
29
29
|
if (commentTools.includes(name)) return onComment ? await onComment(name, args) : { id: 500 }
|
|
30
30
|
if (name === 'list_agents') return listAgents ? await listAgents() : { agents: [selfProfile] }
|
|
31
|
+
if (name === 'get_agent') { assert.equal(args.agent_id, Number(selfProfile.id)); return { agent: getAgent ? await getAgent(args.agent_id) : selfProfile } }
|
|
31
32
|
if (name === 'list_projects') return { projects: [{ id: 1, name: 'Workspace' }] }
|
|
32
33
|
if (name === 'list_tasks') return { tasks: task ? [{ ...ticket }] : [] }
|
|
33
34
|
if (name === 'list_boards') return { boards: [{ id: 4 }] }
|
|
@@ -90,6 +91,10 @@ export function fixture({ agent = 'codex', name, task = true, run, threadError =
|
|
|
90
91
|
messages.push(message)
|
|
91
92
|
await watcher.receive('agent:mention', { channel_id: channel, message })
|
|
92
93
|
},
|
|
94
|
+
async taskComment(content, { id = 300, parent, sender = human, projectId = 1, taskId = 77 } = {}) {
|
|
95
|
+
const comment = { id, task_id: taskId, content, taskSender: sender, ...(parent != null ? { parent_id: parent } : {}) }
|
|
96
|
+
await watcher.receive('task:comment', { project_id: projectId, task_id: taskId, comment })
|
|
97
|
+
},
|
|
93
98
|
async cleanup() { await watcher.close(); rmSync(dir, { recursive: true, force: true }) },
|
|
94
99
|
}
|
|
95
100
|
}
|
package/scripts/certify.mjs
CHANGED
|
@@ -52,10 +52,12 @@ const assertions = [
|
|
|
52
52
|
['new follow-ups only deduplicate later replies', watcher.includes('repliesAfterSource(renderedAgentMessages') && watcher.includes('sourceMessageId: mid')],
|
|
53
53
|
['Mastra snapshots do not block the event loop', mastraHarness.includes('await execFileAsync') && !mastraHarness.includes('spawnSync')],
|
|
54
54
|
['OpenCode exits after work while the event watcher stays idle', runtimeProviders.includes("id: 'opencode'") && runtimeProviders.includes('idleSessionMs: 0') && watcher.includes('idleMs: runtimeIdleMs')],
|
|
55
|
-
['healthy backend watchers
|
|
55
|
+
['healthy backend watchers do not poll MCP activity while idle', watcher.includes('onConnect: () =>') && watcher.includes('void reconcileBacklog()') && !watcher.includes('activityRecoveryTimer') && !watcher.includes('replayRecentActivity') && !watcher.includes("callMcpTool('list_activity'") && !watcher.includes('taskProbeTimer') && !watcher.includes('taskProbeStartTimer')],
|
|
56
56
|
['idle watchers use event notifications without journal or settings polling', watcher.includes('watchFs(stateDir') && !watcher.includes('modelSettingsTimer') && !journal.includes("emit('runtime.heartbeat'")],
|
|
57
|
-
['
|
|
57
|
+
['connection recovery scans assignments without an activity-feed pass', watcher.includes('const reconcileBacklog') && watcher.includes("await handleTaskSignal('task:assigned'") && !watcher.includes('reconcileActivity')],
|
|
58
58
|
['task:assigned is handled', watcher.includes("k === 'task:assigned'")],
|
|
59
|
+
['task creation and update fanout use the assignment verifier', watcher.includes("k === 'task:created'") && watcher.includes("k === 'task:updated'") && watcher.includes('return handleTaskSignal(k, raw)')],
|
|
60
|
+
['compact agent records are hydrated before organization subscription', watcher.includes("callMcpTool('get_agent', { agent_id: selfAgentId })") && watcher.includes('selfAgentId != null && selfOrganizationId == null')],
|
|
59
61
|
['task signals are verified with get_ticket', watcher.includes("callMcpTool('get_ticket'")],
|
|
60
62
|
['review and testing handoffs do not restart work', watcher.includes('taskIsAwaitingReview(task, reviewIds)') && watcher.includes('taskIsAwaitingReview(ticket, typeSets.review)')],
|
|
61
63
|
['review handoff releases the task key for future rework', watcher.includes('seenTasks.delete(taskKey)') && watcher.includes('seenTasks.delete(key)')],
|
|
@@ -63,15 +65,16 @@ const assertions = [
|
|
|
63
65
|
['agent results are delivered independently of ticket completion', watcher.includes('agentFinal: true') && watcher.includes('pendingAgentResults')],
|
|
64
66
|
['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
|
|
65
67
|
['optional ticket comments cannot block verified completion', watcher.includes('callOptionalTicketComment({ projectId, ticketId, content: report.content })') && watcher.includes('no ticket comment tool is exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
|
|
66
|
-
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'
|
|
68
|
+
['ticket comments prefer the advertised modern schema with legacy compatibility', watcher.includes("names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''") && watcher.includes("name === 'create_task_comment'") && watcher.includes("{ parent_id: Number(parentId) }") && watcher.includes("{ text: content }")],
|
|
67
69
|
['uncertain comment writes never trigger duplicate alternate ticket writes', watcher.includes('never try another comment endpoint after an error') && watcher.includes('no alternate ticket write will be attempted') && !watcher.includes("reason: 'not-available'")],
|
|
68
70
|
['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
|
|
69
71
|
['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
|
|
70
|
-
['websocket
|
|
71
|
-
['
|
|
72
|
-
['
|
|
72
|
+
['websocket mention delivery uses a persistent replay guard', watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
|
|
73
|
+
['conversation wake events preserve owned-thread follow-ups and filter other recipients before model start', watcher.includes("memory.has(controlKey, 'active')") && watcher.includes('classifyConversationTarget(msg, [...selfAliases], { threadOwned })') && events.includes("'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
|
|
74
|
+
['owned threads admit concrete peer requests without opening agent chatter loops', events.includes("reason: peerFollowUp ? 'owned-peer-follow-up'") && watcher.includes('This request was authored by another team agent') && watcher.includes('do not append a reciprocal question')],
|
|
75
|
+
['pending-ticket intent is scoped to one clause', events.includes("text.split(/(?<=[.!?])") && events.includes('return tasks && pending && question')],
|
|
73
76
|
['prematurely acknowledged owned-thread replies recover exactly once', watcher.includes("!threadOwned || memory.has(sourceKey, 'received')") && watcher.includes('agent:mention replay recovered for active owned thread')],
|
|
74
|
-
['
|
|
77
|
+
['task comments use assigned-ticket verification and watcher-owned threaded delivery', watcher.includes("k === 'task:comment'") && watcher.includes('taskCommentFromEvent(raw)') && watcher.includes('postTaskCommentOnce') && watcher.includes("surface: 'task-comment'") && watcher.includes("['post_message', 'create_task_comment', 'comment_ticket']")],
|
|
75
78
|
['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
|
|
76
79
|
['agent-authored roots establish ownership and unaddressed legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('const context = resolveOwnThread(cid, parent)') && watcher.includes('})]) : await context') && watcher.includes('_threadRootContent: rootContent')],
|
|
77
80
|
['stand-down and redirects cancel only source-thread workers', watcher.includes('cancelThread(cid, threadRoot') && watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent') && watcher.includes("subtype === 'canceled'")],
|
|
@@ -79,7 +82,7 @@ const assertions = [
|
|
|
79
82
|
['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
|
|
80
83
|
['human-facing ticket references require slugs, never database ids', watcher.includes('TICKET SLUGS, NEVER DATABASE IDS') && watcher.includes('ticketDisplaySlug(task)') && events.includes('export function ticketDisplaySlug') && !events.includes('I finished ticket #${task.id}')],
|
|
81
84
|
['rendered backend replies are checked before every guarded thread post', watcher.includes("callMcpTool('list_message_thread'") && watcher.includes('renderedAgentMessages(live') && watcher.includes('same-content-rendered')],
|
|
82
|
-
['
|
|
85
|
+
['guarded replies cannot race the watcher while explicit channel delivery remains available', watcher.includes("delivery?.watcherOwned && !delivery?.allowChannelMessaging") && watcher.includes('conversationRequestsChannelMessage(text)') && watcher.includes("delivery?.surface === 'task-comment'") && codexConfig.includes('disabled_tools = [')],
|
|
83
86
|
['ACP sessions receive the authenticated OpenVisio proxy', mastraHarness.includes("name: 'openvisio-team-watcher'") && mastraHarness.includes('OPENVISIO_CODEX_MCP_URL')],
|
|
84
87
|
['Codex discovers deferred OpenVisio actions before claiming they are missing', watcher.includes('Codex may defer MCP actions') && watcher.includes('use tool_search to find that exact action') && watcher.includes('initial-list miss is NOT evidence that the server is disconnected')],
|
|
85
88
|
['Codex authentication is injected outside the model', codexConfig.includes('OPENVISIO_CODEX_API_KEY') && codexProxy.includes('toolWithoutCredentialInputs') && codexProxy.includes('client.callTool') && watcher.includes('local OpenVisio MCP bridge injects authentication outside the model')],
|
package/src/events.mjs
CHANGED
|
@@ -21,6 +21,23 @@ export function taskFromEvent(payload) {
|
|
|
21
21
|
return null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Task-comment events use the same rolling envelope variants as task events,
|
|
25
|
+
// with the comment itself nested under `comment`. Keep transport normalization
|
|
26
|
+
// in one place so the watcher can make ownership and sender decisions from a
|
|
27
|
+
// stable shape.
|
|
28
|
+
export function taskCommentFromEvent(payload) {
|
|
29
|
+
if (!payload || typeof payload !== 'object') return null
|
|
30
|
+
const envelopes = [payload, payload.data, payload.payload].filter((value) => value && typeof value === 'object' && !Array.isArray(value))
|
|
31
|
+
const source = Object.assign({}, ...envelopes.slice().reverse())
|
|
32
|
+
const comment = envelopes.map((value) => value.comment).find((value) => value && typeof value === 'object' && !Array.isArray(value))
|
|
33
|
+
if (!comment) return null
|
|
34
|
+
return {
|
|
35
|
+
projectId: source.project_id ?? source.projectId ?? comment.project_id ?? comment.projectId ?? null,
|
|
36
|
+
taskId: source.task_id ?? source.taskId ?? comment.task_id ?? comment.taskId ?? null,
|
|
37
|
+
comment,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export function taskAgentId(task) {
|
|
25
42
|
if (!task || typeof task !== 'object') return null
|
|
26
43
|
return task.agent_id ?? task.agentId ?? task.agent?.id ?? task.assigned_agent?.id ?? task.assignedAgent?.id ?? null
|
|
@@ -397,8 +414,8 @@ export function normalizeRenderedMessageText(value) {
|
|
|
397
414
|
|
|
398
415
|
export function messageSenderIsSelf(row, identity = {}) {
|
|
399
416
|
if (!row || typeof row !== 'object') return false
|
|
400
|
-
const sender = [row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
|
|
401
|
-
const expanded = [row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
417
|
+
const sender = [row.taskSender, row.task_sender, row.sender, row.user, row.author, row.member].find((item) => item && typeof item === 'object') || {}
|
|
418
|
+
const expanded = [row.taskSenderAgent, row.task_sender_agent, row.senderAgent, row.sender_agent, row.agent, sender.agent].find((item) => item && typeof item === 'object')
|
|
402
419
|
const senderKind = String(sender.type ?? sender.kind ?? sender.sender_type ?? row.sender_type ?? row.author_type ?? '').toLowerCase()
|
|
403
420
|
const senderLooksLikeAgent = agentSenderKind(senderKind) || (!humanSenderKind(senderKind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
|
|
404
421
|
const agent = expanded || (senderLooksLikeAgent ? sender : {})
|
|
@@ -484,8 +501,8 @@ function conversationMentions(value, selfAliases) {
|
|
|
484
501
|
|
|
485
502
|
export function messageSenderIsAgent(message) {
|
|
486
503
|
const m = message && typeof message === 'object' ? message : {}
|
|
487
|
-
if (m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
488
|
-
const sender = [m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
|
|
504
|
+
if (m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent || m.agent_id != null || m.sender_agent_id != null || m.senderAgentId != null) return true
|
|
505
|
+
const sender = [m.taskSender, m.task_sender, m.sender, m.user, m.author, m.member].find((item) => item && typeof item === 'object') || {}
|
|
489
506
|
if (sender.agent && typeof sender.agent === 'object') return true
|
|
490
507
|
const kind = String(sender.type ?? sender.kind ?? sender.sender_type ?? m.sender_type ?? m.author_type ?? '').toLowerCase()
|
|
491
508
|
return agentSenderKind(kind) || (!humanSenderKind(kind) && (sender.agent_id != null || sender.identifier != null || (sender.slug != null && !sender.email)))
|
|
@@ -506,7 +523,17 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
|
|
|
506
523
|
|
|
507
524
|
if (!text) return { action: 'ignore', reason: 'empty-message', text, explicitSelf, otherMentions: [] }
|
|
508
525
|
|
|
509
|
-
|
|
526
|
+
// A peer agent may continue a conversation without repeating @name on every
|
|
527
|
+
// turn. Admit only a concrete question or request in a thread this watcher
|
|
528
|
+
// already owns; routine agent status chatter remains silent. This keeps BYO
|
|
529
|
+
// collaboration usable without turning every shared-channel post into an
|
|
530
|
+
// agent-to-agent reply loop.
|
|
531
|
+
const peerFollowUp = messageSenderIsAgent(m) && !explicitSelf && threadOwned && (
|
|
532
|
+
/\?/.test(text) ||
|
|
533
|
+
/\b(?:can|could|would|will|do|did|are|have)\s+you\b/i.test(text) ||
|
|
534
|
+
/^\s*(?:thanks[,!.]?\s+)?(?:please\s+)?(?:review|check|confirm|explain|share|send|post|help|continue|take|handle|onboard)\b/i.test(text)
|
|
535
|
+
)
|
|
536
|
+
if (messageSenderIsAgent(m) && !explicitSelf && !peerFollowUp) {
|
|
510
537
|
return { action: 'ignore', reason: 'other-agent-chatter', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
511
538
|
}
|
|
512
539
|
if (!explicitSelf && otherMentions.length) {
|
|
@@ -531,7 +558,7 @@ export function classifyConversationTarget(message, selfAliases, { threadOwned =
|
|
|
531
558
|
// chatter were handled above and still fail closed.
|
|
532
559
|
if (!explicitSelf) {
|
|
533
560
|
if (threadOwned) {
|
|
534
|
-
return { action: 'handle', reason: 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
561
|
+
return { action: 'handle', reason: peerFollowUp ? 'owned-peer-follow-up' : 'owned-thread-follow-up', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
535
562
|
}
|
|
536
563
|
return { action: 'ignore', reason: 'unaddressed-thread-activity', text, explicitSelf, otherMentions: otherMentions.map((item) => item.name) }
|
|
537
564
|
}
|
|
@@ -557,12 +584,30 @@ export function conversationNeedsCode(value, { rootContent = '' } = {}) {
|
|
|
557
584
|
|
|
558
585
|
export function conversationAsksPendingTickets(value) {
|
|
559
586
|
// Project prefixes such as OPEN-77 are identifiers, not the word “open”.
|
|
560
|
-
// A question about a finished ticket must not become a backlog pickup.
|
|
587
|
+
// A question about a finished ticket must not become a backlog pickup. Keep
|
|
588
|
+
// all three signals in the same sentence/clause so an onboarding message that
|
|
589
|
+
// states "I have zero assigned tickets" and later asks about team cadence does
|
|
590
|
+
// not get hijacked into the pending-ticket shortcut.
|
|
561
591
|
const text = String(value || '').replace(/\b[a-z][a-z0-9]*-\d+\b/gi, '').replace(/\s+/g, ' ').trim()
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
592
|
+
return text.split(/(?<=[.!?])\s+|[;\n]+/).some((clause) => {
|
|
593
|
+
const tasks = /\b(?:ticket|tickets|task|tasks)\b/i.test(clause)
|
|
594
|
+
const pending = /\b(?:pending|open|assigned|backlog|outstanding|in[ -]?progress|working on)\b/i.test(clause)
|
|
595
|
+
const question = /\?|\b(?:what|which|how many|do you|have you|are there|show|list|check|verify|tell me|asked|whether|can you|could you|would you)\b/i.test(clause)
|
|
596
|
+
return tasks && pending && question
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// A guarded reply normally has post_message removed so the runtime cannot race
|
|
601
|
+
// the watcher's single source-thread delivery. Keep that guard unless the human
|
|
602
|
+
// explicitly asks the agent to publish into a named channel. The destination is
|
|
603
|
+
// still resolved from live list_channels by the runtime; this only identifies
|
|
604
|
+
// the narrow class of requests that needs the posting tool.
|
|
605
|
+
export function conversationRequestsChannelMessage(value) {
|
|
606
|
+
const text = String(value || '')
|
|
607
|
+
const action = /\b(?:send|post|message|announce|notify|share|write)\b/i.test(text)
|
|
608
|
+
const channel = /#[a-z0-9][a-z0-9_-]*\b/i.test(text) || /\b(?:in|into|on|to)\s+(?:the\s+)?(?:channel\s+)?[a-z0-9][a-z0-9 _-]{0,80}\s+channel\b/i.test(text) || /\bchannel\s+(?:named\s+|called\s+)?["'`#]?[a-z0-9][a-z0-9 _-]*/i.test(text)
|
|
609
|
+
const negated = /\b(?:do\s+not|don't|dont|never)\s+(?:send|post|message|announce|notify|share|write)\b/i.test(text)
|
|
610
|
+
return action && channel && !negated
|
|
566
611
|
}
|
|
567
612
|
|
|
568
613
|
// Filing a ticket is a team mutation, even when its description asks for code.
|
package/src/watch.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
10
10
|
import { join, dirname } from 'node:path'
|
|
11
11
|
import { OV_DIR, DEFAULT_WORKSPACE, readConfig, writeJson, configPath, onPath, fail, ok, info, slugify, stripSlash, chmodSafe } from './lib.mjs'
|
|
12
12
|
import { connectAgentWs, assertWebSocket } from './ws.mjs'
|
|
13
|
-
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
13
|
+
import { buildTaskCompletionReport, claudeEventEvidence, classifyConversationTarget, conversationAsksPendingTickets, conversationCreatesTicket, conversationNeedsCode, conversationRequestsChannelMessage, failedTaskRevisionIsCurrent, mentionDedupeKeys, messageParentId, messageSenderIsAgent, messageSenderIsSelf, normalizeRenderedMessageText, renderedAgentMessages, taskAgentId, taskAgentIdentifier, taskBelongsToAgent, taskCommentFromEvent, taskFromEvent, taskIsAwaitingReview, taskIsCompleted, taskIsCoordinationOnly, taskRevision, ticketDisplaySlug } from './events.mjs'
|
|
14
14
|
import { WORK_SESSION_TOOL, workSessionRequest, agentProfileContext } from './runtime-control.mjs'
|
|
15
15
|
import { createMastraMemory } from './memory.mjs'
|
|
16
16
|
import { createContextService } from './context-service.mjs'
|
|
@@ -87,7 +87,7 @@ const REPLY_DISCIPLINE = [
|
|
|
87
87
|
' • 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_projects + list_tasks and then get_ticket as needed. Use list_agents only if assignment data requires a numeric identity lookup. 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.',
|
|
88
88
|
' • 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.',
|
|
89
89
|
' • 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.',
|
|
90
|
-
' •
|
|
90
|
+
' • HONOR CHANNEL DESTINATIONS. Reply to an existing request in its original thread. When a teammate explicitly asks you to send, post, announce, or share something in another channel, resolve that exact destination from live list_channels and post there. Use your dedicated agent channel for fresh top-level results, blockers, and introductions only when no other destination was requested. Never invent a channel id or silently substitute a different channel.',
|
|
91
91
|
' • WRITE PLAINLY. Prefer short sentences, commas, periods, and colons. Avoid em dashes except when reproducing quoted text.',
|
|
92
92
|
].join('\n')
|
|
93
93
|
|
|
@@ -732,9 +732,18 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
732
732
|
}),
|
|
733
733
|
reply: createCycleQueue({
|
|
734
734
|
concurrency: 3,
|
|
735
|
-
groupKey: (item) => item.taskRef
|
|
735
|
+
groupKey: (item) => item.taskRef
|
|
736
|
+
? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}`
|
|
737
|
+
: item.delivery?.surface === 'task-comment'
|
|
738
|
+
? `ticket:${item.delivery.projectId}:${item.delivery.ticketId}:comments`
|
|
739
|
+
: `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`,
|
|
736
740
|
run: async (item) => {
|
|
737
|
-
const
|
|
741
|
+
const source = item.taskRef
|
|
742
|
+
? `ticket:${item.taskRef.projectId}:${item.taskRef.ticketId}`
|
|
743
|
+
: item.delivery?.surface === 'task-comment'
|
|
744
|
+
? `ticket:${item.delivery.projectId}:${item.delivery.ticketId}:comments`
|
|
745
|
+
: `thread:${item.delivery?.channelId}:${item.delivery?.parentId}`
|
|
746
|
+
const key = JSON.stringify([source, item.kind === 'coord'])
|
|
738
747
|
const runner = replyRunners.acquire(key, { key, canCoordinate: item.kind === 'coord' })
|
|
739
748
|
activeReplyRunners.add(runner)
|
|
740
749
|
item.control.runner = runner
|
|
@@ -760,7 +769,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
760
769
|
try { replayState = JSON.parse(readFileSync(replayPath, 'utf8')) } catch { /* first run */ }
|
|
761
770
|
const seenMentions = new Set(Array.isArray(replayState.seenMentions) ? replayState.seenMentions : [])
|
|
762
771
|
const recentMentionSignatures = new Map(Array.isArray(replayState.recentMentionSignatures) ? replayState.recentMentionSignatures : [])
|
|
763
|
-
const seenActivities = new Set(Array.isArray(replayState.seenActivities) ? replayState.seenActivities : [])
|
|
764
772
|
const deliveredReplies = new Set(Array.isArray(replayState.deliveredReplies) ? replayState.deliveredReplies : [])
|
|
765
773
|
const pendingReplyDeliveries = new Map(Array.isArray(replayState.pendingReplyDeliveries) ? replayState.pendingReplyDeliveries : [])
|
|
766
774
|
const memory = dependencies.memory || createMastraMemory({
|
|
@@ -808,7 +816,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
808
816
|
writeJson(replayPath, {
|
|
809
817
|
seenMentions: [...seenMentions],
|
|
810
818
|
recentMentionSignatures: [...recentMentionSignatures],
|
|
811
|
-
seenActivities: [...seenActivities],
|
|
812
819
|
deliveredReplies: [...deliveredReplies],
|
|
813
820
|
pendingReplyDeliveries: [...pendingReplyDeliveries],
|
|
814
821
|
failedTaskVersions: [...failedTaskVersions],
|
|
@@ -898,8 +905,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
898
905
|
// hoping poll_inbox re-surfaces the same item. Each queue entry retains that
|
|
899
906
|
// source independently through work, continuation, and delivery.
|
|
900
907
|
let backlogProbeBusy = false
|
|
901
|
-
let activityProbeBusy = false
|
|
902
|
-
let knownProjects = []
|
|
903
908
|
let selfAgentId = null
|
|
904
909
|
let selfOrganizationId = null
|
|
905
910
|
let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
|
|
@@ -944,15 +949,27 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
944
949
|
let selfRead = null
|
|
945
950
|
let selfReadAfter = 0
|
|
946
951
|
const ensureSelfAgent = async () => {
|
|
947
|
-
if (selfAgentId != null) return
|
|
952
|
+
if (selfAgentId != null && selfOrganizationId != null) return
|
|
948
953
|
if (!selfRead && Date.now() < selfReadAfter) return
|
|
949
|
-
if (!selfRead) selfRead =
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
954
|
+
if (!selfRead) selfRead = (async () => {
|
|
955
|
+
if (selfAgentId == null) {
|
|
956
|
+
const data = toolData(await callMcpTool('list_agents'), 'list_agents')
|
|
957
|
+
const agents = Array.isArray(data.agents) ? data.agents : []
|
|
958
|
+
const identified = agents.find((row) => String(row?.identifier || row?.slug || '') === identifier)
|
|
959
|
+
const named = agents.filter((row) => !row?.identifier && !row?.slug && String(row?.name || '').trim().toLowerCase() === String(name || '').trim().toLowerCase())
|
|
960
|
+
rememberSelfAgent(identified || (named.length === 1 ? named[0] : null))
|
|
961
|
+
}
|
|
962
|
+
// Compact list_agents responses can contain only id + name. Hydrate that
|
|
963
|
+
// authenticated match so organization fanout can be subscribed without
|
|
964
|
+
// trusting organization data from an incoming event.
|
|
965
|
+
if (selfAgentId != null && selfOrganizationId == null) {
|
|
966
|
+
const names = await discoverMcpTools()
|
|
967
|
+
if (names.has('get_agent')) {
|
|
968
|
+
const detail = toolData(await callMcpTool('get_agent', { agent_id: selfAgentId }), 'get_agent')
|
|
969
|
+
rememberSelfAgent(detail.agent ?? detail.data?.agent ?? detail)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
})().catch((error) => {
|
|
956
973
|
log('Agent profile unavailable; continuing with task ownership and configured aliases: ' + (error?.message || error))
|
|
957
974
|
}).finally(() => { selfRead = null; selfReadAfter = Date.now() + 60_000 })
|
|
958
975
|
await selfRead
|
|
@@ -981,7 +998,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
981
998
|
}).finally(() => { mcpToolDiscoveryPromise = null })
|
|
982
999
|
return mcpToolDiscoveryPromise
|
|
983
1000
|
}
|
|
984
|
-
const callOptionalTicketComment = async ({ projectId, ticketId, content }) => {
|
|
1001
|
+
const callOptionalTicketComment = async ({ projectId, ticketId, content, parentId }) => {
|
|
985
1002
|
let names
|
|
986
1003
|
try { names = await discoverMcpTools() }
|
|
987
1004
|
catch (e) {
|
|
@@ -995,7 +1012,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
995
1012
|
// reached the server, so never try another comment endpoint after an error.
|
|
996
1013
|
const name = names.has('create_task_comment') ? 'create_task_comment' : names.has('comment_ticket') ? 'comment_ticket' : ''
|
|
997
1014
|
if (!name) return { called: false, reason: 'not-advertised' }
|
|
998
|
-
const args = {
|
|
1015
|
+
const args = {
|
|
1016
|
+
project_id: projectId,
|
|
1017
|
+
ticket_id: ticketId,
|
|
1018
|
+
...(name === 'create_task_comment'
|
|
1019
|
+
? { content, ...(validBackendId(parentId) ? { parent_id: Number(parentId) } : {}) }
|
|
1020
|
+
: { text: content }),
|
|
1021
|
+
}
|
|
999
1022
|
try { return { called: true, tool: name, result: await callMcpTool(name, args) } }
|
|
1000
1023
|
catch (e) {
|
|
1001
1024
|
if (/\b(?:unknown|missing|unsupported) tool\b|\btool\b.*\bnot found\b/i.test(String(e?.message || e))) {
|
|
@@ -1132,7 +1155,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1132
1155
|
const postMessageOnce = async ({ key, channelId, parentId, projectId, content, skipIfAnyAgentReply = false, sourceKey = '', sourceMessageId, allowCancelled = false, isCancelled = () => false }) => {
|
|
1133
1156
|
if (stopping) throw new Error('Watcher is stopping')
|
|
1134
1157
|
const deliveryKey = String(key || '')
|
|
1135
|
-
if (parentId == null) {
|
|
1158
|
+
if (parentId == null && !validBackendId(channelId)) {
|
|
1136
1159
|
channelId = await projectStatusChannel(projectId)
|
|
1137
1160
|
if (channelId == null) throw new Error('No dedicated agent channel available for a new thread')
|
|
1138
1161
|
projectId = undefined // dedicated channels may be organization-scoped
|
|
@@ -1206,6 +1229,34 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1206
1229
|
return run
|
|
1207
1230
|
}
|
|
1208
1231
|
|
|
1232
|
+
// Task comments have no channel thread to inspect before delivery. Record the
|
|
1233
|
+
// delivery attempt before the mutation and use the comment id as the one-level
|
|
1234
|
+
// thread root. An ambiguous mutation error is never replayed automatically.
|
|
1235
|
+
const postTaskCommentOnce = async ({ key, projectId, ticketId, parentId, content, sourceKey = '' }) => {
|
|
1236
|
+
if (stopping) throw new Error('Watcher is stopping')
|
|
1237
|
+
const deliveryKey = String(key || '')
|
|
1238
|
+
const message = String(content || '').trim()
|
|
1239
|
+
if (!deliveryKey || !validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(parentId) || !message) return { posted: false, reason: 'invalid-delivery' }
|
|
1240
|
+
if (deliveredReplies.has(deliveryKey) || memory.has(deliveryKey, 'rendered')) {
|
|
1241
|
+
return { posted: false, reason: 'remembered' }
|
|
1242
|
+
}
|
|
1243
|
+
if (messageDeliveries.has(deliveryKey)) return messageDeliveries.get(deliveryKey)
|
|
1244
|
+
deliveredReplies.add(deliveryKey); trimSeen(deliveredReplies)
|
|
1245
|
+
persistReplay()
|
|
1246
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'attempted', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
|
|
1247
|
+
const run = (async () => {
|
|
1248
|
+
const result = await callOptionalTicketComment({ projectId: Number(projectId), ticketId: Number(ticketId), parentId: Number(parentId), content: message })
|
|
1249
|
+
if (!result.called) {
|
|
1250
|
+
throw new Error('No task-comment tool is advertised by the backend')
|
|
1251
|
+
}
|
|
1252
|
+
memory.remember({ key: deliveryKey, kind: 'delivery', state: 'rendered', summary: message, refs: { projectId: Number(projectId), ticketId: Number(ticketId), threadId: Number(parentId) }, meta: { surface: 'task-comment' } })
|
|
1253
|
+
if (sourceKey) memory.connect(deliveryKey, sourceKey, 'responds_to')
|
|
1254
|
+
return { posted: true, result: result.result }
|
|
1255
|
+
})().finally(() => messageDeliveries.delete(deliveryKey))
|
|
1256
|
+
messageDeliveries.set(deliveryKey, run)
|
|
1257
|
+
return run
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1209
1260
|
async function publishWorkPlan(live, entries, acknowledgement = '') {
|
|
1210
1261
|
const { control, delivery, taskRef } = live
|
|
1211
1262
|
const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
|
|
@@ -1233,7 +1284,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1233
1284
|
deliveryProbeBusy = true
|
|
1234
1285
|
try {
|
|
1235
1286
|
await mapConcurrent([...pendingReplyDeliveries.values()], 3, async (delivery) => {
|
|
1236
|
-
try { await postMessageOnce(delivery) }
|
|
1287
|
+
try { await (delivery.surface === 'task-comment' ? postTaskCommentOnce(delivery) : postMessageOnce(delivery)) }
|
|
1237
1288
|
catch (error) { log('reply remains pending: ' + (error?.message || error)) }
|
|
1238
1289
|
})
|
|
1239
1290
|
} finally { deliveryProbeBusy = false }
|
|
@@ -1426,87 +1477,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1426
1477
|
return publishBlocker({ prompt, taskRef, delivery, notice, ticketNotice, pause: true })
|
|
1427
1478
|
}
|
|
1428
1479
|
|
|
1429
|
-
const replayRecentActivity = async (projects) => {
|
|
1430
|
-
const mentionActivity = []
|
|
1431
|
-
let activityReplayTouched = false
|
|
1432
|
-
await ensureSelfAgent()
|
|
1433
|
-
const mentionNeedles = [...selfAliases].map((alias) => '@' + alias)
|
|
1434
|
-
await mapConcurrent(projects, 3, async (project) => {
|
|
1435
|
-
const activityData = await callMcpTool('list_activity', { project_id: project.id }).then(toolData).catch((error) => {
|
|
1436
|
-
log('activity reconciliation unavailable: ' + (error?.message || error))
|
|
1437
|
-
return {}
|
|
1438
|
-
})
|
|
1439
|
-
const activities = Array.isArray(activityData.activities) ? activityData.activities : Array.isArray(activityData.activity) ? activityData.activity : []
|
|
1440
|
-
for (const item of activities) {
|
|
1441
|
-
const text = JSON.stringify(item)
|
|
1442
|
-
const activityKey = String(project.id) + ':' + String(item.id ?? item.message_id ?? item.messageId ?? text.slice(0, 500))
|
|
1443
|
-
const data = item?.data && typeof item.data === 'object' ? item.data : null
|
|
1444
|
-
const activityMessage = item?.message && typeof item.message === 'object'
|
|
1445
|
-
? item.message
|
|
1446
|
-
: data?.message && typeof data.message === 'object' ? data.message : item
|
|
1447
|
-
const activityText = String(activityMessage?.content ?? activityMessage?.body ?? activityMessage?.text ?? '').toLowerCase()
|
|
1448
|
-
const activityChannelId = item?.channel_id ?? item?.channelId ?? data?.channel_id ?? data?.channelId
|
|
1449
|
-
const activityMessageId = activityMessage?.id ?? activityMessage?.message_id ?? activityMessage?.messageId
|
|
1450
|
-
const activityParentId = messageParentId(activityMessage)
|
|
1451
|
-
const activityThreadRoot = activityParentId != null ? activityParentId : activityMessageId
|
|
1452
|
-
const activityControlKey = threadControlKey(activityChannelId, activityThreadRoot)
|
|
1453
|
-
const ownedActivity = !!activityControlKey && memory.has(activityControlKey, 'active')
|
|
1454
|
-
// Search only the source message, never the whole activity envelope: an
|
|
1455
|
-
// old tagged thread root can otherwise make another agent's new reply
|
|
1456
|
-
// look addressed to this watcher. Explicit mentions and human follow-ups
|
|
1457
|
-
// in a persistently owned thread both enter the guarded per-message path.
|
|
1458
|
-
const addressedActivity = mentionNeedles.some((needle) => activityText.includes(needle)) || ownedActivity || activityParentId != null
|
|
1459
|
-
if (!seenActivities.has(activityKey) && addressedActivity && /message|mention|channel/i.test(text)) {
|
|
1460
|
-
seenActivities.add(activityKey); trimSeen(seenActivities); activityReplayTouched = true
|
|
1461
|
-
// The same logical mention may already have arrived over WebSocket.
|
|
1462
|
-
// Share the id/signature guard instead of starting a second model turn.
|
|
1463
|
-
// Recover the former pre-routing acknowledgement bug exactly once
|
|
1464
|
-
// when an owned-thread source has no received ledger entry.
|
|
1465
|
-
if (markMentionHandled(activityMessage, activityChannelId)) {
|
|
1466
|
-
const activityMentionKeys = mentionDedupeKeys(activityMessage, activityChannelId)
|
|
1467
|
-
const activitySourceKey = `mention:${activityChannelId ?? '?'}:${activityMentionKeys.idKey || activityMentionKeys.signatureKey || activityThreadRoot}`
|
|
1468
|
-
if (memory.has(activitySourceKey, 'received')) continue
|
|
1469
|
-
}
|
|
1470
|
-
mentionActivity.push({
|
|
1471
|
-
projectId: project.id,
|
|
1472
|
-
project: project.name,
|
|
1473
|
-
channelId: activityChannelId,
|
|
1474
|
-
message: activityMessage,
|
|
1475
|
-
activity: item, activityKey,
|
|
1476
|
-
})
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
})
|
|
1480
|
-
if (activityReplayTouched) persistReplay()
|
|
1481
|
-
if (!mentionActivity.length) return
|
|
1482
|
-
log('activity recovery found ' + mentionActivity.length + ' mention(s) -> guarded reply cycle(s)')
|
|
1483
|
-
// Replay each real message through the exact same delivery path as a live
|
|
1484
|
-
// WebSocket mention. This preserves thread ids and lets postMessageOnce
|
|
1485
|
-
// consult the rendered thread before any reply is emitted.
|
|
1486
|
-
for (const mention of mentionActivity) {
|
|
1487
|
-
void onEvent('agent:mention', {
|
|
1488
|
-
project_id: mention.projectId,
|
|
1489
|
-
channel_id: mention.channelId,
|
|
1490
|
-
message: mention.message,
|
|
1491
|
-
_mentionAlreadyMarked: true,
|
|
1492
|
-
})?.then((result) => {
|
|
1493
|
-
if (result?.status === 'retry') { seenActivities.delete(mention.activityKey); persistReplay() }
|
|
1494
|
-
})
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
// The backend can occasionally omit a targeted WebSocket message. Keep a
|
|
1499
|
-
// cached project list from connection recovery and check only recent activity
|
|
1500
|
-
// while idle. This does not scan projects, boards, task types, or tickets and
|
|
1501
|
-
// does not start a provider process unless a new addressed message is found.
|
|
1502
|
-
const reconcileActivity = async () => {
|
|
1503
|
-
if (stopping || !mcpUrl || backlogProbeBusy || activityProbeBusy || !knownProjects.length) return
|
|
1504
|
-
activityProbeBusy = true
|
|
1505
|
-
try { await replayRecentActivity(knownProjects) }
|
|
1506
|
-
catch (e) { log('activity recovery failed: ' + (e?.message || e)) }
|
|
1507
|
-
finally { activityProbeBusy = false }
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
1480
|
// Reconcile everything that may have arrived while disconnected. This spends no
|
|
1511
1481
|
// model tokens unless the tools actually report pending work.
|
|
1512
1482
|
const reconcileBacklog = async () => {
|
|
@@ -1516,7 +1486,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1516
1486
|
try {
|
|
1517
1487
|
const projectsData = toolData(await callMcpTool('list_projects'), 'list_projects')
|
|
1518
1488
|
const projects = Array.isArray(projectsData.projects) ? projectsData.projects : []
|
|
1519
|
-
knownProjects = projects.map((project) => ({ id: project.id, name: project.name }))
|
|
1520
1489
|
await mapConcurrent(projects, 3, async (project) => {
|
|
1521
1490
|
try {
|
|
1522
1491
|
const assigned = []
|
|
@@ -1568,14 +1537,13 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1568
1537
|
}
|
|
1569
1538
|
} catch (error) { log('Project reconciliation failed: ' + (error?.message || error)) }
|
|
1570
1539
|
})
|
|
1571
|
-
await replayRecentActivity(projects)
|
|
1572
1540
|
} catch (e) {
|
|
1573
1541
|
log('backlog reconciliation failed: ' + (e && e.message ? e.message : e))
|
|
1574
1542
|
} finally { backlogProbeBusy = false }
|
|
1575
1543
|
}
|
|
1576
1544
|
|
|
1577
1545
|
const baseFor = (kind, delivery) => delivery?.watcherOwned
|
|
1578
|
-
? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt)
|
|
1546
|
+
? (kind === 'full' ? fullPrompt + '\n\n' + guardedReplyPrompt : guardedReplyPrompt) + (delivery.surface === 'task-comment' ? '\n\nTASK COMMENT DELIVERY: return the final response for the watcher to post as a reply to the source task comment. Do not call post_message, create_task_comment, or comment_ticket yourself.' : '')
|
|
1579
1547
|
: kind === 'intro' ? INTRO : kind === 'full' ? fullPrompt : (kind === 'coord' || kind === 'sweep') ? coordinatePrompt : fastPrompt
|
|
1580
1548
|
// A native final turn ends a cycle. Ticket completion is a separate board
|
|
1581
1549
|
// action chosen by the agent; tool counts never determine either decision.
|
|
@@ -1600,7 +1568,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1600
1568
|
if (key != null && queues[laneName].has(key)) return queues[laneName].enqueue(null, key)
|
|
1601
1569
|
const cycleId = `${journal.runId}:cycle:${++cycleSequence}`
|
|
1602
1570
|
const control = { cycleId, outcome: 'queued', cancelled: false, runner: null, statusTargets: new Set(), enqueuedAt: performance.now() }
|
|
1603
|
-
const
|
|
1571
|
+
const deliveryTicket = delivery?.surface === 'task-comment' ? { projectId: delivery.projectId, ticketId: delivery.ticketId } : null
|
|
1572
|
+
const cycleData = { cycleId, kind, lane: laneName, model: kind === 'full' ? codeModel : liteModel, workdir: itemWorkdir || (laneName === 'work' ? workdir : ''), ...(taskRef ? { ticket: { projectId: taskRef.projectId, ticketId: taskRef.ticketId } } : deliveryTicket ? { ticket: deliveryTicket } : {}), ...(delivery?.surface !== 'task-comment' && delivery ? { thread: { channelId: delivery.channelId, threadId: delivery.parentId } } : {}) }
|
|
1604
1573
|
liveCycles.set(cycleId, { data: cycleData, control, taskRef, delivery })
|
|
1605
1574
|
emit('cycle.queued', { ...cycleData, status: 'queued' })
|
|
1606
1575
|
return queues[laneName].enqueue({ kind, context, targetChannels, taskRef, delivery, workdir: itemWorkdir, control }, key).then((result) => {
|
|
@@ -1658,9 +1627,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1658
1627
|
const activeTaskRef = taskRef
|
|
1659
1628
|
let verifiedTaskRevision = ''
|
|
1660
1629
|
const ticketWorktree = preparedWorkdir || (kind === 'full' && activeTaskRef ? findTicketWorktree(workdir, activeTaskRef.ticketId) : '')
|
|
1630
|
+
const guardedTools = delivery?.surface === 'task-comment'
|
|
1631
|
+
? ['post_message', 'create_task_comment', 'comment_ticket']
|
|
1632
|
+
: delivery?.watcherOwned && !delivery?.allowChannelMessaging ? ['post_message'] : []
|
|
1661
1633
|
const runnerOptions = {
|
|
1662
1634
|
cycleId: cycleControl.cycleId, attempt: cycleControl.attempt || 1,
|
|
1663
|
-
...(
|
|
1635
|
+
...(guardedTools.length ? { disabledMcpTools: guardedTools } : {}),
|
|
1664
1636
|
...(ticketWorktree ? { workdir: ticketWorktree } : {}),
|
|
1665
1637
|
}
|
|
1666
1638
|
const targets = [...new Set(targetChannels.filter((id) => id != null && Number.isFinite(Number(id))).map(Number))]
|
|
@@ -1671,8 +1643,10 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1671
1643
|
}
|
|
1672
1644
|
// credNote + charter live in the cached system prompt now — the per-cycle
|
|
1673
1645
|
// message is just the event context + the small base instruction.
|
|
1674
|
-
const memoryRefs = delivery
|
|
1675
|
-
? {
|
|
1646
|
+
const memoryRefs = delivery?.surface === 'task-comment'
|
|
1647
|
+
? { projectId: delivery.projectId, ticketId: delivery.ticketId, threadId: delivery.parentId }
|
|
1648
|
+
: delivery
|
|
1649
|
+
? { channelId: delivery.channelId, threadId: delivery.parentId }
|
|
1676
1650
|
: activeTaskRef ? { projectId: activeTaskRef.projectId, ticketId: activeTaskRef.ticketId } : {}
|
|
1677
1651
|
let recalled = ''
|
|
1678
1652
|
try { recalled = await memory.context(memoryRefs) }
|
|
@@ -1800,7 +1774,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1800
1774
|
log('guarded reply produced no final text; leaving delivery unrecorded for retry')
|
|
1801
1775
|
} else {
|
|
1802
1776
|
const deliveryStartedAt = performance.now()
|
|
1803
|
-
try { await postMessageOnce({ ...delivery, content: reply }) }
|
|
1777
|
+
try { await (delivery.surface === 'task-comment' ? postTaskCommentOnce({ ...delivery, content: reply }) : postMessageOnce({ ...delivery, content: reply })) }
|
|
1804
1778
|
catch (e) { log('guarded reply delivery failed closed: ' + (e?.message || e)) }
|
|
1805
1779
|
finally { log(`cycle timing delivery=${Math.round(performance.now() - deliveryStartedAt)}ms kind=${kind}`) }
|
|
1806
1780
|
}
|
|
@@ -1814,9 +1788,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1814
1788
|
// Distill an event's WS payload into a directive the model can act on without
|
|
1815
1789
|
// any extra lookup.
|
|
1816
1790
|
const senderName = (m) => {
|
|
1817
|
-
const s = m && (m.sender || m.user || m.author)
|
|
1791
|
+
const s = m && (m.taskSender || m.task_sender || m.sender || m.user || m.author || m.member || m.taskSenderAgent || m.task_sender_agent || m.senderAgent || m.sender_agent || m.agent)
|
|
1818
1792
|
if (!s) return ''
|
|
1819
|
-
return (`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.name || s.email || '')
|
|
1793
|
+
return (`${s.first_name || s.firstName || ''} ${s.last_name || s.lastName || ''}`.trim() || s.name || s.display_name || s.identifier || s.email || '')
|
|
1820
1794
|
}
|
|
1821
1795
|
|
|
1822
1796
|
// Route only concrete repository requests to the coding lane. Conversation
|
|
@@ -1922,6 +1896,71 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1922
1896
|
}
|
|
1923
1897
|
}
|
|
1924
1898
|
|
|
1899
|
+
const handleTaskComment = async (raw) => {
|
|
1900
|
+
const event = taskCommentFromEvent(raw)
|
|
1901
|
+
const projectId = event?.projectId
|
|
1902
|
+
const ticketId = event?.taskId
|
|
1903
|
+
const comment = event?.comment
|
|
1904
|
+
const commentId = comment && (comment.id ?? comment.comment_id ?? comment.commentId)
|
|
1905
|
+
const text = String(comment?.content ?? comment?.body ?? comment?.text ?? comment?.message ?? '').replace(/\s+/g, ' ').trim()
|
|
1906
|
+
if (!validBackendId(projectId) || !validBackendId(ticketId) || !validBackendId(commentId) || !text) {
|
|
1907
|
+
log('task:comment missing project/task/comment identity or content — ignored')
|
|
1908
|
+
return { status: 'ignored-malformed' }
|
|
1909
|
+
}
|
|
1910
|
+
const sourceKey = `task-comment:${Number(projectId)}:${Number(ticketId)}:${Number(commentId)}`
|
|
1911
|
+
if (memory.has(sourceKey, 'received')) {
|
|
1912
|
+
log(`task:comment duplicate project=${projectId} ticket=${ticketId} comment=${commentId} — skipped`)
|
|
1913
|
+
return { status: 'duplicate' }
|
|
1914
|
+
}
|
|
1915
|
+
try { await ensureSelfAgent() }
|
|
1916
|
+
catch (error) { log('agent identity lookup unavailable; using configured aliases: ' + (error?.message || error)) }
|
|
1917
|
+
if (messageSenderIsSelf(comment, { id: selfAgentId, identifier, slug, name })) return { status: 'ignored-self' }
|
|
1918
|
+
const target = classifyConversationTarget(comment, [...selfAliases], { threadOwned: true })
|
|
1919
|
+
if (target.action !== 'handle') {
|
|
1920
|
+
log('task:comment ignored before model start (' + target.reason + ')')
|
|
1921
|
+
return { status: 'ignored-recipient' }
|
|
1922
|
+
}
|
|
1923
|
+
try {
|
|
1924
|
+
const tools = await discoverMcpTools()
|
|
1925
|
+
if (!tools.has('create_task_comment') && !tools.has('comment_ticket')) {
|
|
1926
|
+
log('task:comment reply tool is not advertised — ignored before model start')
|
|
1927
|
+
return { status: 'ignored-no-delivery-tool' }
|
|
1928
|
+
}
|
|
1929
|
+
const ticketData = toolData(await callMcpTool('get_ticket', { project_id: Number(projectId), ticket_id: Number(ticketId) }), 'get_ticket')
|
|
1930
|
+
const ticket = ticketData.ticket ?? ticketData.task ?? ticketData
|
|
1931
|
+
if (String(ticket.id) !== String(ticketId) || (ticket.project_id != null && String(ticket.project_id) !== String(projectId))) {
|
|
1932
|
+
log('task:comment get_ticket returned mismatched identity — ignored')
|
|
1933
|
+
return { status: 'ignored-mismatch' }
|
|
1934
|
+
}
|
|
1935
|
+
if (!await resolveTaskOwner(ticket)) {
|
|
1936
|
+
log(`task:comment ticket #${ticketId} is not assigned to this agent — ignored`)
|
|
1937
|
+
return { status: 'ignored-unassigned' }
|
|
1938
|
+
}
|
|
1939
|
+
const rootCommentId = messageParentId(comment) ?? Number(commentId)
|
|
1940
|
+
const who = senderName(comment)
|
|
1941
|
+
const peer = messageSenderIsAgent(comment)
|
|
1942
|
+
const ticketSlug = ticketDisplaySlug(ticket)
|
|
1943
|
+
memory.remember({ key: sourceKey, kind: 'task-comment', state: 'received', summary: text, refs: { projectId: Number(projectId), ticketId: Number(ticketId), messageId: Number(commentId), threadId: Number(rootCommentId) } })
|
|
1944
|
+
const delivery = {
|
|
1945
|
+
key: `reply:${sourceKey}`,
|
|
1946
|
+
surface: 'task-comment',
|
|
1947
|
+
projectId: Number(projectId),
|
|
1948
|
+
ticketId: Number(ticketId),
|
|
1949
|
+
parentId: Number(rootCommentId),
|
|
1950
|
+
sourceKey,
|
|
1951
|
+
watcherOwned: true,
|
|
1952
|
+
}
|
|
1953
|
+
const label = ticketSlug || String(ticket.title || 'the assigned ticket')
|
|
1954
|
+
const context = `The watcher received a new comment on your assigned ticket ${JSON.stringify(label)} from ${JSON.stringify(who || (peer ? 'another agent' : 'a teammate'))}: ${JSON.stringify(text)}. Authoritative ticket details: ${JSON.stringify({ slug: ticketSlug, title: ticket.title, description: ticket.description, priority: ticket.priority, status: ticket.type?.name ?? ticket.task_type?.name ?? ticket.status ?? ticket.state })}. Answer the comment directly in the context of this ticket. Do not call post_message or any task-comment tool; return only the final reply and the watcher will post it once in the source comment thread.${peer ? ' This was authored by another team agent. Address its concrete question or request without replacing it with ticket inventory or adding an open-ended reciprocal question.' : ''}`
|
|
1955
|
+
log(`task:comment project=${projectId} ticket=${ticketId} comment=${commentId} -> coordination lane`)
|
|
1956
|
+
void drain('coord', context, [], null, delivery)
|
|
1957
|
+
return { status: 'queued' }
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
log(`task:comment verification failed for ticket #${ticketId}: ` + (error?.message || error))
|
|
1960
|
+
return { status: 'retry' }
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1925
1964
|
const conversationEventData = (value) => {
|
|
1926
1965
|
const outer = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
1927
1966
|
const data = outer.data && typeof outer.data === 'object' && !Array.isArray(outer.data) ? outer.data : {}
|
|
@@ -1938,6 +1977,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
1938
1977
|
const mentionIntake = new Map()
|
|
1939
1978
|
function onEvent(k, d) {
|
|
1940
1979
|
if (stopping) return
|
|
1980
|
+
if (k === 'task:comment') return trackOperation(handleTaskComment(d))
|
|
1941
1981
|
const conversationEvent = k === 'agent:mention' || k === 'agent:message' || k === 'channel:message'
|
|
1942
1982
|
if (!conversationEvent) return trackOperation(routeEvent(k, d))
|
|
1943
1983
|
const eventData = conversationEventData(d)
|
|
@@ -2002,13 +2042,16 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2002
2042
|
|
|
2003
2043
|
function routeEvent(k, d) {
|
|
2004
2044
|
const raw = d && typeof d === 'object' ? d : {}
|
|
2005
|
-
if (k === 'task:assigned' || k === 'task:updated') {
|
|
2045
|
+
if (k === 'task:created' || k === 'task:assigned' || k === 'task:updated') {
|
|
2006
2046
|
return handleTaskSignal(k, raw)
|
|
2047
|
+
} else if (k === 'task:comment') {
|
|
2048
|
+
return handleTaskComment(raw)
|
|
2007
2049
|
} else if (k === 'agent:mention') {
|
|
2008
2050
|
const cid = raw.channel_id != null ? raw.channel_id : (raw.channelId != null ? raw.channelId : null)
|
|
2009
2051
|
const msg = raw.message && typeof raw.message === 'object' ? raw.message : {}
|
|
2010
2052
|
const text = String(msg.content || msg.body || msg.text || '').replace(/\s+/g, ' ')
|
|
2011
2053
|
const who = senderName(msg)
|
|
2054
|
+
const peerMessage = messageSenderIsAgent(msg)
|
|
2012
2055
|
// Reply IN THE SAME THREAD: parent is the thread root (the message's own id
|
|
2013
2056
|
// for a top-level mention, or its parent when the mention is itself a reply).
|
|
2014
2057
|
const mid = msg.message_id ?? msg.messageId ?? msg.id ?? null
|
|
@@ -2055,8 +2098,8 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2055
2098
|
// Every runtime carries the source thread through the queue so a later
|
|
2056
2099
|
// redirect can cancel its process. The watcher delivers the final answer
|
|
2057
2100
|
// for every runtime after an authoritative last-moment thread check.
|
|
2058
|
-
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result') => cid != null
|
|
2059
|
-
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true }
|
|
2101
|
+
const conversationDelivery = (stage = 'reply', skipIfAnyAgentReply = stage !== 'result', allowChannelMessaging = false) => cid != null
|
|
2102
|
+
? { key: `reply:${sourceKey}:${stage}`, channelId: Number(cid), parentId: threadRoot, skipIfAnyAgentReply, sourceKey, sourceMessageId: mid, watcherOwned: true, allowChannelMessaging }
|
|
2060
2103
|
: null
|
|
2061
2104
|
// Under-the-hood model control from chat (view / switch the model the agent runs).
|
|
2062
2105
|
const mcmd = cid != null ? parseModelCmd(text) : null
|
|
@@ -2086,6 +2129,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2086
2129
|
return
|
|
2087
2130
|
}
|
|
2088
2131
|
const creatingTicket = conversationCreatesTicket(text)
|
|
2132
|
+
const channelMessaging = !creatingTicket && conversationRequestsChannelMessage(text)
|
|
2089
2133
|
const assignmentKey = controlKey ? `assignment-routing:${controlKey}` : ''
|
|
2090
2134
|
const priorAssignment = assignmentKey ? memory.get(assignmentKey) : null
|
|
2091
2135
|
const retryAssignment = priorAssignment?.state === 'failed' && /^\s*(?:please\s+)?(?:try\s+again|retry)(?:\s+please)?[.!?]*\s*$/i.test(text)
|
|
@@ -2121,20 +2165,21 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2121
2165
|
// subsequent working/typing heartbeat for its lane.
|
|
2122
2166
|
if (cid != null) sendStatus(cid, 'thinking')
|
|
2123
2167
|
const codingMention = !creatingTicket && canCode && conversationNeedsCode(text, { rootContent: raw._threadRootContent })
|
|
2124
|
-
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention)
|
|
2168
|
+
const replyDelivery = conversationDelivery(codingMention ? 'result' : 'reply', !codingMention, channelMessaging)
|
|
2125
2169
|
let ctx = cid != null
|
|
2126
2170
|
? replyDelivery?.watcherOwned
|
|
2127
|
-
? `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}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
2171
|
+
? `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.'} ${channelMessaging ? 'The teammate explicitly requested a message in another channel. Use list_channels to resolve the named destination, then call post_message once with that exact channel_id. Do not substitute your dedicated or source channel for the requested destination. Return a short confirmation for the watcher to deliver in this source thread.' : '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}".` : ''} Use additional discovery, resources, and history when they help answer this request.`
|
|
2128
2172
|
: `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)` : ''}, using the authenticated session bridge. 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.` : ''} Use additional discovery, resources, and history when they help answer this request.${codingMention ? ' Stop only after the work and final result/blocker.' : ' After your single reply, STOP.'}`
|
|
2129
2173
|
: undefined
|
|
2130
2174
|
if (ctx && creatingTicket) ctx += ' This is a ticket-creation request, not repository implementation. Discover create_ticket on the authenticated watcher server, resolve the project, board, and valid type using list_projects, list_boards, and list_task_types, then create the requested ticket once. Follow its actual schema. If a required detail is ambiguous, ask for that detail rather than inventing it. Report the returned ticket reference. Never replay a creation after a timeout or uncertain response.'
|
|
2175
|
+
if (ctx && peerMessage) ctx += ' This request was authored by another team agent. Treat its concrete question or request as real team coordination. Answer the substance directly. Do not replace it with your own ticket inventory, and do not append a reciprocal question or open-ended invitation unless the source explicitly asks for one.'
|
|
2131
2176
|
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.`
|
|
2132
2177
|
if (codingMention) {
|
|
2133
2178
|
// Activity indicators make accepted work visible. Do not add a canned
|
|
2134
2179
|
// pickup message; the first channel message is the verified result or a
|
|
2135
2180
|
// concrete blocker from the work lane.
|
|
2136
2181
|
void drain('full', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2137
|
-
} else void drain(creatingTicket ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2182
|
+
} else void drain(creatingTicket || channelMessaging ? 'coord' : 'fast', ctx, cid == null ? [] : [cid], null, replyDelivery)
|
|
2138
2183
|
} else if (k === 'error') {
|
|
2139
2184
|
const detail = raw && (raw.message || raw.error || raw.reason || raw.code || raw.d?.message || raw.d?.error)
|
|
2140
2185
|
log('error event: ' + (detail ? String(detail) : JSON.stringify(raw)).slice(0, 220))
|
|
@@ -2144,7 +2189,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2144
2189
|
}
|
|
2145
2190
|
|
|
2146
2191
|
// The WebSocket drives real-time reactions (task:created/updated assigned to us,
|
|
2147
|
-
// agent:mention, agent:message, and channel:message). Verified organization subscriptions
|
|
2192
|
+
// task:comment, agent:mention, agent:message, and channel:message). Verified organization subscriptions
|
|
2148
2193
|
// recover channel mentions even when the backend omits targeted dispatch.
|
|
2149
2194
|
// All conversation event types share recipient, ownership and deduplication guards.
|
|
2150
2195
|
log('up — backend WS watcher on ' + wsUrl + (canCode ? ' [code: ' + workdir + ']' : ''))
|
|
@@ -2159,6 +2204,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2159
2204
|
activity.pulse()
|
|
2160
2205
|
emit('connection.connected', { status: 'connected' })
|
|
2161
2206
|
for (const wake of [...recoveryWaiters]) wake()
|
|
2207
|
+
queueMicrotask(() => { if (!stopping) void ensureSelfAgent() })
|
|
2162
2208
|
if (autoStart) setTimeout(() => { if (!stopping) void reconcileBacklog() }, 250)
|
|
2163
2209
|
}, log })
|
|
2164
2210
|
|
|
@@ -2209,10 +2255,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2209
2255
|
}
|
|
2210
2256
|
const deliveryTimer = autoStart ? setInterval(() => void flushPendingReplies(), 30_000) : null
|
|
2211
2257
|
deliveryTimer?.unref?.()
|
|
2212
|
-
const activityRecoveryTimer = autoStart ? setInterval(() => {
|
|
2213
|
-
if (!stopping) void trackOperation(reconcileActivity())
|
|
2214
|
-
}, 30_000) : null
|
|
2215
|
-
activityRecoveryTimer?.unref?.()
|
|
2216
2258
|
const authorizationTimer = autoStart ? setInterval(() => void resumeReadyAuthorizations(), 2_000) : null
|
|
2217
2259
|
authorizationTimer?.unref?.()
|
|
2218
2260
|
let modelSettingsWatcher = null
|
|
@@ -2252,7 +2294,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2252
2294
|
closePromise = (async () => {
|
|
2253
2295
|
clearInterval(authorizationTimer)
|
|
2254
2296
|
clearInterval(deliveryTimer)
|
|
2255
|
-
clearInterval(activityRecoveryTimer)
|
|
2256
2297
|
modelSettingsWatcher?.close()
|
|
2257
2298
|
if (introTimer) clearTimeout(introTimer)
|
|
2258
2299
|
try { handle?.close() } catch { /* already disconnected */ }
|
|
@@ -2277,7 +2318,6 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
|
|
|
2277
2318
|
}
|
|
2278
2319
|
return {
|
|
2279
2320
|
done, close, receive: onEvent, reconcile: () => stopping ? Promise.resolve() : trackOperation(reconcileBacklog()),
|
|
2280
|
-
recoverActivity: () => stopping ? Promise.resolve() : trackOperation(reconcileActivity()),
|
|
2281
2321
|
resumeAuthorizations: resumeReadyAuthorizations, retryDeliveries: flushPendingReplies,
|
|
2282
2322
|
get pending() { return queues.work.size + queues.reply.size + mentionIntake.size },
|
|
2283
2323
|
}
|