fix(path-b): dev_server tool dispatch + state-machine transition

Two bugs caught by the live end-to-end test:

1. Tool dispatch mismatch.
   Gemini tool name "dev_server_list" runs through executeMcpTool's
   _-to-. converter (toolName.replace(/_/g, '.')) and arrives as
   "dev.server.list". The dispatcher only had cases for "dev_server.list",
   so all four dev_server.* tools 404'd as "Unknown tool".

   The AI gracefully fell back to shell.exec + nohup, so Express still
   ran — but the dev_servers table never got populated and the preview
   URL machinery was bypassed. Add aliases for both underscore and
   fully-dotted forms.

2. State machine never transitioned.
   ensureDevContainer wrote state='provisioning'; nothing ever flipped
   it to 'running'. As a result the idle-sweep (which filters by
   state='running') never saw a candidate to suspend.

   Use the first successful exec as the authoritative liveness signal:
   touchActivity() now also flips provisioning|suspended → running and
   clears suspended_at.

Surfaced by the live trace: AI tried dev_server_list, got 404, fell
back to manually grepping the process table.

Made-with: Cursor
This commit is contained in:
2026-04-28 13:57:44 -07:00
parent 5d2a8c5734
commit fb31d111ef
2 changed files with 17 additions and 1 deletions

View File

@@ -369,13 +369,21 @@ export async function POST(request: Request) {
case 'fs.grep':
return await toolFsGrep(principal, params);
// The Gemini tool-name "dev_server_list" maps to dotted action
// "dev.server.list" via executeMcpTool's underscore→dot replace.
// We accept BOTH the original underscore form and the converted
// dotted form so external MCP clients aren't surprised either way.
case 'dev_server.start':
case 'dev.server.start':
return await toolDevServerStart(principal, params);
case 'dev_server.stop':
case 'dev.server.stop':
return await toolDevServerStop(principal, params);
case 'dev_server.list':
case 'dev.server.list':
return await toolDevServerList(principal, params);
case 'dev_server.logs':
case 'dev.server.logs':
return await toolDevServerLogs(principal, params);
case 'ship':
return await toolShip(principal, params);

View File

@@ -275,8 +275,16 @@ export async function resumeDevContainer(projectId: string): Promise<void> {
}
async function touchActivity(projectId: string): Promise<void> {
// Also flips state 'provisioning' → 'running' on first successful exec.
// We can't rely on Coolify's deploy webhook alone (it fires before the
// container's actually accepting docker exec), so the first exec that
// returns is our authoritative liveness signal.
await query(
`UPDATE fs_project_dev_containers SET last_active_at = now() WHERE project_id = $1`,
`UPDATE fs_project_dev_containers
SET last_active_at = now(),
state = CASE WHEN state IN ('provisioning','suspended') THEN 'running' ELSE state END,
suspended_at = NULL
WHERE project_id = $1`,
[projectId],
);
}