feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -625,6 +625,29 @@ export interface TaskCreateRequest {
|
||||
* (in queue order, one at a time). The response then carries `queued: true`.
|
||||
*/
|
||||
queueIfBusy?: boolean;
|
||||
/**
|
||||
* Present = goal mode: the input's text becomes the objective (leading `[use_skills]`
|
||||
* blocks and the like are stripped from the recorded objective; the round-1 message keeps
|
||||
* them) and the server loops the Session until the goal reaches a terminal state.
|
||||
* `budget` is the token budget (uncached input + output); omitted or -1 = unlimited.
|
||||
*/
|
||||
goal?: { budget?: number };
|
||||
}
|
||||
|
||||
/** Goal-mode run state (from goal_state; the chat page's banner restores from the latest row). */
|
||||
export interface GoalStateView {
|
||||
objective: string;
|
||||
status: "active" | "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
/** Token budget; -1 = unlimited. */
|
||||
budget: number;
|
||||
used: number;
|
||||
rounds: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GoalResponse {
|
||||
/** The Session's most recent goal run; null if it never ran one. */
|
||||
goal: GoalStateView | null;
|
||||
}
|
||||
|
||||
export interface TaskCreateResponse {
|
||||
@@ -694,7 +717,23 @@ export type ServerEvent =
|
||||
sessionId: string;
|
||||
source: SessionSource;
|
||||
}
|
||||
| ScheduleServerEvent;
|
||||
| ScheduleServerEvent
|
||||
| GoalServerEvent;
|
||||
|
||||
/** Goal-mode progress on the session channel (the chat page drives its goal banner from these). */
|
||||
export type GoalServerEvent =
|
||||
/** A goal run began (published before the first round). */
|
||||
| { type: "goal_started"; sessionId: string; objective: string; budget: number }
|
||||
/** A round is starting; `used` is the runner's accounting up to this point. */
|
||||
| { type: "goal_round"; sessionId: string; round: number; used: number; budget: number }
|
||||
/** The goal reached a terminal state. */
|
||||
| {
|
||||
type: "goal_finished";
|
||||
sessionId: string;
|
||||
outcome: "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
rounds: number;
|
||||
used: number;
|
||||
};
|
||||
|
||||
/** Schedule notification (user-level event stream; firing and delivery are notified via /api/events). */
|
||||
export type ScheduleServerEvent =
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
|
||||
import { ErrorsRepo } from "./db/repos/errors.js";
|
||||
import { MembersRepo } from "./db/repos/members.js";
|
||||
import { ProjectsRepo } from "./db/repos/projects.js";
|
||||
import { GoalsRepo } from "./db/repos/goals.js";
|
||||
import { SchedulesRepo } from "./db/repos/schedules.js";
|
||||
import { SessionsRepo } from "./db/repos/sessions.js";
|
||||
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
|
||||
@@ -103,6 +104,8 @@ export interface AppDeps {
|
||||
benchmarks: BenchmarkService;
|
||||
snapshots: SnapshotService;
|
||||
schedulesRepo: SchedulesRepo;
|
||||
goalsRepo: GoalsRepo;
|
||||
errorsRepo: ErrorsRepo;
|
||||
scheduler: Scheduler;
|
||||
channels: ChannelHub;
|
||||
manager: SessionManager;
|
||||
@@ -140,6 +143,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
const errorsRepo = new ErrorsRepo(db);
|
||||
const prefsRepo = new UiPrefsRepo(db);
|
||||
const schedulesRepo = new SchedulesRepo(db);
|
||||
const goalsRepo = new GoalsRepo(db);
|
||||
|
||||
const projectConfigService = new ProjectConfigService(config.root);
|
||||
const agentConfigService = new AgentConfigService(config.root);
|
||||
@@ -186,6 +190,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
errors,
|
||||
titles,
|
||||
log,
|
||||
goals: goalsRepo,
|
||||
});
|
||||
managerRef = manager;
|
||||
|
||||
@@ -199,6 +204,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
usage: usageRepo,
|
||||
errors: errorsRepo,
|
||||
schedules: schedulesRepo,
|
||||
goals: goalsRepo,
|
||||
projectConfig: projectConfigService,
|
||||
manager,
|
||||
});
|
||||
@@ -261,6 +267,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
benchmarks,
|
||||
snapshots,
|
||||
schedulesRepo,
|
||||
goalsRepo,
|
||||
errorsRepo,
|
||||
scheduler,
|
||||
channels,
|
||||
manager,
|
||||
|
||||
@@ -212,6 +212,13 @@ export class ErrorsRepo {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cascading cleanup on Agent deletion (unattributed errors carry no agent_id and are unaffected). */
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM error_records WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Repo for goal-mode runtime state: one row per goal run, keyed by autoincrement id (a
|
||||
* Session may run goals repeatedly; the latest row is what the UI shows). The on-disk
|
||||
* GOAL.yaml is the model-facing protocol; this table is the server-side record the banner
|
||||
* restores from and the terminal outcome lands in (including the server-only `aborted`,
|
||||
* which never appears in the file — the on-disk status stays `active` for resuming).
|
||||
*/
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type GoalRowStatus = "active" | "complete" | "blocked" | "budget_limited" | "aborted";
|
||||
|
||||
export interface GoalStateRow {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
objective: string;
|
||||
status: GoalRowStatus;
|
||||
budget: number;
|
||||
used: number;
|
||||
rounds: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function mapRow(r: Record<string, unknown>): GoalStateRow {
|
||||
return {
|
||||
id: Number(r.id),
|
||||
sessionId: r.session_id as string,
|
||||
projectId: r.project_id as string,
|
||||
agentId: r.agent_id as string,
|
||||
objective: r.objective as string,
|
||||
status: r.status as GoalRowStatus,
|
||||
budget: Number(r.budget),
|
||||
used: Number(r.used),
|
||||
rounds: Number(r.rounds),
|
||||
createdAt: r.created_at as string,
|
||||
updatedAt: r.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
export class GoalsRepo {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
/** Register a new goal run (status active, counters at zero); returns the new row id. */
|
||||
create(args: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
objective: string;
|
||||
budget: number;
|
||||
}): number {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO goal_state
|
||||
(session_id, project_id, agent_id, objective, status, budget, used, rounds, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, 0, 0, ?, ?)`,
|
||||
)
|
||||
.run(args.sessionId, args.projectId, args.agentId, args.objective, args.budget, now, now);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
/** Per-round progress refresh (round just started; `used` mirrors the runner's accounting so far). */
|
||||
progress(id: number, rounds: number, used: number): void {
|
||||
this.db
|
||||
.prepare("UPDATE goal_state SET rounds = ?, used = ?, updated_at = ? WHERE id = ?")
|
||||
.run(rounds, used, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
/** Record the terminal outcome. */
|
||||
finish(id: number, status: GoalRowStatus, rounds: number, used: number): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE goal_state SET status = ?, rounds = ?, used = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(status, rounds, used, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The Session's most recent goal run (what the chat page's banner restores from); null if it never ran one. */
|
||||
latestForSession(sessionId: string): GoalStateRow | null {
|
||||
const r = this.db
|
||||
.prepare("SELECT * FROM goal_state WHERE session_id = ? ORDER BY id DESC LIMIT 1")
|
||||
.get(sessionId);
|
||||
return r ? mapRow(r as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
deleteBySession(sessionId: string): void {
|
||||
this.db.prepare("DELETE FROM goal_state WHERE session_id = ?").run(sessionId);
|
||||
}
|
||||
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM goal_state WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM goal_state WHERE project_id = ?").run(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup reconciliation: a goal runs only in SessionManager memory, so a hard crash
|
||||
* (SIGKILL, power loss) leaves its row stuck `active` with no runner behind it — the banner
|
||||
* would then restore a forever-"running" goal. Called once at boot, before the server accepts
|
||||
* connections (nothing can be running yet), so every remaining `active` row is an orphan:
|
||||
* mark them `aborted`. The on-disk GOAL.yaml is left `active` as the documented resume point.
|
||||
* Returns the number of rows reconciled.
|
||||
*/
|
||||
abortOrphanedActive(): number {
|
||||
const result = this.db
|
||||
.prepare("UPDATE goal_state SET status = 'aborted', updated_at = ? WHERE status = 'active'")
|
||||
.run(new Date().toISOString());
|
||||
return Number(result.changes);
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,12 @@ export class SchedulesRepo {
|
||||
return removed;
|
||||
}
|
||||
|
||||
deleteByAgent(projectId: string, agentId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ?")
|
||||
.run(projectId, agentId);
|
||||
}
|
||||
|
||||
deleteByProject(projectId: string): void {
|
||||
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,20 @@ CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files
|
||||
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
|
||||
PRIMARY KEY (project_id, agent_id, name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
|
||||
session_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
objective TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
|
||||
budget INTEGER NOT NULL, -- token budget; -1 = unlimited
|
||||
used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
|
||||
rounds INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
|
||||
CREATE TABLE IF NOT EXISTS ui_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
|
||||
|
||||
@@ -76,6 +76,12 @@ export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
await settleWithin(runnings, 5000);
|
||||
await deps.agentService.deleteAgent(projectId, agentId);
|
||||
deps.sessionsRepo.deleteByAgent(projectId, agentId);
|
||||
// Per-Agent runtime state keyed on the now-removed sessions/agent: drop it so nothing is
|
||||
// orphaned (session ids are never reused, so a leftover row is dead weight). Usage records
|
||||
// are deliberately kept — historical stats survive Agent deletion (see deleteAgent).
|
||||
deps.goalsRepo.deleteByAgent(projectId, agentId);
|
||||
deps.schedulesRepo.deleteByAgent(projectId, agentId);
|
||||
deps.errorsRepo.deleteByAgent(projectId, agentId);
|
||||
} finally {
|
||||
deps.manager.endAgentDeletion(projectId, agentId);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { OmniMessage, ThinkingLevelName } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
ApprovalMode,
|
||||
FilesStatResponse,
|
||||
GoalResponse,
|
||||
MessagesLiveTail,
|
||||
MessagesResponse,
|
||||
ServerEvent,
|
||||
@@ -102,6 +103,28 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional `goal` field of a task request: absent = a regular task (null);
|
||||
* present = goal mode with a token budget (a positive integer, or -1/omitted = unlimited).
|
||||
* The input text is the objective — skills ride the text itself as a `[use_skills]` block,
|
||||
* exactly like a regular task's message.
|
||||
*/
|
||||
function parseGoalField(body: Record<string, unknown>): { budget: number } | null {
|
||||
const goal = body.goal;
|
||||
if (goal === undefined) return null;
|
||||
if (goal === null || typeof goal !== "object" || Array.isArray(goal)) {
|
||||
throw badRequest("goal must be an object.");
|
||||
}
|
||||
const budget = (goal as Record<string, unknown>).budget;
|
||||
if (
|
||||
budget !== undefined &&
|
||||
(typeof budget !== "number" || !Number.isInteger(budget) || (budget <= 0 && budget !== -1))
|
||||
) {
|
||||
throw badRequest("goal.budget must be a positive integer, or -1 for unlimited.");
|
||||
}
|
||||
return { budget: (budget as number | undefined) ?? -1 };
|
||||
}
|
||||
|
||||
/** Agent-level entry: /api/projects/:p/agents/:a/sessions. */
|
||||
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
@@ -290,6 +313,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
deps.sessionsRepo.deleteById(row.sessionId);
|
||||
deps.goalsRepo.deleteBySession(row.sessionId);
|
||||
// Drop the derived-origin entry along with the Session (bulk Agent/Project deletion
|
||||
// may leave stale entries; session ids are never reused, so they are never matched).
|
||||
deps.sessionSources.delete(row.sessionId);
|
||||
@@ -389,10 +413,31 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
app.post("/:sessionId/tasks", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
const input = parseTaskInput(body);
|
||||
const goal = parseGoalField(body);
|
||||
// Per-turn thinking level (optional): validated against the five names; omitted follows
|
||||
// the session's default. A queued follow-up keeps its level for its auto-start.
|
||||
// the session's default. In goal mode it rides every round of the goal; a queued
|
||||
// follow-up keeps its level for its auto-start.
|
||||
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
|
||||
if (goal) {
|
||||
// Goal mode: the input must be plain non-empty text (its marker-stripped text becomes
|
||||
// the objective, re-injected every round — images have no place in the protocol).
|
||||
const input = parseTaskInput(body);
|
||||
const text = input
|
||||
.filter((m) => (m.payload as { type?: string }).type === "text")
|
||||
.map((m) => (m.payload as { text: string }).text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!text || input.some((m) => (m.payload as { type?: string }).type !== "text")) {
|
||||
throw badRequest("goal mode requires text-only input (the objective).");
|
||||
}
|
||||
const { sessionId } = await deps.manager.startGoal(row.sessionId, {
|
||||
input,
|
||||
budget: goal.budget,
|
||||
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
||||
});
|
||||
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
|
||||
}
|
||||
const input = parseTaskInput(body);
|
||||
// Follow-up queue: with queueIfBusy, a busy session enqueues the input instead of 409
|
||||
// (auto-starts as an ordinary next task once idle; the response says which happened).
|
||||
const queueIfBusy = body.queueIfBusy === true;
|
||||
@@ -417,6 +462,24 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.body(null, 202);
|
||||
});
|
||||
|
||||
// The Session's most recent goal run (for restoring the chat page's goal banner on load).
|
||||
app.get("/:sessionId/goal", (c) => {
|
||||
const row = resolveSession(c);
|
||||
const g = deps.goalsRepo.latestForSession(row.sessionId);
|
||||
return c.json({
|
||||
goal: g
|
||||
? {
|
||||
objective: g.objective,
|
||||
status: g.status,
|
||||
budget: g.budget,
|
||||
used: g.used,
|
||||
rounds: g.rounds,
|
||||
updatedAt: g.updatedAt,
|
||||
}
|
||||
: null,
|
||||
} satisfies GoalResponse);
|
||||
});
|
||||
|
||||
app.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
|
||||
@@ -27,6 +27,12 @@ await deps.authService.seedAdmin();
|
||||
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
|
||||
await deps.scheduler.start();
|
||||
|
||||
// Goal mode runs only in SessionManager memory: a hard crash (SIGKILL, power loss) can leave
|
||||
// goal_state rows stuck `active` with no runner behind them. Reconcile them to `aborted` now —
|
||||
// nothing is running yet, so any `active` row is a crash orphan — so the chat banner never
|
||||
// restores a phantom "running" goal. GOAL.yaml on disk stays `active` as the resume point.
|
||||
deps.goalsRepo.abortOrphanedActive();
|
||||
|
||||
// On a loopback bind the App is canonicalized onto one name (`localhost`) and its
|
||||
// counterpart is reserved for previews, so advertise the canonical name — the other one
|
||||
// only 302s back here for App routes (see the canonical-host guard in app.ts).
|
||||
|
||||
@@ -30,7 +30,11 @@ import path from "node:path";
|
||||
import {
|
||||
createAgent,
|
||||
findLatestTraceFile,
|
||||
goalFinishedOf,
|
||||
goalTokenDelta,
|
||||
isGoalRoundInput,
|
||||
isSessionMeta,
|
||||
stripLeadingMarkerBlocks,
|
||||
tracesDir,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
@@ -44,6 +48,7 @@ import type {
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { ServerEvent, SessionStatus } from "../api/types.js";
|
||||
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
|
||||
import type { GoalsRepo } from "../db/repos/goals.js";
|
||||
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
|
||||
import { ApprovalRegistry, makeApprove } from "./approvals.js";
|
||||
import type { PendingApproval } from "./approvals.js";
|
||||
@@ -72,7 +77,13 @@ export interface RuntimeSession {
|
||||
readonly sessionId: string;
|
||||
run(
|
||||
newMessages: OmniMessage[],
|
||||
opts: { approve: ApproveFn; signal: AbortSignal; thinkingLevel?: ThinkingLevelName },
|
||||
opts: {
|
||||
approve: ApproveFn;
|
||||
signal: AbortSignal;
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
/** Present = goal mode: core loops rounds inside this one run (see core SessionRunOptions). */
|
||||
goal?: { budget?: number };
|
||||
},
|
||||
): AsyncGenerator<OmniMessage>;
|
||||
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
|
||||
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
|
||||
@@ -192,6 +203,8 @@ export interface SessionManagerDeps {
|
||||
/** Error persistence (optional: without it, only logs — same as before this was wired up). */
|
||||
errors?: ErrorSink;
|
||||
log?: (line: string) => void;
|
||||
/** Goal run-state persistence (optional like `titles`: without it, goals run but leave no restorable record). */
|
||||
goals?: GoalsRepo;
|
||||
}
|
||||
|
||||
/** One queued follow-up task (`queueIfBusy`): the task input plus the per-turn thinking level it was posted with. */
|
||||
@@ -467,6 +480,177 @@ export class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a goal run: like startTask, but the run is core goal mode — one
|
||||
* `session.run(input, { goal })` call loops rounds until a terminal state, and the
|
||||
* Session stays `running` for the whole goal (every round), so the existing abort
|
||||
* endpoint interrupts the entire loop and schedules queue behind it as usual. Round
|
||||
* inputs are yielded by core and published like any streamed message; progress
|
||||
* additionally goes out as goal_* server events and into goal_state (when a repo is
|
||||
* wired).
|
||||
*/
|
||||
async startGoal(
|
||||
sessionId: string,
|
||||
args: {
|
||||
/** Round-1 input (text-only, route-validated); its marker-stripped text is the objective. */
|
||||
input: OmniMessage[];
|
||||
budget: number;
|
||||
/** Optional per-goal thinking level: rides every round's Task (route-validated). */
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
},
|
||||
): Promise<{ sessionId: string }> {
|
||||
return this.withLock(sessionId, async () => {
|
||||
this.assertOpen();
|
||||
this.assertAgentNotDeleting(sessionId);
|
||||
this.assertSessionNotDeleting(sessionId);
|
||||
const entry = await this.ensureEntry(sessionId);
|
||||
this.assertIdle(entry);
|
||||
// The objective is the user's own text (leading skill-invocation blocks stripped) —
|
||||
// the same derivation core records in GOAL.yaml; used for the run-state row, the
|
||||
// goal_started event, and as title material.
|
||||
const text = args.input
|
||||
.filter(isPlainText("user"))
|
||||
.map((m) => m.payload.text)
|
||||
.join("\n");
|
||||
const objective = stripLeadingMarkerBlocks(text).trim() || text.trim();
|
||||
const ac = new AbortController();
|
||||
entry.status = "running";
|
||||
entry.abort = ac;
|
||||
entry.lastActivityMs = Date.now();
|
||||
this.publishState(entry, "running");
|
||||
const approve = makeApprove({
|
||||
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
|
||||
toolPermission: (name) => entry.session.toolPermission(name),
|
||||
registry: entry.approvals,
|
||||
publishRequest: (pending) =>
|
||||
this.publishEvent(entry, {
|
||||
type: "approval_request",
|
||||
toolCall: pending.toolCall,
|
||||
...(pending.origin !== undefined ? { origin: pending.origin } : {}),
|
||||
}),
|
||||
});
|
||||
const goalId = this.deps.goals?.create({
|
||||
sessionId: entry.sessionId,
|
||||
projectId: entry.projectId,
|
||||
agentId: entry.agentId,
|
||||
objective,
|
||||
budget: args.budget,
|
||||
});
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_started",
|
||||
sessionId: entry.sessionId,
|
||||
objective,
|
||||
budget: args.budget,
|
||||
});
|
||||
const gen = this.goalStream(entry, {
|
||||
input: args.input,
|
||||
budget: args.budget,
|
||||
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
|
||||
approve,
|
||||
signal: ac.signal,
|
||||
...(goalId !== undefined ? { goalId } : {}),
|
||||
});
|
||||
// The objective doubles as the title material (same role as a task's input text).
|
||||
entry.running = this.drive(entry, gen, { userExcerpt: objective });
|
||||
return { sessionId: entry.sessionId };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps core's goal-mode stream for `drive`: round boundaries (the injected `[goal]`
|
||||
* inputs) become goal_round events + goal_state refreshes, and the terminal
|
||||
* `goal_finished` event message becomes the goal_finished server event + the run-state
|
||||
* row's final status. Token numbers mirror core's own accounting (same
|
||||
* `goalTokenDelta`), so the UI shows exactly what the budget check uses.
|
||||
*/
|
||||
private async *goalStream(
|
||||
entry: RuntimeEntry,
|
||||
args: {
|
||||
input: OmniMessage[];
|
||||
budget: number;
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
approve: ApproveFn;
|
||||
signal: AbortSignal;
|
||||
goalId?: number;
|
||||
},
|
||||
): AsyncGenerator<OmniMessage> {
|
||||
const gen = entry.session.run(args.input, {
|
||||
approve: args.approve,
|
||||
signal: args.signal,
|
||||
...(args.thinkingLevel !== undefined ? { thinkingLevel: args.thinkingLevel } : {}),
|
||||
goal: { budget: args.budget },
|
||||
});
|
||||
let round = 0;
|
||||
let used = 0;
|
||||
let finished = false;
|
||||
try {
|
||||
for await (const msg of gen) {
|
||||
used += goalTokenDelta(msg);
|
||||
if (isGoalRoundInput(msg)) {
|
||||
round++;
|
||||
if (args.goalId !== undefined) this.deps.goals?.progress(args.goalId, round, used);
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_round",
|
||||
sessionId: entry.sessionId,
|
||||
round,
|
||||
used,
|
||||
budget: args.budget,
|
||||
});
|
||||
}
|
||||
const outcome = goalFinishedOf(msg);
|
||||
if (outcome) {
|
||||
finished = true;
|
||||
if (args.goalId !== undefined) {
|
||||
this.deps.goals?.finish(
|
||||
args.goalId,
|
||||
outcome.outcome,
|
||||
outcome.rounds,
|
||||
outcome.tokensUsed,
|
||||
);
|
||||
}
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_finished",
|
||||
sessionId: entry.sessionId,
|
||||
outcome: outcome.outcome,
|
||||
rounds: outcome.rounds,
|
||||
used: outcome.tokensUsed,
|
||||
});
|
||||
}
|
||||
yield msg;
|
||||
}
|
||||
if (!finished) {
|
||||
// Defensive: core always ends a goal stream with goal_finished; a stream that
|
||||
// didn't is a cut-off run — close the row so the UI never shows a forever-active
|
||||
// goal.
|
||||
this.finishAborted(entry, args.goalId, round, used);
|
||||
}
|
||||
} catch (err) {
|
||||
// Core throws only on infrastructure failures (e.g. GOAL.yaml writes): close the
|
||||
// run state as aborted, then let drive's defensive catch record the error. Guarded on
|
||||
// `finished`: a throw after the terminal event must not overwrite the row's real
|
||||
// outcome (repo.finish is an unconditional UPDATE) or publish a contradicting event.
|
||||
if (!finished) this.finishAborted(entry, args.goalId, round, used);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes a goal's run state as aborted (stream cut off / infrastructure failure). */
|
||||
private finishAborted(
|
||||
entry: RuntimeEntry,
|
||||
goalId: number | undefined,
|
||||
round: number,
|
||||
used: number,
|
||||
): void {
|
||||
if (goalId !== undefined) this.deps.goals?.finish(goalId, "aborted", round, used);
|
||||
this.publishEvent(entry, {
|
||||
type: "goal_finished",
|
||||
sessionId: entry.sessionId,
|
||||
outcome: "aborted",
|
||||
rounds: round,
|
||||
used,
|
||||
});
|
||||
}
|
||||
|
||||
/** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
|
||||
private launchTask(
|
||||
entry: RuntimeEntry,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { HttpError } from "../http/errors.js";
|
||||
import type { AgentsRepo } from "../db/repos/agents.js";
|
||||
import type { ErrorsRepo } from "../db/repos/errors.js";
|
||||
import type { MembersRepo } from "../db/repos/members.js";
|
||||
import type { GoalsRepo } from "../db/repos/goals.js";
|
||||
import type { ProjectRow, ProjectsRepo } from "../db/repos/projects.js";
|
||||
import type { SessionsRepo } from "../db/repos/sessions.js";
|
||||
import type { SchedulesRepo } from "../db/repos/schedules.js";
|
||||
@@ -53,6 +54,7 @@ export interface ProjectServiceDeps {
|
||||
usage: UsageRepo;
|
||||
errors: ErrorsRepo;
|
||||
schedules: SchedulesRepo;
|
||||
goals: GoalsRepo;
|
||||
projectConfig: ProjectConfigService;
|
||||
manager: SessionManager;
|
||||
}
|
||||
@@ -290,6 +292,7 @@ export class ProjectService {
|
||||
this.deps.usage.deleteByProject(projectId);
|
||||
this.deps.errors.deleteByProject(projectId);
|
||||
this.deps.schedules.deleteByProject(projectId);
|
||||
this.deps.goals.deleteByProject(projectId);
|
||||
await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user