stitchkit 0.94.1 → 0.95.0

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.
@@ -1068,21 +1068,28 @@ function notificationOutboxResource(outbox, config = {}) {
1068
1068
  });
1069
1069
  }
1070
1070
  // src/application/process-lifecycle.ts
1071
+ import { z as z7 } from "zod";
1072
+
1073
+ // src/application/process-lifecycle-transitions.ts
1071
1074
  import { z as z6 } from "zod";
1072
1075
  var LifecycleTerminationSchema = z6.enum([
1073
1076
  "active",
1077
+ "draining",
1074
1078
  "clean",
1075
1079
  "forced",
1080
+ "startup-failed",
1076
1081
  "hot-reload",
1077
1082
  "abnormal"
1078
1083
  ]);
1084
+ var timestamp = z6.string().datetime({ offset: true });
1079
1085
  var LifecycleRunSchema = z6.object({
1080
1086
  runId: z6.string().min(1).max(128),
1081
1087
  pid: z6.number().int().positive(),
1082
1088
  version: z6.string().min(1).max(256),
1083
- startedAt: z6.string().datetime({ offset: true }),
1084
- readyAt: z6.string().datetime({ offset: true }).nullable(),
1085
- stoppedAt: z6.string().datetime({ offset: true }).nullable(),
1089
+ startedAt: timestamp,
1090
+ readyAt: timestamp.nullable(),
1091
+ unavailableAt: timestamp.nullable().default(null),
1092
+ stoppedAt: timestamp.nullable(),
1086
1093
  termination: LifecycleTerminationSchema
1087
1094
  }).strict();
