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.
- package/README.md +34 -4
- package/USER_GUIDE.md +174 -0
- package/bin/cli.mjs +9 -0
- package/package.json +7 -3
- package/scenarios/index.mjs +16 -0
- package/scenarios/routing.scenarios.mjs +348 -0
- package/scenarios/runtime.scenarios.mjs +451 -0
- package/scenarios/transport.scenarios.mjs +407 -0
- package/scenarios/workspace.scenarios.mjs +324 -0
- package/scripts/certify.mjs +12 -8
- package/scripts/run-scenarios.mjs +32 -0
- package/src/agent-journal.mjs +138 -0
- package/src/assignment-routing.mjs +19 -7
- package/src/authorization-resume.mjs +1 -1
- package/src/channel-routing.mjs +15 -8
- package/src/codex-mcp-proxy.mjs +63 -18
- package/src/concurrency.mjs +5 -3
- package/src/cycle-queue.mjs +16 -3
- package/src/events.mjs +91 -39
- package/src/mastra-harness.mjs +128 -22
- package/src/mcp-http.mjs +72 -19
- package/src/memory.mjs +16 -3
- package/src/pr-push.mjs +21 -8
- package/src/runner-pool.mjs +14 -3
- package/src/studio-cli.mjs +60 -0
- package/src/studio-server.mjs +224 -0
- package/src/task-types.mjs +3 -2
- package/src/thread-context.mjs +62 -0
- package/src/watch.mjs +386 -114
- package/src/ws.mjs +20 -1
- package/studio/app.mjs +658 -0
- package/studio/guide.html +122 -0
- package/studio/index.html +61 -0
- package/studio/style.css +223 -0
|
@@ -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
|
+
}
|
package/scripts/certify.mjs
CHANGED
|
@@ -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
|
|
17
|
-
for (const file of readdirSync(join(root,
|
|
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(
|
|
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("
|
|
67
|
-
['coding mentions require an action and concrete repository target', watcher.includes('conversationNeedsCode(text
|
|
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('
|
|
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('
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
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())
|
|
29
|
-
|
|
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
|
|
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) ||
|
|
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)
|