@effect-agent/storage-cloudflare 0.1.0-beta.114 → 0.1.0-beta.116

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.
@@ -16,6 +16,9 @@ import {
16
16
  } from "effect-agent/records";
17
17
  import {
18
18
  AbortCommand,
19
+ WorkerStopCommand,
20
+ WorkerLedgerState,
21
+ workerTerminalFromRecord,
19
22
  AbortIntent,
20
23
  AbortIntentRequest,
21
24
  AdmissionAdmitted,
@@ -1204,6 +1207,14 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
1204
1207
  }).pipe(Effect.mapError(internalFailure(operation)));
1205
1208
  }
1206
1209
 
1210
+ const stopped =
1211
+ yield* sql`SELECT thread_id FROM effect_agent_worker_stops WHERE thread_id = ${validated.threadId}`.pipe(
1212
+ Effect.mapError(sqlFailure(operation)),
1213
+ );
1214
+
1215
+ if (stopped.length > 0)
1216
+ return yield* AdmissionPolicyError.make({ reason: "refused", code: "worker-stopped" });
1217
+
1207
1218
  // The first accepted input fixes ordinary/worker lane identity atomically with admission.
1208
1219
  // Canonical origin materialization can lag admission; a log scan cannot fence that race.
1209
1220
  const firstRows = yield* sql<Record<string, unknown>>`
@@ -1947,7 +1958,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
1947
1958
  );
1948
1959
  }
1949
1960
 
1950
- return { reservation: reservation.value, settlementFailure };
1961
+ return { reservation: reservation.value, reservationRecord, settlementFailure };
1951
1962
  });
1952
1963
 
1953
1964
  const replayFinalization = Effect.fn("DoSubmissionLedger.replayFinalization")(function* (
@@ -2045,13 +2056,49 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
2045
2056
  yield* readReservation(operation, validated.submissionId),
2046
2057
  );
2047
2058
 
2048
- const { reservation, settlementFailure } = state;
2059
+ const { reservation, reservationRecord, settlementFailure } = state;
2049
2060
  const submission = yield* requireSubmission(operation, validated.submissionId);
2050
2061
 
2051
2062
  if (submission.state === "settled")
2052
2063
  return yield* replayFinalization(validated, submission, state);
2053
2064
  const now = yield* currentInstant;
2054
2065
 
2066
+ const terminal =
2067
+ submission.worker_admission_json === null
2068
+ ? undefined
2069
+ : workerTerminalFromRecord(
2070
+ yield* decodeSubmissionSnapshot(operation, submission),
2071
+ reservationRecord,
2072
+ );
2073
+
2074
+ if (terminal !== undefined) {
2075
+ // Admission and finalization serialize here. An accepted correction that this Run
2076
+ // has not applied vetoes its completion, including admission after RunCompleted.
2077
+ const pending =
2078
+ terminal === "completed"
2079
+ ? yield* sql`SELECT submission_id FROM effect_agent_submissions
2080
+ WHERE thread_id = ${submission.thread_id} AND queue_sequence > ${submission.queue_sequence}
2081
+ AND queue_sequence = (SELECT MAX(queue_sequence) FROM effect_agent_submissions WHERE thread_id = ${submission.thread_id})
2082
+ AND (joined_host_submission_id IS NULL OR joined_host_submission_id <> ${submission.submission_id}
2083
+ OR input_applied_record_id IS NULL) LIMIT 1`.pipe(
2084
+ Effect.mapError(sqlFailure(operation)),
2085
+ )
2086
+ : [];
2087
+
2088
+ if (pending.length === 0) {
2089
+ yield* sql`INSERT OR IGNORE INTO effect_agent_worker_stops (thread_id, terminal)
2090
+ VALUES (${submission.thread_id}, ${terminal})`.pipe(
2091
+ Effect.mapError(sqlFailure(operation)),
2092
+ );
2093
+ yield* sql`INSERT OR IGNORE INTO effect_agent_abort_intents (submission_id, author, reason, requested_at)
2094
+ SELECT submission_id, ${submission.principal}, ${`Worker assignment ${terminal}`}, ${now.iso}
2095
+ FROM effect_agent_submissions WHERE thread_id = ${submission.thread_id} AND state <> 'settled'
2096
+ AND submission_id <> ${submission.submission_id}
2097
+ AND (joined_host_submission_id IS NULL OR joined_host_submission_id <> ${submission.submission_id}
2098
+ OR input_applied_record_id IS NULL)`.pipe(Effect.mapError(sqlFailure(operation)));
2099
+ }
2100
+ }
2101
+
2055
2102
  yield* sql`
