@zq-silk/yui 0.6.4 → 0.6.6

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.
@@ -40,6 +40,8 @@ import { join } from "node:path";
40
40
  import { isDeepStrictEqual } from "node:util";
41
41
  import Database from "better-sqlite3";
42
42
  import { mailboxTargetKey } from "../coordination/workMailbox.js";
43
+ import { validatePendingTurnCompletion } from "../executor/turnCompletion.js";
44
+ import { compareRuntimeSessionCandidates, projectRuntimeSessionCandidate } from "../runtime/runtimeSessionCandidate.js";
43
45
  import { validateReviewFinding } from "../review/reviewFinding.js";
44
46
  import { generateHomeIdentity } from "../repository/homeIdentity.js";
45
47
  import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
@@ -579,6 +581,11 @@ export class SqliteTaskStore {
579
581
  if (sessions !== null) {
580
582
  this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
581
583
  ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(role.name, this.#json(sessions), this.#now());
584
+ this.#saveRuntimeSessionCandidate(sessions);
585
+ }
586
+ else {
587
+ this.#db.prepare("DELETE FROM global_role_session_sets WHERE name = ?").run(role.name);
588
+ this.#deleteRuntimeSessionCandidate({ scope: "global", roleName: role.name });
582
589
  }
583
590
  });
584
591
  }
@@ -595,7 +602,11 @@ export class SqliteTaskStore {
595
602
  return this.#getPayload("global_roles", "name = ?", [name]);
596
603
  }
597
604
  removeGlobalRole(name) {
598
- return this.#mutate(() => this.#db.prepare("DELETE FROM global_roles WHERE name = ?").run(name).changes > 0);
605
+ return this.#mutate(() => {
606
+ this.#deleteRuntimeSessionCandidate({ scope: "global", roleName: name });
607
+ this.#db.prepare("DELETE FROM global_role_session_sets WHERE name = ?").run(name);
608
+ return this.#db.prepare("DELETE FROM global_roles WHERE name = ?").run(name).changes > 0;
609
+ });
599
610
  }
600
611
  getGlobalRoleSessionSet(name) {
601
612
  return this.#getPayload("global_role_session_sets", "name = ?", [name]);
@@ -608,6 +619,7 @@ export class SqliteTaskStore {
608
619
  this.#mutate(() => {
609
620
  this.#db.prepare(`INSERT INTO global_role_session_sets (name, payload, updated_at) VALUES (?, ?, ?)
610
621
  ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.roleName, this.#json(sessions), this.#now());
622
+ this.#saveRuntimeSessionCandidate(sessions);
611
623
  });
612
624
  }
613
625
  // -- tasks ------------------------------------------------------------------
@@ -800,8 +812,8 @@ export class SqliteTaskStore {
800
812
  findDurableJobByIdempotencyKey(taskId, key) {
801
813
  return this.#getPayload("durable_jobs", "task_id = ? AND idempotency_key = ?", [taskId, key]);
802
814
  }
803
- listAllDurableJobs() {
804
- return this.#listPayload("durable_jobs", "1 = 1", []);
815
+ listActiveDurableJobs() {
816
+ return this.#sortById(this.#listPayload("durable_jobs", "status IN ('queued', 'running')", []), (job) => `${job.taskId}/${job.id}`);
805
817
  }
806
818
  hasActiveDurableJobs() {
807
819
  const row = this.#db.prepare("SELECT 1 FROM durable_jobs WHERE status IN ('queued', 'running') LIMIT 1").get();
@@ -878,10 +890,19 @@ export class SqliteTaskStore {
878
890
  ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, role.name, this.#json(role), this.#now());
879
891
  this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
880
892
  ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(sessions.owner.taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
893
+ this.#saveRuntimeSessionCandidate(sessions);
894
+ this.#savePendingRuntimeTurnCompletion(sessions);
881
895
  });
882
896
  }
883
897
  removeTaskRole(taskId, name) {
884
898
  return this.#mutate(() => {
899
+ this.#deleteRuntimeSessionCandidate({
900
+ scope: "task",
901
+ taskId,
902
+ roleName: name
903
+ });
904
+ this.#db.prepare("DELETE FROM role_session_sets WHERE task_id = ? AND role_name = ?").run(taskId, name);
905
+ this.#db.prepare("DELETE FROM pending_runtime_turn_completions WHERE task_id = ? AND role_name = ?").run(taskId, name);
885
906
  const result = this.#db.prepare("DELETE FROM task_roles WHERE task_id = ? AND role_name = ?").run(taskId, name);
886
907
  return result.changes > 0;
887
908
  });
