@zooid/transport-matrix 0.13.0 → 0.14.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/dist/index.d.ts +152 -4
- package/dist/index.js +898 -94
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +61 -4
- package/src/context-provider.ts +40 -2
- package/src/index.ts +23 -3
- package/src/invocation-registry.test.ts +22 -0
- package/src/invocation-registry.ts +32 -0
- package/src/matrix-client.ts +58 -34
- package/src/router.test.ts +129 -10
- package/src/router.ts +78 -2
- package/src/task-completion.test.ts +23 -0
- package/src/task-completion.ts +37 -0
- package/src/task-dispatch.test.ts +53 -0
- package/src/task-dispatch.ts +68 -0
- package/src/task-envelope.test.ts +27 -0
- package/src/task-registry.test.ts +50 -0
- package/src/task-registry.ts +152 -0
- package/src/transport.test.ts +403 -114
- package/src/transport.ts +719 -110
package/src/router.ts
CHANGED
|
@@ -50,6 +50,11 @@ export interface ThreadState {
|
|
|
50
50
|
handoffs: Record<string, string[]>
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export interface TaskThreadContext {
|
|
54
|
+
assignee: string
|
|
55
|
+
isRoot: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
interface MaybeEvent {
|
|
54
59
|
type?: string
|
|
55
60
|
room_id?: string
|
|
@@ -71,6 +76,7 @@ export function route(
|
|
|
71
76
|
event: MaybeEvent,
|
|
72
77
|
agents: AgentBinding[],
|
|
73
78
|
threadStates?: Map<string, ThreadState>,
|
|
79
|
+
task?: TaskThreadContext,
|
|
74
80
|
): RouteMatch[] {
|
|
75
81
|
if (event.type !== 'm.room.message') return []
|
|
76
82
|
if (!event.content?.msgtype) return []
|
|
@@ -81,8 +87,27 @@ export function route(
|
|
|
81
87
|
const threadState = threadRoot ? threadStates?.get(threadRoot) : undefined
|
|
82
88
|
|
|
83
89
|
for (const a of agents) {
|
|
84
|
-
if (event.sender === a.userId) continue
|
|
85
90
|
if (!a.rooms.some((r) => r.alias === event.room_id)) continue
|
|
91
|
+
if (task?.isRoot) {
|
|
92
|
+
if (a.name === task.assignee) matches.push(a)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (event.sender === a.userId) continue
|
|
96
|
+
if (task) {
|
|
97
|
+
if (mentions.has(a.userId)) {
|
|
98
|
+
matches.push(a)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const senderAgent = agents.find((x) => x.userId === event.sender)
|
|
102
|
+
if (senderAgent) {
|
|
103
|
+
// A delegated task returns at an invocation terminal boundary, never
|
|
104
|
+
// because a callee happened to post progress prose.
|
|
105
|
+
continue
|
|
106
|
+
} else if (a.name === task.assignee) {
|
|
107
|
+
matches.push(a)
|
|
108
|
+
}
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
86
111
|
if (a.trigger === 'any') {
|
|
87
112
|
matches.push(a)
|
|
88
113
|
continue
|
|
@@ -100,7 +125,7 @@ export function route(
|
|
|
100
125
|
// sender (its caller), never to a callee. Directional continuation
|
|
101
126
|
// keeps agent↔agent handoffs from looping — the call graph is a tree
|
|
102
127
|
// rooted at the human, so returns only ever walk up.
|
|
103
|
-
if (
|
|
128
|
+
if (isReturnRoute(event, a, agents, threadState)) matches.push(a)
|
|
104
129
|
} else {
|
|
105
130
|
// Human (or non-agent) follow-up: continue with the most-recent-posting
|
|
106
131
|
// agent, or inherit the root mention if no agent has posted yet.
|
|
@@ -115,3 +140,54 @@ export function route(
|
|
|
115
140
|
}
|
|
116
141
|
return matches
|
|
117
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True when routing `event` to `agent` is a *return* — a callee's reply
|
|
146
|
+
* bubbling up to the agent that called it — rather than a fresh call or a
|
|
147
|
+
* human follow-up. A callee may address its existing caller explicitly and it
|
|
148
|
+
* is still a return.
|
|
149
|
+
*
|
|
150
|
+
* The transport defers returns to the sender's turn boundary. An agent turn
|
|
151
|
+
* posts one `m.room.message` per buffered chunk (every tool call forces a
|
|
152
|
+
* flush), so treating each chunk as a return woke the caller once per chunk
|
|
153
|
+
* and the two agents read as re-triggering each other. See [[ZOD039]]
|
|
154
|
+
* § Implicit triggers → Directional continuation.
|
|
155
|
+
*/
|
|
156
|
+
export function isReturnRoute(
|
|
157
|
+
event: MaybeEvent,
|
|
158
|
+
agent: AgentBinding,
|
|
159
|
+
agents: AgentBinding[],
|
|
160
|
+
threadState: ThreadState | undefined,
|
|
161
|
+
): boolean {
|
|
162
|
+
if (!threadState || agent.trigger !== 'mention') return false
|
|
163
|
+
const sender = agents.find((x) => x.userId === event.sender)
|
|
164
|
+
if (!sender || sender.name === agent.name) return false
|
|
165
|
+
// Addressing the existing caller explicitly does not reverse the call edge:
|
|
166
|
+
// it is still the callee returning control. This matters for agents that
|
|
167
|
+
// naturally prefix their final answer with `@caller`; treating that as a new
|
|
168
|
+
// call creates the exact A ↔ B cycle directional continuation prevents.
|
|
169
|
+
return threadState.callers[sender.name] === agent.name
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True when recording `callee`’s caller as `caller` would put a cycle in the
|
|
174
|
+
* call graph — i.e. `callee` is already an ancestor of `caller`. The graph has
|
|
175
|
+
* to stay a tree rooted at the human, because `route` walks it upward on every
|
|
176
|
+
* return; a 2-cycle (A calls B, B @mentions A back) would bounce forever.
|
|
177
|
+
* A mention that would close a cycle is a return, not a call, so it routes but
|
|
178
|
+
* records no edge.
|
|
179
|
+
*/
|
|
180
|
+
export function wouldCycleCallers(
|
|
181
|
+
callers: Record<string, string>,
|
|
182
|
+
callee: string,
|
|
183
|
+
caller: string,
|
|
184
|
+
): boolean {
|
|
185
|
+
const seen = new Set<string>()
|
|
186
|
+
let cursor: string | undefined = caller
|
|
187
|
+
while (cursor !== undefined) {
|
|
188
|
+
if (cursor === callee || seen.has(cursor)) return true
|
|
189
|
+
seen.add(cursor)
|
|
190
|
+
cursor = callers[cursor]
|
|
191
|
+
}
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { evaluateCompletion } from './task-completion.js'
|
|
3
|
+
|
|
4
|
+
const base = { agent: 'worker', threadId: '$root', outstanding: 0, awaitingHuman: 0 } as const
|
|
5
|
+
|
|
6
|
+
describe('evaluateCompletion', () => {
|
|
7
|
+
it('uses a summary only after all work has returned', () => {
|
|
8
|
+
expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done', outstanding: 1 }))
|
|
9
|
+
.toEqual({ decision: 'stay_open', reason: 'outstanding_handoff' })
|
|
10
|
+
expect(evaluateCompletion({ ...base, stopReason: 'end_turn', summary: 'done' }))
|
|
11
|
+
.toMatchObject({ decision: 'finish', completion: { status: 'complete', output: { text: 'done' } } })
|
|
12
|
+
})
|
|
13
|
+
it('makes cancellation and limits terminal even with outstanding work', () => {
|
|
14
|
+
expect(evaluateCompletion({ ...base, stopReason: 'cancelled', outstanding: 1 }))
|
|
15
|
+
.toMatchObject({ decision: 'finish', completion: { status: 'cancelled' } })
|
|
16
|
+
expect(evaluateCompletion({ ...base, stopReason: 'max_tokens', outstanding: 1 }))
|
|
17
|
+
.toMatchObject({ decision: 'finish', completion: { status: 'partial', reason: 'max_tokens' } })
|
|
18
|
+
})
|
|
19
|
+
it('does not report an empty successful result', () => {
|
|
20
|
+
expect(evaluateCompletion({ ...base, stopReason: 'end_turn' }))
|
|
21
|
+
.toMatchObject({ decision: 'finish', completion: { status: 'failed', reason: 'no_result' } })
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ThreadCompletion } from '@zooid/core'
|
|
2
|
+
|
|
3
|
+
/** ACP's stable prompt termination values (kept local to avoid an SDK runtime dep). */
|
|
4
|
+
export type StopReason = 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'
|
|
5
|
+
|
|
6
|
+
export interface CompletionInputs {
|
|
7
|
+
agent: string
|
|
8
|
+
threadId: string
|
|
9
|
+
stopReason?: StopReason
|
|
10
|
+
error?: unknown
|
|
11
|
+
summary?: string
|
|
12
|
+
prose?: string
|
|
13
|
+
outstanding: number
|
|
14
|
+
awaitingHuman: number
|
|
15
|
+
}
|
|
16
|
+
export type CompletionDecision =
|
|
17
|
+
| { decision: 'stay_open'; reason: 'outstanding_handoff' | 'awaiting_human' }
|
|
18
|
+
| { decision: 'finish'; completion: ThreadCompletion }
|
|
19
|
+
|
|
20
|
+
export function evaluateCompletion(input: CompletionInputs): CompletionDecision {
|
|
21
|
+
const prose = input.prose?.trim()
|
|
22
|
+
const output = prose ? { type: 'message' as const, text: prose } : undefined
|
|
23
|
+
const finish = (completion: Omit<ThreadCompletion, 'agent' | 'thread_id'>): CompletionDecision => ({
|
|
24
|
+
decision: 'finish', completion: { agent: input.agent, thread_id: input.threadId, ...completion },
|
|
25
|
+
})
|
|
26
|
+
if (input.error !== undefined)
|
|
27
|
+
return finish({ status: 'failed', error: input.error instanceof Error ? input.error.message : String(input.error), ...(output ? { output } : {}) })
|
|
28
|
+
if (input.stopReason === 'cancelled') return finish({ status: 'cancelled', ...(output ? { output } : {}) })
|
|
29
|
+
if (input.stopReason === 'max_tokens' || input.stopReason === 'max_turn_requests')
|
|
30
|
+
return finish({ status: 'partial', reason: input.stopReason, ...(output ? { output } : {}) })
|
|
31
|
+
if (input.stopReason === 'refusal') return finish({ status: 'failed', reason: 'refusal', ...(output ? { output } : {}) })
|
|
32
|
+
if (input.awaitingHuman > 0) return { decision: 'stay_open', reason: 'awaiting_human' }
|
|
33
|
+
if (input.outstanding > 0) return { decision: 'stay_open', reason: 'outstanding_handoff' }
|
|
34
|
+
if (input.summary) return finish({ status: 'complete', output: { type: 'message', text: input.summary } })
|
|
35
|
+
if (output) return finish({ status: 'complete', output })
|
|
36
|
+
return finish({ status: 'failed', reason: 'no_result', error: 'No result produced' })
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildAssignmentContent, checkDelegable, renderCompletionPrompt } from './task-dispatch.js'
|
|
3
|
+
import type { AgentBinding } from './router.js'
|
|
4
|
+
const agents: AgentBinding[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'supervisor',
|
|
7
|
+
userId: '@supervisor:hs',
|
|
8
|
+
rooms: [{ alias: '!r:hs' }],
|
|
9
|
+
trigger: 'mention',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'worker',
|
|
13
|
+
userId: '@worker:hs',
|
|
14
|
+
rooms: [{ alias: '!r:hs' }],
|
|
15
|
+
trigger: 'mention',
|
|
16
|
+
},
|
|
17
|
+
]
|
|
18
|
+
describe('task dispatch', () => {
|
|
19
|
+
it('admits only local room agents', () => {
|
|
20
|
+
expect(checkDelegable('worker', '!r:hs', agents)).toEqual({ ok: true })
|
|
21
|
+
expect(checkDelegable('ghost', '!r:hs', agents)).toMatchObject({
|
|
22
|
+
ok: false,
|
|
23
|
+
reason: expect.stringContaining('unknown_agent'),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
it('makes a visible, unthreaded signed root and renders a return', () => {
|
|
27
|
+
const content = buildAssignmentContent({
|
|
28
|
+
assigneeUserId: '@worker:hs',
|
|
29
|
+
prompt: 'audit',
|
|
30
|
+
start: {
|
|
31
|
+
version: 1,
|
|
32
|
+
assignee: 'worker',
|
|
33
|
+
attempt_id: 'a1',
|
|
34
|
+
parent: { agent: 'supervisor', thread_root: '$p', session_key: '$p' },
|
|
35
|
+
notify: 'caller',
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
expect(content).toMatchObject({
|
|
39
|
+
msgtype: 'm.notice',
|
|
40
|
+
body: '@worker:hs audit',
|
|
41
|
+
'm.mentions': { user_ids: ['@worker:hs'] },
|
|
42
|
+
})
|
|
43
|
+
expect(content['m.relates_to']).toBeUndefined()
|
|
44
|
+
expect(
|
|
45
|
+
renderCompletionPrompt({
|
|
46
|
+
agent: 'worker',
|
|
47
|
+
thread_id: '$task',
|
|
48
|
+
status: 'complete',
|
|
49
|
+
output: { type: 'message', text: 'done' },
|
|
50
|
+
}),
|
|
51
|
+
).toContain('done')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { THREAD_START_FIELD, type ThreadCompletion, type ThreadStartContent } from '@zooid/core'
|
|
2
|
+
import type { AgentBinding } from './router.js'
|
|
3
|
+
export type Admission = { ok: true } | { ok: false; reason: string }
|
|
4
|
+
export function checkDelegable(
|
|
5
|
+
agentName: string,
|
|
6
|
+
roomId: string,
|
|
7
|
+
bindings: AgentBinding[],
|
|
8
|
+
): Admission {
|
|
9
|
+
const target = bindings.find((b) => b.name === agentName)
|
|
10
|
+
if (!target)
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
reason: `unknown_agent: no agent named "${agentName}" is configured here`,
|
|
14
|
+
}
|
|
15
|
+
if (!target.rooms.some((r) => r.alias === roomId))
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: `not_in_room: "${agentName}" is not a member of this room`,
|
|
19
|
+
}
|
|
20
|
+
return { ok: true }
|
|
21
|
+
}
|
|
22
|
+
export function buildAssignmentContent(input: {
|
|
23
|
+
assigneeUserId: string
|
|
24
|
+
prompt: string
|
|
25
|
+
start: ThreadStartContent
|
|
26
|
+
}): { msgtype: string; body: string; [key: string]: unknown } {
|
|
27
|
+
const escaped = input.prompt.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
28
|
+
const body = `${input.assigneeUserId} ${input.prompt}`.trim()
|
|
29
|
+
return {
|
|
30
|
+
msgtype: 'm.notice',
|
|
31
|
+
body,
|
|
32
|
+
format: 'org.matrix.custom.html',
|
|
33
|
+
formatted_body: `<a href="https://matrix.to/#/${encodeURIComponent(input.assigneeUserId)}">${input.assigneeUserId}</a> ${escaped}`,
|
|
34
|
+
'm.mentions': { user_ids: [input.assigneeUserId] },
|
|
35
|
+
[THREAD_START_FIELD]: input.start,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function renderCompletionPrompt(c: ThreadCompletion) {
|
|
39
|
+
return [
|
|
40
|
+
`[task result] ${c.agent} — status: ${c.status} (thread ${c.thread_id})`,
|
|
41
|
+
...(c.reason ? [`reason: ${c.reason}`] : []),
|
|
42
|
+
...(c.error ? [`error: ${c.error}`] : []),
|
|
43
|
+
...(c.output?.text ? ['', c.output.text] : []),
|
|
44
|
+
].join('\n')
|
|
45
|
+
}
|
|
46
|
+
export function renderInvocationReturn(c: ThreadCompletion) {
|
|
47
|
+
return [
|
|
48
|
+
`[handoff result] ${c.agent} — status: ${c.status}`,
|
|
49
|
+
...(c.reason ? [`reason: ${c.reason}`] : []),
|
|
50
|
+
...(c.error ? [`error: ${c.error}`] : []),
|
|
51
|
+
...(c.output?.text ? ['', c.output.text] : []),
|
|
52
|
+
].join('\n')
|
|
53
|
+
}
|
|
54
|
+
export function renderDelivery(notify: 'caller' | 'none'): string {
|
|
55
|
+
return notify === 'caller'
|
|
56
|
+
? 'Each result returns to you as a new turn when that task completes. End your turn now — do not read the task thread to wait for it.'
|
|
57
|
+
: 'No result returns to you. The task thread is the result surface; thread_id is for later reference, not something to wait on.'
|
|
58
|
+
}
|
|
59
|
+
export function renderAssigneeEnvelope(input: { parentAgent: string; prompt: string }): string {
|
|
60
|
+
return [
|
|
61
|
+
`[task] from ${input.parentAgent} — you are the assignee of this thread.`,
|
|
62
|
+
'Call zooid_complete_task with a self-contained summary when you are done;',
|
|
63
|
+
'ending your turn without one publishes your last message as the result.',
|
|
64
|
+
'Sibling task threads are refused here — @mention an agent in this thread to hand off.',
|
|
65
|
+
'',
|
|
66
|
+
input.prompt,
|
|
67
|
+
].join('\n')
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderAssigneeEnvelope, renderDelivery } from './task-dispatch.js'
|
|
3
|
+
|
|
4
|
+
describe('renderAssigneeEnvelope', () => {
|
|
5
|
+
it('names the caller, the owed result, and the depth cap', () => {
|
|
6
|
+
const out = renderAssigneeEnvelope({
|
|
7
|
+
parentAgent: 'zooid-assistant',
|
|
8
|
+
prompt: 'Write a one-sentence bug report.',
|
|
9
|
+
})
|
|
10
|
+
expect(out).toMatch(/^\[task\] from zooid-assistant/)
|
|
11
|
+
expect(out).toMatch(/zooid_complete_task/)
|
|
12
|
+
expect(out).toMatch(/ending your turn without one/i)
|
|
13
|
+
expect(out).toMatch(/@mention/)
|
|
14
|
+
expect(out.endsWith('Write a one-sentence bug report.')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('renderDelivery', () => {
|
|
19
|
+
it('tells a notify:caller supervisor to stop and wait', () => {
|
|
20
|
+
expect(renderDelivery('caller')).toMatch(/new turn/)
|
|
21
|
+
expect(renderDelivery('caller')).toMatch(/do not read the task thread/i)
|
|
22
|
+
})
|
|
23
|
+
it('tells a notify:none supervisor the thread is the result surface', () => {
|
|
24
|
+
expect(renderDelivery('none')).toMatch(/no result returns/i)
|
|
25
|
+
expect(renderDelivery('none')).not.toMatch(/new turn/)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { MAX_OPEN_TASKS_PER_ROOM, TaskRegistry } from './task-registry.js'
|
|
3
|
+
|
|
4
|
+
const parent = {
|
|
5
|
+
agent: 'supervisor',
|
|
6
|
+
threadRoot: '$parent',
|
|
7
|
+
sessionKey: '$parent',
|
|
8
|
+
generation: 0,
|
|
9
|
+
}
|
|
10
|
+
const reserve = (r: TaskRegistry, roomId = '!room:hs') =>
|
|
11
|
+
r.reserve({ roomId, assignee: 'worker', notify: 'caller', parent })
|
|
12
|
+
|
|
13
|
+
describe('TaskRegistry', () => {
|
|
14
|
+
it('enforces the open-task cap independently per room and releases once', () => {
|
|
15
|
+
const r = new TaskRegistry({
|
|
16
|
+
newId: (() => {
|
|
17
|
+
let i = 0
|
|
18
|
+
return () => `t${++i}`
|
|
19
|
+
})(),
|
|
20
|
+
})
|
|
21
|
+
for (let i = 0; i < MAX_OPEN_TASKS_PER_ROOM; i++) expect(reserve(r)).toBeDefined()
|
|
22
|
+
expect(reserve(r)).toBeUndefined()
|
|
23
|
+
const task = r.taskForRoot('$root')
|
|
24
|
+
expect(task).toBeUndefined()
|
|
25
|
+
r.activate('t1', '$root')
|
|
26
|
+
expect(r.close('t1')).toBe(true)
|
|
27
|
+
expect(r.close('t1')).toBe(false)
|
|
28
|
+
expect(reserve(r)).toBeDefined()
|
|
29
|
+
expect(reserve(r, '!other:hs')).toBeDefined()
|
|
30
|
+
})
|
|
31
|
+
it('adopts only issued attempts, retains closed roots, and protects uncertain sends', () => {
|
|
32
|
+
const r = new TaskRegistry({ maxOpenPerRoom: 1, newId: () => 'attempt' })
|
|
33
|
+
const task = reserve(r)!
|
|
34
|
+
r.markUncertain(task.taskId)
|
|
35
|
+
expect(reserve(r)).toBeUndefined()
|
|
36
|
+
expect(r.adopt('forged', '$bad')).toBeUndefined()
|
|
37
|
+
expect(r.adopt(task.attemptId, '$root')?.phase).toBe('open')
|
|
38
|
+
expect(r.openTaskFor('worker', '$root')?.taskId).toBe(task.taskId)
|
|
39
|
+
expect(r.recordSummary(task.taskId, 'first')).toBe('recorded')
|
|
40
|
+
expect(r.recordSummary(task.taskId, 'second')).toBe('already_recorded')
|
|
41
|
+
r.close(task.taskId)
|
|
42
|
+
expect(r.taskForRoot('$root')?.phase).toBe('closed')
|
|
43
|
+
})
|
|
44
|
+
it('bumps session generations after a reset', () => {
|
|
45
|
+
const r = new TaskRegistry()
|
|
46
|
+
expect(r.generationOf('a', '$s')).toBe(0)
|
|
47
|
+
r.bumpGeneration('a', '$s')
|
|
48
|
+
expect(r.generationOf('a', '$s')).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
export const MAX_OPEN_TASKS_PER_ROOM = 5
|
|
3
|
+
export type TaskPhase = 'reserved' | 'uncertain' | 'open' | 'closed'
|
|
4
|
+
export interface TaskRecord {
|
|
5
|
+
taskId: string
|
|
6
|
+
attemptId: string
|
|
7
|
+
roomId: string
|
|
8
|
+
assignee: string
|
|
9
|
+
notify: 'caller' | 'none'
|
|
10
|
+
parent: {
|
|
11
|
+
agent: string
|
|
12
|
+
threadRoot: string
|
|
13
|
+
sessionKey: string
|
|
14
|
+
generation: number
|
|
15
|
+
}
|
|
16
|
+
phase: TaskPhase
|
|
17
|
+
threadRoot?: string
|
|
18
|
+
summary?: string
|
|
19
|
+
runId?: string
|
|
20
|
+
closedAt?: string
|
|
21
|
+
}
|
|
22
|
+
export interface PersistedTask extends Required<Pick<TaskRecord, 'taskId' | 'attemptId' | 'roomId' | 'assignee' | 'notify' | 'parent' | 'phase'>> {
|
|
23
|
+
threadRoot?: string
|
|
24
|
+
summary?: string
|
|
25
|
+
runId: string
|
|
26
|
+
closedAt?: string
|
|
27
|
+
}
|
|
28
|
+
export interface TaskJournal { load(): PersistedTask[]; save(tasks: PersistedTask[]): void }
|
|
29
|
+
export class TaskRegistry {
|
|
30
|
+
private readonly tasks = new Map<string, TaskRecord>()
|
|
31
|
+
private readonly byRoot = new Map<string, string>()
|
|
32
|
+
private readonly generations = new Map<string, number>()
|
|
33
|
+
private readonly runIdValue: string
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly opts: {
|
|
36
|
+
maxOpenPerRoom?: number
|
|
37
|
+
newId?: () => string
|
|
38
|
+
journal?: TaskJournal
|
|
39
|
+
runId?: string
|
|
40
|
+
maxClosedRecords?: number
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
43
|
+
this.runIdValue = this.opts.runId ?? randomUUID()
|
|
44
|
+
}
|
|
45
|
+
private get max() {
|
|
46
|
+
return this.opts.maxOpenPerRoom ?? MAX_OPEN_TASKS_PER_ROOM
|
|
47
|
+
}
|
|
48
|
+
private get runId() { return this.runIdValue }
|
|
49
|
+
private save() {
|
|
50
|
+
if (!this.opts.journal) return
|
|
51
|
+
const rows = [...this.tasks.values()].map((r) => ({ ...r, runId: r.runId ?? this.runId }) as PersistedTask)
|
|
52
|
+
const max = this.opts.maxClosedRecords ?? 500
|
|
53
|
+
const open = rows.filter((r) => r.phase !== 'closed')
|
|
54
|
+
const closed = rows.filter((r) => r.phase === 'closed').sort((a, b) => (b.closedAt ?? '').localeCompare(a.closedAt ?? '')).slice(0, max)
|
|
55
|
+
this.opts.journal.save([...open, ...closed])
|
|
56
|
+
}
|
|
57
|
+
openCount(roomId: string) {
|
|
58
|
+
return [...this.tasks.values()].filter((t) => t.roomId === roomId && t.phase !== 'closed')
|
|
59
|
+
.length
|
|
60
|
+
}
|
|
61
|
+
reserve(
|
|
62
|
+
input: Omit<TaskRecord, 'taskId' | 'attemptId' | 'phase' | 'threadRoot' | 'summary'>,
|
|
63
|
+
): TaskRecord | undefined {
|
|
64
|
+
if (this.openCount(input.roomId) >= this.max) return
|
|
65
|
+
const id = this.opts.newId?.() ?? randomUUID()
|
|
66
|
+
const rec: TaskRecord = {
|
|
67
|
+
taskId: id,
|
|
68
|
+
attemptId: id,
|
|
69
|
+
phase: 'reserved',
|
|
70
|
+
runId: this.runId,
|
|
71
|
+
...input,
|
|
72
|
+
}
|
|
73
|
+
this.tasks.set(id, rec)
|
|
74
|
+
this.save()
|
|
75
|
+
return rec
|
|
76
|
+
}
|
|
77
|
+
activate(taskId: string, threadRoot: string) {
|
|
78
|
+
const r = this.tasks.get(taskId)
|
|
79
|
+
if (!r) return
|
|
80
|
+
r.phase = 'open'
|
|
81
|
+
r.threadRoot = threadRoot
|
|
82
|
+
this.byRoot.set(threadRoot, taskId)
|
|
83
|
+
this.save()
|
|
84
|
+
}
|
|
85
|
+
abandon(taskId: string) {
|
|
86
|
+
this.tasks.delete(taskId)
|
|
87
|
+
this.save()
|
|
88
|
+
}
|
|
89
|
+
markUncertain(taskId: string) {
|
|
90
|
+
const r = this.tasks.get(taskId)
|
|
91
|
+
if (r?.phase === 'reserved') r.phase = 'uncertain'
|
|
92
|
+
this.save()
|
|
93
|
+
}
|
|
94
|
+
adopt(attemptId: string, threadRoot: string) {
|
|
95
|
+
const r = this.tasks.get(attemptId)
|
|
96
|
+
if (!r) return
|
|
97
|
+
if (r.phase === 'closed' || (r.threadRoot && r.threadRoot !== threadRoot)) return r
|
|
98
|
+
this.activate(r.taskId, threadRoot)
|
|
99
|
+
return r
|
|
100
|
+
}
|
|
101
|
+
taskForRoot(threadRoot: string) {
|
|
102
|
+
const id = this.byRoot.get(threadRoot)
|
|
103
|
+
return id ? this.tasks.get(id) : undefined
|
|
104
|
+
}
|
|
105
|
+
openTaskFor(agent: string, root: string) {
|
|
106
|
+
const r = this.taskForRoot(root)
|
|
107
|
+
return r?.phase === 'open' && r.assignee === agent ? r : undefined
|
|
108
|
+
}
|
|
109
|
+
recordSummary(id: string, summary: string) {
|
|
110
|
+
const r = this.tasks.get(id)
|
|
111
|
+
if (!r || r.summary !== undefined) return 'already_recorded' as const
|
|
112
|
+
r.summary = summary
|
|
113
|
+
this.save()
|
|
114
|
+
return 'recorded' as const
|
|
115
|
+
}
|
|
116
|
+
clearSummary(id: string) {
|
|
117
|
+
const r = this.tasks.get(id)
|
|
118
|
+
if (r) r.summary = undefined
|
|
119
|
+
this.save()
|
|
120
|
+
}
|
|
121
|
+
close(id: string) {
|
|
122
|
+
const r = this.tasks.get(id)
|
|
123
|
+
if (!r || r.phase === 'closed') return false
|
|
124
|
+
r.phase = 'closed'
|
|
125
|
+
r.closedAt = new Date().toISOString()
|
|
126
|
+
this.save()
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
/** Reconcile records from a prior daemon run and retain closed roots for trust checks. */
|
|
130
|
+
restore(): TaskRecord[] {
|
|
131
|
+
const rows = this.opts.journal?.load() ?? []
|
|
132
|
+
const interrupted: TaskRecord[] = []
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
const rec: TaskRecord = { ...row }
|
|
135
|
+
if (rec.phase !== 'closed' && rec.runId !== this.runId) {
|
|
136
|
+
rec.phase = 'closed'; rec.closedAt = new Date().toISOString()
|
|
137
|
+
interrupted.push(rec)
|
|
138
|
+
}
|
|
139
|
+
this.tasks.set(rec.taskId, rec)
|
|
140
|
+
if (rec.threadRoot) this.byRoot.set(rec.threadRoot, rec.taskId)
|
|
141
|
+
}
|
|
142
|
+
this.save()
|
|
143
|
+
return interrupted
|
|
144
|
+
}
|
|
145
|
+
generationOf(agent: string, session: string) {
|
|
146
|
+
return this.generations.get(`${agent}::${session}`) ?? 0
|
|
147
|
+
}
|
|
148
|
+
bumpGeneration(agent: string, session: string) {
|
|
149
|
+
const k = `${agent}::${session}`
|
|
150
|
+
this.generations.set(k, this.generationOf(agent, session) + 1)
|
|
151
|
+
}
|
|
152
|
+
}
|