@wopr-network/platform-core 1.37.0 → 1.39.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.
@@ -530,38 +530,38 @@ describe("ContainerUpdater", () => {
530
530
  });
531
531
 
532
532
  it("performs update by pulling, stopping, and recreating", async () => {
533
- // The fleet.update method will be called to recreate the container
534
- // We need to make sure listContainers returns properly
533
+ // Trace through doUpdateBot("bot-1"):
534
+ // 1. getContainerDigest: listContainers + getContainer + inspect + getImage
535
+ // 2. wasRunning check: listContainers + getContainer + inspect
536
+ // 3. docker.pull + followProgress
537
+ // 4. fleet.update(botId, { image }):
538
+ // a. findContainer: listContainers + getContainer
539
+ // b. inspect (wasRunning check)
540
+ // c. pullImage (updates.image set): docker.pull + followProgress
541
+ // d. stop + remove
542
+ // e. createContainer
543
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
544
+ // g. start
545
+ // 5. waitForHealthy: listContainers + getContainer + inspect
546
+ // 6. getContainerDigest (final): listContainers + getContainer + inspect + getImage
535
547
  docker.listContainers
536
- .mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest during updateBot
537
- .mockResolvedValueOnce([{ Id: "container-123" }]) // step 2: find container to stop
538
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer
539
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer after recreate
540
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start -> findContainer
541
- .mockResolvedValueOnce([{ Id: "container-123" }]); // waitForHealthy
548
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
549
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
550
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
551
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
552
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
553
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
542
554
 
543
555
  docker.getContainer.mockReturnValue(container);
544
556
 
545
- // Sequence inspect calls: first two return "running" (for wasRunning checks in
546
- // doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
547
- // assertValidState guard allows the start, subsequent calls use the default (running+healthy).
548
557
  const runningInspect = {
549
558
  Id: "container-123",
550
559
  Image: "sha256:abc123",
551
560
  Created: "2026-01-01T00:00:00Z",
552
561
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
553
562
  };
554
- const stoppedInspect = {
555
- Id: "container-123",
556
- Image: "sha256:abc123",
557
- Created: "2026-01-01T00:00:00Z",
558
- State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
559
- };
560
- container.inspect
561
- .mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
562
- .mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
563
- .mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
564
- .mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
563
+ // All inspect calls return running+healthy (getContainerDigest, wasRunning checks, waitForHealthy, final digest)
564
+ container.inspect.mockResolvedValue(runningInspect);
565
565
 
566
566
  const result = await updater.updateBot("bot-1");
567
567
  expect(result.success).toBe(true);
@@ -576,36 +576,27 @@ describe("ContainerUpdater", () => {
576
576
  resolvePull = cb;
577
577
  });
578
578
 
579
+ // After the first followProgress (which hangs), subsequent calls use default (instant resolve)
580
+ // Trace: getContainerDigest, wasRunning, fleet.update->findContainer,
581
+ // fleet.update->findContainer after recreate, waitForHealthy, final getContainerDigest
579
582
  docker.listContainers
580
- .mockResolvedValueOnce([{ Id: "container-123" }]) // first update: getContainerDigest
581
- .mockResolvedValueOnce([{ Id: "container-123" }]) // first update: step 2
582
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update
583
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start
584
- .mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
585
- .mockResolvedValueOnce([{ Id: "container-123" }]); // get new digest
583
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
584
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
585
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
586
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
587
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
588
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
586
589
 
587
590
  docker.getContainer.mockReturnValue(container);
588
591
 
589
- // Sequence inspect calls: first two return "running" (for wasRunning checks in
590
- // doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
591
- // assertValidState guard allows the start.
592
592
  const runningInspect = {
593
593
  Id: "container-123",
594
594
  Image: "sha256:abc123",
595
595
  Created: "2026-01-01T00:00:00Z",
596
596
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
597
597
  };
598
- const stoppedInspect = {
599
- Id: "container-123",
600
- Image: "sha256:abc123",
601
- Created: "2026-01-01T00:00:00Z",
602
- State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
603
- };
604
- container.inspect
605
- .mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
606
- .mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
607
- .mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
608
- .mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
598
+ // All inspect calls return running+healthy
599
+ container.inspect.mockResolvedValue(runningInspect);
609
600
 
610
601
  // Start first update (will block on pull)
611
602
  const first = updater.updateBot("bot-1");
@@ -631,6 +622,7 @@ describe("ContainerUpdater", () => {
631
622
  const unhealthyContainer = mockContainer({
632
623
  inspect: vi.fn().mockResolvedValue({
633
624
  Id: "container-123",
625
+ Name: "/wopr-test-bot",
634
626
  Image: "sha256:new999",
635
627
  Created: "2026-01-01T00:00:00Z",
636
628
  State: {
@@ -639,25 +631,48 @@ describe("ContainerUpdater", () => {
639
631
  StartedAt: "2026-01-01T00:00:00Z",
640
632
  Health: { Status: "unhealthy" },
641
633
  },
634
+ NetworkSettings: { Ports: {} },
642
635
  }),
643
636
  });
644
637
 
645
- // Make the new container unhealthy so rollback happens
638
+ // Trace through doUpdateBot("bot-1"):
639
+ // 1. getContainerDigest: listContainers + getContainer + inspect + getImage
640
+ // 2. wasRunning check: listContainers + getContainer + inspect
641
+ // 3. docker.pull + followProgress
642
+ // 4. fleet.update(botId, { image }):
643
+ // a. findContainer: listContainers + getContainer
644
+ // b. inspect (wasRunning check in update)
645
+ // c. pullImage (updates.image is set): docker.pull + followProgress
646
+ // d. stop + remove
647
+ // e. createContainer
648
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
649
+ // g. start
650
+ // 5. waitForHealthy: listContainers + getContainer + inspect
651
+ // 6. unhealthy -> rollback via fleet.update:
652
+ // a. findContainer: listContainers + getContainer
653
+ // b. inspect (wasRunning)
654
+ // c. pullImage: docker.pull + followProgress
655
+ // d. stop + remove
656
+ // e. createContainer
657
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
658
+ // g. start
646
659
  docker.listContainers
647
- .mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
648
- .mockResolvedValueOnce([{ Id: "container-123" }]) // step 2: find container to stop
649
- .mockResolvedValueOnce([]) // fleet.update -> findContainer (no existing)
650
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start
651
- .mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
652
- .mockResolvedValueOnce([]) // rollback fleet.update -> findContainer
653
- .mockResolvedValueOnce([{ Id: "container-123" }]); // rollback fleet.start
660
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
661
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
662
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
663
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
664
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
665
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 6a: rollback fleet.update -> findContainer
666
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6f: rollback fleet.update -> findContainer after recreate
654
667
 
655
668
  docker.getContainer
656
- .mockReturnValueOnce(container) // getContainerDigest inspect
657
- .mockReturnValueOnce(container) // step 2: stop + remove
658
- .mockReturnValueOnce(container) // fleet.start
659
- .mockReturnValueOnce(unhealthyContainer) // waitForHealthy inspect
660
- .mockReturnValueOnce(container); // rollback fleet.start
669
+ .mockReturnValueOnce(container) // 1: getContainerDigest
670
+ .mockReturnValueOnce(container) // 2: doUpdateBot wasRunning
671
+ .mockReturnValueOnce(container) // 4a: fleet.update -> findContainer (stop+remove this)
672
+ .mockReturnValueOnce(container) // 4f: fleet.update -> findContainer after recreate (start)
673
+ .mockReturnValueOnce(unhealthyContainer) // 5: waitForHealthy -> unhealthy
674
+ .mockReturnValueOnce(container) // 6a: rollback fleet.update -> findContainer (stop+remove)
675
+ .mockReturnValueOnce(container); // 6f: rollback fleet.update -> findContainer after recreate (start)
661
676
 
662
677
  const result = await updater.updateBot("bot-1");
663
678
  expect(result.success).toBe(false);
@@ -3,6 +3,7 @@ export * from "./fleet-manager.js";
3
3
  export type { FleetNotificationListenerDeps } from "./fleet-notification-listener.js";
4
4
  export { initFleetNotificationListener } from "./fleet-notification-listener.js";
5
5
  export * from "./init-fleet-updater.js";
6
+ export * from "./instance.js";
6
7
  export * from "./repository-types.js";
7
8
  export * from "./rollout-orchestrator.js";
8
9
  export * from "./rollout-strategy.js";
@@ -0,0 +1,171 @@
1
+ import type Docker from "dockerode";
2
+ import { logger } from "../config/logger.js";
3
+ import type { ProxyManagerInterface } from "../proxy/types.js";
4
+ import type { IBotInstanceRepository } from "./bot-instance-repository.js";
5
+ import type { BotEventType, FleetEventEmitter } from "./fleet-event-emitter.js";
6
+ import type { BotProfile } from "./types.js";
7
+
8
+ /**
9
+ * Instance — a runtime handle to a container.
10
+ *
11
+ * FleetManager is the factory: pull image, create container, return Instance.
12
+ * Instance owns its lifecycle: start, stop, remove, setupBilling, setupProxy.
13
+ *
14
+ * Ephemeral instances (e.g., holyshippers) skip billing and proxy setup.
15
+ * They bill per-token at the gateway layer, not per-instance.
16
+ */
17
+
18
+ export interface InstanceDeps {
19
+ docker: Docker;
20
+ profile: BotProfile;
21
+ containerId: string;
22
+ containerName: string;
23
+ url: string;
24
+ /** Optional — non-ephemeral instances use these for billing/proxy/events */
25
+ instanceRepo?: IBotInstanceRepository;
26
+ proxyManager?: ProxyManagerInterface;
27
+ eventEmitter?: FleetEventEmitter;
28
+ }
29
+
30
+ export class Instance {
31
+ readonly id: string;
32
+ readonly containerId: string;
33
+ readonly containerName: string;
34
+ readonly url: string;
35
+ readonly profile: BotProfile;
36
+
37
+ private readonly docker: Docker;
38
+ private readonly instanceRepo: IBotInstanceRepository | undefined;
39
+ private readonly proxyManager: ProxyManagerInterface | undefined;
40
+ private readonly eventEmitter: FleetEventEmitter | undefined;
41
+
42
+ constructor(deps: InstanceDeps) {
43
+ this.id = deps.profile.id;
44
+ this.containerId = deps.containerId;
45
+ this.containerName = deps.containerName;
46
+ this.url = deps.url;
47
+ this.profile = deps.profile;
48
+ this.docker = deps.docker;
49
+ this.instanceRepo = deps.instanceRepo;
50
+ this.proxyManager = deps.proxyManager;
51
+ this.eventEmitter = deps.eventEmitter;
52
+ }
53
+
54
+ /** Emit bot.created — call only from FleetManager.create(), not getInstance() */
55
+ emitCreated(): void {
56
+ this.emit("bot.created");
57
+ }
58
+
59
+ async start(): Promise<void> {
60
+ const container = this.docker.getContainer(this.containerId);
61
+ await container.start();
62
+ logger.info(`Instance started`, { id: this.id, containerName: this.containerName, url: this.url });
63
+ this.emit("bot.started");
64
+ }
65
+
66
+ async stop(): Promise<void> {
67
+ const container = this.docker.getContainer(this.containerId);
68
+ try {
69
+ await container.stop({ t: 10 });
70
+ } catch (err: unknown) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ if (!msg.includes("not running") && !msg.includes("already stopped")) {
73
+ throw err;
74
+ }
75
+ }
76
+ logger.info(`Instance stopped`, { id: this.id, containerName: this.containerName });
77
+ this.emit("bot.stopped");
78
+ }
79
+
80
+ async remove(): Promise<void> {
81
+ const container = this.docker.getContainer(this.containerId);
82
+ try {
83
+ await container.stop({ t: 5 }).catch(() => {});
84
+ await container.remove({ force: true });
85
+ } catch (err: unknown) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ if (!msg.includes("No such container")) {
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ if (this.proxyManager) {
93
+ try {
94
+ await this.proxyManager.removeRoute(this.id);
95
+ } catch (err) {
96
+ logger.warn("Proxy route cleanup failed (non-fatal)", { id: this.id, err });
97
+ }
98
+ }
99
+
100
+ logger.info(`Instance removed`, { id: this.id, containerName: this.containerName });
101
+ this.emit("bot.removed");
102
+ }
103
+
104
+ async status(): Promise<"running" | "stopped" | "gone"> {
105
+ try {
106
+ const container = this.docker.getContainer(this.containerId);
107
+ const info = await container.inspect();
108
+ return info.State.Running ? "running" : "stopped";
109
+ } catch {
110
+ return "gone";
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Register this instance in the billing system.
116
+ * Skip for ephemeral instances — they bill per-token, not per-instance.
117
+ */
118
+ async setupBilling(): Promise<void> {
119
+ if (this.profile.ephemeral) {
120
+ logger.info("Skipping billing setup (ephemeral)", { id: this.id });
121
+ return;
122
+ }
123
+ if (!this.instanceRepo) {
124
+ logger.warn("No instance repo — billing setup skipped", { id: this.id });
125
+ return;
126
+ }
127
+ await this.instanceRepo.register(this.id, this.profile.tenantId, this.profile.name);
128
+ logger.info("Billing registered", { id: this.id, tenantId: this.profile.tenantId });
129
+ }
130
+
131
+ /**
132
+ * Register a proxy route for tenant subdomain routing.
133
+ * Skip for ephemeral instances — they're accessed directly via Docker DNS.
134
+ */
135
+ async setupProxy(): Promise<void> {
136
+ if (this.profile.ephemeral) {
137
+ logger.info("Skipping proxy setup (ephemeral)", { id: this.id });
138
+ return;
139
+ }
140
+ if (!this.proxyManager) {
141
+ logger.warn("No proxy manager — proxy setup skipped", { id: this.id });
142
+ return;
143
+ }
144
+ try {
145
+ const subdomain = this.profile.name.toLowerCase().replace(/_/g, "-");
146
+ const envPort = this.profile.env?.PORT;
147
+ const upstreamPort = envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
148
+ await this.proxyManager.addRoute({
149
+ instanceId: this.id,
150
+ subdomain,
151
+ upstreamHost: this.containerName,
152
+ upstreamPort,
153
+ healthy: true,
154
+ });
155
+ logger.info("Proxy route registered", { id: this.id, subdomain });
156
+ } catch (err) {
157
+ logger.warn("Proxy route registration failed (non-fatal)", { id: this.id, err });
158
+ }
159
+ }
160
+
161
+ private emit(type: BotEventType): void {
162
+ if (this.eventEmitter) {
163
+ this.eventEmitter.emit({
164
+ type,
165
+ botId: this.id,
166
+ tenantId: this.profile.tenantId,
167
+ timestamp: new Date().toISOString(),
168
+ });
169
+ }
170
+ }
171
+ }
@@ -74,6 +74,10 @@ export const botProfileSchema = z.object({
74
74
  discovery: discoveryConfigSchema.optional(),
75
75
  /** Node this bot was placed on at creation time (optional, for future multi-node routing). */
76
76
  nodeId: z.string().uuid().optional(),
77
+ /** Docker network to attach the container to (bypasses NetworkPolicy). */
78
+ network: z.string().min(1).optional(),
79
+ /** When true, disables ReadonlyRootfs and CapDrop for containers that need write access (e.g., ephemeral workers). */
80
+ ephemeral: z.boolean().optional(),
77
81
  });
78
82
 
79
83
  export type BotProfile = z.infer<typeof botProfileSchema>;
@@ -96,6 +100,10 @@ export const createBotSchema = z.object({
96
100
  discovery: discoveryConfigSchema.optional(),
97
101
  /** Node this bot was placed on at creation time (optional, for future multi-node routing). */
98
102
  nodeId: z.string().uuid().optional(),
103
+ /** Docker network to attach the container to. */
104
+ network: z.string().min(1).optional(),
105
+ /** When true, disables ReadonlyRootfs and CapDrop for ephemeral workers. */
106
+ ephemeral: z.boolean().optional(),
99
107
  });
100
108
 
101
109
  /** Schema for updating a bot via the API */
@@ -77,7 +77,6 @@ function mockDocker(container: ReturnType<typeof mockContainer> | null = null) {
77
77
  function mockFleet() {
78
78
  return {
79
79
  update: vi.fn().mockResolvedValue(undefined),
80
- start: vi.fn().mockResolvedValue(undefined),
81
80
  } as unknown as FleetManager;
82
81
  }
83
82
 
@@ -154,7 +153,6 @@ describe("ContainerUpdater", () => {
154
153
  // Verify the update pipeline was called
155
154
  expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable");
156
155
  expect(fleet.update).toHaveBeenCalledWith("bot-1", { image: "ghcr.io/wopr-network/wopr:stable" });
157
- expect(fleet.start).toHaveBeenCalledWith("bot-1");
158
156
  });
159
157
 
160
158
  it("skips start and health check when container was not running", async () => {
@@ -182,7 +180,6 @@ describe("ContainerUpdater", () => {
182
180
 
183
181
  expect(result.success).toBe(true);
184
182
  expect(fleet.update).toHaveBeenCalled();
185
- expect(fleet.start).not.toHaveBeenCalled();
186
183
  });
187
184
 
188
185
  it("considers container healthy when no HEALTHCHECK is configured", async () => {
@@ -259,8 +256,6 @@ describe("ContainerUpdater", () => {
259
256
  expect(fleet.update).toHaveBeenLastCalledWith("bot-1", {
260
257
  image: "ghcr.io/wopr-network/wopr@sha256:abc123",
261
258
  });
262
- // Rollback starts the container since it was running
263
- expect(fleet.start).toHaveBeenCalledTimes(2);
264
259
  });
265
260
 
266
261
  it("rolls back when health check times out (stays in starting)", async () => {
@@ -301,8 +296,8 @@ describe("ContainerUpdater", () => {
301
296
 
302
297
  // --- Rollback on startup failure ---
303
298
 
304
- describe("rollback on startup failure", () => {
305
- it("rolls back when fleet.start throws", async () => {
299
+ describe("rollback on update failure", () => {
300
+ it("rolls back when fleet.update throws (start embedded in update)", async () => {
306
301
  // getContainerDigest
307
302
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
308
303
  docker.getContainer.mockReturnValueOnce(container);
@@ -310,8 +305,10 @@ describe("ContainerUpdater", () => {
310
305
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
311
306
  docker.getContainer.mockReturnValueOnce(container);
312
307
 
313
- // fleet.start fails after fleet.update succeeds
314
- (fleet.start as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Container start failed"));
308
+ // fleet.update fails (which now includes starting the container)
309
+ (fleet.update as ReturnType<typeof vi.fn>)
310
+ .mockRejectedValueOnce(new Error("Container start failed"))
311
+ .mockResolvedValueOnce(undefined); // rollback update succeeds
315
312
 
316
313
  const result = await updater.updateBot("bot-1");
317
314
 
@@ -331,12 +328,10 @@ describe("ContainerUpdater", () => {
331
328
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
332
329
  docker.getContainer.mockReturnValueOnce(container);
333
330
 
334
- // fleet.start fails
335
- (fleet.start as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Container start failed"));
336
- // fleet.update (rollback) also fails
331
+ // fleet.update fails, then rollback fleet.update also fails
337
332
  (fleet.update as ReturnType<typeof vi.fn>)
338
- .mockResolvedValueOnce(undefined) // initial update succeeds
339
- .mockRejectedValueOnce(new Error("Rollback recreate failed")); // rollback fails
333
+ .mockRejectedValueOnce(new Error("Container start failed"))
334
+ .mockRejectedValueOnce(new Error("Rollback recreate failed"));
340
335
 
341
336
  const result = await updater.updateBot("bot-1");
342
337
 
@@ -666,8 +661,6 @@ describe("ContainerUpdater", () => {
666
661
 
667
662
  expect(result.success).toBe(false);
668
663
  expect(result.rolledBack).toBe(true);
669
- // fleet.start should NOT be called at all — container was stopped
670
- expect(fleet.start).not.toHaveBeenCalled();
671
664
  });
672
665
  });
673
666
 
@@ -687,8 +680,6 @@ describe("ContainerUpdater", () => {
687
680
  const result = await updater.updateBot("bot-1");
688
681
 
689
682
  expect(result.success).toBe(true);
690
- // Since wasRunning defaults to false on error, start and health check are skipped
691
- expect(fleet.start).not.toHaveBeenCalled();
692
683
  });
693
684
  });
694
685
  });
@@ -122,17 +122,12 @@ export class ContainerUpdater {
122
122
  });
123
123
  });
124
124
 
125
- // Step 2: Delegate stop -> remove -> recreate to FleetManager.update().
126
- // fleet.update() already implements: stop old -> remove old -> create new,
125
+ // Step 2: Delegate stop -> remove -> recreate -> start to FleetManager.update().
126
+ // fleet.update() already implements: stop old -> remove old -> create new -> start if was running,
127
127
  // with profile rollback if container creation fails.
128
128
  await this.fleet.update(botId, { image: profile.image });
129
129
 
130
- // Step 3: Start the new container only if the old one was running
131
- if (wasRunning) {
132
- await this.fleet.start(botId);
133
- }
134
-
135
- // Step 4: Verify health (only meaningful if container is running)
130
+ // Step 3: Verify health (only meaningful if container is running)
136
131
  if (wasRunning) {
137
132
  const healthy = await this.waitForHealthy(botId);
138
133
 
@@ -229,7 +224,7 @@ export class ContainerUpdater {
229
224
  botId: string,
230
225
  previousImage: string,
231
226
  previousDigest: string | null,
232
- wasRunning: boolean,
227
+ _wasRunning: boolean,
233
228
  ): Promise<UpdateResult> {
234
229
  // Use digest-pinned image reference when available to prevent rollback
235
230
  // from pulling a newer image that was pushed between the update and rollback.
@@ -237,15 +232,9 @@ export class ContainerUpdater {
237
232
  logger.info(`Rolling back bot ${botId} to ${rollbackImage}`);
238
233
 
239
234
  try {
235
+ // fleet.update() handles stop -> remove -> recreate -> start-if-was-running internally
240
236
  await this.fleet.update(botId, { image: rollbackImage });
241
237
 
242
- // Only start on rollback if the container was running before the update
243
- if (wasRunning) {
244
- await this.fleet.start(botId).catch((err) => {
245
- logger.warn(`Failed to start bot ${botId} during rollback (may already be running)`, { err });
246
- });
247
- }
248
-
249
238
  return {
250
239
  botId,
251
240
  success: false,