openvisio-agent 0.19.11 → 0.20.0

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.
@@ -0,0 +1,324 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { createBackendWatcher } from '../src/watch.mjs'
6
+ import { createByoMemoryGraph } from '../src/memory.mjs'
7
+
8
+ const human = { id: 3, name: 'Daniel Adewale', email: 'daniel@example.test' }
9
+ const self = { id: 7, identifier: 'alex', name: 'Alex', added_by: human, organization_id: 3 }
10
+ const pr = 'https://github.com/example/workspace/pull/62'
11
+ const completion = { subtype: 'ok', didCode: true, didRepoMutation: true, didMcpTaskRead: true, didMcpTaskUpdate: true, outputText: `Fixed and verified. PR: ${pr}` }
12
+ const turn = () => new Promise((resolve) => setImmediate(resolve))
13
+
14
+ async function eventually(predicate, description) {
15
+ for (let n = 0; n < 500; n++) { if (predicate()) return; await turn() }
16
+ assert.fail(`Watcher did not reach: ${description}`)
17
+ }
18
+
19
+ export function fixture({ agent = 'codex', task = true, run, threadError = false, commentTools = [], onComment, selfProfile = self } = {}) {
20
+ const dir = mkdtempSync(join(tmpdir(), 'byo-workspace-'))
21
+ const posts = [], runs = [], calls = [], logs = [], messages = [], subscriptions = []
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
+ let watcher, messageId = 100, closed = 0
24
+ const memory = createByoMemoryGraph({ path: join(dir, 'ledger.json') })
25
+ const mcp = {
26
+ listTools: async () => ['list_agents', 'list_projects', 'list_tasks', 'get_ticket', 'update_ticket', 'post_message', 'list_message_thread', 'list_channels', 'list_task_types', 'list_activity', ...commentTools].map((name) => ({ name })),
27
+ callTool: async (name, args = {}) => {
28
+ calls.push({ name, args })
29
+ if (commentTools.includes(name)) return onComment ? await onComment(name, args) : { id: 500 }
30
+ if (name === 'list_agents') return { agents: [selfProfile] }
31
+ if (name === 'list_projects') return { projects: [{ id: 1, name: 'Workspace' }] }
32
+ if (name === 'list_tasks') return { tasks: task ? [{ ...ticket }] : [] }
33
+ if (name === 'list_task_types') return { types: [{ id: 1, name: 'In Progress' }, { id: 2, name: 'Testing' }, { id: 3, name: 'Done' }] }
34
+ if (name === 'get_ticket') { assert.equal(args.project_id, 1); assert.equal(args.ticket_id, 77); return { ticket: { ...ticket } } }
35
+ if (name === 'update_ticket') { Object.assign(ticket, args); return { ticket: { ...ticket } } }
36
+ if (name === 'list_channels') return { channels: [{ id: 2, name: 'alex', agent_id: 7 }, { id: 9, name: 'general' }] }
37
+ if (name === 'list_activity') return { activities: [] }
38
+ if (name === 'list_message_thread') {
39
+ if (typeof threadError === 'function') await threadError()
40
+ else if (threadError) throw new Error('Thread service unavailable')
41
+ return { messages: messages.filter((m) => m.channel_id === args.channel_id && (m.id === args.message_id || m.parent_id === args.message_id)) }
42
+ }
43
+ if (name === 'post_message') {
44
+ const posted = { ...args, id: ++messageId, sender_agent: self }
45
+ posts.push(posted); messages.push(posted)
46
+ return posted
47
+ }
48
+ throw new Error(`Unexpected MCP call ${name}`)
49
+ },
50
+ }
51
+ const createRunner = (options) => {
52
+ let cancel
53
+ return {
54
+ runCycle: async (prompt, model, runtimeOptions) => {
55
+ const entry = { prompt, model, runtimeOptions, options }
56
+ runs.push(entry)
57
+ if (run) return new Promise((resolve, reject) => {
58
+ cancel = () => resolve({ subtype: 'canceled' })
59
+ Promise.resolve(run(entry, { ticket, posts, messages })).then(resolve, reject)
60
+ })
61
+ if (options.workdir) {
62
+ Object.assign(ticket, { status: 'Testing', type_id: 2, description: `PR: ${pr}\nVerification: build passed`, updated_at: 'revision-2' })
63
+ return { ...completion, ...(agent !== 'codex' ? { didResultMessage: true } : {}) }
64
+ }
65
+ return { subtype: 'ok', outputText: 'I checked the current details.' }
66
+ },
67
+ cancelCurrent: async () => cancel?.(),
68
+ close: () => { closed++ },
69
+ }
70
+ }
71
+ const start = () => {
72
+ watcher = createBackendWatcher({ wsUrl: 'wss://unused.example.test', mcpUrl: 'https://unused.example.test/mcp', apiKey: 'test-key', identifier: 'alex', slug: 'test-alex', agent, workdir: join(dir, 'workspace'), model: 'test-code', chatModel: 'test-chat' }, {
73
+ stateDir: dir, memory, mcpClient: mcp, createRunner, connect: () => ({ close() {}, subscribeOrganization(id) { subscriptions.push(id) } }), autoStart: false, log: (value) => logs.push(value),
74
+ })
75
+ return watcher
76
+ }
77
+ start()
78
+ return {
79
+ dir, ticket, posts, runs, calls, logs, messages, memory, subscriptions,
80
+ get watcher() { return watcher },
81
+ get closed() { return closed },
82
+ set threadError(value) { threadError = value },
83
+ start,
84
+ async idle() { await eventually(() => watcher.pending === 0, 'idle queues'); await turn() },
85
+ async mention(content, { parent = 100, id = 200, sender = human, channel = 2 } = {}) {
86
+ const message = { id, parent_id: parent, channel_id: channel, content, sender }
87
+ messages.push(message)
88
+ await watcher.receive('agent:mention', { channel_id: channel, message })
89
+ },
90
+ async cleanup() { await watcher.close(); rmSync(dir, { recursive: true, force: true }) },
91
+ }
92
+ }
93
+
94
+ export const scenarios = []
95
+ const scenario = (id, description, run) => scenarios.push({ id: `workspace-${id}`, category: 'integrated-watcher', description, run })
96
+
97
+ for (const agent of ['codex', 'opencode', 'claude']) {
98
+ for (const source of ['websocket', 'reconciliation']) {
99
+ scenario(`assignment-${agent}-${source}`, `${agent}: ${source} assignment completes once; handoff and reconnect do not restart work`, async () => {
100
+ const f = fixture({ agent })
101
+ try {
102
+ if (source === 'websocket') await f.watcher.receive('task:assigned', { task: { id: 77, project_id: 1 } })
103
+ else await f.watcher.reconcile()
104
+ await f.idle()
105
+ assert.equal(f.runs.length, 1, f.logs.join('\n'))
106
+ assert.equal(f.posts.length, 1, f.logs.join('\n'))
107
+ assert.equal(f.posts[0].channel_id, 2)
108
+ assert.equal(f.posts[0].parent_id, undefined)
109
+ assert.match(f.posts[0].content, /OPEN-77/)
110
+ await f.watcher.receive('task:updated', { task: { id: 77, project_id: 1 } })
111
+ await f.watcher.reconcile()
112
+ await f.idle()
113
+ assert.equal(f.runs.length, 1)
114
+ assert.equal(f.posts.length, 1)
115
+ await f.watcher.close(); f.start()
116
+ await f.watcher.reconcile(); await f.idle()
117
+ assert.equal(f.runs.length, 1)
118
+ assert.equal(f.posts.length, 1)
119
+ } finally { await f.cleanup() }
120
+ })
121
+ }
122
+ }
123
+
124
+ for (const origin of ['fresh-completion', 'legacy-delivery', 'backend-root']) {
125
+ for (const restart of [false, true]) {
126
+ scenario(`repair-${origin}-${restart}`, `Repair follow-up after ${origin}, restart=${restart}, uses coding context and source thread exactly once`, async () => {
127
+ const f = fixture()
128
+ try {
129
+ let parent = 100
130
+ const rootContent = `I finished OPEN-77. PR: ${pr}`
131
+ if (origin === 'fresh-completion') {
132
+ await f.watcher.receive('task:assigned', { task: { id: 77, project_id: 1 } }); await f.idle()
133
+ parent = f.posts[0].id
134
+ } else if (origin === 'legacy-delivery') {
135
+ f.memory.remember({ key: 'legacy-completion', kind: 'delivery', state: 'rendered', summary: rootContent, refs: { channelId: 2 }, meta: { messageId: parent } })
136
+ } else f.messages.push({ id: parent, channel_id: 2, content: rootContent, sender_agent: self })
137
+ if (restart) { await f.watcher.close(); f.start() }
138
+ const beforeRuns = f.runs.length, beforePosts = f.posts.length
139
+ await f.mention('The build failed so run build and fix the issue.', { parent })
140
+ await f.idle()
141
+ assert.equal(f.runs.length, beforeRuns + 1, f.logs.join('\n'))
142
+ assert.ok(f.runs.at(-1).options.workdir)
143
+ assert.ok(f.runs.at(-1).prompt.includes(pr))
144
+ assert.equal(f.posts.length, beforePosts + 1)
145
+ assert.equal(f.posts.at(-1).parent_id, parent)
146
+ await f.mention('The build failed so run build and fix the issue.', { parent })
147
+ await f.idle()
148
+ assert.equal(f.runs.length, beforeRuns + 1)
149
+ assert.equal(f.posts.length, beforePosts + 1)
150
+ } finally { await f.cleanup() }
151
+ })
152
+ }
153
+ }
154
+
155
+ for (const request of ['@Atlas fix this', 'Thanks!', 'Stop working on this.']) {
156
+ scenario(`unowned-${request.replace(/\W+/g, '-')}`, `Unowned thread ${JSON.stringify(request)} cannot start coding`, async () => {
157
+ const f = fixture({ task: false })
158
+ try {
159
+ f.messages.push({ id: 100, channel_id: 2, content: 'Unrelated thread', sender: human })
160
+ await f.mention(request); await f.idle()
161
+ assert.equal(f.runs.length, 0)
162
+ assert.equal(f.posts.length, 0)
163
+ } finally { await f.cleanup() }
164
+ })
165
+ }
166
+
167
+ scenario('cancel-during-model', 'Stand-down cancels only its active work and prevents stale final delivery', async () => {
168
+ const f = fixture({ task: false, run: () => new Promise(() => {}) })
169
+ try {
170
+ await f.mention('@Alex fix the component tests.'); await eventually(() => f.runs.length === 1, 'coding started')
171
+ await f.mention('Stop working on this.', { id: 201 }); await f.idle()
172
+ assert.equal(f.posts.length, 1)
173
+ assert.match(f.posts[0].content, /standing down/)
174
+ assert.equal(f.posts[0].parent_id, 100)
175
+ } finally { await f.cleanup() }
176
+ })
177
+
178
+ scenario('close-during-intake', 'Shutdown does not accept late messages or restart a model', async () => {
179
+ const f = fixture({ task: false })
180
+ try {
181
+ const received = f.mention('@Alex fix the component tests.')
182
+ await f.watcher.close(); await received
183
+ await f.mention('@Alex implement the component.', { id: 202 })
184
+ assert.equal(f.runs.length, 0)
185
+ assert.equal(f.posts.length, 0)
186
+ } finally { await f.cleanup() }
187
+ })
188
+
189
+ scenario('known-thread-read-outage', 'Explicitly addressed message is routed even when optional root lookup fails', async () => {
190
+ const f = fixture({ task: false, threadError: true })
191
+ try {
192
+ await f.mention('@Alex what model are you running?'); await f.idle()
193
+ assert.equal(f.runs.length, 1, f.logs.join('\n'))
194
+ assert.equal(f.posts.length, 0, 'Unavailable authoritative delivery read must not become an unchecked post')
195
+ await f.watcher.close(); f.start()
196
+ f.threadError = false
197
+ await f.watcher.retryDeliveries()
198
+ assert.equal(f.runs.length, 1, 'Restart retries delivery without regenerating the answer')
199
+ assert.equal(f.posts.length, 1)
200
+ await f.watcher.retryDeliveries()
201
+ assert.equal(f.posts.length, 1)
202
+ } finally { await f.cleanup() }
203
+ })
204
+
205
+ for (const assignment of [
206
+ { agent_id: 8, agent: { identifier: 'alex' } },
207
+ { agent_id: 8, agent: { id: 7, identifier: 'alex' } },
208
+ { agent_id: null, agent: { identifier: 'atlas' } },
209
+ ]) {
210
+ scenario(`assignment-conflict-${scenarios.length}`, `Assignment identity ${JSON.stringify(assignment)} cannot start another agent's ticket`, async () => {
211
+ const f = fixture()
212
+ try {
213
+ Object.assign(f.ticket, assignment)
214
+ await f.watcher.receive('task:assigned', { task: { id: 77, project_id: 1 } })
215
+ await f.watcher.reconcile(); await f.idle()
216
+ assert.equal(f.runs.length, 0)
217
+ assert.equal(f.posts.length, 0)
218
+ } finally { await f.cleanup() }
219
+ })
220
+ }
221
+
222
+ scenario('numeric-review-report', 'Numeric-only Testing state produces the verified completion report without another model turn', async () => {
223
+ const f = fixture({ run: async (_, { ticket }) => {
224
+ delete ticket.status
225
+ Object.assign(ticket, { type_id: 2, description: `PR: ${pr}`, updated_at: 'review-2' })
226
+ return completion
227
+ } })
228
+ try {
229
+ await f.watcher.receive('task:assigned', { task: { id: 77, project_id: 1 } }); await f.idle()
230
+ assert.equal(f.runs.length, 1)
231
+ assert.equal(f.posts.length, 1, f.logs.join('\n'))
232
+ await f.watcher.reconcile(); await f.idle()
233
+ assert.equal(f.runs.length, 1)
234
+ } finally { await f.cleanup() }
235
+ })
236
+
237
+ scenario('distinct-source-identical-text', 'Different human message ids with identical text remain distinct requests', async () => {
238
+ const f = fixture({ task: false })
239
+ try {
240
+ await f.mention('@Alex what model are you running?', { id: 200 }); await f.idle()
241
+ await f.mention('@Alex what model are you running?', { id: 201 }); await f.idle()
242
+ assert.equal(f.runs.length, 2)
243
+ assert.equal(f.posts.length, 2)
244
+ } finally { await f.cleanup() }
245
+ })
246
+
247
+ scenario('cancel-pending-then-reactivate', 'New request in a canceled thread cannot resurrect its older pending answer', async () => {
248
+ const f = fixture({ task: false, threadError: true })
249
+ try {
250
+ await f.mention('@Alex what model are you running?'); await f.idle()
251
+ assert.equal(f.runs.length, 1)
252
+ await f.mention('Stop working on this.', { id: 201 }); await f.idle()
253
+ f.threadError = false
254
+ await f.mention('@Alex check the current project details.', { id: 202 }); await f.idle()
255
+ await f.watcher.retryDeliveries()
256
+ assert.equal(f.runs.length, 2)
257
+ // Final response to the new question plus at most its stand-down confirmation.
258
+ assert.equal(f.posts.filter((post) => !/standing down/.test(post.content)).length, 1)
259
+ } finally { await f.cleanup() }
260
+ })
261
+
262
+ scenario('close-during-delivery-read', 'Close waits for in-flight delivery reads and prevents a late post', async () => {
263
+ let release, started = false
264
+ const f = fixture({ task: false })
265
+ try {
266
+ f.memory.remember({ key: 'own-root:2:100', kind: 'thread', state: 'authored', refs: { channelId: 2, threadId: 100 }, meta: { rootContent: 'I am here.' } })
267
+ f.threadError = () => new Promise((resolve) => { started = true; release = resolve })
268
+ await f.mention('@Alex check the current project details.')
269
+ await eventually(() => started, 'delivery read starts')
270
+ let finished = false
271
+ const closing = f.watcher.close().then(() => { finished = true })
272
+ await turn()
273
+ assert.equal(finished, false)
274
+ release(); await closing
275
+ assert.equal(f.posts.length, 0)
276
+ assert.equal(f.watcher.pending, 0)
277
+ } finally { await f.cleanup() }
278
+ })
279
+
280
+ for (const value of [null, '', 0, -1, 'NaN', 'abc', '9007199254740993']) {
281
+ for (const field of ['channel', 'id', 'parent']) {
282
+ if (field === 'parent' && value == null) continue
283
+ scenario(`invalid-${field}-${String(value) || 'empty'}`, `Invalid ${field}=${JSON.stringify(value)} starts no model and makes no post`, async () => {
284
+ const f = fixture({ task: false })
285
+ try {
286
+ await f.mention('@Alex fix the component.', { [field]: value }); await f.idle()
287
+ assert.equal(f.runs.length, 0)
288
+ assert.equal(f.posts.length, 0)
289
+ } finally { await f.cleanup() }
290
+ })
291
+ }
292
+ }
293
+
294
+ for (const parentField of ['parent_id', 'parentId', 'thread_id', 'threadId', 'reply_to', 'replyTo']) {
295
+ for (const idField of ['id', 'message_id', 'messageId']) {
296
+ scenario(`thread-envelope-${parentField}-${idField}`, `Untagged repair uses ${parentField}/${idField} and a numeric-only root author before startup reconciliation`, async () => {
297
+ const f = fixture({ task: false })
298
+ try {
299
+ f.messages.push({ id: 100, channel_id: 2, content: `PR: ${pr}`, sender_agent: { id: 7 } })
300
+ await f.watcher.receive('agent:mention', { channelId: 2, message: { [idField]: 200, [parentField]: 100, content: 'The build failed so run build and fix the issue.', sender: human } })
301
+ await f.idle()
302
+ assert.equal(f.runs.length, 1, f.logs.join('\n'))
303
+ assert.ok(f.runs[0].options.workdir)
304
+ assert.equal(f.posts.length, 1)
305
+ assert.equal(f.posts[0].parent_id, 100)
306
+ } finally { await f.cleanup() }
307
+ })
308
+ }
309
+ }
310
+
311
+ for (const question of ['@Alex do you have pending tickets?', '@Alex can you check if you have pending tasks?', '@Alex just list my pending tickets, do not start work.']) {
312
+ scenario(`pending-discovery-${scenarios.length}`, `Pending discovery follows the explicit start/status contract for ${JSON.stringify(question)}`, async () => {
313
+ const f = fixture()
314
+ try {
315
+ await f.mention(question)
316
+ await eventually(() => f.calls.some((call) => call.name === 'list_tasks'), 'pending list read')
317
+ // Discovery dispatch is asynchronous; let the complete routing chain enter the queue.
318
+ for (let n = 0; n < 12; n++) await turn()
319
+ await f.idle()
320
+ const statusOnly = question.includes('do not start')
321
+ assert.equal(f.runs.filter((run) => run.options.workdir).length, statusOnly ? 0 : 1, f.logs.join('\n'))
322
+ } finally { await f.cleanup() }
323
+ })
324
+ }
@@ -13,8 +13,9 @@ function run(label, command, args, cwd = root) {
13
13
  else process.stdout.write(`✓ ${label}\n`)
14
14
  }
