diff --git a/src/server/priv/static/finalizeBranch.sh b/src/server/priv/static/finalizeBranch.sh index 914831a..6de5b7a 100755 --- a/src/server/priv/static/finalizeBranch.sh +++ b/src/server/priv/static/finalizeBranch.sh @@ -26,6 +26,11 @@ git fetch --quiet origin "$DEFAULT_BRANCH" 2>/dev/null || true CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')" HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo '')" +TITLE="" +if [ -f /fbi-state/title ]; then + TITLE="$(tr -d '\n\r' < /fbi-state/title | head -c 200)" +fi + # Push exit is sourced from the last origin-push log (written by the post- # commit hook). No-remote projects write no log; treat absent log as success. PUSH_EXIT=0 @@ -35,5 +40,5 @@ if [ -f /tmp/last-origin-push.log ]; then fi fi -printf '{"exit_code":%d,"push_exit":%d,"head_sha":"%s","branch":"%s","session_id":"%s"}\n' \ - "$CLAUDE_EXIT" "$PUSH_EXIT" "$HEAD_SHA" "$CURRENT_BRANCH" "${SESSION_ID:-}" > "$RESULT_PATH" +printf '{"exit_code":%d,"push_exit":%d,"head_sha":"%s","branch":"%s","session_id":"%s","title":"%s"}\n' \ + "$CLAUDE_EXIT" "$PUSH_EXIT" "$HEAD_SHA" "$CURRENT_BRANCH" "${SESSION_ID:-}" "$TITLE" > "$RESULT_PATH" diff --git a/src/server/src/fbi/db/runs.gleam b/src/server/src/fbi/db/runs.gleam index 1895c17..75818d3 100644 --- a/src/server/src/fbi/db/runs.gleam +++ b/src/server/src/fbi/db/runs.gleam @@ -362,6 +362,7 @@ pub fn insert_run( db: sqlight.Connection, project_id: Int, prompt: String, + branch: option.Option(String), model: Option(String), effort: Option(String), subagent_model: Option(String), @@ -369,17 +370,15 @@ pub fn insert_run( mock_scenario: Option(String), now: Int, ) -> Result(Run, DbError) { - let branch_name = "claude/run-" <> int.to_string(now) let log_path = "/var/log/fbi/runs/" <> int.to_string(now) <> ".log" - connection.query_one( + use run <- result.try(connection.query_one( "INSERT INTO runs (project_id, prompt, branch_name, state, log_path, created_at, state_entered_at, model, effort, subagent_model, mock, mock_scenario) - VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING " + VALUES (?, ?, '', 'queued', ?, ?, ?, ?, ?, ?, ?, ?) RETURNING " <> columns(), db, [ sqlight.int(project_id), sqlight.text(prompt), - sqlight.text(branch_name), sqlight.text(log_path), sqlight.int(now), sqlight.int(now), @@ -393,9 +392,32 @@ pub fn insert_run( nullable_opt(mock_scenario), ], decoder(), + )) + let branch_name = case branch { + option.Some(b) -> b + option.None -> "claude/run-" <> int.to_string(run.id) + } + connection.query_one( + "UPDATE runs SET branch_name = ? WHERE id = ? RETURNING " <> columns(), + db, + [sqlight.text(branch_name), sqlight.int(run.id)], + decoder(), ) } +pub fn branch_in_use( + db: sqlight.Connection, + branch: String, +) -> Result(Bool, DbError) { + connection.query_one( + "SELECT COUNT(*) FROM runs WHERE branch_name = ? AND state IN ('queued', 'running', 'waiting', 'awaiting_resume')", + db, + [sqlight.text(branch)], + decode.at([0], decode.int), + ) + |> result.map(fn(n) { n > 0 }) +} + pub fn insert_continue_run( db: sqlight.Connection, parent: Run, @@ -523,7 +545,9 @@ pub fn mark_finished( _ -> "failed" } connection.query_one( - "UPDATE runs SET state = ?, exit_code = ?, head_commit = ?, finished_at = ?, error = ?, + "UPDATE runs SET state = ?, exit_code = ?, branch_name = COALESCE(?, branch_name), + head_commit = ?, finished_at = ?, error = ?, + title = COALESCE(?, title), claude_session_id = COALESCE(?, claude_session_id) WHERE id = ? RETURNING " <> columns(), @@ -531,6 +555,10 @@ pub fn mark_finished( [ sqlight.text(state), sqlight.int(outcome.exit_code), + case outcome.branch_pushed { + option.None -> sqlight.null() + option.Some(b) -> sqlight.text(b) + }, case outcome.head_commit { option.None -> sqlight.null() option.Some(c) -> sqlight.text(c) @@ -540,6 +568,10 @@ pub fn mark_finished( option.None -> sqlight.null() option.Some(e) -> sqlight.text(e) }, + case outcome.title { + option.None -> sqlight.null() + option.Some(t) -> sqlight.text(t) + }, case outcome.claude_session_id { option.None -> sqlight.null() option.Some(s) -> sqlight.text(s) diff --git a/src/server/src/fbi/handlers/runs.gleam b/src/server/src/fbi/handlers/runs.gleam index 8625d7a..0d92e6c 100644 --- a/src/server/src/fbi/handlers/runs.gleam +++ b/src/server/src/fbi/handlers/runs.gleam @@ -2,6 +2,7 @@ import fbi/context.{type Context} import fbi/db/connection import fbi/db/projects import fbi/db/runs +import fbi/db/settings import fbi/json/run as run_json import fbi/run/reattach as run_reattach import fbi/run/registry as run_registry @@ -14,7 +15,7 @@ import gleam/http import gleam/int import gleam/json import gleam/list -import gleam/option.{None, Some} +import gleam/option.{type Option, None, Some} import wisp.{type Request, type Response} pub fn handle_list(req: Request, ctx: Context) -> Response { @@ -181,6 +182,10 @@ fn do_continue(req: Request, ctx: Context, run_id: Int) -> Response { wisp.internal_server_error() } Ok(#(actor_subject, bc)) -> { + let global_prompt = case settings.get(ctx.db) { + Ok(s) -> s.global_prompt + Error(_) -> "" + } run_worker.launch( run_worker.LaunchInput( run: new_run, @@ -189,6 +194,7 @@ fn do_continue(req: Request, ctx: Context, run_id: Int) -> Response { cols: 80, rows: 24, broadcaster: bc, + global_prompt: global_prompt, ), actor_subject, ) @@ -247,6 +253,11 @@ fn create(req: Request, ctx: Context, project_id: Int) -> Response { use body <- wisp.require_json(req) let decoder = { use prompt <- decode.field("prompt", decode.string) + use branch <- decode.optional_field( + "branch", + None, + decode.optional(decode.string), + ) use model <- decode.optional_field( "model", None, @@ -268,70 +279,146 @@ fn create(req: Request, ctx: Context, project_id: Int) -> Response { None, decode.optional(decode.string), ) - decode.success(#(prompt, model, effort, subagent_model, mock, mock_scenario)) + use force <- decode.optional_field("force", False, decode.bool) + decode.success(#( + prompt, + branch, + model, + effort, + subagent_model, + mock, + mock_scenario, + force, + )) } case decode.run(body, decoder) { Error(_) -> wisp.bad_request("Invalid request body") - Ok(#(prompt, model, effort, subagent_model, mock, mock_scenario)) -> + Ok(#( + prompt, + branch, + model, + effort, + subagent_model, + mock, + mock_scenario, + force, + )) -> case projects.get(ctx.db, project_id) { Error(_) -> wisp.not_found() - Ok(project) -> { - let now = now_ms() - case - runs.insert_run( - ctx.db, - project_id, - prompt, - model, - effort, - subagent_model, - mock, - mock_scenario, - now, - ) - { - Error(e) -> { - wisp.log_error( - "insert run for project " - <> int.to_string(project_id) - <> ": " - <> connection.describe_error(e), - ) - wisp.internal_server_error() - } - Ok(run) -> - case - run_supervisor.start_run( - ctx.run_registry, - ctx.db, - ctx.config, - run.id, - ) - { - Error(reason) -> { - wisp.log_error( - "start run " <> int.to_string(run.id) <> ": " <> reason, - ) - wisp.internal_server_error() - } - Ok(#(actor_subject, bc)) -> { - run_worker.launch( - run_worker.LaunchInput( - run: run, - project: project, - config: ctx.config, - cols: 80, - rows: 24, - broadcaster: bc, + Ok(project) -> + case branch, force { + Some(b), False -> + case runs.branch_in_use(ctx.db, b) { + Error(_) -> wisp.internal_server_error() + Ok(True) -> + json.object([ + #("error", json.string("branch_in_use")), + #( + "message", + json.string( + "Branch '" + <> b + <> "' is already in use by an active run.", + ), ), - actor_subject, - ) - run_json.encode(run) + ]) |> json.to_string() - |> wisp.json_response(201) - } + |> wisp.json_response(409) + Ok(False) -> + do_create( + ctx, + project, + prompt, + branch, + model, + effort, + subagent_model, + mock, + mock_scenario, + ) } + _, _ -> + do_create( + ctx, + project, + prompt, + branch, + model, + effort, + subagent_model, + mock, + mock_scenario, + ) } + } + } +} + +fn do_create( + ctx: Context, + project: projects.Project, + prompt: String, + branch: option.Option(String), + model: option.Option(String), + effort: option.Option(String), + subagent_model: option.Option(String), + mock: Bool, + mock_scenario: option.Option(String), +) -> Response { + let now = now_ms() + let global_prompt = case settings.get(ctx.db) { + Ok(s) -> s.global_prompt + Error(_) -> "" + } + case + runs.insert_run( + ctx.db, + project.id, + prompt, + branch, + model, + effort, + subagent_model, + mock, + mock_scenario, + now, + ) + { + Error(e) -> { + wisp.log_error( + "insert run for project " + <> int.to_string(project.id) + <> ": " + <> connection.describe_error(e), + ) + wisp.internal_server_error() + } + Ok(run) -> + case + run_supervisor.start_run(ctx.run_registry, ctx.db, ctx.config, run.id) + { + Error(reason) -> { + wisp.log_error( + "start run " <> int.to_string(run.id) <> ": " <> reason, + ) + wisp.internal_server_error() + } + Ok(#(actor_subject, bc)) -> { + run_worker.launch( + run_worker.LaunchInput( + run: run, + project: project, + config: ctx.config, + cols: 80, + rows: 24, + broadcaster: bc, + global_prompt: global_prompt, + ), + actor_subject, + ) + run_json.encode(run) + |> json.to_string() + |> wisp.json_response(201) } } } diff --git a/src/server/src/fbi/run/container_monitor.gleam b/src/server/src/fbi/run/container_monitor.gleam index 67eb073..5355d8d 100644 --- a/src/server/src/fbi/run/container_monitor.gleam +++ b/src/server/src/fbi/run/container_monitor.gleam @@ -198,7 +198,15 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { use head_sha <- decode.optional_field("head_sha", "", decode.string) use branch <- decode.optional_field("branch", "", decode.string) use session_id <- decode.optional_field("session_id", "", decode.string) - decode.success(#(agent_exit, push_exit, head_sha, branch, session_id)) + use title <- decode.optional_field("title", "", decode.string) + decode.success(#( + agent_exit, + push_exit, + head_sha, + branch, + session_id, + title, + )) } case json.parse(json_str, decoder) { Error(_) -> @@ -210,7 +218,7 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { error_message: Some("could not parse result.json"), claude_session_id: None, ) - Ok(#(agent_exit, push_exit, head_sha, branch, session_id)) -> + Ok(#(agent_exit, push_exit, head_sha, branch, session_id, title)) -> RunOutcome( exit_code: agent_exit, branch_pushed: case push_exit { @@ -225,7 +233,10 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { "" -> None sha -> Some(sha) }, - title: None, + title: case title { + "" -> None + t -> Some(t) + }, error_message: case agent_exit { 0 -> None code -> Some("agent exit " <> int.to_string(code)) diff --git a/src/server/src/fbi/run/reattach.gleam b/src/server/src/fbi/run/reattach.gleam index b36ca96..790b2a3 100644 --- a/src/server/src/fbi/run/reattach.gleam +++ b/src/server/src/fbi/run/reattach.gleam @@ -2,6 +2,7 @@ import fbi/config.{type Config} import fbi/db/connection import fbi/db/projects import fbi/db/runs.{type Run, type RunOutcome, RunOutcome} +import fbi/db/settings import fbi/docker import fbi/run/actor as run_actor import fbi/run/broadcaster @@ -290,6 +291,10 @@ pub fn resurrect( <> reason, ) Ok(#(actor_subject, bc)) -> { + let global_prompt = case settings.get(db) { + Ok(s) -> s.global_prompt + Error(_) -> "" + } run_worker.launch( run_worker.LaunchInput( run: child, @@ -298,6 +303,7 @@ pub fn resurrect( cols: 80, rows: 24, broadcaster: bc, + global_prompt: global_prompt, ), actor_subject, ) @@ -371,7 +377,15 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { use head_sha <- decode.optional_field("head_sha", "", decode.string) use branch <- decode.optional_field("branch", "", decode.string) use session_id <- decode.optional_field("session_id", "", decode.string) - decode.success(#(agent_exit, push_exit, head_sha, branch, session_id)) + use title <- decode.optional_field("title", "", decode.string) + decode.success(#( + agent_exit, + push_exit, + head_sha, + branch, + session_id, + title, + )) } case json.parse(json_str, decoder) { Error(_) -> @@ -383,7 +397,7 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { error_message: Some("could not parse result.json"), claude_session_id: None, ) - Ok(#(agent_exit, push_exit, head_sha, branch, session_id)) -> + Ok(#(agent_exit, push_exit, head_sha, branch, session_id, title)) -> RunOutcome( exit_code: agent_exit, branch_pushed: case push_exit { @@ -398,7 +412,10 @@ fn read_outcome(state_dir: String, exit_code: Int) -> RunOutcome { "" -> None sha -> Some(sha) }, - title: None, + title: case title { + "" -> None + t -> Some(t) + }, error_message: case agent_exit { 0 -> None code -> Some("agent exit " <> int.to_string(code)) diff --git a/src/server/src/fbi/run/worker.gleam b/src/server/src/fbi/run/worker.gleam index 62b2f7d..6f8f302 100644 --- a/src/server/src/fbi/run/worker.gleam +++ b/src/server/src/fbi/run/worker.gleam @@ -29,6 +29,7 @@ pub type LaunchInput { cols: Int, rows: Int, broadcaster: Subject(BroadcastMsg), + global_prompt: String, ) } @@ -388,7 +389,19 @@ fn is_regular_directory(path: String) -> Bool { fn path_dirname(path: String) -> String fn build_preamble(input: LaunchInput) -> dict.Dict(String, BitArray) { + let preamble = + "# FBI Session Setup\n\n" + <> "Before starting the task, run these two setup steps:\n\n" + <> "1. Write a concise title (max 60 chars) describing this task:\n" + <> " echo 'Brief task title' > /fbi-state/title\n\n" + <> "2. If your current branch starts with \"claude/run-\", rename it to a\n" + <> " short descriptive kebab-case name reflecting the task:\n" + <> " git branch -m feat/your-task-description\n" + <> " (Skip if the branch already has a meaningful name.)\n\n" + <> "After the setup steps, proceed with the task." dict.from_list([ + #("preamble.txt", bit_array.from_string(preamble)), + #("global.txt", bit_array.from_string(input.global_prompt)), #("prompt.txt", bit_array.from_string(input.run.prompt)), #( "instructions.txt",