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:
rank-Yu
2026-07-27 23:43:24 +08:00
committed by GitHub
parent e1141ca010
commit 46463bee26
61 changed files with 3411 additions and 188 deletions
+40 -1
View File
@@ -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 =
+8
View File
@@ -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,
+7
View File
@@ -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);
+116
View File
@@ -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);
}
+14
View File
@@ -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);
}
+65 -2
View File
@@ -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);
+6
View File
@@ -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).
+185 -1
View File
@@ -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 });
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Goal-mode server tests: GoalsRepo state rows, and SessionManager.startGoal driving core
* goal mode through one `session.run(input, { goal })` call with a fake Session (no real LLM
* requests) — round events, terminal state persistence, and status transitions.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { DatabaseSync } from "node:sqlite";
import {
assistantText,
buildSkillsMessage,
emptyTokenCounts,
goalFinished,
tokenUsage,
userText,
} from "@prismshadow/penguin-core";
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { GoalsRepo } from "../src/db/repos/goals.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
import type { SessionRow } from "../src/db/repos/sessions.js";
import { ChannelHub } from "../src/runtime/channel.js";
import type { ChannelEvent } from "../src/runtime/channel.js";
import { SessionManager } from "../src/runtime/session-manager.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import { SessionSources } from "../src/runtime/session-sources.js";
import { waitFor } from "./helpers.js";
const ROW: SessionRow = {
sessionId: "session-1",
projectId: "p1",
agentId: "a1",
modelId: "m1",
provider: "custom",
workspace: "/tmp/w",
approvalMode: "allow-all",
title: null,
createdAt: "2026-07-06T00:00:00.000Z",
};
function usage(total: number): TokenCounts {
return { cache_read: 0, cache_write: 0, output: 0, total };
}
/** A goal round's injected input, as core's loop would compose it (block + body). */
function roundInput(round: number, body: string): OmniMessage {
return userText(`[goal]\nround: ${round}\nprotocol lines\n[/goal]\n\n${body}`);
}
describe("GoalsRepo", () => {
let db: DatabaseSync;
let repo: GoalsRepo;
beforeEach(() => {
db = openDatabase(":memory:");
repo = new GoalsRepo(db);
});
afterEach(() => db.close());
it("creates, progresses, finishes, and reads back the latest row per session", () => {
const id = repo.create({
sessionId: "s1",
projectId: "p1",
agentId: "a1",
objective: "obj",
budget: -1,
});
repo.progress(id, 2, 1234);
let row = repo.latestForSession("s1");
expect(row).toMatchObject({ id, status: "active", rounds: 2, used: 1234, budget: -1 });
repo.finish(id, "complete", 3, 2000);
row = repo.latestForSession("s1");
expect(row).toMatchObject({ status: "complete", rounds: 3, used: 2000 });
// A later run wins for display.
const id2 = repo.create({
sessionId: "s1",
projectId: "p1",
agentId: "a1",
objective: "obj2",
budget: 500,
});
expect(repo.latestForSession("s1")?.id).toBe(id2);
});
it("deletes by session, by agent, and by project", () => {
repo.create({ sessionId: "s1", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
repo.create({ sessionId: "s2", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
repo.deleteBySession("s1");
expect(repo.latestForSession("s1")).toBeNull();
expect(repo.latestForSession("s2")).not.toBeNull();
// deleteByAgent drops the agent's rows but spares another agent in the same project.
repo.create({ sessionId: "s3", projectId: "p1", agentId: "a2", objective: "o", budget: -1 });
repo.deleteByAgent("p1", "a1");
expect(repo.latestForSession("s2")).toBeNull();
expect(repo.latestForSession("s3")).not.toBeNull();
repo.deleteByProject("p1");
expect(repo.latestForSession("s3")).toBeNull();
});
it("reconciles orphaned active rows to aborted on startup, leaving terminal rows untouched", () => {
const active = repo.create({
sessionId: "s1",
projectId: "p1",
agentId: "a1",
objective: "o",
budget: -1,
});
const done = repo.create({
sessionId: "s2",
projectId: "p1",
agentId: "a1",
objective: "o",
budget: -1,
});
repo.finish(done, "complete", 1, 10);
// A hard crash leaves the running goal's row `active`; boot reconciliation flips only it.
expect(repo.abortOrphanedActive()).toBe(1);
expect(repo.latestForSession("s1")?.status).toBe("aborted");
expect(repo.latestForSession("s2")?.status).toBe("complete");
// Idempotent: a second boot finds nothing left to reconcile.
expect(repo.abortOrphanedActive()).toBe(0);
void active;
});
});
describe("SessionManager.startGoal", () => {
let db: DatabaseSync;
let sessions: SessionsRepo;
let goals: GoalsRepo;
let channels: ChannelHub;
beforeEach(() => {
db = openDatabase(":memory:");
sessions = new SessionsRepo(db);
sessions.insert(ROW);
goals = new GoalsRepo(db);
channels = new ChannelHub();
});
afterEach(() => {
channels.dispose();
db.close();
});
type RunOpts = { thinkingLevel?: string; goal?: { budget?: number } };
/**
* Fake session: `run` asserts it was called in goal mode and emits the whole goal stream
* the way core's loop would — per-round `[goal]` inputs and work, then the terminal
* goal_finished event (the loop itself is core's and is tested in core).
*/
function goalFakeSession(
stream: (input: OmniMessage[]) => OmniMessage[],
): RuntimeSession & { runOpts: RunOpts[] } {
const runOpts: RunOpts[] = [];
return {
sessionId: ROW.sessionId,
runOpts,
toolPermission: () => "rw",
generateTitle: async () => ({ title: null, usage: null }),
compactability: () => "ok" as const,
steer: () => false,
skipReconnectWait: () => false,
async *run(input: OmniMessage[], opts) {
runOpts.push({
...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
...(opts.goal !== undefined ? { goal: opts.goal } : {}),
});
yield* stream(input);
},
async *compact() {},
};
}
function makeManager(session: RuntimeSession, withRepo = true): SessionManager {
return new SessionManager({
sessions,
channels,
sources: new SessionSources(),
loader: { load: async () => session },
recorder: { record: async () => {} },
log: () => {},
...(withRepo ? { goals } : {}),
});
}
it("drives one goal-mode run, publishing goal events and persisting the outcome", async () => {
const text = buildSkillsMessage(["web-design"], "make it work");
const session = goalFakeSession((input) => [
roundInput(1, (input[0]!.payload as { text: string }).text),
assistantText("round 1 work"),
tokenUsage(usage(100), usage(100)),
roundInput(2, "make it work"),
assistantText("round 2 work"),
tokenUsage(usage(200), usage(200)),
goalFinished("complete", 2, 300),
]);
const manager = makeManager(session);
const events: ChannelEvent[] = [];
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
await manager.startGoal(ROW.sessionId, {
input: [userText(text)],
budget: -1,
thinkingLevel: "high",
});
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
// One run call carries the whole goal: the input verbatim, the per-goal thinking
// level, and the goal option (core loops the rounds internally).
expect(session.runOpts).toEqual([{ thinkingLevel: "high", goal: { budget: -1 } }]);
const server = events
.filter((e) => e.event === "server_event")
.map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
// The recorded objective is the user's own text: the [use_skills] prefix is stripped.
expect(server.find((e) => e.type === "goal_started")).toMatchObject({
objective: "make it work",
budget: -1,
});
const rounds = server.filter((e) => e.type === "goal_round");
expect(rounds).toHaveLength(2);
expect(rounds[1]).toMatchObject({ round: 2, used: 100 });
const finished = server.find((e) => e.type === "goal_finished");
expect(finished).toMatchObject({ outcome: "complete", rounds: 2, used: 300 });
const row = goals.latestForSession(ROW.sessionId);
expect(row).toMatchObject({
status: "complete",
rounds: 2,
used: 300,
objective: "make it work",
});
// The round inputs were published on the message stream (no `event:` name) for live viewers.
const published = events
.filter((e) => e.event === undefined)
.map((e) => JSON.parse(e.data) as OmniMessage)
.filter(
(m) =>
m.type === "model_msg" &&
(m.payload as { role?: string }).role === "user" &&
((m.payload as { text?: string }).text ?? "").startsWith("[goal]"),
);
expect(published).toHaveLength(2);
// Round 1 carries the caller's input verbatim — the [use_skills] block included.
expect((published[0]!.payload as { text: string }).text).toContain("[use_skills]");
});
it("409s while a goal is running (mutual exclusion); runs without a goals repo", async () => {
let release: () => void = () => {};
const gate = new Promise<void>((r) => {
release = r;
});
const session = goalFakeSession(() => [roundInput(1, "obj"), goalFinished("complete", 1, 0)]);
const orig = session.run.bind(session);
session.run = async function* (input, opts) {
yield* orig(input, opts);
await gate;
};
const manager = makeManager(session, false);
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
await expect(manager.startTask(ROW.sessionId, [userText("x")])).rejects.toMatchObject({
status: 409,
});
release();
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
// No goals repo wired: the goal still ran and finished without touching one.
expect(goals.latestForSession(ROW.sessionId)).toBeNull();
});
it("a throw after the terminal event does not overwrite the recorded outcome", async () => {
// repo.finish is an unconditional UPDATE: without the `finished` guard, the defensive
// catch would flip a completed row to aborted and publish a contradicting event.
const session = goalFakeSession(() => []);
session.run = async function* () {
yield roundInput(1, "obj");
yield goalFinished("complete", 1, 42);
throw new Error("post-terminal hiccup");
};
const manager = makeManager(session);
const events: ChannelEvent[] = [];
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
status: "complete",
rounds: 1,
used: 42,
});
const finished = events
.filter((e) => e.event === "server_event")
.map((e) => JSON.parse(e.data) as { type: string; outcome?: string })
.filter((e) => e.type === "goal_finished");
expect(finished).toEqual([expect.objectContaining({ outcome: "complete" })]);
});
it("closes the run state as aborted when the stream ends without a terminal event", async () => {
// A cut-off run (infrastructure failure upstream) must not leave the row active.
const session = goalFakeSession(() => [
roundInput(1, "obj"),
assistantText("partial work"),
tokenUsage(usage(50), usage(50)),
]);
const manager = makeManager(session);
const events: ChannelEvent[] = [];
channels.get(ROW.sessionId).subscribe((e) => events.push(e));
await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: 1000 });
await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
status: "aborted",
rounds: 1,
used: 50,
});
const server = events
.filter((e) => e.event === "server_event")
.map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
expect(server.find((e) => e.type === "goal_finished")).toMatchObject({
outcome: "aborted",
rounds: 1,
used: 50,
});
});
it("sanity: emptyTokenCounts helper stays exported for fakes", () => {
expect(emptyTokenCounts().total).toBe(0);
});
});
@@ -582,4 +582,26 @@ describe("session-index", () => {
});
expect(bad.status).toBe(400);
});
it("rejects a malformed goal.budget with 400", async () => {
await configureModels();
const res = await api.post(base(), {});
const { session } = (await res.json()) as SessionCreateResponse;
for (const budget of ["500k", 0, -2, 1.5]) {
const bad = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
input: [{ type: "text", text: "objective" }],
goal: { budget },
});
expect(bad.status).toBe(400);
}
// Image parts have no place in the re-injected objective.
const image = await api.post(`/api/sessions/${session.sessionId}/tasks`, {
input: [
{ type: "text", text: "objective" },
{ type: "image_url", imageUrl: "data:image/png;base64,aGk=" },
],
goal: {},
});
expect(image.status).toBe(400);
});
});