15
15
 
16
- for (const file of readdirSync(join(root, 'src')).filter((name) => name.endsWith('.mjs'))) run(`syntax ${file}`, process.execPath, ['--check', join(root, 'src', file)])
17
- for (const file of readdirSync(join(root, 'bin')).filter((name) => name.endsWith('.mjs'))) run(`syntax ${file}`, process.execPath, ['--check', join(root, 'bin', file)])
16
+ for (const directory of ['src', 'bin', 'studio']) {
17
+ for (const file of readdirSync(join(root, directory)).filter((name) => name.endsWith('.mjs'))) run(`syntax ${directory}/${file}`, process.execPath, ['--check', join(root, directory, file)])
18
+ }
18
19
  run('unit and behavior tests', process.execPath, ['--test'])
19
20
  run('frontend typecheck', 'npm', ['run', 'typecheck'], join(repo, 'frontend'))
20
21
  run('package dry run', 'npm', ['pack', '--dry-run'])
@@ -56,15 +57,18 @@ const assertions = [
56
57
  ['assigned coding completion is posted by the watcher', watcher.includes('announceTaskCompletion') && watcher.includes('postMessageOnce({ key: `completion:${report.key}`')],
57
58
  ['completion requires review/done state and PR evidence', watcher.includes('buildTaskCompletionReport') && watcher.includes('completion report deferred')],
58
59
  ['completion delivery survives reconnect and deduplicates', watcher.includes('pendingCompletionReports: [...pendingCompletionReports]') && watcher.includes('reportedCompletions: [...reportedCompletions]') && watcher.includes('reportedCompletions.has(report.key)')],
59
- ['optional ticket comments cannot block verified completion', watcher.includes("callOptionalMcpTool('comment_ticket', { project_id: projectId, ticket_id: ticketId, text: report.content })") && watcher.includes('comment_ticket is not exposed; completing ticket') && watcher.includes('reportedTaskComments: [...reportedTaskComments]') && watcher.includes('reportedTaskComments.has(report.key)')],
60
+ ['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)')],
61
+ ['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' ? { content } : { text: content }")],
62
+ ['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'")],
60
63
  ['ticket comments cannot masquerade as channel completion', events.includes("didChannelMessage: completed && mcpTool === 'post_message'") && watcher.includes('result.didChannelMessage')],
61
64
  ['single-watcher acquisition is atomic and fails closed', watcher.includes("openSync(lockPath, 'wx')") && watcher.includes('Could not acquire the single-watcher lock')],
62
65
  ['websocket and activity mention delivery share a replay guard', watcher.includes('markMentionHandled(activityMessage, activityChannelId)') && watcher.includes('markMentionHandled(msg, cid)') && watcher.includes('recentMentionSignatures')],
63
66
  ['reconciled mentions reuse the guarded websocket delivery path', watcher.includes("onEvent('agent:mention'") && watcher.includes('_mentionAlreadyMarked: true')],
64
67
  ['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("reason: 'owned-thread-follow-up'") && events.includes("reason: 'explicit-other-recipient'") && events.includes("reason: 'other-agent-chatter'")],
65
68
  ['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')],
66
- ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("!ownedActivity || memory.has(activitySourceKey, 'received')")],
67
- ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text)') && events.includes('const action =') && events.includes('const target =')],
69
+ ['reconciliation recovers untagged human follow-ups in owned threads', watcher.includes("memory.has(activityControlKey, 'active')") && watcher.includes('const addressedActivity = mentionNeedles.some') && watcher.includes("memory.has(activitySourceKey, 'received')")],
70
+ ['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text, { rootContent:') && events.includes('const action =') && events.includes('const target =')],
71
+ ['agent-authored roots establish ownership and legacy roots are verified before routing', watcher.includes('saveOwnThread(channelId, postedId, message)') && watcher.includes('await resolveOwnThread(cid, parent)') && watcher.includes('_threadRootContent: rootContent')],
68
72
  ['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'")],
69
73
  ['thread cancellation is rechecked immediately before watcher delivery', watcher.includes('const newlyCancelled = suppressCancelled()') && watcher.includes('if (newlyCancelled) return newlyCancelled')],
70
74
  ['generic coding pickup messages are absent', !watcher.includes("I've picked this up and will return here with the verified result")],
@@ -79,10 +83,10 @@ const assertions = [
79
83
  ['queued assignments are checked again before model execution', watcher.includes('queued ticket no longer actionable; skipped before model start')],
80
84
  ['feature titles cannot be mistaken for coordination commands', watcher.includes('taskIsCoordinationOnly(taskText)') && events.includes('Uploading files as messages') && events.includes('explicitCoordination')],
81
85
  ['reply runner has no coding workspace or coding charter', watcher.includes("workdir: ''") && watcher.includes("systemPrompt: CHAT_CHARTER + '\\n\\n' + credNote") && opencodeConfig.includes("'*': 'deny'")],
82
- ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('const body = await res.text()')],
86
+ ['MCP requests have a deadline including response bodies', mcpHttp.includes('controller.abort()') && mcpHttp.includes('res.text()')],
83
87
  ['Mastra memory uses real ticket and thread identities', watcher.includes('createMastraMemory') && watcher.includes('await memory.context(memoryRefs)') && memory.includes('new Memory({ storage') && memory.includes('new LibSQLStore') && memory.includes('ticket:${refs.projectId}:${refs.ticketId}') && memory.includes('channel:${refs.channelId}:thread:')],
84
88
  ['Codex and OpenCode use warm scoped Mastra ACP sessions', watcher.includes('createMastraAcpRunner') && watcher.includes('workRunners.acquire(sessionKey') && watcher.includes('replyRunners.acquire(key') && mastraHarness.includes('persistSession: true')],
85
- ['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('await acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
89
+ ['runtime models are selected explicitly before work starts', mastraHarness.includes('getAvailableModels()') && mastraHarness.includes('resolveAvailableModel(requestedModel') && mastraHarness.includes('acp.setModel(resolution.selected)') && modelSelection.includes('automatic model substitution is disabled') && watcher.includes('if (changed) persistModel()')],
86
90
  ['one watcher owns bounded work and per-thread reply sessions', watcher.includes('MAX_CONCURRENT_WORKERS = 3') && watcher.includes('workRunners.release(sessionKey)') && watcher.includes('replyRunners.release(key)') && watcher.includes('groupKey: (item) => item.taskRef')],
87
91
  ['workers serialize shared worktrees while independent worktrees run concurrently', watcher.includes('groupKey: (item) => item.workdir || workdir') && cycleQueue.includes('active.size < concurrency') && cycleQueue.includes('activeGroups.has(candidate[1].group)')],
88
92
  ['work and reply cancellation targets are isolated', watcher.includes('item.control.cancelled = true') && watcher.includes('item.control.runner?.cancelCurrent')],
@@ -130,7 +134,7 @@ const assertions = [
130
134
  ['private PR pushes use an explicit constrained helper', cli.includes("cmd === 'authorize-pr-push'") && cli.includes("cmd === 'push-pr-branch'") && prPush.includes("'push', '-u', identity.remote, destination")],
131
135
  ['PR push helper restricts branch and destination within setup authority', prPush.includes('isSafeAgentBranch(branch)') && prPush.includes('policyAllows(') && prPush.includes('accepts no force, remote, or ref args')],
132
136
  ['Codex recognizes helper authorization as a blocker', events.includes('OPENVISIO_PR_PUSH_AUTH_REQUIRED') && watcher.includes("block?.kind === 'pr-push-authorization-required'")],
133
- ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("Action required: I'm blocked")],
137
+ ['policy blocker is surfaced to the user', watcher.includes('reportPolicyBlock(prompt, activeTaskRef, result.policyBlock, delivery)') && watcher.includes("The local runtime rejected")],
134
138
  ['blocker routing carries explicit task identity', watcher.includes('const activeTaskRef = taskRef') && watcher.includes('taskRef: activeTaskRef')],
135
139
  ['reply discovery failures stay scoped while failed mutations fail closed', watcher.includes('blockingReplyMcpErrors(result?.mcpErrors)') && watcher.includes('preserving the scoped model reply') && events.includes('export function blockingReplyMcpErrors')],
136
140
  ['pending-ticket questions use watcher-owned MCP reads without a chat model cycle', watcher.includes('conversationAsksPendingTickets(text)') && watcher.includes('pending-ticket question -> watcher-owned MCP lookup') && watcher.includes("callMcpReadWithRetry('list_tasks'") && watcher.includes("if (selfAgentId == null) {\n const agentsData = toolData(await callMcpReadWithRetry('list_agents'))")],
@@ -0,0 +1,32 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { scenarios, validateCatalogue } from '../scenarios/index.mjs'
4
+
5
+ validateCatalogue()
6
+ const reportIndex = process.argv.indexOf('--report')
7
+ const listOnly = process.argv.includes('--list')
8
+ const results = []
9
+ const started = performance.now()
10
+ for (const scenario of scenarios) {
11
+ const { run, ...metadata } = scenario
12
+ if (listOnly) { results.push(metadata); continue }
13
+ const start = performance.now()
14
+ try {
15
+ await run()
16
+ results.push({ ...metadata, status: 'passed', durationMs: Math.round((performance.now() - start) * 100) / 100 })
17
+ } catch (error) {
18
+ results.push({ ...metadata, status: 'failed', error: String(error?.stack || error) })
19
+ process.stderr.write(`FAIL ${scenario.id}: ${error?.message || error}\n`)
20
+ }
21
+ }
22
+ const categories = {}
23
+ for (const row of results) categories[row.category] = (categories[row.category] || 0) + 1
24
+ const failed = results.filter((row) => row.status === 'failed').length
25
+ const report = { generatedAt: new Date().toISOString(), node: process.version, total: results.length, passed: listOnly ? undefined : results.length - failed, failed: listOnly ? undefined : failed, durationMs: Math.round(performance.now() - started), categories, scenarios: results }
26
+ if (reportIndex !== -1) {
27
+ if (!process.argv[reportIndex + 1]) throw new Error('--report requires a filename')
28
+ writeFileSync(resolve(process.argv[reportIndex + 1]), JSON.stringify(report, null, 2) + '\n')
29
+ }
30
+ if (listOnly && reportIndex === -1) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
31
+ else process.stdout.write(`${results.length} scenarios: ${results.length - failed} passed, ${failed} failed. ${report.durationMs}ms\n${JSON.stringify(categories)}\n`)
32
+ process.exitCode = failed ? 1 : 0
@@ -0,0 +1,138 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { chmod, lstat, mkdir, open, rename, stat, unlink } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+
6
+ const MAX_FILE_BYTES = 1024 * 1024
7
+ const MAX_PENDING_BYTES = 256 * 1024
8
+ const MAX_EVENT_BYTES = 32 * 1024
9
+ const OMIT = /^(?:apikey|agentapikey|key|password|passwordhash|secret|clientsecret|token|accesstoken|refreshtoken|authorization|cookie|setcookie|env|envvars|environment|environmentvariables|processenv|rawenv|rawinput|rawoutput|reasoning|thoughts?|thinking|chainofthought)$/i
10
+ export const isPrivateThoughtEvent = (type) => /(?:reasoning|thought|thinking)/i.test(String(type || ''))
11
+
12
+ // Keep the journal suitable for direct local display. It records explicit plans,
13
+ // observable tools and public output, never provider reasoning or raw envelopes.
14
+ export function sanitizeJournalData(value, { secrets = [] } = {}) {
15
+ const known = secrets.filter((secret) => typeof secret === 'string' && secret).sort((a, b) => b.length - a.length)
16
+ const seen = new WeakSet()
17
+ let visited = 0
18
+ const cleanString = (text) => {
19
+ let result = text
20
+ for (const secret of known) result = result.split(secret).join('[redacted]')
21
+ result = result
22
+ .replace(/\b(?:npm|gh[pousr]|github_pat|ov[a-z]{1,4})_[A-Za-z0-9_-]{16,}\b/g, '[redacted]')
23
+ .replace(/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{16,}\b/g, '[redacted]')
24
+ .replace(/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[redacted]')
25
+ .replace(/\bAIza[A-Za-z0-9_-]{35}\b/g, '[redacted]')
26
+ .replace(/\bxox[baprs]-[A-Za-z0-9-]{16,}\b/g, '[redacted]')
27
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
28
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [redacted]')
29
+ .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))\s*=\s*(?:"[^"]*"|'[^']*'|[^\s;]+)/g, '$1=[redacted]')
30
+ .replace(/([?&](?:api[_-]?key|token|access_token|secret|password)=)[^&#\s]*/gi, '$1[redacted]')
31
+ .replace(/(https?:\/\/)[^/@\s]+:[^/@\s]+@/gi, '$1[redacted]@')
32
+ return result.slice(0, 4000)
33
+ }
34
+ const walk = (item, depth) => {
35
+ if (++visited > 2000 || depth > 8) return '[truncated]'
36
+ if (typeof item === 'string') return cleanString(item)
37
+ if (typeof item === 'number') return Number.isFinite(item) ? item : null
38
+ if (typeof item === 'bigint') return String(item)
39
+ if (item == null || typeof item === 'boolean') return item
40
+ if (typeof item !== 'object') return undefined
41
+ if (seen.has(item)) return '[circular]'
42
+ seen.add(item)
43
+ if (Array.isArray(item)) return item.slice(0, 80).map((child) => walk(child, depth + 1))
44
+ return Object.fromEntries(Object.entries(item).slice(0, 80).flatMap(([key, child]) => {
45
+ if (OMIT.test(key.replace(/[_-]/g, '')) || isPrivateThoughtEvent(key)) return []
46
+ const safe = walk(child, depth + 1)
47
+ return safe === undefined ? [] : [[cleanString(key).slice(0, 120), safe]]
48
+ }))
49
+ }
50
+ return walk(value, 0)
51
+ }
52
+
53
+ export function createAgentJournal({ stateDir, agent, secrets = [] }) {
54
+ const runId = randomUUID()
55
+ const identity = sanitizeJournalData({ identifier: String(agent?.identifier || agent?.slug || 'agent'), slug: String(agent?.slug || agent?.identifier || 'agent'), provider: String(agent?.provider || 'unknown') }, { secrets })
56
+ const slug = identity.slug.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 60) || 'agent'
57
+ const digest = createHash('sha256').update(identity.identifier + ':' + identity.provider).digest('hex').slice(0, 12)
58
+ const directory = join(stateDir, 'observability')
59
+ const stem = `${slug}-${digest}`
60
+ const path = join(directory, stem + '.jsonl')
61
+ let sequence = 0, pendingBytes = 0, dropped = 0, written = 0, lastError = null
62
+ let timer, heartbeat, closing = false, closePromise, writing = false
63
+ const pending = []
64
+ const ready = (async () => {
65
+ await mkdir(directory, { recursive: true, mode: 0o700 })
66
+ const entry = await lstat(directory)
67
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error('Journal directory must be a real directory')
68
+ await chmod(directory, 0o700)
69
+ })()
70
+ // Initialization errors remain observable from close(), without an unhandled
71
+ // rejection or a synchronous failure in the watcher's critical path.
72
+ let writes = ready.catch((error) => { lastError = error })
73
+
74
+ const rotate = async () => {
75
+ const backup = (number) => join(directory, `${stem}.${number}.jsonl`)
76
+ await unlink(backup(2)).catch((error) => { if (error.code !== 'ENOENT') throw error })
77
+ await rename(backup(1), backup(2)).catch((error) => { if (error.code !== 'ENOENT') throw error })
78
+ await rename(path, backup(1)).catch((error) => { if (error.code !== 'ENOENT') throw error })
79
+ }
80
+ const flush = () => {
81
+ clearTimeout(timer); timer = null
82
+ if (writing || !pending.length) return writes
83
+ writing = true
84
+ const batch = pending.splice(0)
85
+ pendingBytes = 0
86
+ writes = writes.then(async () => {
87
+ await ready
88
+ const contents = batch.join('')
89
+ const size = await stat(path).then((entry) => entry.size).catch((error) => { if (error.code === 'ENOENT') return 0; throw error })
90
+ if (size + Buffer.byteLength(contents) > MAX_FILE_BYTES) await rotate()
91
+ const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW || 0), 0o600)
92
+ try { await handle.chmod(0o600); await handle.writeFile(contents); written += batch.length }
93
+ finally { await handle.close() }
94
+ }).catch((error) => { lastError = error }).finally(() => {
95
+ writing = false
96
+ if (pending.length && !closing && !timer) { timer = setTimeout(() => { void flush() }, 50); timer.unref?.() }
97
+ })
98
+ return writes
99
+ }
100
+ const enqueue = (type, data) => {
101
+ if (isPrivateThoughtEvent(type) || !/^[a-z][a-z0-9_.:-]{0,79}$/i.test(type)) return false
102
+ const safe = sanitizeJournalData(data, { secrets })
103
+ const event = { schemaVersion: 1, id: `${runId}:${++sequence}`, sequence, runId, timestamp: new Date().toISOString(), type, agent: identity, pid: process.pid, data: safe && typeof safe === 'object' ? safe : { value: safe } }
104
+ let line = JSON.stringify(event) + '\n'
105
+ if (Buffer.byteLength(line) > MAX_EVENT_BYTES) {
106
+ event.data = { truncated: true, summary: 'Event exceeded the local journal size limit', cycleId: safe?.cycleId }
107
+ line = JSON.stringify(event) + '\n'
108
+ }
109
+ const size = Buffer.byteLength(line)
110
+ while (pending.length && (pendingBytes + size > MAX_PENDING_BYTES || pending.length >= 256)) {
111
+ pendingBytes -= Buffer.byteLength(pending.shift()); dropped++
112
+ }
113
+ pending.push(line); pendingBytes += size
114
+ if (!timer) { timer = setTimeout(() => { void flush() }, 50); timer.unref?.() }
115
+ return true
116
+ }
117
+ const emit = (type, data = {}) => {
118
+ if (closing) return false
119
+ try { return enqueue(String(type || ''), data) }
120
+ catch (error) { lastError = error; return false }
121
+ }
122
+ emit('runtime.started', { status: 'running' })
123
+ heartbeat = setInterval(() => emit('runtime.heartbeat', { status: 'running', droppedEvents: dropped }), 15_000)
124
+ heartbeat.unref?.()
125
+ const close = () => {
126
+ if (closePromise) return closePromise
127
+ clearInterval(heartbeat)
128
+ emit('runtime.stopped', { status: 'stopped', droppedEvents: dropped })
129
+ closing = true
130
+ closePromise = (async () => {
131
+ while (pending.length || writing) await flush()
132
+ await writes
133
+ return { written, dropped, error: lastError ? sanitizeJournalData(String(lastError.message || lastError), { secrets }) : null }
134
+ })()
135
+ return closePromise
136
+ }
137
+ return { emit, close, runId }
138
+ }
@@ -1,7 +1,9 @@
1
+ import { taskIsAwaitingReview, taskIsCompleted, ticketDisplaySlug } from './events.mjs'
2
+
1
3
  // Assignment requests are routed by the watcher, before the restricted chat
