feat: file-backed goal objectives + compaction accounting fixes
Compaction fixes (observed in a real long run): - the summary message's zeroed tokens froze the goal counter at its pre-compaction value; segments now close with the previously displayed total as a continuity floor - audits and continuations after a summary tail now take execution params (provider/model/agent/variant) from the newest non-summary assistant turn instead of inheriting agent 'compaction' and the summarize model File-backed objectives: - the objective text lives in <data-dir>/goals/<sessionId>.md, keyed by session id (one goal per session, a new goal overwrites the file); metadata carries only an objectiveFile flag so session.updated fanout stays light, and never a path — ids are pattern-validated before any filesystem access - limit raised to 5000 chars, no snapshot field: the UI fetches content via PUT/GET/DELETE /api/goals/objective/:sessionId (behind the blanket /api auth gate), writes the file before stamping metadata, and falls back to an inline objective when the write fails - the loop reads the file fresh on every tick, so objectives are live-editable mid-goal; a missing file falls back to the inline text - scheduled goal tasks write the objective file server-side; VS Code degrades to the audit note (route unavailable there by design)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { abortCurrentOperation, patchSessionMetadata } from '@/sync/session-actions';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
type SessionGoalPayload,
|
||||
@@ -29,6 +30,41 @@ const writeGoal = (
|
||||
return { ...metadata, openchamber: nextNamespace };
|
||||
});
|
||||
|
||||
// File-backed objectives: the text lives in a server-side file keyed by the
|
||||
// session id (one goal per session — a new goal overwrites the old file);
|
||||
// the metadata only carries an `objectiveFile: true` flag so it stays light
|
||||
// for session.updated fanout. If the file write fails (offline blip, VS
|
||||
// Code without the route), the objective falls back to inline metadata.
|
||||
const writeObjectiveFile = async (sessionId: string, content: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteObjectiveFile = (sessionId: string): void => {
|
||||
void runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
/** Fetch the file-backed objective text for display; null when unavailable. */
|
||||
export async function fetchGoalObjectiveContent(sessionId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await runtimeFetch(`/api/goals/objective/${encodeURIComponent(sessionId)}`);
|
||||
if (!response.ok) return null;
|
||||
const parsed = await response.json().catch(() => null) as { content?: unknown } | null;
|
||||
return typeof parsed?.content === 'string' ? parsed.content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SetSessionGoalInput {
|
||||
objective: string;
|
||||
tokenBudget: number | null;
|
||||
@@ -51,13 +87,15 @@ export async function setSessionGoal(
|
||||
const tokenBudget = typeof input.tokenBudget === 'number' && Number.isFinite(input.tokenBudget) && input.tokenBudget > 0
|
||||
? Math.floor(input.tokenBudget)
|
||||
: null;
|
||||
const objectiveFile = await writeObjectiveFile(sessionId, objective);
|
||||
const now = Date.now();
|
||||
await writeGoal(sessionId, directory, (currentGoal) => {
|
||||
if (existing && currentGoal && currentGoal.id === existing.id && existing.status !== 'complete') {
|
||||
// Edit in place: keep accounting, reactivate, clear stale audit state.
|
||||
return {
|
||||
...currentGoal,
|
||||
objective,
|
||||
objective: objectiveFile ? '' : objective,
|
||||
objectiveFile,
|
||||
tokenBudget,
|
||||
status: 'active',
|
||||
statusReason: 'resumed',
|
||||
@@ -67,7 +105,8 @@ export async function setSessionGoal(
|
||||
}
|
||||
return {
|
||||
id: createGoalId(),
|
||||
objective,
|
||||
objective: objectiveFile ? '' : objective,
|
||||
objectiveFile,
|
||||
status: 'active',
|
||||
tokenBudget,
|
||||
tokensUsed: 0,
|
||||
@@ -116,6 +155,7 @@ export async function clearSessionGoal(sessionId: string, directory: string | un
|
||||
wasActive = currentGoal?.status === 'active';
|
||||
return null;
|
||||
});
|
||||
deleteObjectiveFile(sessionId);
|
||||
// Removing a running goal is a "stop" too — abort the current turn like
|
||||
// pause does. A no-op when the session is idle.
|
||||
if (wasActive) {
|
||||
|
||||
@@ -9,11 +9,13 @@ export type SessionGoalStatus = 'active' | 'paused' | 'blocked' | 'budgetLimited
|
||||
|
||||
const SESSION_GOAL_STATUSES: SessionGoalStatus[] = ['active', 'paused', 'blocked', 'budgetLimited', 'complete'];
|
||||
|
||||
export const SESSION_GOAL_OBJECTIVE_CHAR_LIMIT = 2000;
|
||||
export const SESSION_GOAL_OBJECTIVE_CHAR_LIMIT = 5000;
|
||||
|
||||
export interface SessionGoalPayload {
|
||||
id: string;
|
||||
objective: string;
|
||||
/** True when the objective text lives in a server-side file keyed by session id. */
|
||||
objectiveFile: boolean;
|
||||
status: SessionGoalStatus;
|
||||
tokenBudget: number | null;
|
||||
tokensUsed: number;
|
||||
@@ -42,7 +44,8 @@ export function getSessionGoal(session: Session | null | undefined): SessionGoal
|
||||
|
||||
const id = typeof goal.id === 'string' ? goal.id : '';
|
||||
const objective = typeof goal.objective === 'string' ? goal.objective.trim() : '';
|
||||
if (!id || !objective || !isGoalStatus(goal.status)) return null;
|
||||
const objectiveFile = goal.objectiveFile === true;
|
||||
if (!id || (!objective && !objectiveFile) || !isGoalStatus(goal.status)) return null;
|
||||
|
||||
const tokenBudget = typeof goal.tokenBudget === 'number' && Number.isFinite(goal.tokenBudget) && goal.tokenBudget > 0
|
||||
? Math.floor(goal.tokenBudget)
|
||||
@@ -53,6 +56,7 @@ export function getSessionGoal(session: Session | null | undefined): SessionGoal
|
||||
return {
|
||||
id,
|
||||
objective: objective.slice(0, SESSION_GOAL_OBJECTIVE_CHAR_LIMIT),
|
||||
objectiveFile,
|
||||
status: goal.status,
|
||||
tokenBudget,
|
||||
tokensUsed: asCount(goal.tokensUsed),
|
||||
|
||||
Reference in New Issue
Block a user