2056
2103
  UPDATE effect_agent_submissions
2057
2104
  SET state = 'settled', settled_outcome = ${reservation.outcome}
@@ -2083,6 +2130,84 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
2083
2130
  return settlement;
2084
2131
  });
2085
2132
 
2133
+ const inspectWorker = Effect.fn("DoSubmissionLedger.inspectWorker")(function* (
2134
+ threadId: SubmissionSnapshot["threadId"],
2135
+ ) {
2136
+ const operation = "inspect worker";
2137
+
2138
+ return yield* sql
2139
+ .withTransaction(
2140
+ Effect.gen(function* () {
2141
+ const read = Effect.fnUntraced(function* (active: boolean) {
2142
+ const rows =
2143
+ yield* sql`SELECT ${sql.literal(SUBMISSION_COLUMNS)} FROM effect_agent_submissions
2144
+ WHERE thread_id = ${threadId} ${active ? sql`AND state <> 'settled'` : sql``}
2145
+ ORDER BY queue_sequence ${active ? sql`ASC` : sql`DESC`} LIMIT 1`.pipe(
2146
+ Effect.mapError(sqlFailure(operation)),
2147
+ );
2148
+
2149
+ const decoded = yield* decodeSubmissionRows(operation, threadId, rows);
2150
+
2151
+ return decoded[0] === undefined
2152
+ ? null
2153
+ : yield* decodeSubmissionSnapshot(operation, decoded[0]);
2154
+ });
2155
+
2156
+ const latest = yield* read(false);
2157
+ const active = yield* read(true);
2158
+
2159
+ const stops =
2160
+ yield* sql`SELECT terminal FROM effect_agent_worker_stops WHERE thread_id = ${threadId}`.pipe(
2161
+ Effect.mapError(sqlFailure(operation)),
2162
+ );
2163
+
2164
+ return yield* Schema.decodeUnknownEffect(Schema.toType(WorkerLedgerState))({
2165
+ latest,
2166
+ active,
2167
+ stopped: stops.length > 0,
2168
+ ...(stops[0] === undefined || stops[0].terminal === null
2169
+ ? {}
2170
+ : { terminal: stops[0].terminal }),
2171
+ }).pipe(Effect.mapError(internalFailure(operation)));
2172
+ }),
2173
+ )
2174
+ .pipe(Effect.catchTag("SqlError", (cause) => sqlFailure(operation)(cause)));
2175
+ });
2176
+
2177
+ const stopWorker = Effect.fn("DoSubmissionLedger.stopWorker")(function* (
2178
+ request: WorkerStopCommand,
2179
+ ) {
2180
+ const operation = "ledger stop worker";
2181
+
2182
+ const validated = yield* Schema.decodeEffect(WorkerStopCommand)(request).pipe(
2183
+ Effect.mapError(internalFailure(operation)),
2184
+ );
2185
+
2186
+ return yield* inWriteTransaction(
2187
+ operation,
2188
+ Effect.gen(function* () {
2189
+ const now = yield* currentInstant;
2190
+
2191
+ yield* sql`INSERT OR IGNORE INTO effect_agent_worker_stops (thread_id) VALUES (${validated.threadId})`.pipe(
2192
+ Effect.mapError(sqlFailure(operation)),
2193
+ );
2194
+ yield* sql`INSERT OR IGNORE INTO effect_agent_abort_intents (submission_id, author, reason, requested_at)
2195
+ SELECT submission_id, ${validated.author}, 'Worker owner stopped the worker', ${now.iso}
2196
+ FROM effect_agent_submissions WHERE thread_id = ${validated.threadId} AND state <> 'settled'`.pipe(
2197
+ Effect.mapError(sqlFailure(operation)),
2198
+ );
2199
+
2200
+ const rows = yield* sql`SELECT o.submission_id FROM effect_agent_submission_ownership o
2201
+ JOIN effect_agent_submissions s ON s.submission_id = o.submission_id
2202
+ WHERE s.thread_id = ${validated.threadId} AND s.state <> 'settled'`.pipe(
2203
+ Effect.mapError(sqlFailure(operation)),
2204
+ );
2205
+
2206
+ return rows.length;
2207
+ }),
2208
+ );
2209
+ });
2210
+
2086
2211
  const requestAbort: SubmissionLedger["Service"]["requestAbort"] = Effect.fn(
2087
2212
  "DoSubmissionLedger.requestAbort",
2088
2213
  )(function* (request: AbortCommand) {
@@ -2208,6 +2333,13 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
2208
2333
  // The host Attempt already owns the lane; no epoch bump happens here (plan §2.5).
2209
2334
  yield* requireOwnership(operation, host, validated.ownershipToken);
2210
2335
 
2336
+ const stopped =
2337
+ yield* sql`SELECT thread_id FROM effect_agent_worker_stops WHERE thread_id = ${validated.threadId}`.pipe(
2338
+ Effect.mapError(sqlFailure(operation)),
2339
+ );
2340
+
2341
+ if (stopped.length > 0) return [];
2342
+
2211
2343
  const laterRows = yield* sql<Record<string, unknown>>`
2212
2344
  SELECT ${sql.literal(SUBMISSION_COLUMNS)}
2213
2345
  FROM effect_agent_submissions
@@ -3610,6 +3742,8 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
3610
3742
  reserveSettlement,
3611
3743
  finalizeSettlement,
3612
3744
  requestAbort,
3745
+ stopWorker,
3746
+ inspectWorker,
3613
3747
  claimJoining,
3614
3748
  markJoined,
3615
3749
  revertJoining,
@@ -1,4 +1,5 @@
1
1
  import { Schema } from "effect";
2
+ import { ThreadId } from "effect-agent/identifiers";
2
3
  import {
3
4
  MessageDeliveryError,
4
5
  MessageDeliveryPageRequest,
@@ -7,6 +8,8 @@ import {
7
8
  import { CanonicalRecordEnvelope } from "effect-agent/records";
8
9
  import {
9
10
  AbortCommand,
11
+ WorkerStopCommand,
12
+ WorkerLedgerState,
10
13
  AbortIntent,
11
14
  AdmissionConflict,
12
15
  AdmissionPolicyError,
@@ -123,6 +126,26 @@ export class LedgerResolveAdmissionCall extends Schema.TaggedClass<LedgerResolve
123
126
  request: SubmissionLookupByKey,
124
127
  }) {}
125
128
 
129
+ export class LedgerInspectWorkerCall extends Schema.TaggedClass<LedgerInspectWorkerCall>()(
130
+ "LedgerInspectWorker",
131
+ { request: Schema.Struct({ threadId: ThreadId }) },
132
+ ) {}
133
+
134
+ export class LedgerInspectWorkerResult extends Schema.TaggedClass<LedgerInspectWorkerResult>()(
135
+ "LedgerInspectWorkerResult",
136
+ { state: WorkerLedgerState },
137
+ ) {}
138
+
139
+ export class LedgerStopWorkerCall extends Schema.TaggedClass<LedgerStopWorkerCall>()(
140
+ "LedgerStopWorker",
141
+ { request: WorkerStopCommand },
142
+ ) {}
143
+
144
+ export class LedgerStopWorkerResult extends Schema.TaggedClass<LedgerStopWorkerResult>()(
145
+ "LedgerStopWorkerResult",
146
+ { owned: Schema.Natural },
147
+ ) {}
148
+
126
149
  /** Routed `SubmissionLedger.requestAbort` — abort propagation across Objects. */
127
150
  export class LedgerRequestAbortCall extends Schema.TaggedClass<LedgerRequestAbortCall>(
128
151
  "@effect-agent/storage-cloudflare/LedgerRequestAbortCall",
@@ -190,6 +213,8 @@ export const PortRequest = Schema.Union([
190
213
  LedgerLookupCall,
191
214
  LedgerResolveAdmissionCall,
192
215
  LedgerRequestAbortCall,
216
+ LedgerStopWorkerCall,
217
+ LedgerInspectWorkerCall,
193
218
  LedgerRecordChildSettledCall,
194
219
  StoreMaterializeCall,
195
220
  StoreAppendCall,
@@ -290,6 +315,8 @@ export const PortResult = Schema.Union([
290
315
  LedgerLookupResult,
291
316
  LedgerResolveAdmissionResult,
292
317
  LedgerRequestAbortResult,
318
+ LedgerStopWorkerResult,
319
+ LedgerInspectWorkerResult,
293
320
  LedgerRecordChildSettledResult,
294
321
  StoreMaterializeResult,
295
322
  StoreAppendResult,
@@ -40,6 +40,10 @@ import {
40
40
  LedgerRecordChildSettledCall,
41
41
  LedgerRecordChildSettledResult,
42
42
  LedgerRequestAbortCall,
43
+ LedgerStopWorkerCall,
44
+ LedgerInspectWorkerCall,
45
+ LedgerInspectWorkerResult,
46
+ LedgerStopWorkerResult,
43
47
  LedgerRequestAbortResult,
44
48
  LedgerResolveAdmissionCall,
45
49
  LedgerResolveAdmissionResult,
@@ -534,6 +538,39 @@ const makeRoutedLedgerServices = Effect.fn("DoPortRouting.makeRoutedLedgerServic
534
538
  ? local.resolveAdmission(request)
535
539
  : resolveForeignAdmission(request.threadId, request),
536
540
 
541
+ inspectWorker: (threadId) =>
542
+ options.ownsThread(threadId)
543
+ ? local.inspectWorker === undefined
544
+ ? Effect.fail(
545
+ LedgerError.make({
546
+ operation: "inspectWorker",
547
+ message: "Worker inspection unavailable",
548
+ }),
549
+ )
550
+ : local.inspectWorker(threadId)
551
+ : foreignLedgerCall(
552
+ "inspect worker",
553
+ threadId,
554
+ LedgerInspectWorkerCall.make({ request: { threadId } }),
555
+ LedgerInspectWorkerResult,
556
+ NoAdditionalPortFailure,
557
+ ).pipe(Effect.map((reply) => reply.state)),
558
+
559
+ stopWorker: (request) =>
560
+ options.ownsThread(request.threadId)
561
+ ? local.stopWorker === undefined
562
+ ? Effect.fail(
563
+ LedgerError.make({ operation: "stopWorker", message: "Worker stop unavailable" }),
564
+ )
565
+ : local.stopWorker(request)
566
+ : foreignLedgerCall(
567
+ "ledger stop worker",
568
+ request.threadId,
569
+ LedgerStopWorkerCall.make({ request }),
570
+ LedgerStopWorkerResult,
571
+ NoAdditionalPortFailure,
572
+ ).pipe(Effect.map((reply) => reply.owned)),
573
+
537
574
  requestAbort: (request) =>
538
575
  submissionTarget("ledger request abort", request.submissionId).pipe(
539
576
  Effect.flatMap((target) =>
@@ -1065,6 +1102,35 @@ export const executePortRequest = Effect.fn("DoPortRouting.executePortRequest")(
1065
1102
  .pipe(Effect.map((resolution) => LedgerResolveAdmissionResult.make({ resolution }))),
1066
1103
  );
1067
1104
  }
1105
+ case "LedgerInspectWorker": {
1106
+ const ledger = yield* SubmissionLedger;
1107
+
1108
+ return yield* capture(
1109
+ ledger.inspectWorker === undefined
1110
+ ? Effect.fail(
1111
+ LedgerError.make({
1112
+ operation: "inspectWorker",
1113
+ message: "Worker inspection unavailable",
1114
+ }),
1115
+ )
1116
+ : ledger
1117
+ .inspectWorker(request.request.threadId)
1118
+ .pipe(Effect.map((state) => LedgerInspectWorkerResult.make({ state }))),
1119
+ );
1120
+ }
1121
+ case "LedgerStopWorker": {
1122
+ const ledger = yield* SubmissionLedger;
1123
+
1124
+ return yield* capture(
1125
+ ledger.stopWorker === undefined
1126
+ ? Effect.fail(
1127
+ LedgerError.make({ operation: "stopWorker", message: "Worker stop unavailable" }),
1128
+ )
1129
+ : ledger
1130
+ .stopWorker(request.request)
1131
+ .pipe(Effect.map((owned) => LedgerStopWorkerResult.make({ owned }))),
1132
+ );
1133
+ }
1068
1134
  case "LedgerRequestAbort": {
1069
1135
  const ledger = yield* SubmissionLedger;
1070
1136
 
@@ -32,7 +32,12 @@ import {
32
32
  type DoStorageFailpointLocation,
33
33
  } from "../DoStorageError.ts";
34
34
  import { createMessageDeliveryTables } from "./message-delivery-schema.ts";
35
- import { CurrentDoStorageVersion, createNonterminalIndex, doMigrations } from "./migrations.ts";
35
+ import {
36
+ CurrentDoStorageVersion,
37
+ createNonterminalIndex,
38
+ createWorkerStops,
39
+ doMigrations,
40
+ } from "./migrations.ts";
36
41
  import { createRecoveryCheckpointTable } from "./recovery-checkpoint-schema.ts";
37
42
 
38
43
  /**
@@ -376,7 +381,7 @@ const predecessorColumns = {
376
381
  } as const;
377
382
 
378
383
  const checkPredecessorLayout = Effect.fn("DoJournal.checkPredecessorLayout")(function* (
379
- version: 3 | 4 | 5,
384
+ version: 3 | 4 | 5 | 7,
380
385
  ) {
381
386
  const sql = yield* SqlClient.SqlClient;
382
387
 
@@ -402,7 +407,15 @@ const checkPredecessorLayout = Effect.fn("DoJournal.checkPredecessorLayout")(fun
402
407
 
403
408
  const expectedColumns = {
404
409
  ...messageColumns,
405
- ...(version === 5
410
+ ...(version === 7
411
+ ? {
412
+ effect_agent_canonical_records: [
413
+ ...predecessorColumns.effect_agent_canonical_records,
414
+ "outstanding",
415
+ ],
416
+ }
417
+ : {}),
418
+ ...(version >= 5
406
419
  ? {
407
420
  effect_agent_recovery_checkpoints: [
408
421
  "thread_id",
@@ -462,6 +475,34 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
462
475
  failpoint: DoJournalFailpoint = noFailpoint,
463
476
  maxStoredValueBytes: number,
464
477
  ) {
478
+ const verifyWorkerPredecessor = Effect.fnUntraced(function* (workerContract: boolean) {
479
+ const requiredRows = yield* sql<Record<string, unknown>>`
480
+ SELECT name
481
+ FROM sqlite_master
482
+ WHERE (type = 'table'
483
+ AND name IN ${sql.in([...REQUIRED_TABLES, "effect_agent_message_deliveries", "effect_agent_recovery_checkpoints"])}
484
+ ) OR (type = 'index' AND name IN ('effect_agent_submissions_nonterminal', 'effect_agent_records_subtree', 'effect_agent_message_deliveries_pending', 'effect_agent_records_outstanding', 'effect_agent_records_call', 'effect_agent_records_run_input', 'effect_agent_records_worker_input'))
485
+ OR (${workerContract ? 1 : 0} = 1 AND name IN ('effect_agent_worker_stops', 'effect_agent_worker_starts', 'effect_agent_worker_pending', 'effect_agent_worker_execution'))
486
+ ORDER BY name
487
+ `.pipe(Effect.mapError(storageError("verify storage tables")));
488
+
489
+ const required = yield* decodeRows(
490
+ Schema.Array(DoNameRow),
491
+ "sqlite_master",
492
+ "required_tables",
493
+ requiredRows,
494
+ );
495
+
496
+ if (required.length !== REQUIRED_TABLES.length + 9 + (workerContract ? 4 : 0)) {
497
+ return yield* DoStorageCompatibilityError.make({
498
+ actualVersion: CurrentDoStorageVersion,
499
+ supportedVersion: CurrentDoStorageVersion,
500
+ message:
501
+ "The Durable Object claims the current format but is missing required tables or its nonterminal index. Retain the original store for inspection.",
502
+ });
503
+ }
504
+ });
505
+
465
506
  const metaTableRows = yield* sql<Record<string, unknown>>`
466
507
  SELECT name
467
508
  FROM sqlite_master
@@ -643,7 +684,8 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
643
684
  }),
