From 9b70455e7f8d991b0050450836f676700e14c76f Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 5 Jul 2026 23:36:53 +0900 Subject: [PATCH] fix: report accurate is_running after trader update to stop redundant start Live verification of the relaunch (update) branch surfaced this: the update response omitted is_running, so the launcher fired a redundant start that got 400 'Trader is already running', showing an error toast and skipping the dashboard navigation even though the relaunch succeeded. - PUT /traders/:id now reports is_running (a running trader is restarted with the new config, so it stays running). - The already-running rejection carries error_key trader.start.already_running and the shared launcher treats it as success (launch is idempotent). --- api/handler_trader.go | 10 ++++++-- web/src/lib/launch/launchAutopilot.test.ts | 27 ++++++++++++++++++++++ web/src/lib/launch/launchAutopilot.ts | 11 ++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/api/handler_trader.go b/api/handler_trader.go index 4afc7882..65ad3b67 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -731,7 +731,10 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { "trader_id": traderID, "trader_name": req.Name, "ai_model": req.AIModelID, - "message": "Trader updated successfully", + // A running trader is restarted with the new config (async above), so + // report it as running — callers must not fire a redundant start. + "is_running": wasRunning, + "message": "Trader updated successfully", }) } @@ -792,7 +795,10 @@ func (s *Server) handleStartTrader(c *gin.Context) { if existingTrader != nil { status := existingTrader.GetStatus() if isRunning, ok := status["is_running"].(bool); ok && isRunning { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"}) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Trader is already running", + "error_key": "trader.start.already_running", + }) return } // Trader exists but is stopped - remove from memory to reload fresh config diff --git a/web/src/lib/launch/launchAutopilot.test.ts b/web/src/lib/launch/launchAutopilot.test.ts index 40773dec..99670e72 100644 --- a/web/src/lib/launch/launchAutopilot.test.ts +++ b/web/src/lib/launch/launchAutopilot.test.ts @@ -129,6 +129,33 @@ describe('launchAutopilot', () => { ) }) + it('treats a racing already-running rejection as success', async () => { + mocks.runLaunchPreflight.mockResolvedValue(readyPreflight()) + mocks.api.getTraders.mockResolvedValue([ + { trader_id: 't-1', trader_name: 'NOFX Autopilot', is_running: false }, + ]) + mocks.api.updateTrader.mockResolvedValue({ + trader_id: 't-1', + is_running: false, + }) + mocks.api.startTrader.mockRejectedValue( + new ApiError( + 'Trader is already running', + 'trader.start.already_running', + undefined, + 400 + ) + ) + + const outcome = await launchAutopilot({ + ensureStrategy: vi.fn().mockResolvedValue('strat-1'), + }) + + expect(outcome).toEqual( + expect.objectContaining({ ok: true, traderId: 't-1' }) + ) + }) + it('surfaces the server-side preflight result when start is rejected', async () => { mocks.runLaunchPreflight.mockResolvedValue(readyPreflight()) mocks.api.startTrader.mockRejectedValue( diff --git a/web/src/lib/launch/launchAutopilot.ts b/web/src/lib/launch/launchAutopilot.ts index 2f522c29..c49f8974 100644 --- a/web/src/lib/launch/launchAutopilot.ts +++ b/web/src/lib/launch/launchAutopilot.ts @@ -101,7 +101,16 @@ export async function launchAutopilot( : await api.createTrader(traderRequest) if (!autopilot.is_running) { - await api.startTrader(autopilot.trader_id) + try { + await api.startTrader(autopilot.trader_id) + } catch (err) { + // Launch is idempotent: the update path restarts a running trader + // asynchronously, so a racing "already running" rejection is success. + const alreadyRunning = + err instanceof ApiError && + err.errorKey === 'trader.start.already_running' + if (!alreadyRunning) throw err + } } return {