@h-ai/runner 1.0.0-beta.24 → 1.0.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { randomUUID, createHash } from 'crypto';
4
4
  import { core, ok, err } from '@h-ai/core';
5
5
  import { execFile, spawn } from 'child_process';
6
6
  import { once } from 'events';
7
- import { lstat, chmod, rm, readFile, stat, realpath, mkdtemp, writeFile, mkdir } from 'fs/promises';
7
+ import { lstat, chmod, rm, readFile, readlink, stat, realpath, mkdtemp, writeFile, mkdir } from 'fs/promises';
8
8
  import { tmpdir } from 'os';
9
9
  import { join, posix, basename } from 'path';
10
10
  import { promisify } from 'util';
@@ -62,6 +62,8 @@ var RunnerConfigSchema = z.strictObject({
62
62
  maxControlRunning: z.number().int().min(1).max(1e3).default(8),
63
63
  maxQueued: z.number().int().min(1).max(1e5).default(5e3),
64
64
  maxEvents: z.number().int().min(1).max(1e5).default(1e4),
65
+ /** 已结算任务、事件和幂等键的保留期限。未核验的 unknown 不受该期限影响。 */
66
+ retentionMs: z.number().int().min(36e5).max(365 * 864e5).default(7 * 864e5),
65
67
  closeTimeoutMs: z.number().int().min(100).max(12e4).default(2e4),
66
68
  environments: z.array(environment).max(1e3).default([])
67
69
  }).refine((value) => value.pollMs * 3 < value.leaseMs && new Set(value.environments.map((entry) => entry.id)).size === value.environments.length);
@@ -499,7 +501,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
499
501
  return closing;
500
502
  };
501
503
  let activated = false;
502
- const process2 = {
504
+ const process3 = {
503
505
  stdin: child.stdin,
504
506
  stdout: child.stdout,
505
507
  stderr: child.stderr,
@@ -530,7 +532,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
530
532
  await new Promise((resolve, reject) => {
531
533
  child.stdin.write("+", (error) => error ? reject(error) : resolve());
532
534
  });
533
- return ok(process2);
535
+ return ok(process3);
534
536
  } catch {
535
537
  return unavailable();
536
538
  }
@@ -580,7 +582,7 @@ async function startContainer(engine, environment2, resources2, launch, signal,
580
582
  return (await stop()).success ? unavailable() : uncertain();
581
583
  }
582
584
  }