644
685
  ),
645
686
  );
646
- yield* sql`UPDATE effect_agent_meta SET value='7' WHERE key='storage_version'`;
687
+ yield* createWorkerStops;
688
+ yield* sql`UPDATE effect_agent_meta SET value='9' WHERE key='storage_version'`;
647
689
  yield* failpoint("upgrade:after-version");
648
690
  }),
649
691
  )
@@ -679,11 +721,11 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
679
721
  value: string;
680
722
  }>`SELECT value FROM effect_agent_meta WHERE key = 'storage_version'`;
681
723
 
682
- if (current[0]?.value === "7") return;
724
+ if (current[0]?.value === "9") return;
683
725
  if (current[0]?.value !== "6")
684
726
  return yield* DoStorageCompatibilityError.make({
685
727
  actualVersion: -1,
686
- supportedVersion: 7,
728
+ supportedVersion: 9,
687
729
  message: "Storage version changed during native index upgrade",
688
730
  });
689
731
  yield* checkPredecessorLayout(5);
@@ -694,7 +736,7 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
694
736
  if (requiredIndex.length !== 1)
695
737
  return yield* DoStorageCompatibilityError.make({
696
738
  actualVersion: 6,
697
- supportedVersion: 7,
739
+ supportedVersion: 9,
698
740
  message: "Predecessor storage is missing its required nonterminal index",
699
741
  });
