letagents 0.12.21 → 0.12.23

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.
@@ -3,7 +3,8 @@ import { createRequire } from "node:module";
3
3
  import { mkdir, readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
- import { withWorkerStateFence } from "../worker-call-context.js";
6
+ import { withWorkerStateFence, currentWorkerCall } from "../worker-call-context.js";
7
+ import { ensureLocalWorkLeaseSchema, readLocalWorkLeases, assertLocalTaskLeaseMutation, assertLocalWorkLeaseWorker, claimLocalWorkLease, changeLocalWorkLease } from "../../../shared/local-work-leases.mjs";
7
8
  import { ensureLocalThreadRoutingProjectionSchemaAsync, getLocalThreadRoutingAgentKeysForRoots, projectLocalThreadRoutingMessage, runLocalSqliteWriteTransactionAsync, scheduleLocalThreadRoutingBackfill, } from "../../../shared/sqlite-thread-routing.mjs";
8
9
  import { MESSAGE_SENDER_MAX_CODE_POINTS, MESSAGE_SENDER_MAX_UTF8_BYTES, POSTGRES_INTEGER_MAX, isMessageSenderWithinBounds, parseAccountAgentRoutingEnvelope, parsePositivePgIntegerScopedId, } from "../../../shared/message-contracts.mjs";
9
10
  const localImportedRoutingAuthority = Symbol("localImportedRoutingAuthority");
@@ -287,6 +288,7 @@ async function initializeDb() {
287
288
  addColumnIfMissing(database, "local_tasks", "review_agent_key", "TEXT");
288
289
  addColumnIfMissing(database, "local_tasks", "review_agent_session_id", "TEXT");
289
290
  addColumnIfMissing(database, "local_tasks", "review_updated_at", "TEXT");
291
+ ensureLocalWorkLeaseSchema(database);
290
292
  database.exec(`
291
293
  CREATE UNIQUE INDEX IF NOT EXISTS local_chat_messages_sync_key_idx
292
294
  ON local_chat_messages (room_id, sync_key)
@@ -661,7 +663,7 @@ function allocateLocalTaskId(database, roomId) {
661
663
  .run(roomId);
662
664
  return `task_${number}`;
663
665
  }
664
- function mapTaskRow(row) {
666
+ function mapTaskRow(row, database) {
665
667
  const reviewLeaseId = typeof row.review_lease_id === "string" && row.review_lease_id.trim()
666
668
  ? row.review_lease_id
667
669
  : null;
@@ -678,27 +680,28 @@ function mapTaskRow(row) {
678
680
  pr_url: typeof row.pr_url === "string" ? row.pr_url : null,
679
681
  workflow_artifacts: parseJsonArray(row.workflow_artifacts_json, []),
680
682
  workflow_refs: parseJsonArray(row.workflow_refs_json, []),
681
- active_leases: reviewLeaseId
682
- ? [
683
- {
684
- id: reviewLeaseId,
685
- kind: "review",
686
- holder_label: typeof row.review_holder_label === "string"
687
- ? row.review_holder_label
688
- : null,
689
- agent_key: typeof row.review_agent_key === "string"
690
- ? row.review_agent_key
691
- : null,
692
- agent_session_id: typeof row.review_agent_session_id === "string"
693
- ? row.review_agent_session_id
694
- : null,
695
- status: "active",
696
- updated_at: typeof row.review_updated_at === "string"
697
- ? row.review_updated_at
698
- : null,
699
- },
700
- ]
701
- : [],
683
+ active_leases: [...readLocalWorkLeases(database, String(row.room_id), String(row.task_id))
684
+ .map(lease => ({ ...lease, holder_label: lease.actor_label })), ...(reviewLeaseId
685
+ ? [
686
+ {
687
+ id: reviewLeaseId,
688
+ kind: "review",
689
+ holder_label: typeof row.review_holder_label === "string"
690
+ ? row.review_holder_label
691
+ : null,
692
+ agent_key: typeof row.review_agent_key === "string"
693
+ ? row.review_agent_key
694
+ : null,
695
+ agent_session_id: typeof row.review_agent_session_id === "string"
696
+ ? row.review_agent_session_id
697
+ : null,
698
+ status: "active",
699
+ updated_at: typeof row.review_updated_at === "string"
700
+ ? row.review_updated_at
701
+ : null,
702
+ },
703
+ ]
704
+ : [])],
702
705
  created_at: String(row.created_at || ""),
703
706
  updated_at: String(row.updated_at || ""),
704
707
  };
@@ -762,7 +765,7 @@ export async function listLocalTasks(roomId, options = {}) {
762
765
  ORDER BY created_at ASC
763
766
  `)
764
767
  .all(...params)
765
- .map(mapTaskRow);
768
+ .map(row => mapTaskRow(row, database));
766
769
  return { tasks, has_more: false };
767
770
  }
768
771
  export async function listLocalActiveTaskOwnerLeases(roomId) {
@@ -808,60 +811,111 @@ export async function getLocalTask(roomId, taskId) {
808
811
  const row = database
809
812
  .prepare("SELECT * FROM local_tasks WHERE room_id = ? AND task_id = ?")
810
813
  .get(roomId, taskId);
811
- return row ? mapTaskRow(row) : null;
814
+ return row ? mapTaskRow(row, database) : null;
812
815
  }
813
816
  export async function updateLocalTask(roomId, taskId, patch) {
814
- const current = await getLocalTask(roomId, taskId);
815
- if (!current)
816
- throw new Error("Task not found.");
817
- const nextStatus = patch.skip_transition_validation === true
818
- ? typeof patch.status === "string" && patch.status.trim()
819
- ? patch.status.trim()
820
- : current.status
821
- : resolveLocalTaskStatus(current.status, patch.status);
822
- const assigneeAgentKey = patch.assignee_agent_key === undefined
823
- ? current.assignee_agent_key
824
- : typeof patch.assignee_agent_key === "string"
825
- ? patch.assignee_agent_key
826
- : null;
827
- const assigneeAgentInstanceId = patch.assignee_agent_key === undefined
828
- ? current.assignee_agent_instance_id
829
- : assigneeAgentKey && typeof patch.assignee_agent_instance_id === "string"
830
- ? patch.assignee_agent_instance_id
831
- : assigneeAgentKey && typeof patch.actor_instance_id === "string"
832
- ? patch.actor_instance_id
833
- : null;
834
- const assigneeAgentSessionId = patch.assignee_agent_key === undefined
835
- ? current.assignee_agent_session_id
836
- : assigneeAgentKey && typeof patch.assignee_agent_session_id === "string"
837
- ? patch.assignee_agent_session_id
838
- : assigneeAgentKey && typeof patch.agent_session_id === "string"
839
- ? patch.agent_session_id
817
+ const database = await getDb();
818
+ let observed = readLocalWorkLeases(database, roomId, taskId)[0] ?? null;
819
+ return runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
820
+ let row = database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
821
+ if (!row)
822
+ throw new Error("Task not found.");
823
+ if (patch.expected_no_work_lease === true && readLocalWorkLeases(database, roomId, taskId).length) {
824
+ throw new Error("The task lease changed. Refresh the task before trying again.");
825
+ }
826
+ const caller = currentWorkerCall();
827
+ const worker = caller?.agent_key ? {
828
+ agent_key: caller.agent_key, session_id: caller.session_id, actor_label: caller.actor_label || caller.agent_key,
829
+ agent_instance_id: caller.agent_instance_id,
830
+ } : null;
831
+ const supervised = worker ? assertLocalWorkLeaseWorker(database, roomId, worker) : false;
832
+ if (supervised && worker && patch.status === "assigned") {
833
+ if ((patch.assignee_agent_key != null && patch.assignee_agent_key !== worker.agent_key)
834
+ || (patch.assignee != null && patch.assignee !== worker.actor_label)) {
835
+ throw new Error("Use handoff_task_lease to assign work to another worker.");
836
+ }
837
+ observed = claimLocalWorkLease(database, roomId, taskId, worker);
838
+ row = database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId);
839
+ }
840
+ if (worker) {
841
+ assertLocalTaskLeaseMutation(database, row, worker, observed);
842
+ if ((supervised || observed) && ((patch.assignee !== undefined && patch.assignee !== row.assignee)
843
+ || (patch.assignee_agent_key !== undefined && patch.assignee_agent_key !== row.assignee_agent_key)
844
+ || (patch.assignee_agent_session_id !== undefined && patch.assignee_agent_session_id !== row.assignee_agent_session_id)
845
+ || (patch.assignee_agent_key !== undefined && patch.agent_session_id !== undefined && patch.agent_session_id !== row.assignee_agent_session_id)
846
+ || (patch.assignee_agent_instance_id !== undefined && patch.assignee_agent_instance_id !== row.assignee_agent_instance_id)
847
+ || (patch.assignee_agent_key !== undefined && patch.actor_instance_id !== undefined && patch.actor_instance_id !== row.assignee_agent_instance_id))) {
848
+ throw new Error("Use claim_task or handoff_task_lease to change task ownership.");
849
+ }
850
+ }
851
+ else if (observed || readLocalWorkLeases(database, roomId, taskId).length) {
852
+ throw new Error("A registered owning worker is required to update this leased task.");
853
+ }
854
+ const current = mapTaskRow(row, database);
855
+ const nextStatus = supervised && patch.status === "assigned" ? current.status
856
+ : patch.skip_transition_validation === true
857
+ ? typeof patch.status === "string" && patch.status.trim()
858
+ ? patch.status.trim()
859
+ : current.status
860
+ : resolveLocalTaskStatus(current.status, patch.status);
861
+ const assigneeAgentKey = patch.assignee_agent_key === undefined
862
+ ? current.assignee_agent_key
863
+ : typeof patch.assignee_agent_key === "string"
864
+ ? patch.assignee_agent_key
840
865
  : null;
841
- const workflowArtifacts = patch.workflow_artifacts === undefined
842
- ? JSON.stringify(current.workflow_artifacts)
843
- : JSON.stringify(Array.isArray(patch.workflow_artifacts) ? patch.workflow_artifacts : []);
844
- const now = new Date().toISOString();
866
+ const assigneeAgentInstanceId = patch.assignee_agent_key === undefined || (worker && patch.assignee_agent_key === current.assignee_agent_key)
867
+ ? current.assignee_agent_instance_id
868
+ : assigneeAgentKey && typeof patch.assignee_agent_instance_id === "string"
869
+ ? patch.assignee_agent_instance_id
870
+ : assigneeAgentKey && typeof patch.actor_instance_id === "string"
871
+ ? patch.actor_instance_id
872
+ : null;
873
+ const assigneeAgentSessionId = patch.assignee_agent_key === undefined || (worker && patch.assignee_agent_key === current.assignee_agent_key)
874
+ ? current.assignee_agent_session_id
875
+ : assigneeAgentKey && typeof patch.assignee_agent_session_id === "string"
876
+ ? patch.assignee_agent_session_id
877
+ : assigneeAgentKey && typeof patch.agent_session_id === "string"
878
+ ? patch.agent_session_id
879
+ : null;
880
+ const workflowArtifacts = patch.workflow_artifacts === undefined
881
+ ? JSON.stringify(current.workflow_artifacts)
882
+ : JSON.stringify(Array.isArray(patch.workflow_artifacts) ? patch.workflow_artifacts : []);
883
+ const now = new Date().toISOString();
884
+ database
885
+ .prepare(`
886
+ UPDATE local_tasks
887
+ SET status = ?,
888
+ assignee = ?,
889
+ assignee_agent_key = ?,
890
+ assignee_agent_instance_id = ?,
891
+ assignee_agent_session_id = ?,
892
+ pr_url = ?,
893
+ workflow_artifacts_json = ?,
894
+ sync_dirty = 1,
895
+ updated_at = ?
896
+ WHERE room_id = ? AND task_id = ?
897
+ `)
898
+ .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId);
899
+ return mapTaskRow(database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId), database);
900
+ }));
901
+ }
902
+ export async function changeLocalTaskWorkLease(roomId, taskId, input) {
845
903
  const database = await getDb();
846
- await runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => database
847
- .prepare(`
848
- UPDATE local_tasks
849
- SET status = ?,
850
- assignee = ?,
851
- assignee_agent_key = ?,
852
- assignee_agent_instance_id = ?,
853
- assignee_agent_session_id = ?,
854
- pr_url = ?,
855
- workflow_artifacts_json = ?,
856
- sync_dirty = 1,
857
- updated_at = ?
858
- WHERE room_id = ? AND task_id = ?
859
- `)
860
- .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId)));
861
- const updated = await getLocalTask(roomId, taskId);
862
- if (!updated)
863
- throw new Error("Task not found.");
864
- return updated;
904
+ const observed = readLocalWorkLeases(database, roomId, taskId)[0];
905
+ if (!observed)
906
+ throw new Error("This task has no active work lease.");
907
+ return runLocalSqliteWriteTransactionAsync(database, () => withWorkerStateFence(() => {
908
+ const worker = currentWorkerCall();
909
+ if (!worker?.agent_key)
910
+ throw new Error("A registered owning worker is required to change this work lease.");
911
+ const result = changeLocalWorkLease(database, roomId, taskId, {
912
+ ...input, lease_id: input.lease_id ?? observed.id, epoch: input.epoch ?? observed.epoch,
913
+ }, {
914
+ agent_key: worker.agent_key, session_id: worker.session_id, actor_label: worker.actor_label || worker.agent_key,
915
+ });
916
+ const task = mapTaskRow(database.prepare("SELECT * FROM local_tasks WHERE room_id=? AND task_id=?").get(roomId, taskId), database);
917
+ return { action: input.action, task, ...result };
918
+ }));
865
919
  }