@@ -936,12 +957,164 @@ export class SqliteTaskStore {
936
957
  listRoleSessionSets(taskId) {
937
958
  return this.#sortById(this.#listPayload("role_session_sets", "task_id = ?", [taskId]), (set) => set.owner.roleName);
938
959
  }
960
+ listRuntimeSessionCandidates(query = {}) {
961
+ const taskIds = query.taskIds === undefined
962
+ ? undefined
963
+ : [...new Set(query.taskIds)].sort(numericCompare);
964
+ if (taskIds?.length === 0 || (taskIds !== undefined && query.scope === "global")) {
965
+ return [];
966
+ }
967
+ const predicates = [];
968
+ const parameters = [];
969
+ if (query.cleanupRequiredOnly)
970
+ predicates.push("cleanup_required = 1");
971
+ if (taskIds !== undefined) {
972
+ predicates.push("scope = 'task'");
973
+ predicates.push(`task_id IN (${taskIds.map(() => "?").join(", ")})`);
974
+ parameters.push(...taskIds);
975
+ }
976
+ else if (query.scope !== undefined) {
977
+ predicates.push("scope = ?");
978
+ parameters.push(query.scope);
979
+ }
980
+ const where = predicates.length === 0
981
+ ? ""
982
+ : ` WHERE ${predicates.join(" AND ")}`;
983
+ const rows = this.#db.prepare(`SELECT scope, task_id, role_name, agent_id, adapter_id,
984
+ native_session_id, launch_id, status, session_updated_at,
985
+ cleanup_required
986
+ FROM runtime_session_candidates${where}`).all(...parameters);
987
+ const candidates = rows.map((row) => ({
988
+ owner: row.scope === "task"
989
+ ? { scope: "task", taskId: row.task_id, roleName: row.role_name }
990
+ : { scope: "global", roleName: row.role_name },
991
+ agentId: row.agent_id,
992
+ adapterId: row.adapter_id,
993
+ nativeSessionId: row.native_session_id,
994
+ ...(row.launch_id === null ? {} : { launchId: row.launch_id }),
995
+ status: row.status,
996
+ sessionUpdatedAt: row.session_updated_at,
997
+ cleanupRequired: row.cleanup_required === 1
998
+ })).sort(compareRuntimeSessionCandidates);
999
+ for (const candidate of candidates) {
1000
+ if (!this.#runtimeSessionCandidateMatchesSource(candidate)) {
1001
+ throw new StorageRecordError(`Runtime Session projection is inconsistent: ${candidate.owner.scope === "task"
1002
+ ? `${candidate.owner.taskId}/${candidate.owner.roleName}`
1003
+ : `global/${candidate.owner.roleName}`}.`);
1004
+ }
1005
+ }
1006
+ return candidates;
1007
+ }
1008
+ /**
1009
+ * Compare the hot projection with the authoritative current Session without
1010
+ * parsing or validating the RoleSessionSet aggregate. In particular, the
1011
+ * historical `history` field must never enter this cleanup discovery path:
1012
+ * JSON1 reads only the active Agent object selected by `activeAgentId`.
1013
+ */
1014
+ #runtimeSessionCandidateMatchesSource(candidate) {
1015
+ const source = candidate.owner.scope === "task"
1016
+ ? {
1017
+ table: "role_session_sets",
1018
+ where: "task_id = ? AND role_name = ?",
1019
+ parameters: [candidate.owner.taskId, candidate.owner.roleName]
1020
+ }
1021
+ : {
1022
+ table: "global_role_session_sets",
1023
+ where: "name = ?",
1024
+ parameters: [candidate.owner.roleName]
1025
+ };
1026
+ let row;
1027
+ try {
1028
+ row = this.#db.prepare(`WITH source AS (
1029
+ SELECT payload,
1030
+ json_extract(payload, '$.activeAgentId') AS active_agent_id
1031
+ FROM ${source.table}
1032
+ WHERE ${source.where}
1033
+ ), active AS (
1034
+ SELECT payload,
1035
+ active_agent_id,
1036
+ json_extract(
1037
+ payload,
1038
+ '$.sessions.' || json_quote(active_agent_id)
1039
+ ) AS active_session
1040
+ FROM source
1041
+ )
1042
+ SELECT
1043
+ json_extract(payload, '$.owner.scope') AS owner_scope,
1044
+ json_extract(payload, '$.owner.taskId') AS owner_task_id,
1045
+ json_extract(payload, '$.owner.roleName') AS owner_role_name,
1046
+ active_agent_id,
1047
+ json_extract(active_session, '$.agentId') AS agent_id,
1048
+ json_extract(active_session, '$.adapterId') AS adapter_id,
1049
+ json_extract(active_session, '$.nativeSessionId') AS native_session_id,
1050
+ json_extract(active_session, '$.launchId') AS launch_id,
1051
+ json_extract(active_session, '$.status') AS status,
1052
+ json_extract(active_session, '$.updatedAt') AS session_updated_at,
1053
+ CASE
1054
+ WHEN json_extract(active_session, '$.status') NOT IN ('stopped','broken')
1055
+ AND (
1056
+ json_extract(active_session, '$.status') = 'running'
1057
+ OR json_type(active_session, '$.launchId') = 'text'
1058
+ )
1059
+ THEN 1 ELSE 0
1060
+ END AS cleanup_required
1061
+ FROM active`).get(...source.parameters);
1062
+ }
1063
+ catch (error) {
1064
+ const detail = error instanceof Error ? error.message : String(error);
1065
+ throw new StorageRecordError(`Runtime Session projection source is invalid for ${candidate.owner.scope === "task"
1066
+ ? `${candidate.owner.taskId}/${candidate.owner.roleName}`
1067
+ : `global/${candidate.owner.roleName}`}: ${detail}`);
1068
+ }
1069
+ if (row === undefined)
1070
+ return false;
1071
+ return row.owner_scope === candidate.owner.scope
1072
+ && row.owner_task_id === (candidate.owner.scope === "task" ? candidate.owner.taskId : null)
1073
+ && row.owner_role_name === candidate.owner.roleName
1074
+ && row.active_agent_id === candidate.agentId
1075
+ && row.agent_id === candidate.agentId
1076
+ && row.adapter_id === candidate.adapterId
1077
+ && row.native_session_id === candidate.nativeSessionId
1078
+ && (row.launch_id ?? undefined) === candidate.launchId
1079
+ && row.status === candidate.status
1080
+ && row.session_updated_at === candidate.sessionUpdatedAt
1081
+ && row.cleanup_required === (candidate.cleanupRequired ? 1 : 0);
1082
+ }
1083
+ listPendingRuntimeTurnCompletions(taskIds) {
1084
+ const selectedTaskIds = taskIds === undefined
1085
+ ? undefined
1086
+ : [...new Set(taskIds)].sort(numericCompare);
1087
+ if (selectedTaskIds?.length === 0)
1088
+ return [];
1089
+ const where = selectedTaskIds === undefined
1090
+ ? ""
1091
+ : ` WHERE task_id IN (${selectedTaskIds.map(() => "?").join(", ")})`;
1092
+ const rows = this.#db.prepare(`SELECT task_id, role_name, schema_version, agent_id, native_session_id,
1093
+ turn_id, run_id, summary, observed_at, due_at
1094
+ FROM pending_runtime_turn_completions${where}`).all(...(selectedTaskIds ?? []));
1095
+ return rows.map((row) => validatePendingTurnCompletion({
1096
+ schemaVersion: row.schema_version,
1097
+ taskId: row.task_id,
1098
+ roleName: row.role_name,
1099
+ agentId: row.agent_id,
1100
+ nativeSessionId: row.native_session_id,
1101
+ turnId: row.turn_id,
1102
+ runId: row.run_id,
1103
+ summary: row.summary,
1104
+ observedAt: row.observed_at,
1105
+ dueAt: row.due_at
1106
+ })).sort((left, right) => (numericCompare(left.taskId, right.taskId)
1107
+ || numericCompare(left.roleName, right.roleName)
1108
+ || numericCompare(left.runId, right.runId)));
1109
+ }
939
1110
  saveRoleSessionSet(sessions) {
940
1111
  const taskId = sessions.owner.taskId;
941
1112
  this.#requireTask(taskId);
942
1113
  this.#mutate(() => {
943
1114
  this.#db.prepare(`INSERT INTO role_session_sets (task_id, role_name, payload, updated_at) VALUES (?, ?, ?, ?)
944
1115
  ON CONFLICT(task_id, role_name) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, sessions.owner.roleName, this.#json(sessions), this.#now());
1116
+ this.#saveRuntimeSessionCandidate(sessions);
1117
+ this.#savePendingRuntimeTurnCompletion(sessions);
945
1118
  });
