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).
This commit is contained in:
tinkle-community
2026-07-05 23:36:53 +09:00
parent a744328313
commit 9b70455e7f
3 changed files with 45 additions and 3 deletions

View File

@@ -731,7 +731,10 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
"trader_id": traderID, "trader_id": traderID,
"trader_name": req.Name, "trader_name": req.Name,
"ai_model": req.AIModelID, "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 { if existingTrader != nil {
status := existingTrader.GetStatus() status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning { 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 return
} }
// Trader exists but is stopped - remove from memory to reload fresh config // Trader exists but is stopped - remove from memory to reload fresh config

View File

@@ -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 () => { it('surfaces the server-side preflight result when start is rejected', async () => {
mocks.runLaunchPreflight.mockResolvedValue(readyPreflight()) mocks.runLaunchPreflight.mockResolvedValue(readyPreflight())
mocks.api.startTrader.mockRejectedValue( mocks.api.startTrader.mockRejectedValue(

View File

@@ -101,7 +101,16 @@ export async function launchAutopilot(
: await api.createTrader(traderRequest) : await api.createTrader(traderRequest)
if (!autopilot.is_running) { 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 { return {