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:
Bohdan Triapitsyn
2026-07-12 02:49:56 +03:00
parent 2c4b40893c
commit c9ac8676e7
12 changed files with 288 additions and 19 deletions
@@ -10,7 +10,7 @@ import { Textarea } from '@/components/ui/textarea';
import { NumberInput } from '@/components/ui/number-input';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
import {
formatGoalTokens,
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
@@ -32,6 +32,7 @@ interface SessionGoalDialogProps {
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
const { t } = useI18n();
const { goal } = useSessionGoal(sessionId, directory);
const objectiveContent = useGoalObjectiveContent(sessionId, goal);
const [objective, setObjective] = React.useState('');
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
@@ -40,7 +41,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
React.useEffect(() => {
if (!open) return;
setObjective(goal?.objective ?? '');
setObjective(goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? ''));
setBudgetEnabled(Boolean(goal?.tokenBudget));
setTokenBudget(goal?.tokenBudget ?? 200_000);
// Seed the form only when the dialog opens; live goal updates while it is
@@ -48,6 +49,14 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// File-backed objectives fetch async — the content usually lands right
// after the dialog opens. Late-seed the textarea only while it is still
// untouched so a slow fetch never clobbers the user's typing.
React.useEffect(() => {
if (!open || !goal?.objectiveFile || objectiveContent === null) return;
setObjective((current) => (current === '' ? objectiveContent : current));
}, [open, goal?.objectiveFile, objectiveContent]);
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
setBusy(true);
try {
@@ -62,7 +71,8 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
}, [onOpenChange, t]);
const trimmedObjective = objective.trim();
const objectiveChanged = trimmedObjective !== (goal?.objective ?? '');
const savedObjective = goal?.objectiveFile ? (objectiveContent ?? '') : (goal?.objective ?? '');
const objectiveChanged = trimmedObjective !== savedObjective;
const budgetValue = budgetEnabled ? tokenBudget : null;
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
// A completed goal is read-only: remove it and arm a new one instead of
@@ -112,7 +122,7 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
)}
{isCompleted ? (
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{goal.objective}</p>
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{objectiveContent ?? goal.objective}</p>
) : (
<>
<div className="space-y-1">
@@ -1,7 +1,7 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useSessionStatus } from '@/sync/sync-context';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useGoalObjectiveContent, useSessionGoal } from '@/hooks/useSessionGoal';
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
@@ -22,6 +22,7 @@ interface SessionGoalRowProps {
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
const { t } = useI18n();
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
const objectiveContent = useGoalObjectiveContent(sessionId ?? '', goal);
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
const [busy, setBusy] = React.useState(false);
@@ -65,11 +66,11 @@ export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessi
className,
)}
aria-label={t('chat.goal.row.aria')}
title={goal.objective}
title={objectiveContent ?? undefined}
>
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
{goal.note || goal.objective}
{goal.note || objectiveContent || ''}
</span>
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
// The agent stopped but the goal is still active: the server is
+29
View File
@@ -1,5 +1,7 @@
import React from 'react';
import { useSession } from '@/sync/sync-context';
import { getSessionGoal, type SessionGoalPayload } from '@/lib/sessionGoalMetadata';
import { fetchGoalObjectiveContent } from '@/lib/sessionGoalActions';
import { useUIStore } from '@/stores/useUIStore';
export interface SessionGoalState {
@@ -19,3 +21,30 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo
enabled,
};
}
// Effective objective text for display. Inline goals return the metadata
// text directly; file-backed goals fetch the server-side file once per
// goal edit (keyed by id + updatedAt). Display-only: a failed fetch yields
// null and callers degrade gracefully (e.g. VS Code, where the OpenChamber
// route is unavailable — the strip then shows only the audit note).
export function useGoalObjectiveContent(sessionId: string, goal: SessionGoalPayload | null): string | null {
const [fetched, setFetched] = React.useState<string | null>(null);
const fetchKey = goal?.objectiveFile ? `${sessionId}:${goal.id}:${goal.updatedAt}` : '';
React.useEffect(() => {
if (!fetchKey) {
setFetched(null);
return undefined;
}
let alive = true;
void fetchGoalObjectiveContent(sessionId).then((content) => {
if (alive) setFetched(content);
});
return () => {
alive = false;
};
}, [fetchKey, sessionId]);
if (!goal) return null;
return goal.objectiveFile ? fetched : goal.objective;
}
+42 -2
View File
@@ -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) {
+6 -2
View File
@@ -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),