1088
1095
  var LifecycleStateSchema = z6.object({
@@ -1094,17 +1101,20 @@ var PreviousExitSchema = z6.enum([
1094
1101
  "hot-reload",
1095
1102
  "clean",
1096
1103
  "forced",
1104
+ "startup-failed",
1097
1105
  "handoff",
1098
1106
  "abnormal"
1099
1107
  ]);
1100
1108
  var emptyState = () => ({ schemaVersion: 1, runs: [] });
1101
1109
  var epoch = (value) => Date.parse(value);
1102
1110
  var elapsed = (later, earlier) => Math.max(0, epoch(later) - epoch(earlier));
1103
- function normalize(state, retain) {
1111
+ var isOpenRun = (run) => run.termination === "active" || run.termination === "draining";
1112
+ function normalizeLifecycleState(state, retain) {
1104
1113
  const parsed = LifecycleStateSchema.parse(state ?? emptyState());
1105
1114
  const runs = [...parsed.runs];
1106
1115
  for (let index = runs.length - 1;runs.length > retain && index >= 0; index -= 1) {
1107
- if (runs[index]?.termination !== "active")
1116
+ const run = runs[index];
1117
+ if (run && !isOpenRun(run))
1108
1118
  runs.splice(index, 1);
1109
1119
  }
1110
1120
  return { schemaVersion: 1, runs: runs.slice(0, retain) };
@@ -1113,15 +1123,45 @@ var UNKNOWN_VERSION = "unknown";
1113
1123
  function isVersionChange(previous, next) {
1114
1124
  return previous !== UNKNOWN_VERSION && next !== UNKNOWN_VERSION && previous !== next;
1115
1125
  }
1126
+ var RetainSchema = z6.number().int().min(1).max(1000);
1127
+ function previousExitOf(previous, run, overlap) {
1128
+ if (!previous)
1129
+ return "first-boot";
1130
+ const ended = previous.termination;
1131
+ if (ended !== "active" && ended !== "draining")
1132
+ return ended;
1133
+ if (previous.pid === run.pid)
1134
+ return "hot-reload";
1135
+ if (isVersionChange(previous.version, run.version) || overlap === "handoff")
1136
+ return "handoff";
1137
+ return previous.readyAt === null ? "startup-failed" : "abnormal";
1138
+ }
1139
+ function closeAbandoned(candidate, previous, previousExit, run, overlap) {
1140
+ if (!isOpenRun(candidate))
1141
+ return candidate;
1142
+ const closed = { ...candidate, stoppedAt: run.startedAt };
1143
+ if (candidate.pid === run.pid)
1144
+ return { ...closed, termination: "hot-reload" };
1145
+ if (overlap === "handoff")
1146
+ return candidate;
1147
+ if (candidate.runId === previous?.runId && previousExit === "handoff")
1148
+ return candidate;
1149
+ return {
1150
+ ...closed,
1151
+ termination: candidate.readyAt === null ? "startup-failed" : "abnormal"
1152
+ };
1153
+ }
1116
1154
  function transitionProcessStart(source, input) {
1117
- const retain = z6.number().int().min(1).max(1000).parse(input.retain ?? 20);
1118
- const state = normalize(source, 1000);
1155
+ const retain = RetainSchema.parse(input.retain ?? 20);
1156
+ const overlap = input.sameVersionOverlap ?? "abnormal";
1157
+ const state = normalizeLifecycleState(source, 1000);
1119
1158
  const run = LifecycleRunSchema.parse({
1120
1159
  runId: input.runId,
1121
1160
  pid: input.pid,
1122
1161
  version: input.version,
1123
1162
  startedAt: input.now,
1124
1163
  readyAt: null,
1164
+ unavailableAt: null,
1125
1165
  stoppedAt: null,
1126
1166
  termination: "active"
1127
1167
  });
@@ -1129,31 +1169,10 @@ function transitionProcessStart(source, input) {
1129
1169
  throw new Error(`[stitchkit] lifecycle run id already exists: ${run.runId}`);
1130
1170
  }
1131
1171
  const previous = state.runs[0] ?? null;
1132
- let previousExit = "first-boot";
1133
- let downtimeMs = null;
1134
- if (previous?.stoppedAt) {
1135
- previousExit = previous.termination === "forced" || previous.termination === "abnormal" || previous.termination === "hot-reload" ? previous.termination : "clean";
1136
- downtimeMs = elapsed(run.startedAt, previous.stoppedAt);
1137
- } else if (previous) {
1138
- if (previous.pid === run.pid)
1139
- previousExit = "hot-reload";
1140
- else if (isVersionChange(previous.version, run.version))
1141
- previousExit = "handoff";
1142
- else
1143
- previousExit = input.sameVersionOverlap ?? "abnormal";
1144
- }
1145
- const runs = state.runs.map((candidate) => {
1146
- if (candidate.runId !== previous?.runId || candidate.termination !== "active")
1147
- return candidate;
1148
- if (previousExit === "hot-reload") {
1149
- return { ...candidate, stoppedAt: run.startedAt, termination: "hot-reload" };
1150
- }
1151
- if (previousExit === "abnormal") {
1152
- return { ...candidate, stoppedAt: run.startedAt, termination: "abnormal" };
1153
- }
1154
- return candidate;
1155
- });
1156
- const next = normalize({ schemaVersion: 1, runs: [run, ...runs] }, retain);
1172
+ const previousExit = previousExitOf(previous, run, overlap);
1173
+ const processGapMs = previous && !isOpenRun(previous) && previous.stoppedAt ? elapsed(run.startedAt, previous.stoppedAt) : null;
1174
+ const runs = state.runs.map((candidate) => closeAbandoned(candidate, previous, previousExit, run, overlap));
1175
+ const next = normalizeLifecycleState({ schemaVersion: 1, runs: [run, ...runs] }, retain);
1157
1176
  return {
1158
1177
  state: next,
1159
1178
  fact: {
@@ -1167,15 +1186,30 @@ function transitionProcessStart(source, input) {
1167
1186
  previousPid: previous?.pid ?? null,
1168
1187
  previousVersion: previous?.version ?? null,
1169
1188
  versionChanged: previous !== null && isVersionChange(previous.version, run.version),
1170
- downtimeMs
1189
+ processGapMs
1171
1190
  }
1172
1191
  };
1173
1192
  }
1193
+ function findOpen(state, input) {
1194
+ return state.runs.find((run) => run.runId === input.runId && run.pid === input.pid && isOpenRun(run));
1195
+ }
1196
+ function unavailability(state, target, readyAt) {
1197
+ const older = state.runs.slice(state.runs.indexOf(target) + 1);
1198
+ const served = older.find((run) => run.readyAt !== null);
1199
+ if (!served)
1200
+ return { downtimeMs: null, unavailableSince: null };
1201
+ if (served.unavailableAt !== null) {
1202
+ return {
1203
+ downtimeMs: elapsed(readyAt, served.unavailableAt),
1204
+ unavailableSince: served.unavailableAt
1205
+ };
1206
+ }
1207
+ return isOpenRun(served) ? { downtimeMs: 0, unavailableSince: null } : { downtimeMs: null, unavailableSince: null };
1208
+ }
1174
1209
  function transitionProcessReady(source, input) {
1175
- const retain = input.retain ?? 20;
1176
- const state = normalize(source, retain);
1177
- const target = state.runs.find((run) => run.runId === input.runId && run.pid === input.pid && run.termination === "active");
1178
- if (!target) {
1210
+ const state = normalizeLifecycleState(source, input.retain ?? 20);
1211
+ const target = findOpen(state, input);
1212
+ if (target?.termination !== "active") {
1179
1213
  return {
1180
1214
  state,
1181
1215
  fact: {
@@ -1184,6 +1218,8 @@ function transitionProcessReady(source, input) {
1184
1218
  pid: input.pid,
1185
1219
  readyAt: input.now,
1186
1220
  startupMs: 0,
1221
+ downtimeMs: null,
1222
+ unavailableSince: null,
1187
1223
  recorded: false
1188
1224
  }
1189
1225
  };
@@ -1200,14 +1236,45 @@ function transitionProcessReady(source, input) {
1200
1236
  pid: target.pid,
1201
1237
  readyAt,
1202
1238
  startupMs: elapsed(readyAt, target.startedAt),
1239
+ ...unavailability(state, target, readyAt),
1203
1240
  recorded: target.readyAt === null
1204
1241
  }
1205
1242
  };
1206
1243
  }
1244
+ function transitionProcessDraining(source, input) {
1245
+ const state = normalizeLifecycleState(source, input.retain ?? 20);
1246
+ const target = findOpen(state, input);
1247
+ if (target?.termination !== "active") {
1248
+ return {
1249
+ state,
1250
+ fact: {
1251
+ type: "draining",
1252
+ runId: input.runId,
1253
+ pid: input.pid,
1254
+ unavailableAt: target ? target.unavailableAt : null,
1255
+ recorded: false
1256
+ }
1257
+ };
1258
+ }
1259
+ const unavailableAt = target.readyAt === null ? null : input.now;
1260
+ return {
1261
+ state: {
1262
+ schemaVersion: 1,
1263
+ runs: state.runs.map((run) => run.runId === target.runId ? { ...run, termination: "draining", unavailableAt } : run)
1264
+ },
1265
+ fact: {
1266
+ type: "draining",
1267
+ runId: target.runId,
1268
+ pid: target.pid,
1269
+ unavailableAt,
1270
+ recorded: true
1271
+ }
1272
+ };
1273
+ }
1207
1274
  function transitionProcessShutdown(source, input) {
1208
- const state = normalize(source, input.retain ?? 20);
1209
- const target = state.runs.find((run) => run.runId === input.runId && run.pid === input.pid && run.termination === "active");
1210
- const termination = input.forced ? "forced" : "clean";
1275
+ const state = normalizeLifecycleState(source, input.retain ?? 20);
1276
+ const target = findOpen(state, input);
1277
+ const requested = input.forced ? "forced" : "clean";
1211
1278
  if (!target) {
1212
1279
  return {
1213
1280
  state,
@@ -1217,15 +1284,17 @@ function transitionProcessShutdown(source, input) {
1217
1284
  pid: input.pid,
1218
1285
  stoppedAt: input.now,
1219
1286
  uptimeMs: null,
1220
- termination,
1287
+ termination: requested,
1221
1288
  recorded: false
1222
1289
  }
1223
1290
  };
1224
1291
  }
1292
+ const termination = target.readyAt === null ? "startup-failed" : requested;
1293
+ const unavailableAt = target.unavailableAt ?? (target.readyAt === null ? null : input.now);
1225
1294
  return {
1226
1295
  state: {
1227
1296
  schemaVersion: 1,
1228
- runs: state.runs.map((run) => run.runId === target.runId ? { ...run, stoppedAt: input.now, termination } : run)
1297
+ runs: state.runs.map((run) => run.runId === target.runId ? { ...run, unavailableAt, stoppedAt: input.now, termination } : run)
1229
1298
  },
1230
1299
  fact: {
1231
1300
  type: "stopped",
@@ -1238,10 +1307,12 @@ function transitionProcessShutdown(source, input) {
1238
1307
  }
1239
1308
  };
1240
1309
  }
1310
+
1311
+ // src/application/process-lifecycle.ts
1241
1312
  function createProcessLifecycleLedger(config) {
1242
1313
  const clock = config.clock ?? (() => new Date);
1243
- const pid = z6.number().int().positive().parse(config.pid ?? process.pid);
1244
- const retain = z6.number().int().min(1).max(1000).parse(config.retain ?? 20);
1314
+ const pid = z7.number().int().positive().parse(config.pid ?? process.pid);
1315
+ const retain = z7.number().int().min(1).max(1000).parse(config.retain ?? 20);
1245
1316
  const configuredRunId = config.runId;
1246
1317
  const nextRunId = typeof configuredRunId === "function" ? configuredRunId : configuredRunId === undefined ? () => crypto.randomUUID() : () => configuredRunId;
1247
1318
  let runId = nextRunId();
@@ -1285,6 +1356,20 @@ function createProcessLifecycleLedger(config) {
1285
1356
  publish(fact);
1286
1357
  return fact;
1287
1358
  },
1359
+ async recordDraining() {
1360
+ const fact = await config.store.update((state) => {
1361
+ const transition = transitionProcessDraining(state, {
1362
+ runId,
1363
+ pid,
1364
+ now: clock().toISOString(),
1365
+ retain
1366
+ });
1367
+ return { state: transition.state, result: transition.fact };
1368
+ });
1369
+ if (fact.recorded)
1370
+ publish(fact);
1371
+ return fact;
1372
+ },
1288
1373
  async recordShutdown(input) {
1289
1374
  const fact = await config.store.update((state) => {
1290
1375
  const transition = transitionProcessShutdown(state, {
@@ -1301,11 +1386,11 @@ function createProcessLifecycleLedger(config) {
1301
1386
  return fact;
1302
1387
  },
1303
1388
  async current() {
1304
- const state = normalize(await config.store.read(), retain);
1389
+ const state = normalizeLifecycleState(await config.store.read(), retain);
1305
1390
  return state.runs.find((run) => run.runId === runId && run.pid === pid) ?? null;
1306
1391
  },
1307
1392
  async runs() {
1308
- return normalize(await config.store.read(), retain).runs;
1393
+ return normalizeLifecycleState(await config.store.read(), retain).runs;
1309
1394
  },
1310
1395
  subscribe(listener) {
1311
1396
  listeners.add(listener);
@@ -1340,6 +1425,9 @@ function lifecycleLedgerResource(ledger, config) {
1340
1425
  async activate() {
1341
1426
  await ledger.recordReady();
1342
1427
  },
1428
+ async stopAdmission() {
1429
+ await ledger.recordDraining();
1430
+ },
1343
1431
  async close() {
1344
1432
  await stop(false);
1345
1433
  },
@@ -1349,27 +1437,27 @@ function lifecycleLedgerResource(ledger, config) {
1349
1437
  });
1350
1438
  }
1351
1439
  // src/application/revision-signal.ts
1352
- import { z as z7 } from "zod";
1353
- var NonNegativeSafeIntegerSchema = z7.number().int().nonnegative().safe();
1354
- var PositiveSafeIntegerSchema2 = z7.number().int().positive().safe();
1355
- var TimerDelaySchema = z7.number().int().positive().max(2147483647);
1356
- var RevisionSignalStateSchema = z7.enum(["open", "closed"]);
1357
- var RevisionSignalWaitOutcomeSchema = z7.enum([
1440
+ import { z as z8 } from "zod";
1441
+ var NonNegativeSafeIntegerSchema = z8.number().int().nonnegative().safe();
1442
+ var PositiveSafeIntegerSchema2 = z8.number().int().positive().safe();
1443
+ var TimerDelaySchema = z8.number().int().positive().max(2147483647);
1444
+ var RevisionSignalStateSchema = z8.enum(["open", "closed"]);
1445
+ var RevisionSignalWaitOutcomeSchema = z8.enum([
1358
1446
  "changed",
1359
1447
  "timed-out",
1360
1448
  "aborted",
1361
1449
  "closed",
1362
1450
  "capacity"
1363
1451
  ]);
1364
- var RevisionSignalWaitResultSchema = z7.object({
1452
+ var RevisionSignalWaitResultSchema = z8.object({
1365
1453
  outcome: RevisionSignalWaitOutcomeSchema,
1366
1454
  revision: NonNegativeSafeIntegerSchema
1367
1455
  }).strict().readonly();
1368
- var RevisionSignalAdvanceResultSchema = z7.object({
1369
- outcome: z7.enum(["advanced", "closed"]),
1456
+ var RevisionSignalAdvanceResultSchema = z8.object({
1457
+ outcome: z8.enum(["advanced", "closed"]),
1370
1458
  revision: NonNegativeSafeIntegerSchema
1371
1459
  }).strict().readonly();
1372
- var RevisionSignalSnapshotSchema = z7.object({
1460
+ var RevisionSignalSnapshotSchema = z8.object({
1373
1461
  state: RevisionSignalStateSchema,
1374
1462
  revision: NonNegativeSafeIntegerSchema,
1375
1463
  pending: NonNegativeSafeIntegerSchema,
@@ -1919,6 +2007,7 @@ export {
1919
2007
  transitionProcessStart,
1920
2008
  transitionProcessShutdown,
1921
2009
  transitionProcessReady,
2010
+ transitionProcessDraining,
1922
2011
  sqliteKeyspaceBackend,
1923
2012
  readDiagnosticJournalLockDiagnosis,
1924
2013
  projectApplicationStatus,
@@ -1775,6 +1775,27 @@ function markProviderOrigin(error) {
1775
1775
  function hasProviderOrigin(error) {
1776
1776
  return typeof error === "object" && error !== null && providerOrigin.has(error);
1777
1777
  }
1778
+ function markProviderStream(stream) {
1779
+ const reader = stream.getReader();
1780
+ return new ReadableStream({
1781
+ async pull(controller) {
1782
+ let read;
1783
+ try {
1784
+ read = await reader.read();
1785
+ } catch (error) {
1786
+ controller.error(isOwnInputRefusal(error) ? error : markProviderOrigin(error));
1787
+ return;
1788
+ }
1789
+ if (read.done)
1790
+ controller.close();
1791
+ else
1792
+ controller.enqueue(read.value);
1793
+ },
1794
+ cancel(reason) {
1795
+ return reader.cancel(reason);
1796
+ }
1797
+ });
1798
+ }
1778
1799
 
1779
1800
  // src/agent-runtime/run-operation-lifecycle.ts
1780
1801
  function createAgentRunOperationLifecycle(config) {
@@ -1840,7 +1861,8 @@ function createAgentRunOperationLifecycle(config) {
1840
1861
  }
1841
1862
  await startModelRequest(callId, step);
1842
1863
  try {
1843
- return await doStream();
1864
+ const result = await doStream();
1865
+ return { ...result, stream: markProviderStream(result.stream) };
1844
1866
  } catch (error) {
1845
1867
  throw isOwnInputRefusal(error) ? error : markProviderOrigin(error);
1846
1868
  }
@@ -290,11 +290,11 @@ cutovers are covered by the executable
290
290
  | `createApplication` | function | compose a validated resource DAG into one startup, readiness, admission and shutdown state machine |
291
291
  | `StateStore` / `StateStoreUpdate` | _type_ | async atomic read/update boundary shared by durable application state machines |
292
292
  | `createProcessLifecycleLedger` / `ProcessLifecycleLedger` / `ProcessLifecycleLedgerConfig` | function / _type_ | durable bounded run history and typed fact subscription |
293
- | `LifecycleRunSchema` / `LifecycleRun` / `LifecycleStateSchema` / `LifecycleState` / `LifecycleTerminationSchema` / `LifecycleTermination` | schema / _type_ | versioned process-run state and termination vocabulary |
294
- | `PreviousExitSchema` / `PreviousExit` / `StartFact` / `ReadyFact` / `ShutdownFact` / `ProcessLifecycleFact` | schema / _type_ | first-boot, hot-reload, clean, forced, handoff and abnormal lifecycle facts |
295
- | `transitionProcessStart` / `transitionProcessReady` / `transitionProcessShutdown` | function | pure idempotent runId+pid-owned transitions |
296
- | `LifecycleTransition` / `TransitionStartInput` / `TransitionReadyInput` / `TransitionShutdownInput` / `SameVersionOverlap` | _type_ | pure transition inputs, the same-build overlap policy (`abnormal` default / `handoff`) and state+fact result |
297
- | `lifecycleLedgerResource` / `LifecycleLedgerResource` / `LifecycleLedgerResourceConfig` | function / _type_ | managed-resource adapter that records start, readiness and shutdown |
293
+ | `LifecycleRunSchema` / `LifecycleRun` / `LifecycleStateSchema` / `LifecycleState` / `LifecycleTerminationSchema` / `LifecycleTermination` | schema / _type_ | versioned process-run state — open (`active`, `draining` with `unavailableAt`) and ended (`clean`, `forced`, `startup-failed`, `hot-reload`, `abnormal`) |
294
+ | `PreviousExitSchema` / `PreviousExit` / `StartFact` / `ReadyFact` / `DrainingFact` / `ShutdownFact` / `ProcessLifecycleFact` | schema / _type_ | first-boot, hot-reload, clean, forced, startup-failed, handoff and abnormal lifecycle facts; `processGapMs` on start, `downtimeMs` (unavailable → ready) on ready |
295
+ | `transitionProcessStart` / `transitionProcessReady` / `transitionProcessDraining` / `transitionProcessShutdown` | function | pure idempotent runId+pid-owned transitions |
296
+ | `LifecycleTransition` / `TransitionStartInput` / `TransitionRunInput` / `TransitionShutdownInput` / `SameVersionOverlap` | _type_ | pure transition inputs (`TransitionRunInput` for ready and draining, extended with `forced` for shutdown), the same-build overlap policy (`abnormal` default / `handoff`) and state+fact result |
297
+ | `lifecycleLedgerResource` / `LifecycleLedgerResource` / `LifecycleLedgerResourceConfig` | function / _type_ | managed-resource adapter that records start, readiness, admission stop (draining) and shutdown |
298
298
  | `createNotificationOutbox` / `NotificationOutbox` / `NotificationOutboxConfig` | function / _type_ | bounded at-least-once queue with durable claims, leases, retries and supersede |
299
299
  | `NotificationOutboxItem` / `NotificationOutboxReceipt` / `NotificationOutboxState` / `NotificationSend` / `NotificationFailureClassification` / `DroppedNotification` / `EnqueueNotification` | _type_ | versioned queue records, stable send identity and delivery decisions |
300
300
  | `notificationOutboxStateSchema` | function | strict persisted-state schema over an application payload schema |
@@ -815,39 +815,74 @@ application-owned.
815
815
  ## Durable process facts and owner notifications
816
816
 
817
817
  `createProcessLifecycleLedger` records a bounded versioned list of process runs.
818
- Start, readiness and shutdown target both `runId` and `pid`, so hot reload and
819
- PID reuse cannot close the wrong generation. A start classifies what happened to
820
- the newest run before it, and the classification is decided by three facts —
821
- whether that run recorded its own exit, whether the pid is the same, and whether
822
- the version changed:
818
+ Start, readiness, draining and shutdown target both `runId` and `pid`, so hot
819
+ reload and PID reuse cannot close the wrong generation. `lifecycleLedgerResource`
820
+ drives it from the kernel's phases: `start` records the start, `activate` the
821
+ readiness, `stopAdmission` the drain — the run's `unavailableAt`, the moment it
822
+ stops answering new work — and `close` / `force` the stop. A run that stops
823
+ without ever becoming ready (a failed startup rolls back with `close` only)
824
+ ends as `startup-failed`, not `clean`.
825
+
826
+ A start classifies what happened to the newest run before it, and the
827
+ classification is decided by four facts — whether that run recorded its own
828
+ exit, whether the pid is the same, whether the version changed and whether it
829
+ ever became ready:
823
830
 
824
831
  | newest run | same pid | version | `previousExit` | predecessor's `termination` |
825
832
  |---|---|---|---|---|
826
833
  | none | — | — | `first-boot` | — |
827
- | recorded `stoppedAt` | — | — | `clean` / `forced` / `abnormal` as recorded | unchanged |
828
- | still `active` | yes | — | `hot-reload` | `hot-reload`, closed at the new start |
829
- | still `active` | no | changed | `handoff` | stays `active`; it records its own stop later |
830
- | still `active` | no | same | `abnormal` (default) | `abnormal`, closed at the new start — an upper bound, the crash time is unknown |
834
+ | recorded `stoppedAt` | — | — | `clean` / `forced` / `startup-failed` / `abnormal` as recorded | unchanged |
835
+ | still open | yes | — | `hot-reload` | `hot-reload`, closed at the new start |
836
+ | still open | no | changed | `handoff` | stays open; it records its own stop later |
837
+ | still open, was ready | no | same | `abnormal` (default) | `abnormal`, closed at the new start — an upper bound, the crash time is unknown |
838
+ | still open, never ready | no | same | `startup-failed` (default) | `startup-failed`, closed at the new start |
839
+
840
+ Every other open run the start finds was abandoned too and is closed the same
841
+ way — two crashes in a row leave two open runs, and both are closed, not only
842
+ the newest. Open means `active` or `draining`.
831
843
 
832
844
  A version of `unknown` on either side is never a version change, so a dev build
833
845
  after a crashed release reads `abnormal`, not `handoff`. `forced` means the
834
846
  process itself acknowledged a kill; `abnormal` means a successor found it dead.
835
847
 
836
- The last row is a choice, and the default is the single-process deployment:
848
+ ### Downtime is the window nobody answered in
849
+
850
+ `ReadyFact.downtimeMs` is `readyAt` of the new run minus `unavailableAt` of the
851
+ last run that served — the predecessor's drain plus the successor's boot. A
852
+ 110 s forced drain followed by a 4 s boot is 114 s of downtime; the gap between
853
+ the two processes alone would read 4 s before and after that defect was fixed.
854
+ Runs that never became ready answered nobody, so the count passes them: after a
855
+ failed start it still opens where the last serving run stopped answering. It is
856
+ `0` when that run still answers (a handoff whose predecessor has not drained
857
+ yet) and `null` on first boot and when the end was never timed — a crash or a
858
+ hot reload records no `unavailableAt`. `unavailableSince` names the moment it is
859
+ counted from. The gap between two processes is still there, under its own
860
+ name: `StartFact.processGapMs` is the start minus the predecessor's recorded
861
+ stop.
862
+
863
+ The ledger records what the process says while it can say it. stitchkit never
864
+ calls `process.exit` (ADR 0074), so a stop is recorded on the kernel's `close`
865
+ or `force` path, both awaited. An application that exits the process on its
866
+ own before its shutdown settles loses that last write; the successor then
867
+ finds the run open and closes it as `abnormal`, keeping the `unavailableAt`
868
+ already recorded — which is why the drain moment is written first, at
869
+ `stopAdmission`, and the downtime survives a stop that was never recorded.
870
+
871
+ The `abnormal` default is a choice, and it is the single-process deployment:
837
872
  one process per build, a new pid of the same build means the old one stopped
838
- answering. Where two processes of **one** build overlap on purpose — a
839
- cluster, a zero-downtime reload of the same build — pass
840
- `sameVersionOverlap: 'handoff'` to the ledger, and the predecessor stays
841
- `active` until it records its own shutdown. The cost of that setting is
842
- symmetric: a real crash under it is reported as a handoff and the dead run
843
- stays `active` in the ledger until retention drops it. The list is kept in the
844
- order the transitions wrote it — every write goes through one atomic update,
845
- so that order is the causal one, and a successor whose clock lags its
846
- predecessor still finds it at the head; `startedAt` is data, not the sort key.
847
- Retention (`retain`, default 20) drops finished runs first and an active one —
848
- a live handoff predecessor — only when nothing finished is left. Facts are
849
- published through the ledger's own subscription and resource value;
850
- `ApplicationEventSink` remains the strict application-state stream.
873
+ answering. Where processes of **one** build overlap on purpose — a cluster, a
874
+ zero-downtime reload of the same build — pass `sameVersionOverlap: 'handoff'`
875
+ to the ledger, and every open run stays open until it records its own
876
+ shutdown. The cost of that setting is symmetric: a real crash under it is
877
+ reported as a handoff and the dead run stays open in the ledger until
878
+ retention drops it. The list is kept in the order the transitions wrote it —
879
+ every write goes through one atomic update, so that order is the causal one,
880
+ and a successor whose clock lags its predecessor still finds it at the head;
881
+ `startedAt` is data, not the sort key. Retention (`retain`, default 20) drops
882
+ finished runs first and an open one — a live handoff predecessor — only when
883
+ nothing finished is left. Facts are published through the ledger's own
884
+ subscription and resource value; `ApplicationEventSink` remains the strict
885
+ application-state stream.
851
886
 
852
887
  `createNotificationOutbox` is the transport-neutral durable delivery side. It
853
888
  persists before send, claims an item with an expiring lease, carries one stable
@@ -76,11 +76,11 @@ own, recorded as an ADR.
76
76
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
77
77
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
78
78
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
79
- | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 39 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
79
+ | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 40 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
80
80
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the managed-resource conformance kit |
81
81
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
82
82
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
83
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 21 of the 39 minors since 0.56.2, most recently 0.94.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
83
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 21 of the 40 minors since 0.56.2, most recently 0.94.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
84
84
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
85
85
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
86
86
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -90,7 +90,7 @@ own, recorded as an ADR.
90
90
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
91
91
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
92
92
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
93
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 39 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
93
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 8 of the 40 minors since 0.56.2, most recently 0.95.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
94
94
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
95
95
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
96
96
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -1,14 +1,36 @@
1
- # Upgrading 0.37.0 → 0.50.0 — stitchkit docs slice
1
+ # Upgrading 0.37.0 → 0.53.0 — stitchkit docs slice
2
2
 
3
3
  > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
4
  > `llms.txt` in this package.
5
5
 
6
- Contains: Guide: Upgrading, 0.37.0 → 0.50.0 (docs/guide/upgrading.md)
6
+ Contains: Guide: Upgrading, 0.37.0 → 0.53.0 (docs/guide/upgrading.md)
7
7
 
8
8
  ==============================================================================
9
- # Guide: Upgrading, 0.37.0 → 0.50.0 (docs/guide/upgrading.md)
9
+ # Guide: Upgrading, 0.37.0 → 0.53.0 (docs/guide/upgrading.md)
10
10
  ==============================================================================
11
11
 
12
+ ## Released migration: 0.53.0
13
+
14
+ ### Realtime `emit` returns `boolean` instead of `void`
15
+
16
+ Every **call site** compiles and behaves exactly as before — the return value
17
+ is new information (`true` = accepted by the transport, `false` = dropped
18
+ while the browser client was disconnected), not a behavior change. What breaks
19
+ is **implementing** the interfaces: a test mock or app-side adapter of
20
+ `SocketIOClient` / `RealtimeClient` / `ValidatedRealtimeSocket` /
21
+ `RealtimeServer` whose `emit` returns `void` no longer typechecks.
22
+
23
+ ```ts
24
+ // before — a void mock satisfied the interface
25
+ const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => {} }
26
+ // after — report acceptance (true is what a live server-side emit reports)
27
+ const mock: Pick<RealtimeClient<S, C>, 'emit'> = { emit: () => true }
28
+ ```
29
+
30
+ While migrating, consider replacing hand-rolled `if (client.connected)` guards
31
+ with the new honest surface: check `client.emit(...) === false`, or observe
32
+ drops centrally with `onDroppedEmit`.
33
+
12
34
  ## Released migration: 0.50.0
13
35
 
14
36
  ### The factory's scope union now covers per-endpoint overrides