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">