@zooid/transport-matrix 0.12.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.
@@ -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
+ }