700
742
  yield* failpoint("upgrade:before-mutation");
@@ -711,7 +753,8 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
711
753
  );
712
754
  yield* failpoint("upgrade:after-mutation");
713
755
  yield* failpoint("upgrade:before-version");
714
- yield* sql`UPDATE effect_agent_meta SET value='7' WHERE key='storage_version'`;
756
+ yield* createWorkerStops;
757
+ yield* sql`UPDATE effect_agent_meta SET value='9' WHERE key='storage_version'`;
715
758
  yield* failpoint("upgrade:after-version");
716
759
  }),
717
760
  )
@@ -724,6 +767,96 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
724
767
  }),
725
768
  ),
726
769
  );
770
+ } else if (version.value === "7") {
771
+ yield* sql
772
+ .withTransaction(
773
+ Effect.gen(function* () {
774
+ const current = yield* sql<{
775
+ value: string;
776
+ }>`SELECT value FROM effect_agent_meta WHERE key = 'storage_version'`;
777
+
778
+ if (current[0]?.value === "9") return;
779
+ if (current[0]?.value !== "7")
780
+ return yield* DoStorageCompatibilityError.make({
781
+ actualVersion: -1,
782
+ supportedVersion: 9,
783
+ message: "Storage version changed during worker stop upgrade",
784
+ });
785
+ yield* checkPredecessorLayout(7);
786
+ yield* verifyWorkerPredecessor(false);
787
+ yield* failpoint("upgrade:before-mutation");
788
+ yield* createWorkerStops;
789
+ yield* failpoint("upgrade:after-mutation");
790
+ yield* failpoint("upgrade:before-version");
791
+ yield* sql`UPDATE effect_agent_meta SET value='9' WHERE key='storage_version'`;
792
+ yield* failpoint("upgrade:after-version");
793
+ }),
794
+ )
795
+ .pipe(
796
+ Effect.mapError((cause) =>
797
+ DoStorageError.make({
798
+ operation: "upgrade worker stop",
799
+ message: "Worker stop upgrade failed",
800
+ cause,
801
+ }),
802
+ ),
803
+ );
804
+ } else if (version.value === "8") {
805
+ yield* sql
806
+ .withTransaction(
807
+ Effect.gen(function* () {
808
+ const current = yield* sql<{
809
+ value: string;
810
+ }>`SELECT value FROM effect_agent_meta WHERE key = 'storage_version'`;
811
+
812
+ if (current[0]?.value === "9") return;
813
+ if (current[0]?.value !== "8")
814
+ return yield* DoStorageCompatibilityError.make({
815
+ actualVersion: -1,
816
+ supportedVersion: 9,
817
+ message: "Storage version changed during assignment seal upgrade",
818
+ });
819
+ yield* checkPredecessorLayout(7);
820
+ yield* verifyWorkerPredecessor(true);
821
+ const columns = yield* sql`PRAGMA table_info(effect_agent_worker_stops)`;
822
+
823
+ yield* Schema.decodeUnknownEffect(
824
+ Schema.Tuple([
825
+ Schema.Struct({
826
+ cid: Schema.Literal(0),
827
+ name: Schema.Literal("thread_id"),
828
+ type: Schema.Literal("TEXT"),
829
+ notnull: Schema.Literal(1),
830
+ dflt_value: Schema.Null,
831
+ pk: Schema.Literal(1),
832
+ }),
833
+ ]),
834
+ )(columns).pipe(
835
+ Effect.mapError(() =>
836
+ DoStorageCompatibilityError.make({
837
+ actualVersion: 8,
838
+ supportedVersion: 9,
839
+ message: "Unsupported worker seal layout; no upgrade was committed",
840
+ }),
841
+ ),
842
+ );
843
+ yield* failpoint("upgrade:before-mutation");
844
+ yield* sql`ALTER TABLE effect_agent_worker_stops ADD COLUMN terminal TEXT`;
845
+ yield* failpoint("upgrade:after-mutation");
846
+ yield* failpoint("upgrade:before-version");
847
+ yield* sql`UPDATE effect_agent_meta SET value='9' WHERE key='storage_version'`;
848
+ yield* failpoint("upgrade:after-version");
849
+ }),
850
+ )
851
+ .pipe(
852
+ Effect.mapError((cause) =>
853
+ DoStorageError.make({
854
+ operation: "upgrade assignment seals",
855
+ message: "Assignment seal upgrade failed",
856
+ cause,
857
+ }),
858
+ ),
859
+ );
727
860
  } else if (version.value !== String(CurrentDoStorageVersion)) {
728
861
  const actualVersion = Number.parseInt(version.value, 10);
729
862
 
@@ -733,35 +866,12 @@ const ensureCurrentStorage = Effect.fn("DoJournal.ensureCurrentStorage")(functio
733
866
  message:
734
867
  `The Durable Object uses unsupported storage version ${version.value}; ` +
735
868
  `this build supports exactly version ${CurrentDoStorageVersion}. ` +
736
- "Only supported v2, v3, v4, v5 and v6 can be upgraded automatically. Keep the original store and use a compatible library version.",
869
+ "Only supported v2, v3, v4, v5, v6, v7 and v8 can be upgraded automatically. Keep the original store and use a compatible library version.",
737
870
  });
