letagents 0.12.20 → 0.12.22
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/mcp/local-state/local-chat.js +132 -75
- package/dist/mcp/server/runtime/supervisor-bridge.js +5 -1
- package/dist/mcp/server/runtime/worker-bearer.js +3 -0
- package/dist/mcp/server/tools/tasks/api.js +14 -0
- package/dist/shared/activation-routing.js +2 -468
- package/package.json +1 -1
- package/shared/activation-routing.d.mts +139 -0
- package/shared/activation-routing.mjs +468 -0
- package/shared/local-supervised-routing.d.mts +9 -0
- package/shared/local-supervised-routing.mjs +93 -0
- package/shared/local-work-leases.d.mts +24 -0
- package/shared/local-work-leases.mjs +160 -0
- package/shared/message-contracts.d.mts +2 -0
- package/shared/message-contracts.mjs +11 -0
- package/shared/room-api-origin.d.mts +3 -0
- package/shared/room-api-origin.mjs +12 -0
- package/shared/sqlite-thread-routing.d.mts +4 -0
- package/shared/sqlite-thread-routing.mjs +30 -3
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Shared by desktop and MCP writers. All mutations run inside their write transaction. */
|
|
4
|
+
export function ensureLocalWorkLeaseSchema(db) {
|
|
5
|
+
db.exec(`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS local_work_leases (
|
|
7
|
+
id TEXT PRIMARY KEY, room_id TEXT NOT NULL, task_id TEXT NOT NULL,
|
|
8
|
+
kind TEXT NOT NULL DEFAULT 'work' CHECK(kind='work'),
|
|
9
|
+
status TEXT NOT NULL CHECK(status IN ('active','released','revoked','expired')),
|
|
10
|
+
agent_key TEXT NOT NULL, agent_session_id TEXT NOT NULL, agent_instance_id TEXT,
|
|
11
|
+
actor_label TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>=0),
|
|
12
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_heartbeat_at TEXT NOT NULL,
|
|
13
|
+
expires_at TEXT, revoked_reason TEXT,
|
|
14
|
+
FOREIGN KEY(room_id,task_id) REFERENCES local_tasks(room_id,task_id) ON DELETE CASCADE
|
|
15
|
+
) STRICT;
|
|
16
|
+
CREATE UNIQUE INDEX IF NOT EXISTS local_work_lease_owner
|
|
17
|
+
ON local_work_leases(room_id,task_id) WHERE status='active';
|
|
18
|
+
CREATE INDEX IF NOT EXISTS local_work_lease_session ON local_work_leases(agent_session_id,status);
|
|
19
|
+
CREATE TRIGGER IF NOT EXISTS local_work_lease_task_changed
|
|
20
|
+
AFTER UPDATE OF status,assignee,assignee_agent_key,assignee_agent_session_id ON local_tasks
|
|
21
|
+
BEGIN
|
|
22
|
+
UPDATE local_work_leases SET status='revoked',revoked_reason='Task completed or ownership changed',updated_at=NEW.updated_at
|
|
23
|
+
WHERE room_id=NEW.room_id AND task_id=NEW.task_id AND status='active'
|
|
24
|
+
AND (NEW.status NOT IN ('assigned','in_progress','blocked','in_review')
|
|
25
|
+
OR NEW.assignee_agent_key IS NOT agent_key
|
|
26
|
+
OR NEW.assignee_agent_session_id IS NOT agent_session_id
|
|
27
|
+
OR NEW.assignee IS NOT actor_label);
|
|
28
|
+
END;
|
|
29
|
+
CREATE TRIGGER IF NOT EXISTS local_work_lease_task_deleted AFTER DELETE ON local_tasks
|
|
30
|
+
BEGIN DELETE FROM local_work_leases WHERE room_id=OLD.room_id AND task_id=OLD.task_id; END;
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readLocalWorkLeases(db, roomId, taskId) {
|
|
35
|
+
return db.prepare(`SELECT * FROM local_work_leases WHERE room_id=? AND task_id=? AND status='active'
|
|
36
|
+
AND (expires_at IS NULL OR expires_at>?)`).all(roomId, taskId, new Date().toISOString());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertLocalWorkLeaseWorker(db, roomId, worker) {
|
|
40
|
+
if (!worker?.agent_key || !worker.session_id) throw new Error("A registered worker is required for task ownership.");
|
|
41
|
+
const hasSessions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_supervisor_sessions'").get();
|
|
42
|
+
const session = hasSessions && db.prepare(`SELECT s.ended_at,g.revoked_at,g.room_id,g.agent_key FROM local_supervisor_sessions s
|
|
43
|
+
JOIN local_supervisor_grants g USING(grant_id) WHERE s.session_id=?`).get(worker.session_id);
|
|
44
|
+
if ((worker.supervised && !session) || (session && (session.ended_at || session.revoked_at
|
|
45
|
+
|| session.room_id !== roomId || session.agent_key !== worker.agent_key))) {
|
|
46
|
+
throw new Error("Local worker authority ended before the task mutation.");
|
|
47
|
+
}
|
|
48
|
+
return Boolean(session);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function taskRow(db, roomId, taskId) {
|
|
52
|
+
const task = db.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
|
|
53
|
+
if (!task) throw new Error("Task not found.");
|
|
54
|
+
return task;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function expireLeases(db, roomId, taskId, now) {
|
|
58
|
+
db.prepare(`UPDATE local_work_leases SET status='expired',updated_at=?
|
|
59
|
+
WHERE room_id=? AND task_id=? AND status='active' AND expires_at IS NOT NULL AND expires_at<=?`)
|
|
60
|
+
.run(now, roomId, taskId, now);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mintLease(db, roomId, taskId, worker, now) {
|
|
64
|
+
const epoch = Number(db.prepare("SELECT COALESCE(MAX(epoch),-1)+1 AS epoch FROM local_work_leases WHERE room_id=? AND task_id=?")
|
|
65
|
+
.get(roomId, taskId).epoch);
|
|
66
|
+
const id = `local_work_${randomUUID()}`;
|
|
67
|
+
db.prepare(`INSERT INTO local_work_leases(id,room_id,task_id,status,agent_key,agent_session_id,agent_instance_id,
|
|
68
|
+
actor_label,epoch,created_at,updated_at,last_heartbeat_at) VALUES(?,?,?,'active',?,?,?,?,?,?,?,?)`)
|
|
69
|
+
.run(id, roomId, taskId, worker.agent_key, worker.session_id, worker.agent_instance_id ?? null,
|
|
70
|
+
worker.actor_label, epoch, now, now, now);
|
|
71
|
+
return db.prepare("SELECT * FROM local_work_leases WHERE id=?").get(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assignTask(db, roomId, taskId, worker, status, now) {
|
|
75
|
+
db.prepare(`UPDATE local_tasks SET status=?,assignee=?,assignee_agent_key=?,assignee_agent_instance_id=?,
|
|
76
|
+
assignee_agent_session_id=?,sync_dirty=1,updated_at=? WHERE room_id=? AND task_id=?`)
|
|
77
|
+
.run(status, worker?.actor_label ?? null, worker?.agent_key ?? null, worker?.agent_instance_id ?? null,
|
|
78
|
+
worker?.session_id ?? null, now, roomId, taskId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function claimLocalWorkLease(db, roomId, taskId, worker) {
|
|
82
|
+
if (!assertLocalWorkLeaseWorker(db, roomId, worker)) throw new Error("A supervised worker is required for a local work lease.");
|
|
83
|
+
const task = taskRow(db, roomId, taskId);
|
|
84
|
+
const now = new Date().toISOString();
|
|
85
|
+
expireLeases(db, roomId, taskId, now);
|
|
86
|
+
const active = readLocalWorkLeases(db, roomId, taskId)[0];
|
|
87
|
+
if (active) {
|
|
88
|
+
if (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key) {
|
|
89
|
+
throw new Error("This task is already owned by another worker. Use a lease handoff.");
|
|
90
|
+
}
|
|
91
|
+
return active; // Retry never changes the failure-time cutoff or ownership fence.
|
|
92
|
+
}
|
|
93
|
+
const reclaim = ['assigned', 'in_progress', 'blocked', 'in_review'].includes(task.status)
|
|
94
|
+
&& task.assignee_agent_key === worker.agent_key;
|
|
95
|
+
if (task.status !== 'accepted' && !reclaim) throw new Error("Claim an accepted task, or reclaim your own assigned task.");
|
|
96
|
+
if (task.assignee_agent_key && task.assignee_agent_key !== worker.agent_key) throw new Error("This task is assigned to another worker.");
|
|
97
|
+
assignTask(db, roomId, taskId, worker, reclaim ? task.status : 'assigned', now);
|
|
98
|
+
return mintLease(db, roomId, taskId, worker, now);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Worker APIs must call this after taking the write lock, before changing any task fields. */
|
|
102
|
+
export function assertLocalTaskLeaseMutation(db, task, worker, expected) {
|
|
103
|
+
const supervised = assertLocalWorkLeaseWorker(db, task.room_id, worker);
|
|
104
|
+
const active = readLocalWorkLeases(db, task.room_id, task.task_id)[0];
|
|
105
|
+
if (expected !== undefined && ((active?.id ?? null) !== (expected?.id ?? null)
|
|
106
|
+
|| (active?.epoch ?? null) !== (expected?.epoch ?? null))) throw new Error("The task lease changed before this update.");
|
|
107
|
+
const reviewOwner = task.review_lease_id && task.review_agent_key === worker.agent_key
|
|
108
|
+
&& task.review_agent_session_id === worker.session_id && task.status === 'in_review';
|
|
109
|
+
if (active && (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key) && !reviewOwner) {
|
|
110
|
+
throw new Error("This task's work lease belongs to another worker.");
|
|
111
|
+
}
|
|
112
|
+
if (!active && supervised && ['assigned','in_progress','blocked','in_review'].includes(task.status) && !reviewOwner) {
|
|
113
|
+
throw new Error("Claim this task to establish a current work lease before updating it.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A null actor is reserved for the desktop's explicit human lease controls. */
|
|
118
|
+
export function changeLocalWorkLease(db, roomId, taskId, input, worker) {
|
|
119
|
+
if (worker) assertLocalWorkLeaseWorker(db, roomId, worker);
|
|
120
|
+
const task = taskRow(db, roomId, taskId);
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
expireLeases(db, roomId, taskId, now);
|
|
123
|
+
const active = readLocalWorkLeases(db, roomId, taskId)[0];
|
|
124
|
+
if (!active) throw new Error("This task has no active work lease.");
|
|
125
|
+
if ((input.lease_id && active.id !== input.lease_id)
|
|
126
|
+
|| (input.epoch !== undefined && active.epoch !== input.epoch)) throw new Error("The task lease changed. Refresh the task before trying again.");
|
|
127
|
+
if (worker && (active.agent_session_id !== worker.session_id || active.agent_key !== worker.agent_key)) {
|
|
128
|
+
throw new Error("Only the current worker can release or hand off this work lease.");
|
|
129
|
+
}
|
|
130
|
+
let target = null;
|
|
131
|
+
if (input.action === 'handoff') {
|
|
132
|
+
const targets = db.prepare(`SELECT s.session_id,s.instance_id,g.agent_key,s.public_json FROM local_supervisor_sessions s
|
|
133
|
+
JOIN local_supervisor_grants g USING(grant_id) WHERE g.room_id=? AND g.agent_key=?
|
|
134
|
+
AND g.revoked_at IS NULL AND s.ended_at IS NULL`)
|
|
135
|
+
.all(roomId, input.target_actor_key ?? '').filter(row =>
|
|
136
|
+
(!input.target_agent_session_id || row.session_id === input.target_agent_session_id)
|
|
137
|
+
&& (!input.target_actor_instance_id || row.instance_id === input.target_actor_instance_id));
|
|
138
|
+
if (targets.length !== 1) throw new Error("Choose one active local worker session for this handoff.");
|
|
139
|
+
const session = JSON.parse(targets[0].public_json);
|
|
140
|
+
target = { ...session, supervised: true };
|
|
141
|
+
assertLocalWorkLeaseWorker(db, roomId, target);
|
|
142
|
+
} else if (input.action !== 'release') throw new Error("Unknown work lease action.");
|
|
143
|
+
db.prepare("UPDATE local_work_leases SET status='released',updated_at=? WHERE id=?").run(now, active.id);
|
|
144
|
+
assignTask(db, roomId, taskId, target, target ? (task.status === 'blocked' ? 'assigned' : task.status) : 'accepted', now);
|
|
145
|
+
return { released_lease: { ...active, status: 'released', updated_at: now },
|
|
146
|
+
new_lease: target ? mintLease(db, roomId, taskId, target, now) : null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function endLocalWorkerLeases(db, sessionId, now) {
|
|
150
|
+
db.prepare(`UPDATE local_work_leases SET status='revoked',revoked_reason='Worker session ended',updated_at=?
|
|
151
|
+
WHERE agent_session_id=? AND status='active'`).run(now, sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function heartbeatLocalWorkLeases(db, sessionId, now) {
|
|
155
|
+
db.prepare(`UPDATE local_work_leases SET last_heartbeat_at=?,updated_at=?
|
|
156
|
+
WHERE agent_session_id=? AND status='active' AND (expires_at IS NULL OR expires_at>?)`)
|
|
157
|
+
.run(now, now, sessionId, now);
|
|
158
|
+
return db.prepare(`SELECT id,epoch FROM local_work_leases WHERE agent_session_id=? AND status='active'
|
|
159
|
+
AND (expires_at IS NULL OR expires_at>?)`).all(sessionId, now);
|
|
160
|
+
}
|
|
@@ -107,3 +107,14 @@ export function parseAccountAgentRoutingEnvelope(routing) {
|
|
|
107
107
|
controlAuthorized: routing.control_authorized === true,
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Only daemon publication identities can link an answer to its source receipt. */
|
|
112
|
+
export function parseSupervisedReplySourceNumber(clientMessageId) {
|
|
113
|
+
if (!clientMessageId) return null;
|
|
114
|
+
const parts = clientMessageId.split(":");
|
|
115
|
+
if (parts[0] !== "supervised-room" || parts.at(-2) !== "reply" || parts.at(-1) !== "v1") return null;
|
|
116
|
+
const body = parts.slice(1, -2);
|
|
117
|
+
if (body.length !== 2 && body.length !== 3) return null;
|
|
118
|
+
const source = body.at(-1);
|
|
119
|
+
return source ? parsePositivePgIntegerScopedId(source, "msg") : null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** An in-process storage adapter, never an HTTP endpoint or cloud credential audience. */
|
|
2
|
+
export const LOCAL_ROOM_API_ORIGIN = "letagents-local://rooms";
|
|
3
|
+
|
|
4
|
+
export function isLocalRoomApi(value) {
|
|
5
|
+
return value === LOCAL_ROOM_API_ORIGIN;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Preserve the existing HTTP normalization while retaining the explicit local authority. */
|
|
9
|
+
export function roomApiOrigin(value) {
|
|
10
|
+
if (isLocalRoomApi(value)) return LOCAL_ROOM_API_ORIGIN;
|
|
11
|
+
return new URL(value).origin;
|
|
12
|
+
}
|
|
@@ -70,3 +70,7 @@ export function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
70
70
|
signal?: AbortSignal;
|
|
71
71
|
},
|
|
72
72
|
): Promise<Map<number, Set<string>>>;
|
|
73
|
+
|
|
74
|
+
export function ensureRequestedRootsProjected(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], options?: { foregroundTimeBudgetMs?: number; scheduleOnTimeout?: boolean; signal?: AbortSignal }): Promise<void>;
|
|
75
|
+
export function readProjectedLocalThreadRoutingAgentKeys(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], identities: readonly RoutingIdentityLike[]): Map<number, Set<string>>;
|
|
76
|
+
export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
|
|
@@ -788,7 +788,7 @@ export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersI
|
|
|
788
788
|
}
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
-
async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
|
|
791
|
+
export async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
|
|
792
792
|
if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
|
|
793
793
|
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
794
794
|
}
|
|
@@ -869,6 +869,33 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
869
869
|
if (rootNumbers.length === 0 || identities.length === 0) return new Map();
|
|
870
870
|
await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
|
|
871
871
|
|
|
872
|
+
const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
|
|
873
|
+
for (;;) {
|
|
874
|
+
const step = batches.next();
|
|
875
|
+
if (step.done) return step.value;
|
|
876
|
+
await yieldToEventLoop();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Read prepared projections synchronously inside an existing message transaction. */
|
|
881
|
+
export function readProjectedLocalThreadRoutingAgentKeys(database, roomId, rootNumbers, identities) {
|
|
882
|
+
const statements = requestedRootProjectionStatements(database);
|
|
883
|
+
const rootsJson = JSON.stringify(rootNumbers);
|
|
884
|
+
if (pendingRequestedRoots(statements, roomId, rootsJson).length
|
|
885
|
+
|| statements.invalidated.all(roomId, rootsJson).length) {
|
|
886
|
+
throw new LocalThreadRoutingProjectionChangedError();
|
|
887
|
+
}
|
|
888
|
+
const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
|
|
889
|
+
for (;;) {
|
|
890
|
+
const step = batches.next();
|
|
891
|
+
if (step.done) return step.value;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
|
|
896
|
+
|
|
897
|
+
function* readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities) {
|
|
898
|
+
if (!rootNumbers.length || !identities.length) return new Map();
|
|
872
899
|
const keysByHash = new Map();
|
|
873
900
|
const durableKeysByHash = new Map();
|
|
874
901
|
for (const identity of identities) {
|
|
@@ -938,7 +965,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
938
965
|
keys.add(agentKey);
|
|
939
966
|
result.set(root, keys);
|
|
940
967
|
}
|
|
941
|
-
|
|
968
|
+
yield;
|
|
942
969
|
}
|
|
943
970
|
|
|
944
971
|
const aliasInputs = [];
|
|
@@ -1032,7 +1059,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
1032
1059
|
keys.add(agentKey);
|
|
1033
1060
|
result.set(root, keys);
|
|
1034
1061
|
}
|
|
1035
|
-
|
|
1062
|
+
yield;
|
|
1036
1063
|
}
|
|
1037
1064
|
return result;
|
|
1038
1065
|
}
|