|
|
@@ -94,9 +94,10 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * The OpenAI-compatible endpoint every saddle assignment is sent to,
|
|
|
- * read from the environment: `SADDLE_LLM_BASE_URL` (required — dispatch
|
|
|
- * pauses without it), `SADDLE_LLM_API_KEY` and `SADDLE_LLM_MODEL`.
|
|
|
+ * The OpenAI-compatible endpoint every saddle assignment is sent
|
|
|
+ * to, read from the deployment's model configuration (the Admin →
|
|
|
+ * Model page's `model_config` row — {@link ModelConfigService});
|
|
|
+ * dispatch pauses while no base URL is configured.
|
|
|
*/
|
|
|
public class LlmEndpoint : Object {
|
|
|
|
|
|
@@ -105,15 +106,15 @@ namespace ClankerSaddle.Services {
|
|
|
public string model { get; set; default = ""; }
|
|
|
|
|
|
/** The configured endpoint, or null when the base URL is unset. */
|
|
|
- public static LlmEndpoint? from_environment() {
|
|
|
- var base_url = (Environment.get_variable("SADDLE_LLM_BASE_URL") ?? "").strip();
|
|
|
- if (base_url.length == 0) {
|
|
|
+ public static LlmEndpoint? from_config(string base_url, string api_key, string model) {
|
|
|
+ var trimmed = base_url.strip();
|
|
|
+ if (trimmed.length == 0) {
|
|
|
return null;
|
|
|
}
|
|
|
return new LlmEndpoint() {
|
|
|
- base_url = base_url,
|
|
|
- api_key = (Environment.get_variable("SADDLE_LLM_API_KEY") ?? "").strip(),
|
|
|
- model = (Environment.get_variable("SADDLE_LLM_MODEL") ?? "").strip()
|
|
|
+ base_url = trimmed,
|
|
|
+ api_key = api_key.strip(),
|
|
|
+ model = model.strip()
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
@@ -167,6 +168,7 @@ namespace ClankerSaddle.Services {
|
|
|
private UnderstandingService understandings = inject<UnderstandingService>();
|
|
|
private DiscoveryService discoveries = inject<DiscoveryService>();
|
|
|
private Astralis.WebConfig config = inject<Astralis.WebConfig>();
|
|
|
+ private ModelConfigService model_config = inject<ModelConfigService>();
|
|
|
|
|
|
/**
|
|
|
* Registers a saddle container: inserts the row with a freshly
|
|
|
@@ -822,17 +824,17 @@ namespace ClankerSaddle.Services {
|
|
|
* along so pushed telemetry can key on it. Project checkouts
|
|
|
* are addressed by the `SADDLE_PROJECTS_ROOT` convention
|
|
|
* (`root/task-<task id>/name`) and cloned from each project's
|
|
|
- * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
|
|
|
- * environment.
|
|
|
+ * `git_url`; the LLM endpoint comes from the deployment's
|
|
|
+ * model configuration ({@link ModelConfigService}).
|
|
|
*/
|
|
|
public async Json.Object build_assignment_payload(Saddle saddle, TaskEntity task,
|
|
|
int64 session_id, ProjectService projects, string? resume_notice = null,
|
|
|
bool? branch_advanced = null,
|
|
|
string? context_update = null) throws Error {
|
|
|
- var endpoint = LlmEndpoint.from_environment();
|
|
|
+ var endpoint = yield model_config.saddle_endpoint();
|
|
|
if (endpoint == null) {
|
|
|
throw new Error(Quark.from_string("clanker-saddle"), 24,
|
|
|
- "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
|
|
|
+ "No LLM endpoint configured (set it on the Admin → Model page)");
|
|
|
}
|
|
|
|
|
|
var project = task.project_id > 0
|
|
|
@@ -1086,6 +1088,16 @@ namespace ClankerSaddle.Services {
|
|
|
payload.add_boolean_value(true);
|
|
|
}
|
|
|
|
|
|
+ // Goal 28 task D2: `turn_cap_parked` — this task's lineage
|
|
|
+ // already spent its turn-cap checkpoint-park, so a cap hit
|
|
|
+ // in the assigned run is the final hard kill (the daemon
|
|
|
+ // reads the flag into the loop's cap arm). Served on EVERY
|
|
|
+ // assignment of the task, fresh or resumed.
|
|
|
+ if (task.turn_cap_parked) {
|
|
|
+ payload.set_member_name("turn_cap_parked");
|
|
|
+ payload.add_boolean_value(true);
|
|
|
+ }
|
|
|
+
|
|
|
// The wire's `branch_advanced` (resume pushes): whether the
|
|
|
// shared branch moved while this task was parked — false or
|
|
|
// absent means the daemon omits the pull instruction entirely.
|
|
|
@@ -1500,6 +1512,56 @@ namespace ClankerSaddle.Services {
|
|
|
yield pulse.bump(task_id, "park refused (nothing to wait on)");
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Goal 28 task D2: the wrap-up instruction riding the
|
|
|
+ * turn-cap park's resuming assignment (the one-shot
|
|
|
+ * `pending_resume_notice` channel). Factual, not shaming, and
|
|
|
+ * it MUST instruct wrap-up — the resumed run gets no second
|
|
|
+ * checkpoint-park.
|
|
|
+ */
|
|
|
+ internal const string TURN_CAP_WRAP_UP_NOTICE =
|
|
|
+ "Turn cap park: your previous run hit the harness turn cap without finishing, "
|
|
|
+ + "so it was parked instead of failed. WRAP UP NOW — finish the task: complete "
|
|
|
+ + "remaining commits, push your branch, submit your report with what is done "
|
|
|
+ + "and what remains. Do not start new exploratory work. If this run reaches "
|
|
|
+ + "the turn cap again without finishing, it will be stopped for good.";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Goal 28 task D2: the checkpoint-park bookkeeping for a
|
|
|
+ * `suspended` push carrying reason `turn_cap` — {@link
|
|
|
+ * TaskService.set_parked} flips the row (status suspended,
|
|
|
+ * sessions closed 'parked', suspended_at stamped), the
|
|
|
+ * wrap-up notice makes it IMMEDIATELY resume-offerable (the
|
|
|
+ * pending_resume_notice lift), and `turn_cap_parked` stamps
|
|
|
+ * the lineage so every later assignment flags the daemon that
|
|
|
+ * the next cap hit is final. The state_change event carries
|
|
|
+ * from/to/text naming the turn cap so the friction miner's
|
|
|
+ * checkpoint-park counter sees it.
|
|
|
+ */
|
|
|
+ private async void park_for_turn_cap(int64 task_id, string summary) throws Error {
|
|
|
+ yield task_service.set_parked(task_id);
|
|
|
+ var task = yield db.query<TaskEntity>()
|
|
|
+ .where(expr("id == $0", elem<int64?>(task_id)))
|
|
|
+ .first_async();
|
|
|
+ if (task != null) {
|
|
|
+ ((!)task).turn_cap_parked = true;
|
|
|
+ if (((!)task).pending_resume_notice == null) {
|
|
|
+ ((!)task).pending_resume_notice = TURN_CAP_WRAP_UP_NOTICE;
|
|
|
+ }
|
|
|
+ yield db.update_async<TaskEntity>((!)task);
|
|
|
+ }
|
|
|
+ var parked = new Json.Object();
|
|
|
+ parked.set_string_member("from", STATUS_WORKING);
|
|
|
+ parked.set_string_member("to", STATUS_SUSPENDED);
|
|
|
+ parked.set_string_member("text", "Turn cap park — "
|
|
|
+ + (summary.length > 0 ? summary : "the run hit the harness turn cap"));
|
|
|
+ yield event_service.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM,
|
|
|
+ EVENT_SCOPE_TASK, task_id, parked);
|
|
|
+ yield task_service.add_system_comment(task_id,
|
|
|
+ "turn cap park: the run parked at the harness turn cap — the wrap-up notice rides the resume");
|
|
|
+ yield pulse.bump(task_id, "turn cap park");
|
|
|
+ }
|
|
|
+
|
|
|
private async int64 goal_id_of_run(int64 task_id) throws Error {
|
|
|
var task = yield db.query<TaskEntity>()
|
|
|
.where(expr("id == $0", elem<int64?>(task_id)))
|
|
|
@@ -1601,7 +1663,8 @@ namespace ClankerSaddle.Services {
|
|
|
* once, ever.
|
|
|
*/
|
|
|
public async void apply_result_state(ClankerSaddle.Data.Session session,
|
|
|
- string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
|
|
|
+ string state, Json.Object? block, string? summary, TaskService tasks,
|
|
|
+ string reason = "") throws Error {
|
|
|
int64 task_id = session.task_id;
|
|
|
int64 goal_id = yield goal_id_of_run(task_id);
|
|
|
bool terminal = yield task_is_terminal(task_id);
|
|
|
@@ -1698,6 +1761,20 @@ namespace ClankerSaddle.Services {
|
|
|
// remains; busy/idle derive from the sessions).
|
|
|
break;
|
|
|
case "suspended":
|
|
|
+ // Goal 28 task D2: the turn-cap checkpoint-park —
|
|
|
+ // the daemon's FIRST cap hit pushes `suspended`
|
|
|
+ // with reason `turn_cap` and a summary naming the
|
|
|
+ // cap. The park mechanics run (status suspended,
|
|
|
+ // session closed 'parked') PLUS the one-shot
|
|
|
+ // wrap-up notice makes the task immediately
|
|
|
+ // resume-offerable (no wake source needed — the
|
|
|
+ // wrap-up must happen NOW) and the lineage flag
|
|
|
+ // `turn_cap_parked` rides every later assignment
|
|
|
+ // so the next cap hit is the final hard kill.
|
|
|
+ if (reason == "turn_cap") {
|
|
|
+ yield park_for_turn_cap(task_id, summary ?? "");
|
|
|
+ break;
|
|
|
+ }
|
|
|
// `saddle suspend`: the same parking as a block, but
|
|
|
// the web drives resumption — a wake of the affine
|
|
|
// saddle once a goal event lands, the resume
|