738
871
  }
739
872
  }
740
873
 
741
- const requiredRows = yield* sql<Record<string, unknown>>`
742
- SELECT name
743
- FROM sqlite_master
744
- WHERE (type = 'table'
745
- AND name IN ${sql.in([...REQUIRED_TABLES, "effect_agent_message_deliveries", "effect_agent_recovery_checkpoints"])}
746
- ) OR (type = 'index' AND name IN ('effect_agent_submissions_nonterminal', 'effect_agent_records_subtree', 'effect_agent_message_deliveries_pending', 'effect_agent_records_outstanding', 'effect_agent_records_call', 'effect_agent_records_run_input', 'effect_agent_records_worker_input'))
747
- ORDER BY name
748
- `.pipe(Effect.mapError(storageError("verify storage tables")));
749
-
750
- const required = yield* decodeRows(
751
- Schema.Array(DoNameRow),
752
- "sqlite_master",
753
- "required_tables",
754
- requiredRows,
755
- );
756
-
757
- if (required.length !== REQUIRED_TABLES.length + 9) {
758
- return yield* DoStorageCompatibilityError.make({
759
- actualVersion: CurrentDoStorageVersion,
760
- supportedVersion: CurrentDoStorageVersion,
761
- message:
762
- "The Durable Object claims the current format but is missing required tables or its nonterminal index. Retain the original store for inspection.",
763
- });
764
- }
874
+ yield* verifyWorkerPredecessor(true);
765
875
 
