talking-stick 0.1.0-alpha
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 +166 -0
- package/dist/cli.js +701 -0
- package/dist/commands.js +70 -0
- package/dist/config.js +31 -0
- package/dist/db.js +177 -0
- package/dist/errors.js +20 -0
- package/dist/identity.js +184 -0
- package/dist/index.js +12 -0
- package/dist/install.js +272 -0
- package/dist/mcp-server.js +171 -0
- package/dist/path-resolution.js +101 -0
- package/dist/process-utils.js +93 -0
- package/dist/server.js +3 -0
- package/dist/service.js +980 -0
- package/dist/session-store.js +80 -0
- package/dist/skill-install.js +107 -0
- package/dist/types.js +1 -0
- package/docs/ambient-presence.md +191 -0
- package/docs/releases/0.1.0-alpha.md +32 -0
- package/docs/talking-stick-plan.md +1156 -0
- package/package.json +40 -0
- package/skills/talking-stick/SKILL.md +132 -0
- package/skills/talking-stick/agents/openai.yaml +4 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { ancestorPaths, resolveContextPath } from "./path-resolution.js";
|
|
5
|
+
import { defaultPolicy } from "./config.js";
|
|
6
|
+
import { openDatabase, withImmediateTransaction } from "./db.js";
|
|
7
|
+
import { ProtocolError } from "./errors.js";
|
|
8
|
+
import { createSystemProcessInspector } from "./process-utils.js";
|
|
9
|
+
export class TalkingStickService {
|
|
10
|
+
db;
|
|
11
|
+
policy;
|
|
12
|
+
now;
|
|
13
|
+
ownsDatabase;
|
|
14
|
+
processLivenessChecker;
|
|
15
|
+
hostId;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.db = options.db ?? openDatabase(options);
|
|
18
|
+
this.ownsDatabase = !options.db;
|
|
19
|
+
this.now = options.now ?? (() => new Date());
|
|
20
|
+
this.policy = { ...defaultPolicy, ...options.policy };
|
|
21
|
+
this.hostId = options.hostId ?? os.hostname();
|
|
22
|
+
this.processLivenessChecker =
|
|
23
|
+
options.processLivenessChecker ??
|
|
24
|
+
createDefaultProcessLivenessChecker(this.hostId);
|
|
25
|
+
}
|
|
26
|
+
close() {
|
|
27
|
+
if (this.ownsDatabase && this.db.open) {
|
|
28
|
+
this.db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
listRooms(input = {}) {
|
|
32
|
+
const now = this.now();
|
|
33
|
+
if (!input.context_path) {
|
|
34
|
+
const rows = this.db
|
|
35
|
+
.prepare("SELECT * FROM path_rooms ORDER BY canonical_path")
|
|
36
|
+
.all();
|
|
37
|
+
return {
|
|
38
|
+
rooms: rows.map((row) => this.mapRoomForList(row, now))
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const resolved = resolveContextPath(input.context_path);
|
|
42
|
+
const ancestors = ancestorPaths(resolved.canonical_context_path, resolved.workspace_root);
|
|
43
|
+
return {
|
|
44
|
+
rooms: this.findRoomsByCanonicalPaths(ancestors).map((row) => this.mapRoomForList(row, now))
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
joinPath(input) {
|
|
48
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
49
|
+
assertNonEmpty(input.context_path, "context_path");
|
|
50
|
+
const resolved = resolveContextPath(input.context_path);
|
|
51
|
+
const now = this.now();
|
|
52
|
+
const timestamp = now.toISOString();
|
|
53
|
+
return withImmediateTransaction(this.db, () => {
|
|
54
|
+
const roomSelection = this.findOrCreateRoomForJoin(resolved, input.force_new === true, timestamp);
|
|
55
|
+
this.upsertMember(roomSelection.room.room_id, input.agent_id, timestamp, input.process_metadata);
|
|
56
|
+
const freshRoom = this.requireRoom(roomSelection.room.room_id);
|
|
57
|
+
return {
|
|
58
|
+
agent_id: input.agent_id,
|
|
59
|
+
room_id: freshRoom.room_id,
|
|
60
|
+
canonical_path: freshRoom.canonical_path,
|
|
61
|
+
requested_path: resolved.requested_path,
|
|
62
|
+
workspace_root: resolved.workspace_root,
|
|
63
|
+
joined_existing_room: roomSelection.joinedExistingRoom,
|
|
64
|
+
warning: roomSelection.warning,
|
|
65
|
+
policy: { ...this.policy },
|
|
66
|
+
room_state: this.mapRoom(this.inspectRoom(freshRoom, now), now),
|
|
67
|
+
handoff_template: handoffTemplate()
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async waitForTurn(input) {
|
|
72
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
73
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
74
|
+
const maxWaitMs = input.max_wait_ms ?? this.policy.waitForTurnMaxWaitMs;
|
|
75
|
+
const deadline = Date.now() + Math.max(0, maxWaitMs);
|
|
76
|
+
while (true) {
|
|
77
|
+
this.warmRoomTurnLiveness(input.room_id);
|
|
78
|
+
const result = withImmediateTransaction(this.db, () => this.waitForTurnOnce(input));
|
|
79
|
+
if (result.status !== "not_yet" || Date.now() >= deadline) {
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
const remainingMs = deadline - Date.now();
|
|
83
|
+
await sleep(Math.min(this.policy.waitForTurnPollMs, remainingMs));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
heartbeat(input) {
|
|
87
|
+
const now = this.now();
|
|
88
|
+
const timestamp = now.toISOString();
|
|
89
|
+
const nextLeaseExpiresAt = this.expiresAt(now, this.policy.ownerLeaseTtlMs);
|
|
90
|
+
this.warmRoomTurnLiveness(input.room_id);
|
|
91
|
+
return withImmediateTransaction(this.db, () => {
|
|
92
|
+
const room = this.requireRoom(input.room_id);
|
|
93
|
+
this.assertOwnerMutation(room, input, now);
|
|
94
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
95
|
+
this.db
|
|
96
|
+
.prepare(`
|
|
97
|
+
UPDATE path_rooms
|
|
98
|
+
SET lease_expires_at = ?, updated_at = ?, state = 'owned'
|
|
99
|
+
WHERE room_id = ?
|
|
100
|
+
`)
|
|
101
|
+
.run(nextLeaseExpiresAt, timestamp, input.room_id);
|
|
102
|
+
return {
|
|
103
|
+
status: "ok",
|
|
104
|
+
room_id: input.room_id,
|
|
105
|
+
turn_id: room.turn_id,
|
|
106
|
+
lease_id: input.lease_id,
|
|
107
|
+
lease_expires_at: nextLeaseExpiresAt
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
releaseStick(input) {
|
|
112
|
+
validateHandoff(input.handoff);
|
|
113
|
+
const now = this.now();
|
|
114
|
+
const timestamp = now.toISOString();
|
|
115
|
+
this.warmRoomTurnLiveness(input.room_id);
|
|
116
|
+
return withImmediateTransaction(this.db, () => {
|
|
117
|
+
const room = this.requireRoom(input.room_id);
|
|
118
|
+
this.assertOwnerMutation(room, input, now);
|
|
119
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
120
|
+
const eventSeq = this.appendEvent({
|
|
121
|
+
room_id: input.room_id,
|
|
122
|
+
turn_id: room.turn_id,
|
|
123
|
+
event_type: "release",
|
|
124
|
+
from_agent_id: input.agent_id,
|
|
125
|
+
to_agent_id: null,
|
|
126
|
+
handoff: input.handoff,
|
|
127
|
+
reason: null,
|
|
128
|
+
created_at: timestamp
|
|
129
|
+
});
|
|
130
|
+
const nextMember = this.findNextActiveMember(input.room_id, input.agent_id, now);
|
|
131
|
+
const reservedFor = nextMember?.agent_id ?? null;
|
|
132
|
+
const claimExpiresAt = reservedFor
|
|
133
|
+
? this.expiresAt(now, this.policy.claimTtlMs)
|
|
134
|
+
: null;
|
|
135
|
+
this.db
|
|
136
|
+
.prepare(`
|
|
137
|
+
UPDATE path_rooms
|
|
138
|
+
SET sequence_index = ?,
|
|
139
|
+
owner = NULL,
|
|
140
|
+
reserved_for = ?,
|
|
141
|
+
pending_handoff_event_seq = ?,
|
|
142
|
+
lease_id = NULL,
|
|
143
|
+
lease_expires_at = NULL,
|
|
144
|
+
claim_expires_at = ?,
|
|
145
|
+
state = ?,
|
|
146
|
+
updated_at = ?
|
|
147
|
+
WHERE room_id = ?
|
|
148
|
+
`)
|
|
149
|
+
.run(nextMember?.ordinal ?? room.sequence_index, reservedFor, eventSeq, claimExpiresAt, reservedFor ? "reserved" : "idle", timestamp, input.room_id);
|
|
150
|
+
return {
|
|
151
|
+
status: "released",
|
|
152
|
+
room_id: input.room_id,
|
|
153
|
+
reserved_for: reservedFor,
|
|
154
|
+
event_seq: eventSeq
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
passStick(input) {
|
|
159
|
+
validateHandoff(input.handoff);
|
|
160
|
+
assertNonEmpty(input.to_agent_id, "to_agent_id");
|
|
161
|
+
const now = this.now();
|
|
162
|
+
const timestamp = now.toISOString();
|
|
163
|
+
this.warmRoomTurnLiveness(input.room_id);
|
|
164
|
+
return withImmediateTransaction(this.db, () => {
|
|
165
|
+
const room = this.requireRoom(input.room_id);
|
|
166
|
+
this.assertOwnerMutation(room, input, now);
|
|
167
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
168
|
+
const target = this.getMember(input.room_id, input.to_agent_id);
|
|
169
|
+
if (!target || !this.isMemberActive(target, now)) {
|
|
170
|
+
throw new ProtocolError("unknown_member", "pass_stick target must be an active room member in the MVP.", { to_agent_id: input.to_agent_id });
|
|
171
|
+
}
|
|
172
|
+
const eventSeq = this.appendEvent({
|
|
173
|
+
room_id: input.room_id,
|
|
174
|
+
turn_id: room.turn_id,
|
|
175
|
+
event_type: "pass",
|
|
176
|
+
from_agent_id: input.agent_id,
|
|
177
|
+
to_agent_id: input.to_agent_id,
|
|
178
|
+
handoff: input.handoff,
|
|
179
|
+
reason: null,
|
|
180
|
+
created_at: timestamp
|
|
181
|
+
});
|
|
182
|
+
this.db
|
|
183
|
+
.prepare(`
|
|
184
|
+
UPDATE path_rooms
|
|
185
|
+
SET sequence_index = ?,
|
|
186
|
+
owner = NULL,
|
|
187
|
+
reserved_for = ?,
|
|
188
|
+
pending_handoff_event_seq = ?,
|
|
189
|
+
lease_id = NULL,
|
|
190
|
+
lease_expires_at = NULL,
|
|
191
|
+
claim_expires_at = ?,
|
|
192
|
+
state = 'reserved',
|
|
193
|
+
updated_at = ?
|
|
194
|
+
WHERE room_id = ?
|
|
195
|
+
`)
|
|
196
|
+
.run(target.ordinal, input.to_agent_id, eventSeq, this.expiresAt(now, this.policy.claimTtlMs), timestamp, input.room_id);
|
|
197
|
+
return {
|
|
198
|
+
status: "passed",
|
|
199
|
+
room_id: input.room_id,
|
|
200
|
+
reserved_for: input.to_agent_id,
|
|
201
|
+
event_seq: eventSeq
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
takeoverStick(input) {
|
|
206
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
207
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
208
|
+
assertNonEmpty(input.reason, "reason");
|
|
209
|
+
const now = this.now();
|
|
210
|
+
const timestamp = now.toISOString();
|
|
211
|
+
this.warmRoomTurnLiveness(input.room_id);
|
|
212
|
+
return withImmediateTransaction(this.db, () => {
|
|
213
|
+
const room = this.requireRoom(input.room_id);
|
|
214
|
+
const inspection = this.inspectRoomForMutation(room, now);
|
|
215
|
+
if (room.turn_id !== input.expected_turn_id) {
|
|
216
|
+
throw new ProtocolError("turn_mismatch", "The supplied turn does not match the current room turn.", {
|
|
217
|
+
current_owner: room.owner,
|
|
218
|
+
current_turn_id: room.turn_id,
|
|
219
|
+
room_state: inspection.state
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
223
|
+
const takeoverKind = this.assertTakeoverEligible(room, input.agent_id, now, inspection);
|
|
224
|
+
const nextTurnId = room.turn_id + 1;
|
|
225
|
+
const leaseId = randomUUID();
|
|
226
|
+
const revokedAgentId = takeoverKind === "claim_timeout" || takeoverKind === "recipient_gone"
|
|
227
|
+
? room.reserved_for
|
|
228
|
+
: room.owner;
|
|
229
|
+
this.db
|
|
230
|
+
.prepare(`
|
|
231
|
+
UPDATE path_rooms
|
|
232
|
+
SET owner = ?,
|
|
233
|
+
reserved_for = NULL,
|
|
234
|
+
pending_handoff_event_seq = NULL,
|
|
235
|
+
turn_id = ?,
|
|
236
|
+
lease_id = ?,
|
|
237
|
+
lease_expires_at = ?,
|
|
238
|
+
claim_expires_at = NULL,
|
|
239
|
+
state = 'owned',
|
|
240
|
+
updated_at = ?
|
|
241
|
+
WHERE room_id = ?
|
|
242
|
+
`)
|
|
243
|
+
.run(input.agent_id, nextTurnId, leaseId, this.expiresAt(now, this.policy.ownerLeaseTtlMs), timestamp, input.room_id);
|
|
244
|
+
this.appendEvent({
|
|
245
|
+
room_id: input.room_id,
|
|
246
|
+
turn_id: nextTurnId,
|
|
247
|
+
event_type: "takeover",
|
|
248
|
+
from_agent_id: revokedAgentId,
|
|
249
|
+
to_agent_id: input.agent_id,
|
|
250
|
+
handoff: null,
|
|
251
|
+
reason: input.reason,
|
|
252
|
+
created_at: timestamp
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
status: "your_turn",
|
|
256
|
+
room_id: input.room_id,
|
|
257
|
+
turn_id: nextTurnId,
|
|
258
|
+
lease_id: leaseId,
|
|
259
|
+
revoked_agent_id: revokedAgentId,
|
|
260
|
+
reason: takeoverKind
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
getRoomState(input) {
|
|
265
|
+
const now = this.now();
|
|
266
|
+
const room = this.requireRoom(input.room_id);
|
|
267
|
+
const inspection = this.inspectRoom(room, now);
|
|
268
|
+
return {
|
|
269
|
+
room: this.mapRoom(inspection, now),
|
|
270
|
+
members: inspection.members.map((member) => this.mapMember(member, now))
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
getRoomEvents(input) {
|
|
274
|
+
const afterEventSeq = input.after_event_seq ?? 0;
|
|
275
|
+
const limit = Math.min(input.limit ?? 100, 500);
|
|
276
|
+
return this.db
|
|
277
|
+
.prepare(`
|
|
278
|
+
SELECT *
|
|
279
|
+
FROM room_events
|
|
280
|
+
WHERE room_id = ? AND event_seq > ?
|
|
281
|
+
ORDER BY event_seq
|
|
282
|
+
LIMIT ?
|
|
283
|
+
`)
|
|
284
|
+
.all(input.room_id, afterEventSeq, limit)
|
|
285
|
+
.map((row) => this.mapEvent(row));
|
|
286
|
+
}
|
|
287
|
+
waitForTurnOnce(input) {
|
|
288
|
+
const now = this.now();
|
|
289
|
+
const timestamp = now.toISOString();
|
|
290
|
+
const room = this.requireRoom(input.room_id);
|
|
291
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
292
|
+
const inspection = this.inspectRoomForMutation(room, now);
|
|
293
|
+
if (room.state === "closed") {
|
|
294
|
+
return { status: "closed", room_id: input.room_id };
|
|
295
|
+
}
|
|
296
|
+
if (!room.owner && !room.reserved_for) {
|
|
297
|
+
return this.grantTurn(room, input.agent_id, now);
|
|
298
|
+
}
|
|
299
|
+
if (inspection.state === "recipient_gone") {
|
|
300
|
+
if (room.reserved_for !== input.agent_id &&
|
|
301
|
+
this.isClaimTakeoverEligible(room, input.agent_id, now, inspection)) {
|
|
302
|
+
return {
|
|
303
|
+
status: "takeover_available",
|
|
304
|
+
room_id: input.room_id,
|
|
305
|
+
turn_id: room.turn_id,
|
|
306
|
+
room_state: "recipient_gone",
|
|
307
|
+
reason: "recipient_gone",
|
|
308
|
+
reserved_for: room.reserved_for ?? undefined
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (room.reserved_for) {
|
|
313
|
+
if (room.reserved_for === input.agent_id &&
|
|
314
|
+
inspection.state !== "recipient_gone") {
|
|
315
|
+
return this.grantTurn(room, input.agent_id, now);
|
|
316
|
+
}
|
|
317
|
+
if (inspection.state !== "recipient_gone" &&
|
|
318
|
+
this.hasExpired(room.claim_expires_at, now) &&
|
|
319
|
+
this.isClaimTakeoverEligible(room, input.agent_id, now, inspection)) {
|
|
320
|
+
return {
|
|
321
|
+
status: "takeover_available",
|
|
322
|
+
room_id: input.room_id,
|
|
323
|
+
turn_id: room.turn_id,
|
|
324
|
+
room_state: "reserved",
|
|
325
|
+
reason: "claim_timeout",
|
|
326
|
+
reserved_for: room.reserved_for
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (inspection.state === "owner_gone" && room.owner !== input.agent_id) {
|
|
331
|
+
return {
|
|
332
|
+
status: "takeover_available",
|
|
333
|
+
room_id: input.room_id,
|
|
334
|
+
turn_id: room.turn_id,
|
|
335
|
+
room_state: "owner_gone",
|
|
336
|
+
reason: "owner_gone",
|
|
337
|
+
current_owner: room.owner ?? undefined
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (room.owner && room.owner !== input.agent_id && inspection.state === "stale_owner") {
|
|
341
|
+
return {
|
|
342
|
+
status: "takeover_available",
|
|
343
|
+
room_id: input.room_id,
|
|
344
|
+
turn_id: room.turn_id,
|
|
345
|
+
room_state: "stale_owner",
|
|
346
|
+
reason: "owner_timeout",
|
|
347
|
+
current_owner: room.owner
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
status: "not_yet",
|
|
352
|
+
cursor: String(this.latestEventSeq(input.room_id)),
|
|
353
|
+
room_state: inspection.state
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
grantTurn(room, agentId, now) {
|
|
357
|
+
const timestamp = now.toISOString();
|
|
358
|
+
const nextTurnId = room.turn_id + 1;
|
|
359
|
+
const leaseId = randomUUID();
|
|
360
|
+
const pendingEvent = room.pending_handoff_event_seq
|
|
361
|
+
? this.getEventBySeq(room.pending_handoff_event_seq)
|
|
362
|
+
: null;
|
|
363
|
+
const reason = claimReasonForEvent(pendingEvent);
|
|
364
|
+
this.db
|
|
365
|
+
.prepare(`
|
|
366
|
+
UPDATE path_rooms
|
|
367
|
+
SET owner = ?,
|
|
368
|
+
reserved_for = NULL,
|
|
369
|
+
pending_handoff_event_seq = NULL,
|
|
370
|
+
turn_id = ?,
|
|
371
|
+
lease_id = ?,
|
|
372
|
+
lease_expires_at = ?,
|
|
373
|
+
claim_expires_at = NULL,
|
|
374
|
+
state = 'owned',
|
|
375
|
+
updated_at = ?
|
|
376
|
+
WHERE room_id = ?
|
|
377
|
+
`)
|
|
378
|
+
.run(agentId, nextTurnId, leaseId, this.expiresAt(now, this.policy.ownerLeaseTtlMs), timestamp, room.room_id);
|
|
379
|
+
this.appendEvent({
|
|
380
|
+
room_id: room.room_id,
|
|
381
|
+
turn_id: nextTurnId,
|
|
382
|
+
event_type: "claim",
|
|
383
|
+
from_agent_id: pendingEvent?.from_agent_id ?? null,
|
|
384
|
+
to_agent_id: agentId,
|
|
385
|
+
handoff: null,
|
|
386
|
+
reason: null,
|
|
387
|
+
created_at: timestamp
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
status: "your_turn",
|
|
391
|
+
room_id: room.room_id,
|
|
392
|
+
turn_id: nextTurnId,
|
|
393
|
+
lease_id: leaseId,
|
|
394
|
+
handoff: pendingEvent ? this.mapEvent(pendingEvent).handoff : null,
|
|
395
|
+
from_agent_id: pendingEvent?.from_agent_id ?? null,
|
|
396
|
+
reason
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
findOrCreateRoomForJoin(resolved, forceNew, timestamp) {
|
|
400
|
+
const ancestors = ancestorPaths(resolved.canonical_context_path, resolved.workspace_root);
|
|
401
|
+
const existingAncestor = this.findDeepestRoom(ancestors);
|
|
402
|
+
if (forceNew) {
|
|
403
|
+
const exactRoom = this.findRoomByCanonicalPath(resolved.canonical_context_path);
|
|
404
|
+
if (exactRoom) {
|
|
405
|
+
return { room: exactRoom, joinedExistingRoom: true };
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
room: this.createRoom(resolved.canonical_context_path, timestamp),
|
|
409
|
+
joinedExistingRoom: false,
|
|
410
|
+
warning: existingAncestor
|
|
411
|
+
? `Created nested room inside ${existingAncestor.canonical_path}`
|
|
412
|
+
: undefined
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (existingAncestor) {
|
|
416
|
+
return { room: existingAncestor, joinedExistingRoom: true };
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
room: this.createRoom(resolved.workspace_root, timestamp),
|
|
420
|
+
joinedExistingRoom: false
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
createRoom(canonicalPath, timestamp) {
|
|
424
|
+
const roomId = randomUUID();
|
|
425
|
+
this.db
|
|
426
|
+
.prepare(`
|
|
427
|
+
INSERT INTO path_rooms (
|
|
428
|
+
room_id,
|
|
429
|
+
canonical_path,
|
|
430
|
+
sequence_index,
|
|
431
|
+
owner,
|
|
432
|
+
reserved_for,
|
|
433
|
+
pending_handoff_event_seq,
|
|
434
|
+
turn_id,
|
|
435
|
+
lease_id,
|
|
436
|
+
lease_expires_at,
|
|
437
|
+
claim_expires_at,
|
|
438
|
+
state,
|
|
439
|
+
updated_at
|
|
440
|
+
)
|
|
441
|
+
VALUES (?, ?, 0, NULL, NULL, NULL, 0, NULL, NULL, NULL, 'idle', ?)
|
|
442
|
+
`)
|
|
443
|
+
.run(roomId, canonicalPath, timestamp);
|
|
444
|
+
return this.requireRoom(roomId);
|
|
445
|
+
}
|
|
446
|
+
upsertMember(roomId, agentId, timestamp, processMetadata) {
|
|
447
|
+
const existing = this.getMember(roomId, agentId);
|
|
448
|
+
const normalizedMetadata = normalizeProcessMetadata(processMetadata);
|
|
449
|
+
if (existing) {
|
|
450
|
+
const room = this.requireRoom(roomId);
|
|
451
|
+
const mergedMetadata = this.mergeMemberProcessMetadata(room, existing, normalizedMetadata);
|
|
452
|
+
this.db
|
|
453
|
+
.prepare(`
|
|
454
|
+
UPDATE room_members
|
|
455
|
+
SET last_seen_at = ?,
|
|
456
|
+
status = 'active',
|
|
457
|
+
host_id = ?,
|
|
458
|
+
pid = ?,
|
|
459
|
+
process_started_at = ?,
|
|
460
|
+
session_kind = ?,
|
|
461
|
+
display_name = ?
|
|
462
|
+
WHERE room_id = ? AND agent_id = ?
|
|
463
|
+
`)
|
|
464
|
+
.run(timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, roomId, agentId);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const nextOrdinal = this.db
|
|
468
|
+
.prepare("SELECT MAX(ordinal) + 1 AS next_ordinal FROM room_members WHERE room_id = ?")
|
|
469
|
+
.get(roomId)?.next_ordinal ?? 0;
|
|
470
|
+
this.db
|
|
471
|
+
.prepare(`
|
|
472
|
+
INSERT INTO room_members (
|
|
473
|
+
room_id,
|
|
474
|
+
agent_id,
|
|
475
|
+
ordinal,
|
|
476
|
+
joined_at,
|
|
477
|
+
last_seen_at,
|
|
478
|
+
status,
|
|
479
|
+
host_id,
|
|
480
|
+
pid,
|
|
481
|
+
process_started_at,
|
|
482
|
+
session_kind,
|
|
483
|
+
display_name
|
|
484
|
+
)
|
|
485
|
+
VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
|
486
|
+
`)
|
|
487
|
+
.run(roomId, agentId, nextOrdinal, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name);
|
|
488
|
+
}
|
|
489
|
+
mergeMemberProcessMetadata(room, existing, incoming) {
|
|
490
|
+
if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
|
|
491
|
+
return incoming;
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
host_id: existing.host_id,
|
|
495
|
+
pid: existing.pid,
|
|
496
|
+
process_started_at: existing.process_started_at,
|
|
497
|
+
session_kind: existing.session_kind,
|
|
498
|
+
display_name: existing.display_name
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
shouldPreserveExactMemberProcessMetadata(room, existing, incoming) {
|
|
502
|
+
const isCurrentHolderOrRecipient = room.owner === existing.agent_id || room.reserved_for === existing.agent_id;
|
|
503
|
+
if (!isCurrentHolderOrRecipient) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
if (!hasExactProcessIdentity(existing)) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
if (!hasExactProcessIdentity(incoming)) {
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
return (sessionKindPriority(incoming.session_kind) <
|
|
513
|
+
sessionKindPriority(existing.session_kind));
|
|
514
|
+
}
|
|
515
|
+
touchMember(roomId, agentId, timestamp) {
|
|
516
|
+
const result = this.db
|
|
517
|
+
.prepare(`
|
|
518
|
+
UPDATE room_members
|
|
519
|
+
SET last_seen_at = ?, status = 'active'
|
|
520
|
+
WHERE room_id = ? AND agent_id = ?
|
|
521
|
+
`)
|
|
522
|
+
.run(timestamp, roomId, agentId);
|
|
523
|
+
if (result.changes === 0) {
|
|
524
|
+
throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
assertOwnerMutation(room, input, now) {
|
|
528
|
+
const inspection = this.inspectRoomForMutation(room, now);
|
|
529
|
+
if (room.turn_id !== input.expected_turn_id) {
|
|
530
|
+
throw new ProtocolError("turn_mismatch", "The supplied turn does not match the current room turn.", {
|
|
531
|
+
current_owner: room.owner,
|
|
532
|
+
current_turn_id: room.turn_id,
|
|
533
|
+
room_state: inspection.state
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (room.owner !== input.agent_id ||
|
|
537
|
+
room.lease_id !== input.lease_id ||
|
|
538
|
+
room.state !== "owned" ||
|
|
539
|
+
(inspection.state !== "owned" && inspection.state !== "stale_owner")) {
|
|
540
|
+
throw new ProtocolError("stale_lease", "The supplied lease is no longer current for this room.", {
|
|
541
|
+
current_owner: room.owner,
|
|
542
|
+
current_turn_id: room.turn_id,
|
|
543
|
+
room_state: inspection.state
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
assertTakeoverEligible(room, agentId, now, inspection) {
|
|
548
|
+
if (inspection.state === "recipient_gone") {
|
|
549
|
+
if (!this.isClaimTakeoverEligible(room, agentId, now, inspection)) {
|
|
550
|
+
throw new ProtocolError("takeover_ineligible", "Agent is not eligible to take over this reserved turn.", {
|
|
551
|
+
reserved_for: room.reserved_for,
|
|
552
|
+
room_state: inspection.state
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return "recipient_gone";
|
|
556
|
+
}
|
|
557
|
+
if (room.reserved_for && this.hasExpired(room.claim_expires_at, now)) {
|
|
558
|
+
if (!this.isClaimTakeoverEligible(room, agentId, now, inspection)) {
|
|
559
|
+
throw new ProtocolError("takeover_ineligible", "Agent is not eligible to take over this reserved turn.", {
|
|
560
|
+
reserved_for: room.reserved_for,
|
|
561
|
+
room_state: inspection.state
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return "claim_timeout";
|
|
565
|
+
}
|
|
566
|
+
if (inspection.state === "owner_gone" && room.owner) {
|
|
567
|
+
if (room.owner === agentId) {
|
|
568
|
+
throw new ProtocolError("takeover_ineligible", "The current owner cannot take over its own dead lease.", {
|
|
569
|
+
current_owner: room.owner,
|
|
570
|
+
room_state: inspection.state
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return "owner_gone";
|
|
574
|
+
}
|
|
575
|
+
if (room.owner && this.hasExpired(room.lease_expires_at, now)) {
|
|
576
|
+
if (room.owner === agentId) {
|
|
577
|
+
throw new ProtocolError("takeover_ineligible", "The current owner cannot take over its own stale lease.", {
|
|
578
|
+
current_owner: room.owner,
|
|
579
|
+
room_state: inspection.state
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return "owner_timeout";
|
|
583
|
+
}
|
|
584
|
+
throw new ProtocolError("takeover_not_available", "No takeover timeout is currently available for this room.", { room_state: inspection.state });
|
|
585
|
+
}
|
|
586
|
+
isClaimTakeoverEligible(room, agentId, now, inspection) {
|
|
587
|
+
if (!room.reserved_for || room.reserved_for === agentId) {
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const pendingEvent = room.pending_handoff_event_seq
|
|
591
|
+
? this.getEventBySeq(room.pending_handoff_event_seq)
|
|
592
|
+
: null;
|
|
593
|
+
const priorOwner = pendingEvent?.from_agent_id ?? null;
|
|
594
|
+
if (priorOwner === agentId) {
|
|
595
|
+
return !this.hasOtherClaimTakeoverCandidate(inspection.members, room.reserved_for, agentId, now);
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
hasOtherClaimTakeoverCandidate(members, reservedFor, candidateAgentId, now) {
|
|
600
|
+
return members.some((member) => member.agent_id !== candidateAgentId &&
|
|
601
|
+
member.agent_id !== reservedFor &&
|
|
602
|
+
this.hasRecentPresence(member, now));
|
|
603
|
+
}
|
|
604
|
+
findNextActiveMember(roomId, afterAgentId, now) {
|
|
605
|
+
const members = this.getMembers(roomId);
|
|
606
|
+
if (members.length <= 1) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const ownerIndex = members.findIndex((member) => member.agent_id === afterAgentId);
|
|
610
|
+
if (ownerIndex === -1) {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
for (let offset = 1; offset < members.length; offset += 1) {
|
|
614
|
+
const candidate = members[(ownerIndex + offset) % members.length];
|
|
615
|
+
if (this.hasRecentPresence(candidate, now)) {
|
|
616
|
+
return candidate;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
appendEvent(input) {
|
|
622
|
+
const result = this.db
|
|
623
|
+
.prepare(`
|
|
624
|
+
INSERT INTO room_events (
|
|
625
|
+
event_id,
|
|
626
|
+
room_id,
|
|
627
|
+
turn_id,
|
|
628
|
+
event_type,
|
|
629
|
+
from_agent_id,
|
|
630
|
+
to_agent_id,
|
|
631
|
+
handoff_json,
|
|
632
|
+
reason,
|
|
633
|
+
created_at
|
|
634
|
+
)
|
|
635
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
636
|
+
`)
|
|
637
|
+
.run(randomUUID(), input.room_id, input.turn_id, input.event_type, input.from_agent_id, input.to_agent_id, input.handoff ? JSON.stringify(input.handoff) : null, input.reason, input.created_at);
|
|
638
|
+
return Number(result.lastInsertRowid);
|
|
639
|
+
}
|
|
640
|
+
getRoomRow(roomId) {
|
|
641
|
+
return this.db
|
|
642
|
+
.prepare("SELECT * FROM path_rooms WHERE room_id = ?")
|
|
643
|
+
.get(roomId);
|
|
644
|
+
}
|
|
645
|
+
requireRoom(roomId) {
|
|
646
|
+
const room = this.getRoomRow(roomId);
|
|
647
|
+
if (!room) {
|
|
648
|
+
throw new ProtocolError("room_not_found", "Room was not found.");
|
|
649
|
+
}
|
|
650
|
+
return room;
|
|
651
|
+
}
|
|
652
|
+
findRoomByCanonicalPath(canonicalPath) {
|
|
653
|
+
return (this.db
|
|
654
|
+
.prepare("SELECT * FROM path_rooms WHERE canonical_path = ?")
|
|
655
|
+
.get(canonicalPath) ?? null);
|
|
656
|
+
}
|
|
657
|
+
findRoomsByCanonicalPaths(canonicalPaths) {
|
|
658
|
+
if (canonicalPaths.length === 0) {
|
|
659
|
+
return [];
|
|
660
|
+
}
|
|
661
|
+
const placeholders = canonicalPaths.map(() => "?").join(", ");
|
|
662
|
+
return this.db
|
|
663
|
+
.prepare(`SELECT * FROM path_rooms WHERE canonical_path IN (${placeholders})`)
|
|
664
|
+
.all(...canonicalPaths);
|
|
665
|
+
}
|
|
666
|
+
findDeepestRoom(canonicalPaths) {
|
|
667
|
+
const rows = this.findRoomsByCanonicalPaths(canonicalPaths);
|
|
668
|
+
const byPath = new Map(rows.map((row) => [row.canonical_path, row]));
|
|
669
|
+
for (const candidate of canonicalPaths) {
|
|
670
|
+
const row = byPath.get(candidate);
|
|
671
|
+
if (row) {
|
|
672
|
+
return row;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
getMember(roomId, agentId) {
|
|
678
|
+
return (this.db
|
|
679
|
+
.prepare("SELECT * FROM room_members WHERE room_id = ? AND agent_id = ?")
|
|
680
|
+
.get(roomId, agentId) ?? null);
|
|
681
|
+
}
|
|
682
|
+
getMembers(roomId) {
|
|
683
|
+
return this.db
|
|
684
|
+
.prepare("SELECT * FROM room_members WHERE room_id = ? ORDER BY ordinal")
|
|
685
|
+
.all(roomId);
|
|
686
|
+
}
|
|
687
|
+
getEventBySeq(eventSeq) {
|
|
688
|
+
return (this.db
|
|
689
|
+
.prepare("SELECT * FROM room_events WHERE event_seq = ?")
|
|
690
|
+
.get(eventSeq) ?? null);
|
|
691
|
+
}
|
|
692
|
+
latestEventSeq(roomId) {
|
|
693
|
+
return (this.db
|
|
694
|
+
.prepare("SELECT MAX(event_seq) AS event_seq FROM room_events WHERE room_id = ?")
|
|
695
|
+
.get(roomId)?.event_seq ?? 0);
|
|
696
|
+
}
|
|
697
|
+
expiresAt(now, ttlMs) {
|
|
698
|
+
return new Date(now.getTime() + ttlMs).toISOString();
|
|
699
|
+
}
|
|
700
|
+
hasExpired(timestamp, now) {
|
|
701
|
+
return timestamp !== null && Date.parse(timestamp) <= now.getTime();
|
|
702
|
+
}
|
|
703
|
+
isMemberActive(member, now) {
|
|
704
|
+
if (this.getMemberProcessLiveness(member) === "gone") {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
return this.hasRecentPresence(member, now);
|
|
708
|
+
}
|
|
709
|
+
hasRecentPresence(member, now) {
|
|
710
|
+
return (now.getTime() - Date.parse(member.last_seen_at) <=
|
|
711
|
+
this.policy.presenceTtlMs);
|
|
712
|
+
}
|
|
713
|
+
inspectRoom(room, now) {
|
|
714
|
+
const members = this.getMembers(room.room_id);
|
|
715
|
+
const ownerMember = room.owner
|
|
716
|
+
? members.find((member) => member.agent_id === room.owner) ?? null
|
|
717
|
+
: null;
|
|
718
|
+
const reservedMember = room.reserved_for
|
|
719
|
+
? members.find((member) => member.agent_id === room.reserved_for) ?? null
|
|
720
|
+
: null;
|
|
721
|
+
let state;
|
|
722
|
+
if (room.state === "closed") {
|
|
723
|
+
state = "closed";
|
|
724
|
+
}
|
|
725
|
+
else if (room.owner) {
|
|
726
|
+
const ownerLiveness = ownerMember
|
|
727
|
+
? this.getMemberProcessLiveness(ownerMember)
|
|
728
|
+
: "gone";
|
|
729
|
+
if (ownerLiveness === "gone") {
|
|
730
|
+
state = "owner_gone";
|
|
731
|
+
}
|
|
732
|
+
else if (this.hasExpired(room.lease_expires_at, now)) {
|
|
733
|
+
state = "stale_owner";
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
state = "owned";
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else if (room.reserved_for) {
|
|
740
|
+
const reservedLiveness = reservedMember
|
|
741
|
+
? this.getMemberProcessLiveness(reservedMember)
|
|
742
|
+
: "gone";
|
|
743
|
+
state = reservedLiveness === "gone" ? "recipient_gone" : "reserved";
|
|
744
|
+
}
|
|
745
|
+
else if (!members.some((member) => this.isMemberActive(member, now))) {
|
|
746
|
+
state = "dormant";
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
state = "idle";
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
room,
|
|
753
|
+
members,
|
|
754
|
+
ownerMember,
|
|
755
|
+
reservedMember,
|
|
756
|
+
state
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
inspectRoomForMutation(room, now) {
|
|
760
|
+
const members = this.getMembers(room.room_id);
|
|
761
|
+
const ownerMember = room.owner
|
|
762
|
+
? members.find((member) => member.agent_id === room.owner) ?? null
|
|
763
|
+
: null;
|
|
764
|
+
const reservedMember = room.reserved_for
|
|
765
|
+
? members.find((member) => member.agent_id === room.reserved_for) ?? null
|
|
766
|
+
: null;
|
|
767
|
+
let state;
|
|
768
|
+
if (room.state === "closed") {
|
|
769
|
+
state = "closed";
|
|
770
|
+
}
|
|
771
|
+
else if (room.owner) {
|
|
772
|
+
const ownerLiveness = ownerMember
|
|
773
|
+
? this.getMemberProcessLiveness(ownerMember)
|
|
774
|
+
: "gone";
|
|
775
|
+
if (ownerLiveness === "gone") {
|
|
776
|
+
state = "owner_gone";
|
|
777
|
+
}
|
|
778
|
+
else if (this.hasExpired(room.lease_expires_at, now)) {
|
|
779
|
+
state = "stale_owner";
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
state = "owned";
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
else if (room.reserved_for) {
|
|
786
|
+
const reservedLiveness = reservedMember
|
|
787
|
+
? this.getMemberProcessLiveness(reservedMember)
|
|
788
|
+
: "gone";
|
|
789
|
+
state = reservedLiveness === "gone" ? "recipient_gone" : "reserved";
|
|
790
|
+
}
|
|
791
|
+
else if (!members.some((member) => this.hasRecentPresence(member, now))) {
|
|
792
|
+
state = "dormant";
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
state = "idle";
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
room,
|
|
799
|
+
members,
|
|
800
|
+
ownerMember,
|
|
801
|
+
reservedMember,
|
|
802
|
+
state
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
mapRoomForList(room, now) {
|
|
806
|
+
let state;
|
|
807
|
+
if (room.state === "closed") {
|
|
808
|
+
state = "closed";
|
|
809
|
+
}
|
|
810
|
+
else if (room.owner) {
|
|
811
|
+
state = this.hasExpired(room.lease_expires_at, now)
|
|
812
|
+
? "stale_owner"
|
|
813
|
+
: "owned";
|
|
814
|
+
}
|
|
815
|
+
else if (room.reserved_for) {
|
|
816
|
+
state = "reserved";
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
const members = this.getMembers(room.room_id);
|
|
820
|
+
state = members.some((member) => this.hasRecentPresence(member, now))
|
|
821
|
+
? "idle"
|
|
822
|
+
: "dormant";
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
...room,
|
|
826
|
+
state
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
warmRoomTurnLiveness(roomId) {
|
|
830
|
+
const room = this.getRoomRow(roomId);
|
|
831
|
+
if (!room) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const members = this.getMembers(roomId);
|
|
835
|
+
if (room.owner) {
|
|
836
|
+
const owner = members.find((member) => member.agent_id === room.owner);
|
|
837
|
+
if (owner) {
|
|
838
|
+
this.getMemberProcessLiveness(owner);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (room.reserved_for) {
|
|
843
|
+
const reserved = members.find((member) => member.agent_id === room.reserved_for);
|
|
844
|
+
if (reserved) {
|
|
845
|
+
this.getMemberProcessLiveness(reserved);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
getMemberProcessLiveness(member) {
|
|
850
|
+
return this.processLivenessChecker({
|
|
851
|
+
host_id: member.host_id,
|
|
852
|
+
pid: member.pid,
|
|
853
|
+
process_started_at: member.process_started_at,
|
|
854
|
+
session_kind: member.session_kind,
|
|
855
|
+
display_name: member.display_name
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
deriveRoomState(room, now) {
|
|
859
|
+
return this.inspectRoom(room, now).state;
|
|
860
|
+
}
|
|
861
|
+
mapRoom(inspection, now) {
|
|
862
|
+
const row = inspection.room;
|
|
863
|
+
return {
|
|
864
|
+
...row,
|
|
865
|
+
state: inspection.state
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
mapMember(row, now) {
|
|
869
|
+
return {
|
|
870
|
+
...row,
|
|
871
|
+
status: this.isMemberActive(row, now) ? "active" : "inactive"
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
mapEvent(row) {
|
|
875
|
+
return {
|
|
876
|
+
event_seq: row.event_seq,
|
|
877
|
+
event_id: row.event_id,
|
|
878
|
+
room_id: row.room_id,
|
|
879
|
+
turn_id: row.turn_id,
|
|
880
|
+
event_type: row.event_type,
|
|
881
|
+
from_agent_id: row.from_agent_id,
|
|
882
|
+
to_agent_id: row.to_agent_id,
|
|
883
|
+
handoff: row.handoff_json
|
|
884
|
+
? JSON.parse(row.handoff_json)
|
|
885
|
+
: null,
|
|
886
|
+
reason: row.reason,
|
|
887
|
+
created_at: row.created_at
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function normalizeProcessMetadata(processMetadata) {
|
|
892
|
+
return {
|
|
893
|
+
host_id: processMetadata?.host_id ?? null,
|
|
894
|
+
pid: processMetadata?.pid ?? null,
|
|
895
|
+
process_started_at: processMetadata?.process_started_at ?? null,
|
|
896
|
+
session_kind: processMetadata?.session_kind ?? "mcp_harness",
|
|
897
|
+
display_name: processMetadata?.display_name ?? null
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function createDefaultProcessLivenessChecker(currentHostId) {
|
|
901
|
+
// This cache keeps normal polling from forking `ps` on every room inspection.
|
|
902
|
+
// A deeper move to out-of-transaction liveness refresh is possible later, but
|
|
903
|
+
// for the MVP we keep the lock boundary simple and the probe shared/cached.
|
|
904
|
+
const inspector = createSystemProcessInspector({ cacheTtlMs: 1_000 });
|
|
905
|
+
return (metadata) => {
|
|
906
|
+
if (metadata.pid === null ||
|
|
907
|
+
metadata.process_started_at === null ||
|
|
908
|
+
metadata.process_started_at.trim() === "") {
|
|
909
|
+
return "unknown";
|
|
910
|
+
}
|
|
911
|
+
if (metadata.host_id && metadata.host_id !== currentHostId) {
|
|
912
|
+
return "unknown";
|
|
913
|
+
}
|
|
914
|
+
if (process.platform === "win32") {
|
|
915
|
+
return "unknown";
|
|
916
|
+
}
|
|
917
|
+
const inspection = inspector.inspect(metadata.pid);
|
|
918
|
+
if (inspection === undefined) {
|
|
919
|
+
return "unknown";
|
|
920
|
+
}
|
|
921
|
+
if (inspection === null || !inspection.startTime) {
|
|
922
|
+
return "gone";
|
|
923
|
+
}
|
|
924
|
+
return inspection.startTime === metadata.process_started_at
|
|
925
|
+
? "alive"
|
|
926
|
+
: "gone";
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
function hasExactProcessIdentity(metadata) {
|
|
930
|
+
return (metadata.pid !== null &&
|
|
931
|
+
metadata.pid !== undefined &&
|
|
932
|
+
metadata.process_started_at !== null &&
|
|
933
|
+
metadata.process_started_at !== undefined &&
|
|
934
|
+
metadata.process_started_at.trim() !== "");
|
|
935
|
+
}
|
|
936
|
+
function sessionKindPriority(sessionKind) {
|
|
937
|
+
switch (sessionKind) {
|
|
938
|
+
case "human_guardian":
|
|
939
|
+
return 3;
|
|
940
|
+
case "mcp_harness":
|
|
941
|
+
return 2;
|
|
942
|
+
case "human_cli":
|
|
943
|
+
return 1;
|
|
944
|
+
default:
|
|
945
|
+
return 2;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function claimReasonForEvent(pendingEvent) {
|
|
949
|
+
if (!pendingEvent) {
|
|
950
|
+
return "open_claim";
|
|
951
|
+
}
|
|
952
|
+
return pendingEvent.event_type === "pass" ? "direct_pass" : "sequence";
|
|
953
|
+
}
|
|
954
|
+
function validateHandoff(handoff) {
|
|
955
|
+
if (!handoff || typeof handoff !== "object") {
|
|
956
|
+
throw new ProtocolError("invalid_handoff", "handoff must be an object.", { field: "handoff" });
|
|
957
|
+
}
|
|
958
|
+
if (!handoff.status || !handoff.status.trim()) {
|
|
959
|
+
throw new ProtocolError("invalid_handoff", "handoff.status must be non-empty.", { field: "status" });
|
|
960
|
+
}
|
|
961
|
+
if (!handoff.next_action || !handoff.next_action.trim()) {
|
|
962
|
+
throw new ProtocolError("invalid_handoff", "handoff.next_action must be non-empty.", { field: "next_action" });
|
|
963
|
+
}
|
|
964
|
+
if (handoff.artifacts !== undefined && !Array.isArray(handoff.artifacts)) {
|
|
965
|
+
throw new ProtocolError("invalid_handoff", "handoff.artifacts must be an array when provided.", { field: "artifacts" });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function assertNonEmpty(value, field) {
|
|
969
|
+
if (!value || !value.trim()) {
|
|
970
|
+
throw new ProtocolError("invalid_input", `${field} must be non-empty.`, {
|
|
971
|
+
field
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
function handoffTemplate() {
|
|
976
|
+
return {
|
|
977
|
+
status: "What I did:\nWhat I learned:\nOpen risks:",
|
|
978
|
+
next_action: "What the next agent should do next."
|
|
979
|
+
};
|
|
980
|
+
}
|