2
4
  // runtime can mistake its own tool limits for the coding worker's capabilities.
3
5
  export function assignmentStatusOnly(value) {
4
- return /\b(?:do not|don't|dont|stop|cancel|stand down|status only|just (?:list|check|tell|show)|only (?:list|check|tell|show))\b/i.test(String(value || ''))
6
+ return /\b(?:(?:do\s+not|don't|dont)\s+(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick|work)|stop\s+(?:working|handling|on\s+this)|cancel\s+(?:this|that|it|the\s+(?:task|ticket))|stand\s+down|status\s+only|just\s+(?:list|check|tell|show)|only\s+(?:list|check|tell|show))\b/i.test(String(value || ''))
5
7
  }
6
8
 
7
9
  export function assignmentRequest(value) {
@@ -11,22 +13,32 @@ export function assignmentRequest(value) {
11
13
  const tickets = /\b(?:tickets?|tasks?|assignments?|backlog)\b/i.test(text) || slugs.length > 0
12
14
  const action = /\b(?:start|begin|resume|continue|implement|fix|finish|complete|handle|execute|dispatch|pick\s+up|work\s+(?:on|through))\b/i.test(text)
13
15
  const assigned = /\b(?:assigned|gave)\s+(?:\S+\s+){0,3}you\b|\byou\s+have\b.*\b(?:pending|assigned)\b/i.test(text)
16
+ if (!action && /\?|\b(?:check|list|show|tell|whether|how\s+many)\b/i.test(text)) return null
14
17
  return tickets && (action || assigned) ? { slugs } : null
15
18
  }
16
19
 
17
20
  export async function routeAssignments({ request, loadTickets, handleTask, isCancelled = () => false }) {
18
21
  const tickets = await loadTickets()
19
- const selected = tickets.filter((ticket) => !request.slugs.length || request.slugs.includes(ticket.slug))
22
+ if (!Array.isArray(tickets)) throw new Error('The assignment list was not a valid ticket list.')
23
+ const selected = tickets.filter((ticket) => ticket && (!request.slugs.length || request.slugs.includes(ticketDisplaySlug(ticket))))
20
24
  // A project-scoped slug must resolve uniquely before any work is started.
21
25
  for (const slug of request.slugs) {
22
- if (selected.filter((ticket) => ticket.slug === slug).length !== 1) {
26
+ if (selected.filter((ticket) => ticketDisplaySlug(ticket) === slug).length !== 1) {
23
27
  throw new Error(`I couldn't uniquely resolve ${slug} among my open assignments.`)
24
28
  }
25
29
  }
26
- const actionable = selected.filter((ticket) => !ticket.awaitingReview)
30
+ const actionable = selected.filter((ticket) => !ticket.awaitingReview && !taskIsAwaitingReview(ticket) && !taskIsCompleted(ticket))
31
+ if (isCancelled()) return 0
32
+ if (actionable.some((ticket) => ticket.id == null || (ticket.projectId ?? ticket.project_id) == null)) {
33
+ throw new Error('An assignment is missing its ticket or project identity.')
34
+ }
35
+ const dispatched = new Set()
27
36
  for (const ticket of actionable) {
28
- if (isCancelled()) return
29
- await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId } })
37
+ if (isCancelled()) break
38
+ const key = `${ticket.projectId ?? ticket.project_id}:${ticket.id}`
39
+ if (dispatched.has(key)) continue
40
+ await handleTask('task:assigned', { task: { id: ticket.id, project_id: ticket.projectId ?? ticket.project_id } })
41
+ dispatched.add(key)
30
42
  }
31
- return actionable.length
43
+ return dispatched.size
32
44
  }
@@ -1,7 +1,7 @@
1
1
  // Authorization recovery takes precedence over the generic failed-revision
2
2
  // gate. Clearing only the policy block left tickets permanently stuck.
3
3
  export function releaseAuthorizedPause(key, { blocked, repos, failed, seen, authorized }) {
4
- if (!blocked.has(key) || !repos.has(key) || !authorized(repos.get(key))) return false
4
+ if (!blocked.has(key) || !repos.has(key) || authorized(repos.get(key)) !== true) return false
5
5
  blocked.delete(key)
6
6
  repos.delete(key)
7
7
  failed.delete(key)