583
- return ok(process2);
585
+ return ok(process3);
584
586
  } catch {
585
587
  if (envFile) {
586
588
  try {
@@ -609,10 +611,83 @@ async function prepareWarmContainer(engine, environment2, signal, record) {
609
611
  inputs: {}
610
612
  }, signal, record, true);
611
613
  }
614
+ var processIdentity = z.strictObject({
615
+ pid: z.number().int().positive().max(2 ** 31 - 1),
616
+ hostKey: z.string().regex(/^[a-f\d]{64}$/u),
617
+ started: z.string().regex(/^\d+$/u).max(20)
618
+ });
619
+ async function hostKey() {
620
+ const [machine, boot, namespace] = await Promise.all([
621
+ readFile("/etc/machine-id", "utf8"),
622
+ readFile("/proc/sys/kernel/random/boot_id", "utf8"),
623
+ readlink("/proc/self/ns/pid")
624
+ ]);
625
+ if (!/^[a-f\d]{32}$/u.test(machine.trim()) || !/^[a-f\d-]{36}$/u.test(boot.trim()) || !/^pid:\[\d+\]$/u.test(namespace)) {
626
+ throw new Error("Runner host identity unavailable");
627
+ }
628
+ return createHash("sha256").update(JSON.stringify([
629
+ machine.trim(),
630
+ boot.trim(),
631
+ namespace
632
+ ])).digest("hex");
633
+ }
634
+ async function startTime(pid) {
635
+ const raw = await readFile(`/proc/${pid}/stat`, "utf8");
636
+ const index = raw.lastIndexOf(") ");
637
+ const field = index < 0 ? void 0 : raw.slice(index + 2).split(/\s+/u)[19];
638
+ if (!field || !/^\d+$/u.test(field))
639
+ throw new Error("Runner process identity unavailable");
640
+ return field;
641
+ }
642
+ async function readRunnerProcess() {
643
+ if (process.platform !== "linux")
644
+ return void 0;
645
+ try {
646
+ const [host, started] = await Promise.all([
647
+ hostKey(),
648
+ startTime(process.pid)
649
+ ]);
650
+ return { pid: process.pid, hostKey: host, started };
651
+ } catch {
652
+ return void 0;
653
+ }
654
+ }
655
+ async function confirmRunnerProcessReleased(identity) {
656
+ const record = processIdentity.safeParse(identity);
657
+ if (!record.success || process.platform !== "linux")
658
+ return false;
659
+ const { pid, hostKey: previous, started } = record.data;
660
+ try {
661
+ if (previous !== await hostKey())
662
+ return false;
663
+ try {
664
+ return await startTime(pid) !== started;
665
+ } catch (error) {
666
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
667
+ return false;
668
+ try {
669
+ process.kill(pid, 0);
670
+ return false;
671
+ } catch (cause) {
672
+ return cause instanceof Error && "code" in cause && cause.code === "ESRCH";
673
+ }
674
+ }
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
612
679
  var EXECUTIONS = "hai_runner_executions";
613
680
  var EVENTS = "hai_runner_events";
614
681
  var GATE = "hai_runner_gate";
615
682
  var ACTIVE = "(state IN ('running', 'cancelling') OR (state = 'unknown' AND reconciled_at IS NULL))";
683
+ var RETENTION_STATES = [
684
+ "succeeded",
685
+ "failed",
686
+ "cancelled",
687
+ "unknown"
688
+ ];
689
+ var RETENTION_BATCH = 128;
690
+ var MAX_EVENT_BYTES = 16 * 1024 * 1024;
616
691
  var text = { type: "TEXT", notNull: true };
617
692
  var nullableText = { type: "TEXT" };
618
693
  var integer = { type: "BIGINT", notNull: true };
@@ -629,6 +704,7 @@ var executions = {
629
704
  reason: nullableText,
630
705
  owner_node: nullableText,
631
706
  owner_generation: nullableText,
707
+ owner_process_json: payload,
632
708
  pinned_node: nullableText,
633
709
  pinned_generation: nullableText,
634
710
  parent_id: nullableText,
@@ -636,6 +712,7 @@ var executions = {
636
712
  queue_until: integer,
637
713
  run_until: integer,
638
714
  event_sequence: integer,
715
+ event_bytes: { ...integer, defaultValue: 0 },
639
716
  cpu_milli: integer,
640
717
  memory_mib: integer,
641
718
  pids: integer,
@@ -673,13 +750,12 @@ function hash(value) {
673
750
  function databaseValue(value) {
674
751
  return typeof value === "string" ? JSON.parse(value) : value;
675
752
  }
676
- function project(row) {
677
- const request = databaseValue(row.request_json);
753
+ function project(row, environmentId) {
678
754
  return {
679
755
  id: row.id,
680
756
  status: row.state,
681
757
  owner: row.owner_node && row.owner_generation ? { nodeId: row.owner_node, generation: row.owner_generation } : void 0,
682
- environmentId: request.environment?.id,
758
+ environmentId,
683
759
  output: row.result_json === null ? void 0 : databaseValue(row.result_json).value,
684
760
  failure: row.reason ?? void 0,
685
761
  createdAt: Number(row.created_at),
@@ -687,6 +763,9 @@ function project(row) {
687
763
  reconciledAt: row.reconciled_at === null ? void 0 : Number(row.reconciled_at)
688
764
  };
689
765
  }
766
+ function projectRequest(row) {
767
+ return project(row, databaseValue(row.request_json).environment?.id);
768
+ }
690
769
  function unavailable2() {
691
770
  return err(HaiRunnerError.UNAVAILABLE, runnerM("runner_unavailable"));
692
771
  }
@@ -731,6 +810,7 @@ function within(actual, ceiling) {
731
810
  async function readyStore() {
732
811
  if (!reldb.isInitialized)
733
812
  return unavailable2();
813
+ const addedColumns = ["event_bytes", "owner_process_json"];
734
814
  for (const [name, def] of [
735
815
  [
736
816
  GATE,
@@ -753,8 +833,19 @@ async function readyStore() {
753
833
  ]) {
754
834
  const result = await reldb.ddl.createTable(name, def);
755
835
  if (!result.success) {
756
- const existing = await reldb.schemaColumns(name);
757
- if (!existing.success || Object.keys(def).some((column) => !existing.data.includes(column)))
836
+ const existing2 = await reldb.schemaColumns(name);
837
+ if (!existing2.success || Object.keys(def).some((column) => !existing2.data.includes(column) && !(name === EXECUTIONS && addedColumns.includes(column))))
838
+ return unavailable2();
839
+ }
840
+ }
841
+ const existing = await reldb.schemaColumns(EXECUTIONS);
842
+ if (!existing.success)
843
+ return unavailable2();
844
+ for (const column of addedColumns.filter((column2) => !existing.data.includes(column2))) {
845
+ const added = await reldb.ddl.addColumn(EXECUTIONS, column, executions[column]);
846
+ if (!added.success) {
847
+ const refreshed = await reldb.schemaColumns(EXECUTIONS);
848
+ if (!refreshed.success || !refreshed.data.includes(column))
758
849
  return unavailable2();
759
850
  }
760
851
  }
@@ -779,6 +870,36 @@ async function readyStore() {
779
870
  "hai_runner_concurrency_idx",
780
871
  ["concurrency_key", "state"]
781
872
  ],
873
+ [
874
+ EXECUTIONS,
875
+ "hai_runner_parent_idx",
876
+ ["parent_id"]
877
+ ],
878
+ [
879
+ EXECUTIONS,
880
+ "hai_runner_retention_idx",
881
+ [
882
+ "state",
883
+ "reconciled_at",
884
+ "updated_at",
885
+ "id"
886
+ ]
887
+ ],
888
+ [
889
+ EXECUTIONS,
890
+ "hai_runner_queue_due_idx",
891
+ ["state", "queue_until"]
892
+ ],
893
+ [
894
+ EXECUTIONS,
895
+ "hai_runner_lease_due_idx",
896
+ ["state", "leased_until"]
897
+ ],
898
+ [
899
+ EXECUTIONS,
900
+ "hai_runner_run_due_idx",
901
+ ["state", "run_until"]
902
+ ],
782
903
  [
783
904
  EVENTS,
784
905
  "hai_runner_event_cursor_idx",
@@ -789,11 +910,11 @@ async function readyStore() {
789
910
  if (!result.success) {
790
911
  if (reldb.config?.type !== "mysql")
791
912
  return unavailable2();
792
- const existing = await reldb.sql.query(
913
+ const existing2 = await reldb.sql.query(
793
914
  "SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX",
794
915
  [table, name]
795
916
  );
796
- if (!existing.success || JSON.stringify(existing.data.map((row) => row.column_name)) !== JSON.stringify(fields))
917
+ if (!existing2.success || JSON.stringify(existing2.data.map((row) => row.column_name)) !== JSON.stringify(fields))
797
918
  return unavailable2();
798
919
  }
799
920
  }
@@ -801,11 +922,35 @@ async function readyStore() {
801
922
  const seed = await reldb.sql.execute(`INSERT INTO ${GATE} (id, revision) VALUES (1, 0)${syntax}`);
802
923
  return seed.success ? ok(void 0) : unavailable2();
803
924
  }
804
- function makeStore(config, owner) {
925
+ function makeStore(config, owner, ownerProcess) {
805
926
  const envById = new Map(config.environments.map((item) => [item.id, item]));
806
927
  const envKeys = config.environments.filter((item) => !item.architecture || item.architecture === process.arch).map((item) => hash(item));
807
928
  const now = clock();
808
929
  const resultValue = reldb.config?.type === "postgresql" ? "CAST(? AS JSONB)" : reldb.config?.type === "mysql" ? "CAST(CONVERT(? USING utf8mb4) AS JSON)" : "?";
930
+ const statementTime = reldb.config?.type === "postgresql" ? "CAST(FLOOR(EXTRACT(EPOCH FROM statement_timestamp()) * 1000) AS BIGINT)" : reldb.config?.type === "mysql" ? "CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS SIGNED)" : now;
931
+ const processJson = ownerProcess ? JSON.stringify(ownerProcess) : null;
932
+ const environmentPath = reldb.json.extract("request_json", "$.environment.id");
933
+ const environmentProjection = reldb.config?.type === "mysql" ? `JSON_UNQUOTE(${environmentPath.sql})` : reldb.config?.type === "postgresql" ? "request_json #>> ?::text[]" : environmentPath.sql;
934
+ async function summary(id2, scopeKey) {
935
+ const row = data(await reldb.sql.get(`
936
+ SELECT id, state, reason, owner_node, owner_generation, result_json, created_at, updated_at, reconciled_at,
937
+ ${environmentProjection} AS environment_id
938
+ FROM ${EXECUTIONS} WHERE id = ? AND scope_key = ?`, [...environmentPath.params, id2, scopeKey]));
939
+ return row ? project(row, row.environment_id ?? void 0) : void 0;
940
+ }
941
+ async function parentSettled(sql, row) {
942
+ if (!row.parent_id)
943
+ return true;
944
+ const parent = data(await sql.get(`
945
+ SELECT state, reconciled_at FROM ${EXECUTIONS}
946
+ WHERE id = ? AND owner_node = ? AND owner_generation = ? AND scope_key = ?`, [
947
+ row.parent_id,
948
+ row.owner_node,
949
+ row.owner_generation,
950
+ row.scope_key
951
+ ]));
952
+ return !!parent && (parent.state === "unknown" ? parent.reconciled_at !== null : ["succeeded", "failed", "cancelled"].includes(parent.state));
953
+ }
809
954
  async function sweep(sql) {
810
955
  data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'failed', reason = 'queue-timeout', reuse_key = NULL, updated_at = ${now}
811
956
  WHERE state = 'queued' AND queue_until <= ${now}`));
@@ -814,13 +959,51 @@ function makeStore(config, owner) {
814
959
  data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelling', reason = 'run-timeout', updated_at = ${now}
815
960
  WHERE state = 'running' AND run_until <= ${now} AND leased_until > ${now}`));
816
961
  }
962
+ async function expired(sql, limit) {
963
+ const pages = await Promise.all(RETENTION_STATES.map((state) => {
964
+ const endedAt = state === "unknown" ? "q.reconciled_at" : "q.updated_at";
965
+ return sql.query(`
966
+ SELECT q.id, ${endedAt} AS ended_at FROM ${EXECUTIONS} q
967
+ WHERE q.state = ? AND q.reconciled_at ${state === "unknown" ? "IS NOT NULL" : "IS NULL"}
968
+ AND ${endedAt} <= ${statementTime} - ?
969
+ AND NOT EXISTS (SELECT 1 FROM ${EXECUTIONS} child WHERE child.parent_id = q.id)
970
+ ORDER BY ${endedAt}, q.updated_at, q.id LIMIT ?`, [state, config.retentionMs, limit]);
971
+ }));
972
+ return pages.flatMap((page) => data(page)).sort((left, right) => Number(left.ended_at) - Number(right.ended_at)).slice(0, limit);
973
+ }
817
974
  return {
818
975
  async sweep() {
976
+ const due = await reldb.sql.get(`
977
+ SELECT id FROM ${EXECUTIONS} WHERE (state = 'queued' AND queue_until <= ${statementTime})
978
+ OR (state IN ('running', 'cancelling') AND leased_until <= ${statementTime})
979
+ OR (state = 'running' AND run_until <= ${statementTime}) LIMIT 1`);
980
+ if (!due.success)
981
+ return unavailable2();
982
+ if (!due.data)
983
+ return ok(void 0);
819
984
  return serialized(async (sql) => {
820
985
  await sweep(sql);
821
986
  return ok(void 0);
822
987
  });
823
988
  },
989
+ async prune() {
990
+ try {
991
+ if (!(await expired(reldb.sql, 1)).length)
992
+ return ok(0);
993
+ } catch {
994
+ return unavailable2();
995
+ }
996
+ return serialized(async (sql) => {
997
+ const entries = await expired(sql, RETENTION_BATCH);
998
+ if (!entries.length)
999
+ return ok(0);
1000
+ const ids = entries.map((entry) => entry.id);
1001
+ const keys = ids.map(() => "?").join(", ");
1002
+ data(await sql.execute(`DELETE FROM ${EVENTS} WHERE execution_id IN (${keys})`, ids));
1003
+ const removed = data(await sql.execute(`DELETE FROM ${EXECUTIONS} WHERE id IN (${keys})`, ids));
1004
+ return ok(removed.changes);
1005
+ });
1006
+ },
824
1007
  async submit(request, pool) {
825
1008
  const scopeKey = hash([request.scope.tenantId, request.scope.userId]);
826
1009
  const id2 = hash([scopeKey, request.kind, request.idempotencyKey ?? randomUUID()]);
@@ -841,7 +1024,7 @@ function makeStore(config, owner) {
841
1024
  return serialized(async (sql) => {
842
1025
  const existing = await find(sql, id2, scopeKey) ?? (reuse ? data(await sql.get(`SELECT * FROM ${EXECUTIONS} WHERE reuse_key = ?`, [reuse])) : null);
843
1026
  if (existing) {
844
- return existing.request_key === requestKey ? ok(project(existing)) : conflict();
1027
+ return existing.request_key === requestKey ? ok(projectRequest(existing)) : conflict();
845
1028
  }
846
1029
  const environment2 = request.environmentId ? envById.get(request.environmentId) : void 0;
847
1030
  if (request.warmPool && (!pool || !environment2))
@@ -902,22 +1085,21 @@ function makeStore(config, owner) {
902
1085
  const row = await find(sql, id2, scopeKey);
903
1086
  if (!row)
904
1087
  throw new Error("Newly inserted execution could not be read");
905
- return ok(project(row));
1088
+ return ok(projectRequest(row));
906
1089
  });
907
1090
  },
908
1091
  async find(scope, kind, key) {
909
1092
  try {
910
1093
  const scopeKey = hash([scope.tenantId, scope.userId]);
911
- const row = await find(reldb.sql, hash([scopeKey, kind, key]), scopeKey);
912
- return ok(row ? project(row) : void 0);
1094
+ return ok(await summary(hash([scopeKey, kind, key]), scopeKey));
913
1095
  } catch {
914
1096
  return unavailable2();
915
1097
  }
916
1098
  },
917
1099
  async get(scope, id2) {
918
1100
  try {
919
- const row = await find(reldb.sql, id2, hash([scope.tenantId, scope.userId]));
920
- return row ? ok(project(row)) : missing();
1101
+ const found = await summary(id2, hash([scope.tenantId, scope.userId]));
1102
+ return found ? ok(found) : missing();
921
1103
  } catch {
922
1104
  return unavailable2();
923
1105
  }
@@ -932,7 +1114,7 @@ function makeStore(config, owner) {
932
1114
  data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelled', reason = 'parent-ended', updated_at = ${now} WHERE parent_id = ? AND state = 'queued'`, [id2]));
933
1115
  data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'cancelling', reason = 'parent-ended', updated_at = ${now} WHERE parent_id = ? AND state = 'running'`, [id2]));
934
1116
  const changed = await find(sql, id2, row.scope_key);
935
- return changed ? ok(project(changed)) : missing();
1117
+ return changed ? ok(projectRequest(changed)) : missing();
936
1118
  });
937
1119
  },
938
1120
  async reserveWarm(pool) {
@@ -944,7 +1126,8 @@ function makeStore(config, owner) {
944
1126
  environment: environment2
945
1127
  });
946
1128
  const key = hash([pool.kind, pool.key]);
947
- return serialized(async (sql) => {
1129
+ const usage = columns(environment2.resources);
1130
+ async function canReserve(sql) {
948
1131
  const [totalsResult, reservedResult] = await Promise.all([
949
1132
  sql.get(`
950
1133
  SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN state = 'running' AND pool_fingerprint = ?
@@ -959,9 +1142,8 @@ function makeStore(config, owner) {
959
1142
  const totals = data(totalsResult);
960
1143
  const reserved = data(reservedResult);
961
1144
  if (!totals || !reserved || Number(totals.warm) >= pool.minReady || Number(totals.total) >= Math.min(pool.maxTotal, Number(totals.ceiling)) || Number(reserved.active) >= config.toolMaxRunning) {
962
- return ok(null);
1145
+ return false;
963
1146
  }
964
- const usage = columns(environment2.resources);
965
1147
  const capacity = columns(config.toolCapacity);
966
1148
  const occupied = [
967
1149
  Number(reserved.cpu),
@@ -969,7 +1151,16 @@ function makeStore(config, owner) {
969
1151
  Number(reserved.pids),
970
1152
  Number(reserved.scratch)
971
1153
  ];
972
- if (usage.some((amount, index) => amount + occupied[index] > capacity[index]))
1154
+ return !usage.some((amount, index) => amount + occupied[index] > capacity[index]);
1155
+ }
1156
+ try {
1157
+ if (!await canReserve(reldb.sql))
1158
+ return ok(null);
1159
+ } catch {
1160
+ return unavailable2();
1161
+ }
1162
+ return serialized(async (sql) => {
1163
+ if (!await canReserve(sql))
973
1164
  return ok(null);
974
1165
  const id2 = hash([
975
1166
  owner.generation,
@@ -1014,12 +1205,25 @@ function makeStore(config, owner) {
1014
1205
  return result.success && result.data.changes === 1 ? ok(void 0) : lost();
1015
1206
  },
1016
1207
  async claim(kinds, category, pools = [], warmIds = []) {
1017
- return serialized(async (sql) => {
1018
- if (!kinds.length)
1019
- return ok(null);
1208
+ if (!kinds.length)
1209
+ return ok(null);
1210
+ const capacityClass = category === "tool" ? "tool" : "default";
1211
+ const parentFilter = category === "control" ? "IS NOT NULL" : "IS NULL";
1212
+ const queued = await reldb.sql.get(`
1213
+ SELECT id FROM ${EXECUTIONS} WHERE state = 'queued' AND capacity_class = ? AND parent_id ${parentFilter}
1214
+ AND queue_until > ${now} AND kind IN (${kinds.map(() => "?").join(",")})
1215
+ AND (pinned_node IS NULL OR pinned_node = ?) AND (pinned_generation IS NULL OR pinned_generation = ?) LIMIT 1`, [
1216
+ capacityClass,
1217
+ ...kinds,
1218
+ owner.nodeId,
1219
+ owner.generation
1220
+ ]);
1221
+ if (!queued.success)
1222
+ return unavailable2();
1223
+ if (!queued.data)
1224
+ return ok(null);
1225
+ async function eligible(sql, includeRequest) {
1020
1226
  const maxRunning = category === "tool" ? config.toolMaxRunning : category === "control" ? config.maxControlRunning : config.maxRunning;
1021
- const capacityClass = category === "tool" ? "tool" : "default";
1022
- const parentFilter = category === "control" ? "IS NOT NULL" : "IS NULL";
1023
1227
  const reserved = data(await sql.get(
1024
1228
  `SELECT COUNT(*) AS active, COALESCE(SUM(cpu_milli),0) AS cpu, COALESCE(SUM(memory_mib),0) AS memory,
1025
1229
  COALESCE(SUM(pids),0) AS pids, COALESCE(SUM(scratch_mib),0) AS scratch
@@ -1027,7 +1231,7 @@ function makeStore(config, owner) {
1027
1231
  [owner.nodeId, capacityClass]
1028
1232
  ));
1029
1233
  if (!reserved)
1030
- return ok(null);
1234
+ return null;
1031
1235
  const fingerprints = category === "tool" ? pools.flatMap((pool) => {
1032
1236
  const environment2 = envById.get(pool.environmentId);
1033
1237
  return environment2 ? [
@@ -1043,8 +1247,8 @@ function makeStore(config, owner) {
1043
1247
  const canStartCold = Number(reserved.active) < maxRunning;
1044
1248
  const eligibleEnvironment = envKeys.length ? ` OR q.environment_key IN (${envKeys.map(() => "?").join(",")})` : "";
1045
1249
  const [cpu, memory, pids, scratch] = columns(category === "tool" ? config.toolCapacity : config.capacity);
1046
- const row = data(await sql.get(`
1047
- SELECT q.* FROM ${EXECUTIONS} q
1250
+ return data(await sql.get(`
1251
+ SELECT q.id${includeRequest ? ", q.kind, q.request_json, q.parent_id, q.pool_fingerprint" : ""} FROM ${EXECUTIONS} q
1048
1252
  WHERE q.state = 'queued' AND q.capacity_class = ? AND q.queue_until > ${now} AND q.parent_id ${parentFilter}
1049
1253
  AND (q.pinned_node IS NULL OR q.pinned_node = ?) AND (q.pinned_generation IS NULL OR q.pinned_generation = ?)
1050
1254
  AND q.kind IN (${kinds.map(() => "?").join(",")}) AND (q.environment_key IS NULL${eligibleEnvironment})
@@ -1083,6 +1287,15 @@ function makeStore(config, owner) {
1083
1287
  scratch - Number(reserved.scratch),
1084
1288
  config.maxRunningPerScope
1085
1289
  ]));
1290
+ }
1291
+ try {
1292
+ if (!await eligible(reldb.sql, false))
1293
+ return ok(null);
1294
+ } catch {
1295
+ return unavailable2();
1296
+ }
1297
+ return serialized(async (sql) => {
1298
+ const row = await eligible(sql, true);
1086
1299
  if (!row)
1087
1300
  return ok(null);
1088
1301
  const request = databaseValue(row.request_json);
@@ -1090,11 +1303,12 @@ function makeStore(config, owner) {
1090
1303
  SELECT * FROM ${EXECUTIONS} WHERE id IN (${warmIds.map(() => "?").join(",")}) AND pool_fingerprint = ?
1091
1304
  AND state = 'running' AND warm_state = 'ready' AND leased_until > ${now} ORDER BY created_at, id LIMIT 1`, [...warmIds, row.pool_fingerprint])) : null;
1092
1305
  const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET state = 'running', owner_node = ?, owner_generation = ?,
1093
- container_json = ${resultValue}, leased_until = ${now} + ?, run_until = ${now} + ?, updated_at = ${now}
1306
+ container_json = ${resultValue}, owner_process_json = ${resultValue}, leased_until = ${now} + ?, run_until = ${now} + ?, updated_at = ${now}
1094
1307
  WHERE id = ? AND state = 'queued' AND queue_until > ${now}`, [
1095
1308
  owner.nodeId,
1096
1309
  owner.generation,
1097
1310
  standby?.container_json ? JSON.stringify(databaseValue(standby.container_json)) : null,
1311
+ request.environment ? null : processJson,
1098
1312
  config.leaseMs,
1099
1313
  request.runTimeoutMs,
1100
1314
  row.id
@@ -1134,11 +1348,20 @@ function makeStore(config, owner) {
1134
1348
  const row = await find(reldb.sql, id2, hash([scope.tenantId, scope.userId]));
1135
1349
  if (!row)
1136
1350
  return missing();
1137
- if (row.state !== "unknown" || row.owner_node !== owner.nodeId || row.reconciled_at !== null || !row.owner_generation || !row.container_json) {
1351
+ if (row.state !== "unknown" || row.owner_node !== owner.nodeId || row.reconciled_at !== null || !row.owner_generation) {
1138
1352
  return conflict();
1139
1353
  }
1140
- const container = databaseValue(row.container_json);
1141
- return container.engine === config.engine ? ok({ container, generation: row.owner_generation }) : conflict();
1354
+ if (row.container_json) {
1355
+ const container = databaseValue(row.container_json);
1356
+ return container.engine === config.engine ? ok({ container, generation: row.owner_generation }) : conflict();
1357
+ }
1358
+ if (row.environment_key !== null || !row.owner_process_json || !await parentSettled(reldb.sql, row))
1359
+ return conflict();
1360
+ return ok({
1361
+ container: void 0,
1362
+ process: databaseValue(row.owner_process_json),
1363
+ generation: row.owner_generation
1364
+ });
1142
1365
  } catch {
1143
1366
  return unavailable2();
1144
1367
  }
@@ -1146,10 +1369,16 @@ function makeStore(config, owner) {
1146
1369
  async reconcile(scope, id2, generation, review) {
1147
1370
  return serialized(async (sql) => {
1148
1371
  const scopeKey = hash([scope.tenantId, scope.userId]);
1372
+ if (review.hostResourcesStopped) {
1373
+ const row2 = await find(sql, id2, scopeKey);
1374
+ if (!row2 || !await parentSettled(sql, row2))
1375
+ return conflict();
1376
+ }
1377
+ const proof = review.hostResourcesStopped ? "container_json IS NULL AND environment_key IS NULL AND owner_process_json IS NOT NULL" : "container_json IS NOT NULL";
1149
1378
  const result = data(await sql.execute(`UPDATE ${EXECUTIONS}
1150
1379
  SET reconciled_at = ${now}, reconciliation_json = ${resultValue}, reuse_key = NULL, updated_at = ${now}
1151
1380
  WHERE id = ? AND scope_key = ? AND owner_node = ? AND owner_generation = ? AND state = 'unknown'
1152
- AND reconciled_at IS NULL AND container_json IS NOT NULL`, [
1381
+ AND reconciled_at IS NULL AND ${proof}`, [
1153
1382
  JSON.stringify(review),
1154
1383
  id2,
1155
1384
  scopeKey,
@@ -1159,7 +1388,7 @@ function makeStore(config, owner) {
1159
1388
  if (result.changes !== 1)
1160
1389
  return conflict();
1161
1390
  const row = await find(sql, id2, scopeKey);
1162
- return row ? ok(project(row)) : missing();
1391
+ return row ? ok(projectRequest(row)) : missing();
1163
1392
  });
1164
1393
  },
1165
1394
  async renew(id2) {
@@ -1190,10 +1419,18 @@ function makeStore(config, owner) {
1190
1419
  return ok(row.data.state);
1191
1420
  },
1192
1421
  async emit(id2, value) {
1422
+ const encoded = JSON.stringify({ value });
1423
+ const bytes = Buffer.byteLength(encoded);
1193
1424
  const result = await reldb.tx.wrap(async (sql) => {
1194
- const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET event_sequence = event_sequence + 1
1425
+ const changed = data(await sql.execute(`UPDATE ${EXECUTIONS} SET event_sequence = event_sequence + 1, event_bytes = event_bytes + ?
1195
1426
  WHERE id = ? AND owner_generation = ? AND state = 'running' AND leased_until > ${now}
1196
- AND run_until > ${now} AND event_sequence < ?`, [id2, owner.generation, config.maxEvents]));
1427
+ AND run_until > ${now} AND event_sequence < ? AND event_bytes <= ?`, [
1428
+ bytes,
1429
+ id2,
1430
+ owner.generation,
1431
+ config.maxEvents,
1432
+ MAX_EVENT_BYTES - bytes
1433
+ ]));
1197
1434
  if (changed.changes !== 1)
1198
1435
  return lost();
1199
1436
  const row = data(await sql.get(`SELECT event_sequence FROM ${EXECUTIONS} WHERE id = ?`, [id2]));
@@ -1206,7 +1443,7 @@ function makeStore(config, owner) {
1206
1443
  hash([id2, sequence]),
1207
1444
  id2,
1208
1445
  sequence,
1209
- JSON.stringify({ value }),
1446
+ encoded,
1210
1447
  id2
1211
1448
  ]));
1212
1449
  if (published.changes !== 1)
@@ -1216,9 +1453,14 @@ function makeStore(config, owner) {
1216
1453
  return result.success ? result.data : unavailable2();
1217
1454
  },
1218
1455
  async events(scope, id2, after, limit) {
1219
- const result = await this.get(scope, id2);
1220
- if (!result.success)
1221
- return result;
1456
+ const owner2 = await reldb.sql.get(`SELECT id FROM ${EXECUTIONS} WHERE id = ? AND scope_key = ?`, [
1457
+ id2,
1458
+ hash([scope.tenantId, scope.userId])
1459
+ ]);
1460
+ if (!owner2.success)
1461
+ return unavailable2();
1462
+ if (!owner2.data)
1463
+ return missing();
1222
1464
  const page = await reldb.sql.query(
1223
1465
  `SELECT sequence_number, event_json, created_at FROM ${EVENTS}
1224
1466
  WHERE execution_id = ? AND sequence_number > ? ORDER BY sequence_number ASC LIMIT ?`,
@@ -1315,6 +1557,7 @@ function createRunner() {
1315
1557
  let timer;
1316
1558
  let tick;
1317
1559
  let sweepAt = 0;
1560
+ let pruneAt = 0;
1318
1561
  let unconfirmedCleanup = false;
1319
1562
  const running = /* @__PURE__ */ new Map();
1320
1563
  const handlers = /* @__PURE__ */ new Map();
@@ -1379,19 +1622,19 @@ function createRunner() {
1379
1622
  }
1380
1623
  function launch(claim, handler, current, settings, holder, warmed) {
1381
1624
  const abort = new AbortController();
1382
- let process2;
1625
+ let process3;
1383
1626
  let uncertain2 = false;
1384
1627
  const closeProcess = async () => {
1385
- const started = await process2;
1628
+ const started = await process3;
1386
1629
  if (started?.success)
1387
1630
  return (await started.data.close()).success;
1388
1631
  return warmed ? (await warmed.close()).success : !uncertain2;
1389
1632
  };
1390
1633
  async function start(options) {
1391
1634
  const { environment: environment2, resources: resources2 } = claim.request;
1392
- if (abort.signal.aborted || process2 || !environment2 || !resources2)
1635
+ if (abort.signal.aborted || process3 || !environment2 || !resources2)
1393
1636
  return invalid2();
1394
- process2 = (async () => {
1637
+ process3 = (async () => {
1395
1638
  const renewed = await current.renew(claim.id);
1396
1639
  if (!renewed.success)
1397
1640
  return renewed;
@@ -1405,7 +1648,7 @@ function createRunner() {
1405
1648
  uncertain2 = true;
1406
1649
  return started;
1407
1650
  })();
1408
- return process2;
1651
+ return process3;
1409
1652
  }
1410
1653
  async function execute2() {
1411
1654
  const context = {
@@ -1484,6 +1727,12 @@ function createRunner() {
1484
1727
  }
1485
1728
  if (closingRequested)
1486
1729
  return;
1730
+ if (pruneAt <= performance.now()) {
1731
+ const pruned = await current.prune();
1732
+ if (!pruned.success)
1733
+ logger.warn("Managed execution retention failed", { code: pruned.error.code });
1734
+ pruneAt = performance.now() + (pruned.success && pruned.data ? 250 : 1e3);
1735
+ }
1487
1736
  const kinds = [
1488
1737
  ...handlers.keys()
1489
1738
  ];
@@ -1543,9 +1792,10 @@ function createRunner() {
1543
1792
  generation: randomUUID()
1544
1793
  };
1545
1794
  config = parsed.data;
1546
- store = makeStore(config, owner);
1795
+ store = makeStore(config, owner, config.executor ? await readRunnerProcess() : void 0);
1547
1796
  unconfirmedCleanup = false;
1548
1797
  sweepAt = 0;
1798
+ pruneAt = 0;
1549
1799
  warmAt = 0;
1550
1800
  if (config.executor) {
1551
1801
  timer = setInterval(poll, config.pollMs);
@@ -1692,7 +1942,7 @@ function createRunner() {
1692
1942
  const current = store;
1693
1943
  if (!current || !config?.executor || closingRequested)
1694
1944
  return inactive();
1695
- if (!validAddress(scope, id2) || !review || typeof review.operatorId !== "string" || !runnerKind.safeParse(review.operatorId).success || typeof review.reason !== "string" || !review.reason.trim() || review.reason.length > 300 || /[\r\n\0]/u.test(review.reason)) {
1945
+ if (!validAddress(scope, id2) || !review || typeof review.operatorId !== "string" || !runnerKind.safeParse(review.operatorId).success || typeof review.reason !== "string" || review.hostResourcesStopped !== void 0 && review.hostResourcesStopped !== true || !review.reason.trim() || review.reason.length > 300 || /[\r\n\0]/u.test(review.reason)) {
1696
1946
  return invalid2();
1697
1947
  }
1698
1948
  if (running.has(id2))
@@ -1700,14 +1950,19 @@ function createRunner() {
1700
1950
  const evidence = await current.reconciliation(scope, id2);
1701
1951
  if (!evidence.success)
1702
1952
  return evidence;
1703
- const verified = await confirmContainerReleased(evidence.data.container);
1704
- if (!verified.success)
1705
- return verified;
1953
+ if (evidence.data.container) {
1954
+ const verified = await confirmContainerReleased(evidence.data.container);
1955
+ if (!verified.success)
1956
+ return verified;
1957
+ } else if (review.hostResourcesStopped !== true || !await confirmRunnerProcessReleased(evidence.data.process)) {
1958
+ return cleanupUnconfirmed();
1959
+ }
1706
1960
  if (current !== store || closingRequested)
1707
1961
  return inactive();
1708
1962
  const reconciled = await current.reconcile(scope, id2, evidence.data.generation, {
1709
1963
  operatorId: review.operatorId,
1710
- reason: review.reason.trim()
1964
+ reason: review.reason.trim(),
1965
+ ...!evidence.data.container && { hostResourcesStopped: true }
1711
1966
  });
1712
1967
  if (reconciled.success)
1713
1968
  logger.info("Unknown execution resources reconciled", { executionId: id2, operatorId: review.operatorId });