866
920
  export async function claimLocalTaskReviewLease(roomId, taskId, input) {
867
921
  const current = await getLocalTask(roomId, taskId);
@@ -7,6 +7,8 @@ import { currentRoom, getCurrentSupervisedRoomAuthority } from "./room-state.js"
7
7
  import { hasSupervisedWorkerAuthority } from "./worker-bearer.js";
8
8
  import { currentWorkerCall } from "../../worker-call-context.js";
9
9
  export async function roomScopedApiCall(input) {
10
+ // A successful send does not prove that preceding messages were read.
11
+ const preserveCursor = input.preserve_session_cursor || (input.options?.method ?? "GET").toUpperCase() !== "GET";
10
12
  const supervised = hasSupervisedWorkerAuthority();
11
13
  const exactRoomAuthority = supervised ? getCurrentSupervisedRoomAuthority() : null;
12
14
  if (supervised && (!exactRoomAuthority || input.room_id !== exactRoomAuthority)) {
@@ -36,7 +38,7 @@ export async function roomScopedApiCall(input) {
36
38
  try {
37
39
  const result = await apiCall(input.room_path(apiRoomId), options);
38
40
  if (!supervised) {
39
- touchRoomSession(input.room_id, input.preserve_session_cursor ? undefined : getLastMessageId(result));
41
+ touchRoomSession(input.room_id, preserveCursor ? undefined : getLastMessageId(result));
40
42
  }
41
43
  return result;
42
44
  }
@@ -53,7 +55,7 @@ export async function roomScopedApiCall(input) {
53
55
  const result = await apiCall(input.project_path(input.project_id), options);
54
56
  if (input.room_id) {
55
57
  if (!supervised) {
56
- touchRoomSession(input.room_id, input.preserve_session_cursor ? undefined : getLastMessageId(result));
58
+ touchRoomSession(input.room_id, preserveCursor ? undefined : getLastMessageId(result));
57
59
  }
58
60
  }
59
61
  return result;
@@ -65,7 +65,7 @@ export function registerPostReasoningTool(server) {
65
65
  publisher_agent_session_id: agentSession?.session_id ?? null,
66
66
  });
67
67
  milestoneMessageId = milestoneMessage.id;
68
- touchCurrentRoom(milestoneMessageId);
68
+ touchCurrentRoom();
69
69
  }
70
70
  await syncRoomPresence(effectiveLocalRoomId, identity, {
71
71
  status: status ?? getRememberedRoomPresence(effectiveLocalRoomId, identity).status,
@@ -158,7 +158,7 @@ export function registerPostReasoningTool(server) {
158
158
  });
159
159
  milestoneMessageId =
160
160
  typeof milestoneMessage.id === "string" ? milestoneMessage.id : null;
161
- touchCurrentRoom(milestoneMessageId ?? undefined);
161
+ touchCurrentRoom();
162
162
  }
163
163
  if (status) {
164
164
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, {
@@ -90,7 +90,7 @@ async function sendMessageFromTool(input) {
90
90
  publisher_agent_key: agentSession?.agent_key ?? null,
91
91
  publisher_agent_session_id: agentSession?.session_id ?? null,
92
92
  });
93
- touchCurrentRoom(message.id);
93
+ touchCurrentRoom();
94
94
  return jsonToolResponse({
95
95
  ...message,
96
96
  agent_identity: toPublicAgentIdentity(identity),
@@ -112,7 +112,7 @@ async function sendMessageFromTool(input) {
112
112
  })),
113
113
  },
114
114
  });
115
- touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
115
+ touchCurrentRoom();
116
116
  await syncRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity, getRememberedRoomPresence(targetRoomId ?? currentRoom?.room_id ?? null, identity), agentSession);