946
1119
  }
947
1120
  saveTaskRoleSessionSet(sessions) {
@@ -954,6 +1127,56 @@ export class SqliteTaskStore {
954
1127
  const session = set.sessions[set.activeAgentId];
955
1128
  return session === undefined ? null : session;
956
1129
  }
1130
+ #saveRuntimeSessionCandidate(sessions) {
1131
+ const candidate = projectRuntimeSessionCandidate(sessions);
1132
+ if (candidate === null) {
1133
+ this.#deleteRuntimeSessionCandidate(sessions.owner);
1134
+ return;
1135
+ }
1136
+ const taskId = candidate.owner.scope === "task" ? candidate.owner.taskId : "";
1137
+ this.#db.prepare(`INSERT INTO runtime_session_candidates (
1138
+ scope, task_id, role_name, agent_id, adapter_id, native_session_id,
1139
+ launch_id, status, session_updated_at, cleanup_required
1140
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1141
+ ON CONFLICT(scope, task_id, role_name) DO UPDATE SET
1142
+ agent_id = excluded.agent_id,
1143
+ adapter_id = excluded.adapter_id,
1144
+ native_session_id = excluded.native_session_id,
1145
+ launch_id = excluded.launch_id,
1146
+ status = excluded.status,
1147
+ session_updated_at = excluded.session_updated_at,
1148
+ cleanup_required = excluded.cleanup_required`).run(candidate.owner.scope, taskId, candidate.owner.roleName, candidate.agentId, candidate.adapterId, candidate.nativeSessionId, candidate.launchId ?? null, candidate.status, candidate.sessionUpdatedAt, candidate.cleanupRequired ? 1 : 0);
1149
+ }
1150
+ #deleteRuntimeSessionCandidate(owner) {
1151
+ this.#db.prepare(`DELETE FROM runtime_session_candidates
1152
+ WHERE scope = ? AND task_id = ? AND role_name = ?`).run(owner.scope, owner.scope === "task" ? owner.taskId : "", owner.roleName);
1153
+ }
1154
+ /** Maintain the independent pending-Turn projection in the same write txn. */
1155
+ #savePendingRuntimeTurnCompletion(sessions) {
1156
+ const owner = sessions.owner;
1157
+ const pending = sessions.pendingTurnCompletion;
1158
+ if (pending === null) {
1159
+ this.#db.prepare("DELETE FROM pending_runtime_turn_completions WHERE task_id = ? AND role_name = ?").run(owner.taskId, owner.roleName);
1160
+ return;
1161
+ }
1162
+ const normalized = validatePendingTurnCompletion(pending);
1163
+ if (normalized.taskId !== owner.taskId || normalized.roleName !== owner.roleName) {
1164
+ throw new StorageRecordError(`Pending Turn completion owner does not match Role session: ${owner.taskId}/${owner.roleName}.`);
1165
+ }
1166
+ this.#db.prepare(`INSERT INTO pending_runtime_turn_completions (
1167
+ task_id, role_name, schema_version, agent_id, native_session_id,
1168
+ turn_id, run_id, summary, observed_at, due_at
1169
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1170
+ ON CONFLICT(task_id, role_name) DO UPDATE SET
1171
+ schema_version = excluded.schema_version,
1172
+ agent_id = excluded.agent_id,
1173
+ native_session_id = excluded.native_session_id,
1174
+ turn_id = excluded.turn_id,
1175
+ run_id = excluded.run_id,
1176
+ summary = excluded.summary,
1177
+ observed_at = excluded.observed_at,
1178
+ due_at = excluded.due_at`).run(normalized.taskId, normalized.roleName, normalized.schemaVersion, normalized.agentId, normalized.nativeSessionId, normalized.turnId, normalized.runId, normalized.summary, normalized.observedAt, normalized.dueAt);
1179
+ }
957
1180
  // -- work items -------------------------------------------------------------
958
1181
  nextWorkItemId(taskId) { return this.#nextTaskRecordId(taskId, "workItem"); }
959
1182
  getWorkItem(taskId, workItemId) {
@@ -1156,15 +1379,26 @@ export class SqliteTaskStore {
1156
1379
  * replaces the adapter's per-Task in-memory sweep, so Controller deadline
1157
1380
  * arming no longer materializes every Task and Run in JavaScript.
1158
1381
  */
1159
- listPendingProviderRetries() {
1160
- const rows = this.#db.prepare(`SELECT ar.task_id AS taskId, ar.run_id AS runId, ar.role_name AS roleName,
1382
+ listPendingProviderRetries(taskIds) {
1383
+ const selectedTaskIds = taskIds === undefined
1384
+ ? undefined
1385
+ : [...new Set(taskIds)].sort(numericCompare);
1386
+ if (selectedTaskIds?.length === 0)
1387
+ return [];
1388
+ const taskPredicate = selectedTaskIds === undefined
1389
+ ? ""
1390
+ : ` AND tc.task_id IN (${selectedTaskIds.map(() => "?").join(", ")})`;
1391
+ const rows = this.#db.prepare(`SELECT DISTINCT ar.task_id AS taskId, ar.run_id AS runId, ar.role_name AS roleName,
1161
1392
  json_extract(ar.payload, '$.providerRetry.nextAttemptAt') AS nextAttemptAt
1162
- FROM agent_runs ar
1163
- JOIN tasks_catalog tc ON tc.task_id = ar.task_id
1164
- WHERE ar.status = 'active'
1165
- AND tc.is_active = 1
1166
- AND json_extract(ar.payload, '$.providerRetry.nextAttemptAt') IS NOT NULL`).all();
1167
- return rows;
1393
+ FROM tasks_catalog tc INDEXED BY idx_tasks_active
1394
+ JOIN active_runs ap ON ap.task_id = tc.task_id
1395
+ JOIN agent_runs ar ON ar.task_id = ap.task_id AND ar.run_id = ap.run_id
1396
+ WHERE tc.is_active = 1
1397
+ AND ar.status = 'active'
1398
+ AND json_extract(ar.payload, '$.providerRetry.nextAttemptAt') IS NOT NULL${taskPredicate}`).all(...(selectedTaskIds ?? []));
1399
+ return rows.sort((left, right) => (numericCompare(left.taskId, right.taskId)
1400
+ || numericCompare(left.roleName, right.roleName)
1401
+ || numericCompare(left.runId, right.runId)));
1168
1402
  }
1169
1403
  // -- review rounds ----------------------------------------------------------
1170
1404
  nextReviewRoundId(taskId) { return this.#nextTaskRecordId(taskId, "reviewRound"); }
@@ -1213,11 +1447,82 @@ export class SqliteTaskStore {
1213
1447
  ON CONFLICT(task_id, pointer) DO UPDATE SET run_id = excluded.run_id, payload = excluded.payload, updated_at = excluded.updated_at`).run(taskId, pointer, runId, payload, this.#now());
1214
1448
  });
1215
1449
  }
1216
- #getActiveRun(taskId, pointer) {
1450
+ #readActiveRunPointer(taskId, pointer) {
1217
1451
  const row = this.#db.prepare("SELECT run_id FROM active_runs WHERE task_id = ? AND pointer = ?").get(taskId, pointer);
1218
1452
  if (row === undefined)
1219
1453
  return null;
1220
- return this.getAgentRun(taskId, row.run_id);
1454
+ return {
1455
+ runId: row.run_id,
1456
+ run: this.getAgentRun(taskId, row.run_id)
1457
+ };
1458
+ }
1459
+ #assertActiveRunForWrite(run, lane) {
1460
+ if (run.status !== "active") {
1461
+ throw new StorageRecordError(`Active Agent run must have active status: ${run.id}`);
1462
+ }
1463
+ const hasGroup = run.executionGroupId !== undefined;
1464
+ const hasLane = run.executionLaneId !== undefined;
1465
+ if (hasGroup !== hasLane) {
1466
+ throw new StorageRecordError(`Agent Run execution lineage is incomplete: ${run.id}.`);
1467
+ }
1468
+ if (lane !== undefined
1469
+ && (run.executionGroupId !== lane.executionGroupId
1470
+ || run.executionLaneId !== lane.executionLaneId)) {
1471
+ throw new StorageRecordError(`Active Agent run execution lineage does not match its Lane: ${run.id}`);
1472
+ }
1473
+ }
1474
+ #getActiveRoleRun(taskId, roleName) {
1475
+ const pointer = this.#readActiveRunPointer(taskId, roleName);
1476
+ if (pointer === null)
1477
+ return null;
1478
+ const task = this.getTask(taskId);
1479
+ if (task === null) {
1480
+ throw new StorageRecordError(`Active Agent run Task is missing: ${taskId}/${roleName}`);
1481
+ }
1482
+ if (pointer.run === null) {
1483
+ // Retired Tasks are an explicit historical isolation boundary. Their
1484
+ // retained pointer rows may intentionally reference a missing Run.
1485
+ if (task.status === "retired")
1486
+ return null;
1487
+ throw new StorageRecordError(`Active Agent run pointer is dangling: ${taskId}/${roleName}`);
1488
+ }
1489
+ const run = pointer.run;
1490
+ if (task.status === "retired")
1491
+ return run;
1492
+ if (run.id !== pointer.runId
1493
+ || run.taskId !== taskId
1494
+ || run.roleName !== roleName
1495
+ || run.status !== "active"
1496
+ || (run.executionGroupId === undefined) !== (run.executionLaneId === undefined)) {
1497
+ throw new StorageRecordError(`Active Agent run pointer is invalid: ${taskId}/${roleName}`);
1498
+ }
1499
+ return run;
1500
+ }
1501
+ #getActiveLaneRun(taskId, executionGroupId, executionLaneId) {
1502
+ const pointerKey = executionLaneActiveRunKey(executionGroupId, executionLaneId);
1503
+ const pointer = this.#readActiveRunPointer(taskId, pointerKey);
1504
+ if (pointer === null)
1505
+ return null;
1506
+ const task = this.getTask(taskId);
1507
+ if (task === null) {
1508
+ throw new StorageRecordError(`Active Agent run Task is missing: ${taskId}/${pointerKey}`);
1509
+ }
1510
+ if (pointer.run === null) {
1511
+ if (task.status === "retired")
1512
+ return null;
1513
+ throw new StorageRecordError(`Active Agent run pointer is dangling: ${taskId}/${pointerKey}`);
1514
+ }
1515
+ const run = pointer.run;
1516
+ if (task.status === "retired")
1517
+ return run;
1518
+ if (run.id !== pointer.runId
1519
+ || run.taskId !== taskId
1520
+ || run.status !== "active"
1521
+ || run.executionGroupId !== executionGroupId
1522
+ || run.executionLaneId !== executionLaneId) {
1523
+ throw new StorageRecordError(`Active Agent run pointer is invalid: ${taskId}/${pointerKey}`);
1524
+ }
1525
+ return run;
1221
1526
  }
1222
1527
  #clearActiveRun(taskId, pointer) {
1223
1528
  this.#mutate(() => {
@@ -1225,44 +1530,94 @@ export class SqliteTaskStore {
1225
1530
  });
1226
1531
  }
1227
1532
  getActiveAgentRun(taskId, roleName) {
1228
- return this.#getActiveRun(taskId, roleName);
1533
+ return this.#getActiveRoleRun(taskId, roleName);
1229
1534
  }
1230
1535
  saveActiveAgentRun(run) {
1231
- if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
1536
+ if (run.executionGroupId !== undefined || run.executionLaneId !== undefined) {
1232
1537
  this.saveActiveExecutionLaneRun(run);
1233
1538
  return;
1234
1539
  }
1235
- this.#saveActiveRun(run.taskId, run.roleName, run.id);
1540
+ this.transaction((store) => {
1541
+ this.#assertActiveRunForWrite(run);
1542
+ const current = store.getActiveAgentRun(run.taskId, run.roleName);
1543
+ if (current !== null && current.id !== run.id) {
1544
+ throw new StorageRecordError(`Role already has an active Agent run: ${run.taskId}/${run.roleName}`);
1545
+ }
1546
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
1547
+ if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
1548
+ throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
1549
+ }
1550
+ // Keep the Run row and its active pointer in the same transaction. The
1551
+ // adapter may have already written the row; this upsert remains
1552
+ // intentionally idempotent for that backend-neutral path.
1553
+ store.saveAgentRun(run);
1554
+ this.#saveActiveRun(run.taskId, run.roleName, run.id);
1555
+ });
1236
1556
  }
1237
1557
  clearActiveAgentRun(taskId, roleName) {
1238
1558
  // Older Controller paths only know the Role key. When that key points
1239
1559
  // at a lane-backed Run, remove the matching lane pointer too; preserve
1240
1560
  // every other lane for the same Role in a multi-lane group.
1241
- const rolePointer = this.#getActiveRun(taskId, roleName);
1242
- this.#clearActiveRun(taskId, roleName);
1243
- if (rolePointer === null)
1244
- return;
1245
- const laneRows = this.#db.prepare("SELECT task_id, pointer FROM active_runs WHERE task_id = ?").all(taskId);
1246
- for (const row of laneRows) {
1247
- if (executionLaneActiveRunKeyParts(row.pointer) !== null) {
1248
- const laneRun = this.#getActiveRun(taskId, row.pointer);
1249
- if (laneRun !== null && laneRun.id === rolePointer.id) {
1561
+ this.transaction(() => {
1562
+ const rolePointer = this.#readActiveRunPointer(taskId, roleName);
1563
+ this.#clearActiveRun(taskId, roleName);
1564
+ if (rolePointer === null)
1565
+ return;
1566
+ const laneRows = this.#db.prepare("SELECT pointer, run_id FROM active_runs WHERE task_id = ?").all(taskId);
1567
+ for (const row of laneRows) {
1568
+ if (executionLaneActiveRunKeyParts(row.pointer) !== null
1569
+ && row.run_id === rolePointer.runId) {
1250
1570
  this.#clearActiveRun(taskId, row.pointer);
1251
1571
  }
1252
1572
  }
1253
- }
1573
+ });
1254
1574
  }
1255
1575
  getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
1256
- return this.#getActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
1576
+ return this.#getActiveLaneRun(taskId, executionGroupId, executionLaneId);
1257
1577
  }
1258
1578
  saveActiveExecutionLaneRun(run) {
1259
1579
  if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
1260
1580
  throw new StorageRecordError(`Active execution-lane run requires group and lane ids: ${run.id}`);
1261
1581
  }
1262
- this.#saveActiveRun(run.taskId, executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId), run.id);
1582
+ this.transaction((store) => {
1583
+ const key = executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId);
1584
+ this.#assertActiveRunForWrite(run, {
1585
+ executionGroupId: run.executionGroupId,
1586
+ executionLaneId: run.executionLaneId
1587
+ });
1588
+ const current = store.getActiveExecutionLaneRun(run.taskId, run.executionGroupId, run.executionLaneId);
1589
+ if (current !== null && current.id !== run.id) {
1590
+ throw new StorageRecordError(`Execution Lane already has an active Agent run: ${run.taskId}/${key}`);
1591
+ }
1592
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
1593
+ if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
1594
+ throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
1595
+ }
1596
+ const rolePointer = this.#readActiveRunPointer(run.taskId, run.roleName);
1597
+ store.saveAgentRun(run);
1598
+ this.#saveActiveRun(run.taskId, key, run.id);
1599
+ // Keep the legacy role pointer for the single-lane delivery path, but
1600
+ // never replace it when another Lane already owns that Role.
1601
+ if (rolePointer === null) {
1602
+ this.#saveActiveRun(run.taskId, run.roleName, run.id);
1603
+ }
1604
+ });
1263
1605
  }
1264
1606
  clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
1265
- this.#clearActiveRun(taskId, executionLaneActiveRunKey(executionGroupId, executionLaneId));
1607
+ this.transaction(() => {
1608
+ const key = executionLaneActiveRunKey(executionGroupId, executionLaneId);
1609
+ const lanePointer = this.#readActiveRunPointer(taskId, key);
1610
+ this.#clearActiveRun(taskId, key);
1611
+ if (lanePointer === null)
1612
+ return;
1613
+ const roleRows = this.#db.prepare("SELECT pointer, run_id FROM active_runs WHERE task_id = ?").all(taskId);
1614
+ for (const row of roleRows) {
1615
+ if (executionLaneActiveRunKeyParts(row.pointer) === null
1616
+ && row.run_id === lanePointer.runId) {
1617
+ this.#clearActiveRun(taskId, row.pointer);
1618
+ }
1619
+ }
1620
+ });
1266
1621
  }
1267
1622
  // -- messages ----------------------------------------------------------------
1268
1623
  nextMessageId(taskId) { return this.#nextTaskRecordId(taskId, "message"); }
@@ -1296,6 +1651,17 @@ export class SqliteTaskStore {
1296
1651
  listInputRequests(taskId) {
1297
1652
  return this.#sortById(this.#listPayload("input_requests", "task_id = ?", [taskId]), (request) => request.id);
1298
1653
  }
1654
+ listOpenInputRequests(taskIds) {
1655
+ const selectedTaskIds = taskIds === undefined
1656
+ ? undefined
1657
+ : [...new Set(taskIds)].sort(numericCompare);
1658
+ if (selectedTaskIds?.length === 0)
1659
+ return [];
1660
+ const where = selectedTaskIds === undefined
1661
+ ? "status = 'open'"
1662
+ : `status = 'open' AND task_id IN (${selectedTaskIds.map(() => "?").join(", ")})`;
1663
+ return this.#sortById(this.#listPayload("input_requests", where, selectedTaskIds ?? []), (request) => `${request.taskId}/${request.id}`);
1664
+ }
1299
1665
  listAllInputRequests() {
1300
1666
  return this.#sortById(this.#listPayload("input_requests", "1=1", []), (request) => `${request.taskId}/${request.id}`);
1301
1667
  }
@@ -1413,6 +1779,13 @@ export class SqliteTaskStore {
1413
1779
  const rows = this.#db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
1414
1780
  return rows.map((row) => this.#rowToMailbox(row));
1415
1781
  }
1782
+ listReadyWorkMailboxes() {
1783
+ const rows = this.#db.prepare(`SELECT target_kind, task_id, role_name, next_sequence, processing, pending
1784
+ FROM mailboxes
1785
+ WHERE processing IS NOT NULL OR pending IS NOT NULL
1786
+ ORDER BY target_key`).all();
1787
+ return rows.map((row) => this.#rowToMailbox(row));
1788
+ }
1416
1789
  saveWorkMailbox(mailbox) {
1417
1790
  const cols = this.#mailboxCols(mailbox.target);
1418
1791
  this.#mutate(() => {
@@ -1500,7 +1873,7 @@ export class SqliteTaskStore {
1500
1873
  return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
1501
1874
  }
1502
1875
  listPendingWakeups() {
1503
- return this.listWorkMailboxes()
1876
+ return this.listReadyWorkMailboxes()
1504
1877
  .flatMap((mailbox) => {
1505
1878
  const wakeup = pendingWakeupProjection(mailbox);
1506
1879
  return wakeup === null ? [] : [wakeup];
@@ -55,6 +55,7 @@ const READ_ONLY_STORE_METHODS = new Set([
55
55
  "getChangeSet",
56
56
  "listIntegrationAttempts",
57
57
  "getIntegrationAttempt",
58
+ "listActiveDurableJobs",
58
59
  "listRoles",
59
60
  "getRole",
60
61
  "listManagedWorkspaces",
@@ -67,6 +68,8 @@ const READ_ONLY_STORE_METHODS = new Set([
67
68
  "getRoleSessionSet",
68
69
  "getTaskRoleSessionSet",
69
70
  "listRoleSessionSets",
71
+ "listRuntimeSessionCandidates",
72
+ "listPendingRuntimeTurnCompletions",
70
73
  "getRoleSession",
71
74
  "getWorkItem",
72
75
  "listWorkItems",
@@ -81,6 +84,7 @@ const READ_ONLY_STORE_METHODS = new Set([
81
84
  "listMessages",
82
85
  "getInputRequest",
83
86
  "listInputRequests",
87
+ "listOpenInputRequests",
84
88
  "listAllInputRequests",
85
89
  "listDecisions",
86
90
  "getDecision",
@@ -89,6 +93,7 @@ const READ_ONLY_STORE_METHODS = new Set([
89
93
  "listEvents",
90
94
  "getWorkMailbox",
91
95
  "listWorkMailboxes",
96
+ "listReadyWorkMailboxes",
92
97
  "getPendingWakeup",
93
98
  "listPendingWakeups",
94
99
  "getLeaderFailure",