@runuai/host 0.9.150 → 0.9.152

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -46,14 +46,30 @@ export function activeLoaderHostTasks(): HostTask[] {
46
46
  .all();
47
47
  }
48
48
 
49
+ /** ADR-154: the runs whose runtime survives this process's start running:
50
+ * each task marked running whose admitted run is open and established. */
51
+ export function runningTaskRuns(db: Db = getDb()): Array<{ taskId: string; runEpoch: number }> {
52
+ const rows = db
53
+ .select({ taskId: schema.hostTasks.taskId })
54
+ .from(schema.hostTasks)
55
+ .where(eq(schema.hostTasks.statusMirror, "running"))
56
+ .all();
57
+ return rows.flatMap(({ taskId }) => {
58
+ const run = readTaskRunEpochState(taskId, db);
59
+ return run !== null && run.assignmentOpen && run.runtimeEstablishedEpoch === run.highestRunEpoch && run.highestRunEpoch > 0
60
+ ? [{ taskId, runEpoch: run.highestRunEpoch }]
61
+ : [];
62
+ });
63
+ }
64
+
49
65
  /** Remove a provisional first-seen row when admission loses a lifecycle gate. */
50
- export function deleteHostTask(taskId: string): void {
66
+ export function deleteHostTask(taskId: string, db: Db = getDb()): void {
51
67
  // Row deletions are rare and load-bearing; a silent one cost a live
52
68
  // machine-task forensic session (2026-08-27). Name the caller.
53
69
  console.warn(
54
70
  `[runtime-state] deleting host task row ${taskId}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`,
55
71
  );
56
- getDb()
72
+ db
57
73
  .delete(schema.hostTasks)
58
74
  .where(eq(schema.hostTasks.taskId, taskId))
59
75
  .run();
@@ -62,9 +78,10 @@ export function deleteHostTask(taskId: string): void {
62
78
  /** Final orphan-GC metadata purge. Runtime/filesystem and credential proofs
63
79
  * happen before this transaction; a SQLite failure leaves every task-owned
64
80
  * row intact rather than acknowledging a partial metadata deletion. */
65
- export function purgeHostTaskState(taskId: string): void {
81
+ export function purgeHostTaskState(taskId: string, db: Db = getDb()): void {
66
82
  console.warn(`[runtime-state] purging all host state for task ${taskId}`);
67
- getDb().transaction((tx) => {
83
+ db.transaction((tx) => {
84
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
68
85
  tx.delete(schema.hostAgentSessions)
69
86
  .where(eq(schema.hostAgentSessions.taskId, taskId))
70
87
  .run();
@@ -172,8 +189,9 @@ export function recordTaskUpResult(
172
189
  result: TaskUpResult,
173
190
  environment: TaskEnvironmentLocator,
174
191
  envMode: ProjectEnvMode,
192
+ db: Db = getDb(),
175
193
  ): void {
176
- upsertHostTask(taskId, {
194
+ upsertHostTaskIn(db, taskId, {
177
195
  codeServerPort: result.codeServerPort ?? null,
178
196
  previewPorts: JSON.stringify(result.previewPorts ?? []),
179
197
  composeProject: result.composeProject,
@@ -188,15 +206,409 @@ export function recordTaskUpResult(
188
206
  });
189
207
  }
190
208
 
191
- export function recordTaskStarting(taskId: string): void {
192
- upsertHostTask(taskId, {
193
- statusMirror: "starting",
194
- lockedAt: Date.now(),
195
- startedAt: Date.now(),
196
- endedAt: null,
209
+ export type TaskRunEpochAdmission =
210
+ | { kind: "legacy" }
211
+ | {
212
+ kind: "accepted";
213
+ relation: "advanced" | "retry";
214
+ previousRunEpoch: number;
215
+ hadTask: boolean;
216
+ }
217
+ | {
218
+ kind: "stale";
219
+ currentRunEpoch: number;
220
+ reason: "older" | "closed";
221
+ };
222
+
223
+ export interface TaskRunEpochState {
224
+ /** Highest v1 dispatch this runner has ever admitted for the task. */
225
+ highestRunEpoch: number;
226
+ /** False after Stop/teardown; only a strictly higher epoch may reopen it. */
227
+ assignmentOpen: boolean;
228
+ /** Zero until a runtime has successfully completed task-up for this epoch. */
229
+ runtimeEstablishedEpoch: number;
230
+ }
231
+
232
+ export type CloseTaskRunAssignmentResult =
233
+ | { kind: "missing" }
234
+ | { kind: "mismatch"; currentRunEpoch: number }
235
+ | { kind: "already_closed"; runEpoch: number }
236
+ | { kind: "closed"; runEpoch: number };
237
+
238
+ export type StoppedTaskRunAssignment =
239
+ | { kind: "legacy" }
240
+ | { kind: "closed"; runEpoch: number };
241
+
242
+ function persistedRunEpoch(value: number): number {
243
+ if (!Number.isSafeInteger(value) || value < 1) {
244
+ throw new Error("persisted assignment run epoch is invalid");
245
+ }
246
+ return value;
247
+ }
248
+
249
+ function taskRunEpochStateIn(
250
+ db: DbExecutor,
251
+ taskId: string,
252
+ ): TaskRunEpochState | null {
253
+ const assignment = db
254
+ .select()
255
+ .from(schema.hostTaskRunAssignments)
256
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
257
+ .get();
258
+ if (!assignment) return null;
259
+ const task = db
260
+ .select({ runEpoch: schema.hostTasks.runEpoch })
261
+ .from(schema.hostTasks)
262
+ .where(eq(schema.hostTasks.taskId, taskId))
263
+ .get();
264
+ return {
265
+ highestRunEpoch: persistedRunEpoch(assignment.highestRunEpoch),
266
+ assignmentOpen: assignment.assignmentOpen,
267
+ runtimeEstablishedEpoch: hostTaskRunEpoch(task ?? {}),
268
+ };
269
+ }
270
+
271
+ /** Read all three durable assignment facts from one SQLite snapshot. */
272
+ export function readTaskRunEpochState(
273
+ taskId: string,
274
+ db: Db = getDb(),
275
+ ): TaskRunEpochState | null {
276
+ return db.transaction((tx) => taskRunEpochStateIn(tx, taskId));
277
+ }
278
+
279
+ /**
280
+ * A runtime may be reused only when both authorities name this exact run:
281
+ * the assignment is still open at its monotonic floor, and task-up recorded
282
+ * the runtime as fully established. Legacy reuse remains available only on a
283
+ * task that has never crossed the v1 floor.
284
+ */
285
+ export function canReuseTaskRuntimeForRun(
286
+ taskId: string,
287
+ runEpoch: number | undefined,
288
+ db: Db = getDb(),
289
+ ): boolean {
290
+ if (
291
+ runEpoch !== undefined &&
292
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
293
+ ) {
294
+ throw new Error("task run epoch must be a positive safe integer");
295
+ }
296
+ return db.transaction((tx) => {
297
+ const state = taskRunEpochStateIn(tx, taskId);
298
+ const task = tx
299
+ .select({
300
+ runEpoch: schema.hostTasks.runEpoch,
301
+ statusMirror: schema.hostTasks.statusMirror,
302
+ })
303
+ .from(schema.hostTasks)
304
+ .where(eq(schema.hostTasks.taskId, taskId))
305
+ .get();
306
+ if (runEpoch === undefined) {
307
+ return (
308
+ state === null &&
309
+ task !== undefined &&
310
+ hostTaskRunEpoch(task) === 0 &&
311
+ task.statusMirror === "running"
312
+ );
313
+ }
314
+ return (
315
+ state !== null &&
316
+ state.assignmentOpen &&
317
+ state.highestRunEpoch === runEpoch &&
318
+ state.runtimeEstablishedEpoch === runEpoch &&
319
+ task?.statusMirror === "running"
320
+ );
197
321
  });
198
322
  }
199
323
 
324
+ /**
325
+ * Persist the assignment fence as soon as a v1 task-up reaches the runner.
326
+ * The floor is deliberately independent of the removable runtime row. A
327
+ * same-epoch retry is admitted only while the assignment remains open; a
328
+ * higher epoch atomically reopens it, and nothing can lower it.
329
+ */
330
+ export function admitTaskRunEpoch(
331
+ taskId: string,
332
+ runEpoch: number | undefined,
333
+ db: Db = getDb(),
334
+ ): TaskRunEpochAdmission {
335
+ if (
336
+ runEpoch !== undefined &&
337
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
338
+ ) {
339
+ throw new Error("task run epoch must be a positive safe integer");
340
+ }
341
+ return db.transaction((tx) => {
342
+ const existingTask = tx
343
+ .select({ taskId: schema.hostTasks.taskId })
344
+ .from(schema.hostTasks)
345
+ .where(eq(schema.hostTasks.taskId, taskId))
346
+ .get();
347
+ const assignment = tx
348
+ .select()
349
+ .from(schema.hostTaskRunAssignments)
350
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
351
+ .get();
352
+ if (runEpoch === undefined) {
353
+ if (assignment !== undefined) {
354
+ throw new Error(
355
+ `legacy task-up is refused after run ${String(assignment.highestRunEpoch)} was admitted`,
356
+ );
357
+ }
358
+ return { kind: "legacy" } as const;
359
+ }
360
+ if (assignment === undefined) {
361
+ tx.insert(schema.hostTaskRunAssignments)
362
+ .values({
363
+ taskId,
364
+ highestRunEpoch: runEpoch,
365
+ assignmentOpen: true,
366
+ updatedAt: Date.now(),
367
+ })
368
+ .run();
369
+ return {
370
+ kind: "accepted",
371
+ relation: "advanced",
372
+ previousRunEpoch: 0,
373
+ hadTask: existingTask !== undefined,
374
+ } as const;
375
+ }
376
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
377
+ if (runEpoch < currentRunEpoch) {
378
+ return { kind: "stale", currentRunEpoch, reason: "older" } as const;
379
+ }
380
+ if (runEpoch === currentRunEpoch) {
381
+ if (!assignment.assignmentOpen) {
382
+ return { kind: "stale", currentRunEpoch, reason: "closed" } as const;
383
+ }
384
+ return {
385
+ kind: "accepted",
386
+ relation: "retry",
387
+ previousRunEpoch: currentRunEpoch,
388
+ hadTask: existingTask !== undefined,
389
+ } as const;
390
+ }
391
+ tx.update(schema.hostTaskRunAssignments)
392
+ .set({
393
+ highestRunEpoch: runEpoch,
394
+ assignmentOpen: true,
395
+ updatedAt: Date.now(),
396
+ })
397
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
398
+ .run();
399
+ return {
400
+ kind: "accepted",
401
+ relation: "advanced",
402
+ previousRunEpoch: currentRunEpoch,
403
+ hadTask: existingTask !== undefined,
404
+ } as const;
405
+ });
406
+ }
407
+
408
+ /**
409
+ * Close one exact assignment. A delayed Stop for an older run is harmless.
410
+ * The established-runtime epoch remains available for exact cleanup and
411
+ * forensics; the independent closed bit is what makes reuse impossible.
412
+ */
413
+ export function closeTaskRunAssignment(
414
+ taskId: string,
415
+ runEpoch: number,
416
+ db: Db = getDb(),
417
+ ): CloseTaskRunAssignmentResult {
418
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
419
+ throw new Error("task run epoch must be a positive safe integer");
420
+ }
421
+ return db.transaction((tx) => {
422
+ const assignment = tx
423
+ .select()
424
+ .from(schema.hostTaskRunAssignments)
425
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
426
+ .get();
427
+ if (!assignment) return { kind: "missing" } as const;
428
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
429
+ if (currentRunEpoch !== runEpoch) {
430
+ return { kind: "mismatch", currentRunEpoch } as const;
431
+ }
432
+ if (!assignment.assignmentOpen) {
433
+ return { kind: "already_closed", runEpoch } as const;
434
+ }
435
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
436
+ return { kind: "closed", runEpoch } as const;
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Close the exact assignment named by Stop. While the caller holds the task
442
+ * lifecycle slot, this may also adopt a pre-v1 runtime whose durable task row
443
+ * still has epoch zero. Persisting that adopted epoch closed prevents a
444
+ * delayed same-epoch task-up from reviving the runtime after Stop.
445
+ */
446
+ export function closeTaskRunAssignmentForStop(
447
+ taskId: string,
448
+ runEpoch: number,
449
+ db: Db = getDb(),
450
+ ): CloseTaskRunAssignmentResult {
451
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
452
+ throw new Error("task run epoch must be a positive safe integer");
453
+ }
454
+ return db.transaction((tx) => {
455
+ const assignment = tx
456
+ .select()
457
+ .from(schema.hostTaskRunAssignments)
458
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
459
+ .get();
460
+ if (assignment) {
461
+ const currentRunEpoch = persistedRunEpoch(assignment.highestRunEpoch);
462
+ if (currentRunEpoch !== runEpoch) {
463
+ return { kind: "mismatch", currentRunEpoch } as const;
464
+ }
465
+ if (!assignment.assignmentOpen) {
466
+ return { kind: "already_closed", runEpoch } as const;
467
+ }
468
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
469
+ return { kind: "closed", runEpoch } as const;
470
+ }
471
+
472
+ const task = tx
473
+ .select({ runEpoch: schema.hostTasks.runEpoch })
474
+ .from(schema.hostTasks)
475
+ .where(eq(schema.hostTasks.taskId, taskId))
476
+ .get();
477
+ if (task === undefined || hostTaskRunEpoch(task) !== 0) {
478
+ return { kind: "missing" } as const;
479
+ }
480
+
481
+ const now = Date.now();
482
+ tx.insert(schema.hostTaskRunAssignments)
483
+ .values({
484
+ taskId,
485
+ highestRunEpoch: runEpoch,
486
+ assignmentOpen: false,
487
+ createdAt: now,
488
+ updatedAt: now,
489
+ })
490
+ .run();
491
+ return { kind: "closed", runEpoch } as const;
492
+ });
493
+ }
494
+
495
+ /**
496
+ * Close and identify the assignment whose runtime was just proven stopped.
497
+ * The caller MUST hold the task lifecycle slot: that is what makes "current"
498
+ * the exact run associated with the physical stop, rather than whichever run
499
+ * happens to be current when a later status publisher wakes up.
500
+ */
501
+ export function closeCurrentTaskRunAssignmentForStoppedRuntime(
502
+ taskId: string,
503
+ db: Db = getDb(),
504
+ ): StoppedTaskRunAssignment {
505
+ return db.transaction((tx) => {
506
+ const assignment = tx
507
+ .select()
508
+ .from(schema.hostTaskRunAssignments)
509
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
510
+ .get();
511
+ if (!assignment) return { kind: "legacy" } as const;
512
+ const runEpoch = persistedRunEpoch(assignment.highestRunEpoch);
513
+ if (assignment.assignmentOpen) {
514
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
515
+ }
516
+ return { kind: "closed", runEpoch } as const;
517
+ });
518
+ }
519
+
520
+ function closeCurrentTaskRunAssignmentIn(
521
+ db: DbExecutor,
522
+ taskId: string,
523
+ ): void {
524
+ const now = Date.now();
525
+ db.update(schema.hostTaskRunAssignments)
526
+ .set({ assignmentOpen: false, updatedAt: now })
527
+ .where(eq(schema.hostTaskRunAssignments.taskId, taskId))
528
+ .run();
529
+ }
530
+
531
+ /** Record successful task-up only for the exact still-open assignment. */
532
+ export function recordTaskRuntimeEstablished(
533
+ taskId: string,
534
+ runEpoch: number,
535
+ db: Db = getDb(),
536
+ ): void {
537
+ if (!Number.isSafeInteger(runEpoch) || runEpoch < 1) {
538
+ throw new Error("task run epoch must be a positive safe integer");
539
+ }
540
+ db.transaction((tx) => {
541
+ const state = taskRunEpochStateIn(tx, taskId);
542
+ if (
543
+ state === null ||
544
+ !state.assignmentOpen ||
545
+ state.highestRunEpoch !== runEpoch
546
+ ) {
547
+ throw new Error("task runtime does not match the open assignment");
548
+ }
549
+ const task = tx
550
+ .select({ statusMirror: schema.hostTasks.statusMirror })
551
+ .from(schema.hostTasks)
552
+ .where(eq(schema.hostTasks.taskId, taskId))
553
+ .get();
554
+ if (task?.statusMirror !== "running") {
555
+ throw new Error("task runtime has not completed task-up");
556
+ }
557
+ tx.update(schema.hostTasks)
558
+ .set({ runEpoch, updatedAt: Date.now() })
559
+ .where(eq(schema.hostTasks.taskId, taskId))
560
+ .run();
561
+ });
562
+ }
563
+
564
+ export function recordTaskStarting(
565
+ taskId: string,
566
+ runEpoch?: number,
567
+ db: Db = getDb(),
568
+ ): void {
569
+ if (
570
+ runEpoch !== undefined &&
571
+ (!Number.isSafeInteger(runEpoch) || runEpoch < 1)
572
+ ) {
573
+ throw new Error("task run epoch must be a positive safe integer");
574
+ }
575
+ db.transaction((tx) => {
576
+ const assignment = taskRunEpochStateIn(tx, taskId);
577
+ if (runEpoch === undefined) {
578
+ if (assignment !== null) {
579
+ throw new Error("legacy task start is refused after v1 admission");
580
+ }
581
+ } else if (
582
+ assignment === null ||
583
+ !assignment.assignmentOpen ||
584
+ assignment.highestRunEpoch !== runEpoch
585
+ ) {
586
+ throw new Error("task run epoch does not match the open assignment");
587
+ }
588
+ upsertHostTaskIn(tx, taskId, {
589
+ statusMirror: "starting",
590
+ lockedAt: Date.now(),
591
+ startedAt: Date.now(),
592
+ endedAt: null,
593
+ // A starting row is not proof that provisioning completed. The exact
594
+ // epoch is installed only by recordTaskRuntimeEstablished.
595
+ ...(runEpoch === undefined ? {} : { runEpoch: 0 }),
596
+ });
597
+ });
598
+ }
599
+
600
+ /** The successfully established runtime epoch; zero includes legacy rows and
601
+ * v1 rows still provisioning. Anything malformed fails closed. */
602
+ export function hostTaskRunEpoch(task: {
603
+ runEpoch?: number | null;
604
+ }): number {
605
+ const value = task.runEpoch ?? 0;
606
+ if (!Number.isSafeInteger(value) || value < 0) {
607
+ throw new Error("persisted task run epoch is invalid");
608
+ }
609
+ return value;
610
+ }
611
+
200
612
  /** Persist the provider-owned crash-recovery identity before provisioning may
201
613
  * create worktrees, containers, networks, or volumes. Status remains
202
614
  * `starting`; the enriched locator is written by `recordTaskUpResult`. */
@@ -212,9 +624,13 @@ export function recordTaskEnvironmentPrepared(
212
624
  });
213
625
  }
214
626
 
215
- export function recordTaskDown(taskId: string, status: string | undefined): void {
216
- const db = getDb();
627
+ export function recordTaskDown(
628
+ taskId: string,
629
+ status: string | undefined,
630
+ db: Db = getDb(),
631
+ ): void {
217
632
  db.transaction((tx) => {
633
+ closeCurrentTaskRunAssignmentIn(tx, taskId);
218
634
  upsertHostTaskIn(tx, taskId, {
219
635
  codeServerPort: null,
220
636
  previewPorts: "[]",