117
117
  // The `replied` receipt transition is server-owned: message creation marks
118
118
  // the publisher's receipt on the reply target atomically with the reply
@@ -48,7 +48,7 @@ export function registerPostStatusTool(server) {
48
48
  publisher_agent_key: agentSession?.agent_key ?? null,
49
49
  publisher_agent_session_id: agentSession?.session_id ?? null,
50
50
  });
51
- touchCurrentRoom(message.id);
51
+ touchCurrentRoom();
52
52
  return jsonToolResponse({
53
53
  success: true,
54
54
  status_posted: status,
@@ -76,7 +76,7 @@ export function registerPostStatusTool(server) {
76
76
  }),
77
77
  },
78
78
  });
79
- touchCurrentRoom(typeof message.id === "string" ? message.id : undefined);
79
+ touchCurrentRoom();
80
80
  return jsonToolResponse({
81
81
  success: true,
82
82
  status_posted: status,
@@ -1,5 +1,6 @@
1
1
  import { encodeRoomIdPath } from "../../../room-id.js";
2
2
  import { addLocalTask, apiCall, claimLocalTaskReviewLease, getLocalTask, isLocalRoomStorageEnabled, listLocalTasks, releaseLocalTaskReviewLease, resolveLocalRoomStorageIdentifiers, roomScopedApiCall, updateLocalTask, } from "../../runtime.js";
3
+ import { changeLocalTaskWorkLease } from "../../../local-state/local-chat.js";
3
4
  export function taskCollectionRoomPath(roomId) {
4
5
  return `/rooms/${encodeRoomIdPath(roomId)}/tasks`;
5
6
  }
@@ -129,6 +130,17 @@ export async function postCanonicalTaskAction(roomId, taskId, actionPath, body)
129
130
  released_lease: null,
130
131
  };
131
132
  }
