letagents 0.12.21 → 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.
|
@@ -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:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
:
|
|
827
|
-
|
|
828
|
-
?
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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);
|
|
@@ -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
|
@@ -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
|
+
}
|