766
876
  return makeJournal(sql, failpoint, maxStoredValueBytes);
767
877
  });
@@ -8,7 +8,23 @@ import { createMessageDeliveryTables } from "./message-delivery-schema.ts";
8
8
  import { createRecoveryCheckpointTable } from "./recovery-checkpoint-schema.ts";
9
9
 
10
10
  /** The current storage version recorded in `effect_agent_meta`. */
11
- export const CurrentDoStorageVersion = 7;
11
+ export const CurrentDoStorageVersion = 9;
12
+
13
+ /** One permanent destination inbox fence, including workers stopped before admission. */
14
+ export const createWorkerStops = Effect.gen(function* () {
15
+ const sql = yield* SqlClient.SqlClient;
16
+
17
+ yield* sql`CREATE TABLE effect_agent_worker_stops (thread_id TEXT PRIMARY KEY NOT NULL, terminal TEXT)`;
18
+ yield* sql`CREATE INDEX effect_agent_worker_starts ON effect_agent_message_deliveries(owner_thread_id,
19
+ json_extract(record_json, '$.envelope.workerAdmission.origin.worker.delegationId'),
20
+ json_extract(record_json, '$.envelope.workerAdmission.origin.worker.targetAgentId'), message_id)
21
+ WHERE message_id = json_extract(record_json, '$.envelope.workerAdmission.origin.firstMessageId')`;
22
+ yield* sql`CREATE INDEX effect_agent_worker_pending ON effect_agent_message_deliveries(owner_thread_id,
23
+ json_extract(record_json, '$.envelope.workerAdmission.origin.worker.threadId'), message_id)
24
+ WHERE state IN ('pending', 'parked') AND json_extract(record_json, '$.receipt') IS NULL`;
25
+ yield* sql`CREATE INDEX effect_agent_worker_execution ON effect_agent_canonical_records(thread_id,
26
+ json_extract(record_json, '$.payload._tag'), sequence) WHERE json_extract(record_json, '$.payload.runId') IS NOT NULL`;
27
+ });
12
28
 
13
29
  /** Index only outstanding obligations, ordered by the recovery scan's stable cursor. */
14
30
  export const createNonterminalIndex = Effect.gen(function* () {
@@ -272,6 +288,7 @@ export const doMigrations = SqliteMigrator.fromRecord({
272
288
  yield* createMessageDeliveryTables;
273
289
  yield* createMessageDeliveryPendingIndex;
274
290
  yield* createRecoveryCheckpointTable;
291
+ yield* createWorkerStops;
275
292
  yield* sql`
276
293
  CREATE TABLE effect_agent_meta (
277
294
  key TEXT PRIMARY KEY NOT NULL,