133
+ if ((action === "handoff" || action === "release") && existingTask.active_leases?.some(lease => lease.kind === "work")) {
134
+ return await changeLocalTaskWorkLease(sqliteRoomId, taskId, {
135
+ action, lease_id: typeof body.lease_id === "string" ? body.lease_id : null,
136
+ target_actor_key: typeof body.target_actor_key === "string" ? body.target_actor_key : null,
137
+ target_actor_instance_id: typeof body.target_actor_instance_id === "string" ? body.target_actor_instance_id : null,
138
+ target_agent_session_id: typeof body.target_agent_session_id === "string" ? body.target_agent_session_id : null,
139
+ });
140
+ }
141
+ if ((action === "handoff" || action === "release") && body.lease_id) {
142
+ throw new Error("The task lease changed. Refresh the task before trying again.");
143
+ }
132
144
  if (action === "handoff") {
133
145
  const targetActorKey = typeof body.target_actor_key === "string" && body.target_actor_key.trim()
134
146
  ? body.target_actor_key.trim()
@@ -141,6 +153,7 @@ export async function postCanonicalTaskAction(roomId, taskId, actionPath, body)
141
153
  : null;
142
154
  const task = targetActorKey
143
155
  ? await updateLocalTask(sqliteRoomId, taskId, {
156
+ expected_no_work_lease: true,
144
157
  ...(existingTask.status === "accepted" ? { status: "assigned" } : {}),
145
158
  assignee: targetActorKey,
146
159
  assignee_agent_key: targetActorKey,
@@ -158,6 +171,7 @@ export async function postCanonicalTaskAction(roomId, taskId, actionPath, body)
158
171
  if (action === "release") {
159
172
  const releasableStatuses = new Set(["assigned", "in_progress", "blocked", "in_review"]);
160
173
  const patch = {
174
+ expected_no_work_lease: true,
161
175
  assignee: null,
162
176
  assignee_agent_key: null,
163
177
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.21",
4
- "description": "Let Agents Chat MCP server for AI agent communication",
3
+ "version": "0.12.23",
4
+ "description": "Let Agents Chat \u2014 MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
7
7
  "bin": {
@@ -1,5 +1,5 @@
1
1
  export type AgentMessageActivationDecision = "activate" | "silent" | "unclear";
2
- export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "system_event" | "unaddressed";
2
+ export type AgentMessageActivationReason = "self_message" | "explicit_mention" | "explicit_other_mention" | "broadcast" | "reply_target" | "other_reply_target" | "thread_participant" | "task_owner" | "small_room" | "recent_conversation" | "jev_routed" | "system_event" | "unaddressed";
3
3
  /** Send-time human fallback only; never use this to re-route historical reads. */
4
4
  export declare function humanConversationFallback(input: {
5
5
  source: string | null;
@@ -0,0 +1,24 @@
1
+ import type { SqliteRoutingDatabase as SqliteDatabase } from './sqlite-thread-routing.mjs';
2
+ export type LocalWorkLeaseWorker = {
3
+ agent_key: string; session_id: string; actor_label: string; agent_instance_id?: string | null; supervised?: boolean;
4
+ };
5
+ export type LocalWorkLease = {
6
+ id: string; room_id: string; task_id: string; kind: 'work'; status: string;
7
+ agent_key: string; agent_session_id: string; agent_instance_id: string | null; actor_label: string;
8
+ epoch: number; created_at: string; updated_at: string; last_heartbeat_at: string;
9
+ expires_at: string | null; revoked_reason: string | null;
10
+ };
11
+ export type LocalWorkLeaseAction = {
12
+ action: 'release' | 'handoff'; lease_id?: string | null; epoch?: number;
13
+ target_actor_key?: string | null; target_actor_instance_id?: string | null; target_agent_session_id?: string | null;
14
+ };
15
+ export function ensureLocalWorkLeaseSchema(db: SqliteDatabase): void;
16
+ export function readLocalWorkLeases(db: SqliteDatabase, roomId: string, taskId: string): LocalWorkLease[];
17
+ export function assertLocalWorkLeaseWorker(db: SqliteDatabase, roomId: string, worker: LocalWorkLeaseWorker): boolean;
18
+ export function claimLocalWorkLease(db: SqliteDatabase, roomId: string, taskId: string, worker: LocalWorkLeaseWorker): LocalWorkLease;
19
+ export function assertLocalTaskLeaseMutation(db: SqliteDatabase, task: Record<string, unknown>, worker: LocalWorkLeaseWorker,
20
+ expected?: Pick<LocalWorkLease, 'id' | 'epoch'> | null): void;
21
+ export function changeLocalWorkLease(db: SqliteDatabase, roomId: string, taskId: string, input: LocalWorkLeaseAction,
22
+ worker: LocalWorkLeaseWorker | null): { released_lease: LocalWorkLease; new_lease: LocalWorkLease | null };
23
+ export function endLocalWorkerLeases(db: SqliteDatabase, sessionId: string, now: string): void;
24
+ export function heartbeatLocalWorkLeases(db: SqliteDatabase, sessionId: string, now: string): Array<{id: string; epoch: number}>;
@@ -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
+ }