detached 61666d4

fix(goal): resume truncated autonomous work (#184)

* fix(goal): resume truncated autonomous work

* fix(goal): harden continuation transitions

* fix(goal): recover pending compaction
commit
61666d42e7dbb3daffe1041afcf5954e4c72589b
author
bnema · · 2026-09-06 10:33 UTC
committer
GitHub · · 2026-09-06 10:33 UTC
parent
1840cdb
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
16 files · +577 -43
M README.md +3 -3
@@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
268268 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
269269 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
270270 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
271-- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
271+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
272272 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
273273 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
274274
@@ -352,7 +352,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
352352
353353 ### `/goal`
354354
355-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
355+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
356356
357357 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
358358
@@ -364,7 +364,7 @@ Goals are unlimited by default. Zut records token use for each goal but only enf
364364 }
365365 ```
366366
367-When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
367+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
368368
369369 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
370370
@@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
268 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.268 - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
269 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).269 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md).
270 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).270 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
271-- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.271+- `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
272 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.272 - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes.
273 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.273 - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool.
274 274
@@ -352,7 +352,7 @@ Extension-registered commands appear under a divider at the bottom of the popup,
352 352
353 ### `/goal`353 ### `/goal`
354 354
355-In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.355+In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session.
356 356
357 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:357 Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`:
358 358
@@ -364,7 +364,7 @@ Goals are unlimited by default. Zut records token use for each goal but only enf
364 }364 }
365 ```365 ```
366 366
367-When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.367+When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. If a provider truncates an active goal because its output limit was reached, zut first compacts the durable conversation state, then resumes the goal in a fresh leased turn; this capacity boundary does not count as no progress or a task blocker. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it.
368 368
369 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.369 A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it.
370 370
M packages/agent/cli.go +23 -0
@@ -705,6 +705,7 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
705705 var (
706706 errGoalActiveReplacement = errors.New("active goal replacement")
707707 errGoalMissionMismatch = errors.New("goal mission mismatch")
708+ errGoalStaleUpdate = errors.New("stale goal update")
708709 errGoalStorageUnavailable = errors.New("goal storage unavailable")
709710 errGoalStateWrite = errors.New("goal state write")
710711 )
@@ -740,6 +741,8 @@ func safeGoalCommitMessage(err error) string {
740741 return "goal transition rejected: the current goal is still active"
741742 case errors.Is(err, errGoalMissionMismatch):
742743 return "goal transition rejected: it does not belong to the active mission"
744+ case errors.Is(err, errGoalStaleUpdate):
745+ return "goal transition rejected: the active goal changed"
743746 case errors.Is(err, errGoalStorageUnavailable):
744747 return "goal state unavailable: session persistence is disabled"
745748 case errors.Is(err, errGoalStateWrite):
@@ -757,6 +760,26 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
757760 if !ok {
758761 return nil
759762 }
763+ if update.Status == core.GoalSuperseded {
764+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
765+ return nil
766+ }
767+ if update.GoalID != sess.Meta.Goal.ID {
768+ return errGoalStaleUpdate
769+ }
770+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
771+ return errGoalMissionMismatch
772+ }
773+ replacement := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
774+ if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 {
775+ budget := *goalMaxTokenBudget[0]
776+ replacement.TokenBudget = &budget
777+ }
778+ if err := sess.SupersedeGoal(replacement); err != nil {
779+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
780+ }
781+ return nil
782+ }
760783 if update.Status == core.GoalActive {
761784 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
762785 return errGoalActiveReplacement
@@ -705,6 +705,7 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
705 var (705 var (
706 errGoalActiveReplacement = errors.New("active goal replacement")706 errGoalActiveReplacement = errors.New("active goal replacement")
707 errGoalMissionMismatch = errors.New("goal mission mismatch")707 errGoalMissionMismatch = errors.New("goal mission mismatch")
708+ errGoalStaleUpdate = errors.New("stale goal update")
708 errGoalStorageUnavailable = errors.New("goal storage unavailable")709 errGoalStorageUnavailable = errors.New("goal storage unavailable")
709 errGoalStateWrite = errors.New("goal state write")710 errGoalStateWrite = errors.New("goal state write")
710 )711 )
@@ -740,6 +741,8 @@ func safeGoalCommitMessage(err error) string {
740 return "goal transition rejected: the current goal is still active"741 return "goal transition rejected: the current goal is still active"
741 case errors.Is(err, errGoalMissionMismatch):742 case errors.Is(err, errGoalMissionMismatch):
742 return "goal transition rejected: it does not belong to the active mission"743 return "goal transition rejected: it does not belong to the active mission"
744+ case errors.Is(err, errGoalStaleUpdate):
745+ return "goal transition rejected: the active goal changed"
743 case errors.Is(err, errGoalStorageUnavailable):746 case errors.Is(err, errGoalStorageUnavailable):
744 return "goal state unavailable: session persistence is disabled"747 return "goal state unavailable: session persistence is disabled"
745 case errors.Is(err, errGoalStateWrite):748 case errors.Is(err, errGoalStateWrite):
@@ -757,6 +760,26 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo
757 if !ok {760 if !ok {
758 return nil761 return nil
759 }762 }
763+ if update.Status == core.GoalSuperseded {
764+ if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
765+ return nil
766+ }
767+ if update.GoalID != sess.Meta.Goal.ID {
768+ return errGoalStaleUpdate
769+ }
770+ if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID {
771+ return errGoalMissionMismatch
772+ }
773+ replacement := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}
774+ if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 {
775+ budget := *goalMaxTokenBudget[0]
776+ replacement.TokenBudget = &budget
777+ }
778+ if err := sess.SupersedeGoal(replacement); err != nil {
779+ return fmt.Errorf("%w: %w", errGoalStateWrite, err)
780+ }
781+ return nil
782+ }
760 if update.Status == core.GoalActive {783 if update.Status == core.GoalActive {
761 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {784 if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive {
762 return errGoalActiveReplacement785 return errGoalActiveReplacement
M packages/agent/cli_session_test.go +44 -0
@@ -302,6 +302,50 @@ func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
302302 }
303303 }
304304
305+func TestPersistGoalToolResultSupersedesActiveGoalInMission(t *testing.T) {
306+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ defer sess.Close()
311+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "try the legacy API", Status: core.GoalActive}); err != nil {
312+ t.Fatal(err)
313+ }
314+ missionID := sess.Meta.Mission.ID
315+ originalID := sess.Meta.Goal.ID
316+
317+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "use the supported API", GoalID: originalID, MissionID: missionID}}
318+ if err := persistGoalToolResult(sess, result); err != nil {
319+ t.Fatal(err)
320+ }
321+ if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != core.GoalActive || got.MissionID != missionID || got.Owner != core.GoalOwnerManager {
322+ t.Fatalf("replacement goal = %#v", got)
323+ }
324+ if len(sess.Meta.GoalHistory) != 1 || sess.Meta.GoalHistory[0].ID != originalID || sess.Meta.GoalHistory[0].Status != core.GoalSuperseded {
325+ t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
326+ }
327+}
328+
329+func TestPersistGoalToolResultRejectsStaleSupersedingGoal(t *testing.T) {
330+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ defer sess.Close()
335+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "new user goal", Status: core.GoalActive}); err != nil {
336+ t.Fatal(err)
337+ }
338+ before := *sess.Meta.Goal
339+
340+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "stale replacement", GoalID: "older-goal", MissionID: sess.Meta.Mission.ID}}
341+ if err := persistGoalToolResult(sess, result); !errors.Is(err, errGoalStaleUpdate) {
342+ t.Fatalf("persist stale supersede error = %v", err)
343+ }
344+ if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
345+ t.Fatalf("stale supersede changed state: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
346+ }
347+}
348+
305349 func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
306350 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
307351 if err != nil {
@@ -302,6 +302,50 @@ func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) {
302 }302 }
303 }303 }
304 304
305+func TestPersistGoalToolResultSupersedesActiveGoalInMission(t *testing.T) {
306+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ defer sess.Close()
311+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "try the legacy API", Status: core.GoalActive}); err != nil {
312+ t.Fatal(err)
313+ }
314+ missionID := sess.Meta.Mission.ID
315+ originalID := sess.Meta.Goal.ID
316+
317+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "use the supported API", GoalID: originalID, MissionID: missionID}}
318+ if err := persistGoalToolResult(sess, result); err != nil {
319+ t.Fatal(err)
320+ }
321+ if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != core.GoalActive || got.MissionID != missionID || got.Owner != core.GoalOwnerManager {
322+ t.Fatalf("replacement goal = %#v", got)
323+ }
324+ if len(sess.Meta.GoalHistory) != 1 || sess.Meta.GoalHistory[0].ID != originalID || sess.Meta.GoalHistory[0].Status != core.GoalSuperseded {
325+ t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
326+ }
327+}
328+
329+func TestPersistGoalToolResultRejectsStaleSupersedingGoal(t *testing.T) {
330+ sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
331+ if err != nil {
332+ t.Fatal(err)
333+ }
334+ defer sess.Close()
335+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "new user goal", Status: core.GoalActive}); err != nil {
336+ t.Fatal(err)
337+ }
338+ before := *sess.Meta.Goal
339+
340+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalSuperseded, Objective: "stale replacement", GoalID: "older-goal", MissionID: sess.Meta.Mission.ID}}
341+ if err := persistGoalToolResult(sess, result); !errors.Is(err, errGoalStaleUpdate) {
342+ t.Fatalf("persist stale supersede error = %v", err)
343+ }
344+ if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
345+ t.Fatalf("stale supersede changed state: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
346+ }
347+}
348+
305 func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {349 func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) {
306 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")350 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version")
307 if err != nil {351 if err != nil {
M packages/agent/modes/auto_compact_test.go +10 -0
@@ -316,6 +316,16 @@ func TestDecodeCompactHandoff(t *testing.T) {
316316 raw: `{"version":1,"reason":"forced_length"}`,
317317 want: compactContinuationState{reason: compactContinuationForcedLength},
318318 },
319+ {
320+ name: "goal compaction pending",
321+ raw: `{"version":1,"reason":"goal_compaction_pending"}`,
322+ want: compactContinuationState{reason: compactContinuationGoalCompactionPending},
323+ },
324+ {
325+ name: "goal",
326+ raw: `{"version":1,"reason":"goal"}`,
327+ want: compactContinuationState{reason: compactContinuationGoal},
328+ },
319329 {
320330 name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
321331 {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
@@ -316,6 +316,16 @@ func TestDecodeCompactHandoff(t *testing.T) {
316 raw: `{"version":1,"reason":"forced_length"}`,316 raw: `{"version":1,"reason":"forced_length"}`,
317 want: compactContinuationState{reason: compactContinuationForcedLength},317 want: compactContinuationState{reason: compactContinuationForcedLength},
318 },318 },
319+ {
320+ name: "goal compaction pending",
321+ raw: `{"version":1,"reason":"goal_compaction_pending"}`,
322+ want: compactContinuationState{reason: compactContinuationGoalCompactionPending},
323+ },
324+ {
325+ name: "goal",
326+ raw: `{"version":1,"reason":"goal"}`,
327+ want: compactContinuationState{reason: compactContinuationGoal},
328+ },
319 {329 {
320 name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},330 name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`},
321 {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},331 {name: "unknown reason", raw: `{"version":1,"reason":"other"}`},
M packages/agent/modes/compact_queue_test.go +215 -0
@@ -69,6 +69,54 @@ type autoCompactPacerClient struct {
6969 calls int
7070 }
7171
72+type goalLengthClient struct {
73+ requests chan provider.Request
74+ onThird func()
75+
76+ mu sync.Mutex
77+ calls int
78+}
79+
80+func (c *goalLengthClient) Name() string { return "goal-length-test" }
81+
82+func (c *goalLengthClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
83+ c.mu.Lock()
84+ c.calls++
85+ call := c.calls
86+ c.mu.Unlock()
87+
88+ out := make(chan provider.Event, 2)
89+ go func() {
90+ defer close(out)
91+ c.requests <- req
92+ switch call {
93+ case 1:
94+ out <- provider.EventDone{
95+ Stop: provider.StopLength,
96+ Message: provider.Message{
97+ Role: provider.RoleAssistant,
98+ Content: []provider.Content{provider.TextBlock{Text: "partial work"}},
99+ },
100+ }
101+ case 2:
102+ out <- provider.EventTextDelta{Delta: "durable summary"}
103+ out <- provider.EventDone{Stop: provider.StopEnd}
104+ default:
105+ if c.onThird != nil {
106+ c.onThird()
107+ }
108+ out <- provider.EventDone{
109+ Stop: provider.StopEnd,
110+ Message: provider.Message{
111+ Role: provider.RoleAssistant,
112+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
113+ },
114+ }
115+ }
116+ }()
117+ return out, nil
118+}
119+
72120 func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }
73121
74122 func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
@@ -141,6 +189,173 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
141189 close(client.releaseCompaction)
142190 }
143191
192+func TestActiveGoalLengthStopCompactsAndContinuesAsGoal(t *testing.T) {
193+ var goalMu sync.Mutex
194+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
195+ currentGoal := func() *core.SessionGoal {
196+ goalMu.Lock()
197+ defer goalMu.Unlock()
198+ return cloneSessionGoal(goal)
199+ }
200+ persistGoal := func(next *core.SessionGoal) error {
201+ goalMu.Lock()
202+ goal = cloneSessionGoal(next)
203+ goalMu.Unlock()
204+ return nil
205+ }
206+ client := &goalLengthClient{
207+ requests: make(chan provider.Request, 3),
208+ onThird: func() {
209+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
210+ },
211+ }
212+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
213+ persistedHandoff := make(chan string, 4)
214+ interactive := NewInteractive(InteractiveConfig{
215+ Agent: agent,
216+ CurrentGoal: currentGoal,
217+ PersistGoal: persistGoal,
218+ PersistCompactHandoff: func(state json.RawMessage) error {
219+ persistedHandoff <- string(state)
220+ return nil
221+ },
222+ })
223+ interactive.runCtx = context.Background()
224+
225+ run, err := interactive.startGoalRun(currentGoal())
226+ if err != nil || run == nil {
227+ t.Fatalf("start goal run = (%v, %v)", run, err)
228+ }
229+ interactive.startGoalContinuation(context.Background(), mustGoalContinuationMessage(t, interactive), run)
230+
231+ first := receiveRequest(t, client.requests)
232+ if first.Messages[len(first.Messages)-1].Meta[goalContinueMetaKey] != "true" {
233+ t.Fatalf("initial request tail = %#v, want goal continuation", first.Messages)
234+ }
235+ select {
236+ case handoff := <-persistedHandoff:
237+ if handoff != `{"version":1,"reason":"goal_compaction_pending"}` {
238+ t.Fatalf("pre-compaction handoff = %q, want durable goal owner", handoff)
239+ }
240+ case <-time.After(2 * time.Second):
241+ t.Fatal("goal handoff was not persisted before compaction")
242+ }
243+ compaction := receiveRequest(t, client.requests)
244+ if compaction.MaxTokens != 4096 {
245+ t.Fatalf("second request MaxTokens = %d, want compaction request", compaction.MaxTokens)
246+ }
247+ continued := receiveRequest(t, client.requests)
248+ if continued.Messages[len(continued.Messages)-1].Meta[goalContinueMetaKey] != "true" {
249+ t.Fatalf("post-compaction request tail = %#v, want leased goal continuation", continued.Messages)
250+ }
251+
252+ waitInteractiveIdle(t, interactive)
253+}
254+
255+func TestRestoredPendingGoalCompactionRunsBeforeGoalLease(t *testing.T) {
256+ var goalMu sync.Mutex
257+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
258+ currentGoal := func() *core.SessionGoal {
259+ goalMu.Lock()
260+ defer goalMu.Unlock()
261+ return cloneSessionGoal(goal)
262+ }
263+ persistGoal := func(next *core.SessionGoal) error {
264+ goalMu.Lock()
265+ goal = cloneSessionGoal(next)
266+ goalMu.Unlock()
267+ return nil
268+ }
269+ client := &goalLengthClient{
270+ requests: make(chan provider.Request, 2),
271+ calls: 1,
272+ onThird: func() {
273+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
274+ },
275+ }
276+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
277+ agent.SetMessages([]provider.Message{
278+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "original goal work"}}},
279+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "truncated"}}},
280+ })
281+ interactive := NewInteractive(InteractiveConfig{
282+ Agent: agent,
283+ CurrentGoal: currentGoal,
284+ PersistGoal: persistGoal,
285+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal_compaction_pending"}`),
286+ })
287+ interactive.runCtx = context.Background()
288+
289+ interactive.startRestoredCompactHandoff(context.Background())
290+ compaction := receiveRequest(t, client.requests)
291+ if compaction.MaxTokens != 4096 {
292+ t.Fatalf("restored first request MaxTokens = %d, want compaction", compaction.MaxTokens)
293+ }
294+ request := receiveRequest(t, client.requests)
295+ if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
296+ t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
297+ }
298+ waitInteractiveIdle(t, interactive)
299+}
300+
301+func TestRestoredGoalCompactHandoffResumesWithGoalLease(t *testing.T) {
302+ var goalMu sync.Mutex
303+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
304+ currentGoal := func() *core.SessionGoal {
305+ goalMu.Lock()
306+ defer goalMu.Unlock()
307+ return cloneSessionGoal(goal)
308+ }
309+ persistGoal := func(next *core.SessionGoal) error {
310+ goalMu.Lock()
311+ goal = cloneSessionGoal(next)
312+ goalMu.Unlock()
313+ return nil
314+ }
315+ client := &goalLengthClient{
316+ requests: make(chan provider.Request, 1),
317+ calls: 2,
318+ onThird: func() {
319+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
320+ },
321+ }
322+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
323+ interactive := NewInteractive(InteractiveConfig{
324+ Agent: agent,
325+ CurrentGoal: currentGoal,
326+ PersistGoal: persistGoal,
327+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal"}`),
328+ })
329+ interactive.runCtx = context.Background()
330+
331+ interactive.startRestoredCompactHandoff(context.Background())
332+ request := receiveRequest(t, client.requests)
333+ if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
334+ t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
335+ }
336+ waitInteractiveIdle(t, interactive)
337+}
338+
339+func mustGoalContinuationMessage(t *testing.T, interactive *Interactive) provider.Message {
340+ t.Helper()
341+ message, ok := interactive.goalContinuationMessage()
342+ if !ok {
343+ t.Fatal("goal continuation message unavailable")
344+ }
345+ return message
346+}
347+
348+func receiveRequest(t *testing.T, requests <-chan provider.Request) provider.Request {
349+ t.Helper()
350+ select {
351+ case req := <-requests:
352+ return req
353+ case <-time.After(2 * time.Second):
354+ t.Fatal("timed out waiting for provider request")
355+ return provider.Request{}
356+ }
357+}
358+
144359 func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
145360 var goalMu sync.Mutex
146361 goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
@@ -69,6 +69,54 @@ type autoCompactPacerClient struct {
69 calls int69 calls int
70 }70 }
71 71
72+type goalLengthClient struct {
73+ requests chan provider.Request
74+ onThird func()
75+
76+ mu sync.Mutex
77+ calls int
78+}
79+
80+func (c *goalLengthClient) Name() string { return "goal-length-test" }
81+
82+func (c *goalLengthClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
83+ c.mu.Lock()
84+ c.calls++
85+ call := c.calls
86+ c.mu.Unlock()
87+
88+ out := make(chan provider.Event, 2)
89+ go func() {
90+ defer close(out)
91+ c.requests <- req
92+ switch call {
93+ case 1:
94+ out <- provider.EventDone{
95+ Stop: provider.StopLength,
96+ Message: provider.Message{
97+ Role: provider.RoleAssistant,
98+ Content: []provider.Content{provider.TextBlock{Text: "partial work"}},
99+ },
100+ }
101+ case 2:
102+ out <- provider.EventTextDelta{Delta: "durable summary"}
103+ out <- provider.EventDone{Stop: provider.StopEnd}
104+ default:
105+ if c.onThird != nil {
106+ c.onThird()
107+ }
108+ out <- provider.EventDone{
109+ Stop: provider.StopEnd,
110+ Message: provider.Message{
111+ Role: provider.RoleAssistant,
112+ Content: []provider.Content{provider.TextBlock{Text: "continued"}},
113+ },
114+ }
115+ }
116+ }()
117+ return out, nil
118+}
119+
72 func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }120 func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" }
73 121
74 func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {122 func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
@@ -141,6 +189,173 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
141 close(client.releaseCompaction)189 close(client.releaseCompaction)
142 }190 }
143 191
192+func TestActiveGoalLengthStopCompactsAndContinuesAsGoal(t *testing.T) {
193+ var goalMu sync.Mutex
194+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
195+ currentGoal := func() *core.SessionGoal {
196+ goalMu.Lock()
197+ defer goalMu.Unlock()
198+ return cloneSessionGoal(goal)
199+ }
200+ persistGoal := func(next *core.SessionGoal) error {
201+ goalMu.Lock()
202+ goal = cloneSessionGoal(next)
203+ goalMu.Unlock()
204+ return nil
205+ }
206+ client := &goalLengthClient{
207+ requests: make(chan provider.Request, 3),
208+ onThird: func() {
209+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
210+ },
211+ }
212+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
213+ persistedHandoff := make(chan string, 4)
214+ interactive := NewInteractive(InteractiveConfig{
215+ Agent: agent,
216+ CurrentGoal: currentGoal,
217+ PersistGoal: persistGoal,
218+ PersistCompactHandoff: func(state json.RawMessage) error {
219+ persistedHandoff <- string(state)
220+ return nil
221+ },
222+ })
223+ interactive.runCtx = context.Background()
224+
225+ run, err := interactive.startGoalRun(currentGoal())
226+ if err != nil || run == nil {
227+ t.Fatalf("start goal run = (%v, %v)", run, err)
228+ }
229+ interactive.startGoalContinuation(context.Background(), mustGoalContinuationMessage(t, interactive), run)
230+
231+ first := receiveRequest(t, client.requests)
232+ if first.Messages[len(first.Messages)-1].Meta[goalContinueMetaKey] != "true" {
233+ t.Fatalf("initial request tail = %#v, want goal continuation", first.Messages)
234+ }
235+ select {
236+ case handoff := <-persistedHandoff:
237+ if handoff != `{"version":1,"reason":"goal_compaction_pending"}` {
238+ t.Fatalf("pre-compaction handoff = %q, want durable goal owner", handoff)
239+ }
240+ case <-time.After(2 * time.Second):
241+ t.Fatal("goal handoff was not persisted before compaction")
242+ }
243+ compaction := receiveRequest(t, client.requests)
244+ if compaction.MaxTokens != 4096 {
245+ t.Fatalf("second request MaxTokens = %d, want compaction request", compaction.MaxTokens)
246+ }
247+ continued := receiveRequest(t, client.requests)
248+ if continued.Messages[len(continued.Messages)-1].Meta[goalContinueMetaKey] != "true" {
249+ t.Fatalf("post-compaction request tail = %#v, want leased goal continuation", continued.Messages)
250+ }
251+
252+ waitInteractiveIdle(t, interactive)
253+}
254+
255+func TestRestoredPendingGoalCompactionRunsBeforeGoalLease(t *testing.T) {
256+ var goalMu sync.Mutex
257+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
258+ currentGoal := func() *core.SessionGoal {
259+ goalMu.Lock()
260+ defer goalMu.Unlock()
261+ return cloneSessionGoal(goal)
262+ }
263+ persistGoal := func(next *core.SessionGoal) error {
264+ goalMu.Lock()
265+ goal = cloneSessionGoal(next)
266+ goalMu.Unlock()
267+ return nil
268+ }
269+ client := &goalLengthClient{
270+ requests: make(chan provider.Request, 2),
271+ calls: 1,
272+ onThird: func() {
273+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
274+ },
275+ }
276+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
277+ agent.SetMessages([]provider.Message{
278+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "original goal work"}}},
279+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "truncated"}}},
280+ })
281+ interactive := NewInteractive(InteractiveConfig{
282+ Agent: agent,
283+ CurrentGoal: currentGoal,
284+ PersistGoal: persistGoal,
285+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal_compaction_pending"}`),
286+ })
287+ interactive.runCtx = context.Background()
288+
289+ interactive.startRestoredCompactHandoff(context.Background())
290+ compaction := receiveRequest(t, client.requests)
291+ if compaction.MaxTokens != 4096 {
292+ t.Fatalf("restored first request MaxTokens = %d, want compaction", compaction.MaxTokens)
293+ }
294+ request := receiveRequest(t, client.requests)
295+ if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
296+ t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
297+ }
298+ waitInteractiveIdle(t, interactive)
299+}
300+
301+func TestRestoredGoalCompactHandoffResumesWithGoalLease(t *testing.T) {
302+ var goalMu sync.Mutex
303+ goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalActive}
304+ currentGoal := func() *core.SessionGoal {
305+ goalMu.Lock()
306+ defer goalMu.Unlock()
307+ return cloneSessionGoal(goal)
308+ }
309+ persistGoal := func(next *core.SessionGoal) error {
310+ goalMu.Lock()
311+ goal = cloneSessionGoal(next)
312+ goalMu.Unlock()
313+ return nil
314+ }
315+ client := &goalLengthClient{
316+ requests: make(chan provider.Request, 1),
317+ calls: 2,
318+ onThird: func() {
319+ _ = persistGoal(&core.SessionGoal{ID: "goal-1", Objective: "finish the goal", Status: core.GoalPaused})
320+ },
321+ }
322+ agent := core.NewAgent(client, "test-model", "", goalToolRegistry())
323+ interactive := NewInteractive(InteractiveConfig{
324+ Agent: agent,
325+ CurrentGoal: currentGoal,
326+ PersistGoal: persistGoal,
327+ InitialCompactHandoff: json.RawMessage(`{"version":1,"reason":"goal"}`),
328+ })
329+ interactive.runCtx = context.Background()
330+
331+ interactive.startRestoredCompactHandoff(context.Background())
332+ request := receiveRequest(t, client.requests)
333+ if request.Messages[len(request.Messages)-1].Meta[goalContinueMetaKey] != "true" {
334+ t.Fatalf("restored request tail = %#v, want goal continuation", request.Messages)
335+ }
336+ waitInteractiveIdle(t, interactive)
337+}
338+
339+func mustGoalContinuationMessage(t *testing.T, interactive *Interactive) provider.Message {
340+ t.Helper()
341+ message, ok := interactive.goalContinuationMessage()
342+ if !ok {
343+ t.Fatal("goal continuation message unavailable")
344+ }
345+ return message
346+}
347+
348+func receiveRequest(t *testing.T, requests <-chan provider.Request) provider.Request {
349+ t.Helper()
350+ select {
351+ case req := <-requests:
352+ return req
353+ case <-time.After(2 * time.Second):
354+ t.Fatal("timed out waiting for provider request")
355+ return provider.Request{}
356+ }
357+}
358+
144 func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {359 func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) {
145 var goalMu sync.Mutex360 var goalMu sync.Mutex
146 goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}361 goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive}
M packages/agent/modes/goal.go +41 -1
@@ -30,10 +30,13 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
3030 return provider.Message{}, false
3131 }
3232 text := goal.Objective +
33- "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."
33+ "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal. If the current objective has become invalid and a replacement is necessary, you may supersede it only with a concrete objective inside the same mission; never use superseded to avoid difficult work or broaden scope."
3434 if goal.ConsecutiveNoProgressTurns > 0 {
3535 text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."
3636 }
37+ if goal.ID != "" {
38+ text += " When superseding this goal, use goal_id " + goal.ID + "."
39+ }
3740 if goal.MissionID != "" {
3841 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
3942 }
@@ -82,6 +85,43 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
8285 return true
8386 }
8487
88+// startReservedGoalContinuation starts a goal turn after another controller
89+// has already reserved the interactive turn slot by setting busy. It is used
90+// by compact handoffs so a fresh-context continuation receives the same lease,
91+// accounting, and no-progress policy as an ordinary idle goal turn.
92+func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
93+ message, ok := i.goalContinuationMessage()
94+ if !ok || i.cfg.CurrentGoal == nil {
95+ i.mu.Lock()
96+ i.busy = false
97+ i.mu.Unlock()
98+ i.resetCompactHandoff()
99+ i.invalidate()
100+ return
101+ }
102+ goal := copySessionGoal(i.cfg.CurrentGoal())
103+ if goal == nil || i.limitGoalBeforeRun(goal) {
104+ i.mu.Lock()
105+ i.busy = false
106+ i.mu.Unlock()
107+ i.resetCompactHandoff()
108+ i.invalidate()
109+ return
110+ }
111+ run, err := i.startGoalRun(goal)
112+ if err != nil || run == nil {
113+ i.mu.Lock()
114+ i.busy = false
115+ i.mu.Unlock()
116+ if err != nil {
117+ i.ReportError(err)
118+ }
119+ i.invalidate()
120+ return
121+ }
122+ i.startGoalContinuation(parent, message, run)
123+}
124+
85125 func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
86126 goalMessage, goalActive := i.goalContinuationMessage()
87127 i.mu.Lock()
@@ -30,10 +30,13 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) {
30 return provider.Message{}, false30 return provider.Message{}, false
31 }31 }
32 text := goal.Objective +32 text := goal.Objective +
33- "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal."33+ "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal. If the current objective has become invalid and a replacement is necessary, you may supersede it only with a concrete objective inside the same mission; never use superseded to avoid difficult work or broaden scope."
34 if goal.ConsecutiveNoProgressTurns > 0 {34 if goal.ConsecutiveNoProgressTurns > 0 {
35 text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."35 text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue."
36 }36 }
37+ if goal.ID != "" {
38+ text += " When superseding this goal, use goal_id " + goal.ID + "."
39+ }
37 if goal.MissionID != "" {40 if goal.MissionID != "" {
38 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."41 text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission."
39 }42 }
@@ -82,6 +85,43 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool
82 return true85 return true
83 }86 }
84 87
88+// startReservedGoalContinuation starts a goal turn after another controller
89+// has already reserved the interactive turn slot by setting busy. It is used
90+// by compact handoffs so a fresh-context continuation receives the same lease,
91+// accounting, and no-progress policy as an ordinary idle goal turn.
92+func (i *Interactive) startReservedGoalContinuation(parent context.Context) {
93+ message, ok := i.goalContinuationMessage()
94+ if !ok || i.cfg.CurrentGoal == nil {
95+ i.mu.Lock()
96+ i.busy = false
97+ i.mu.Unlock()
98+ i.resetCompactHandoff()
99+ i.invalidate()
100+ return
101+ }
102+ goal := copySessionGoal(i.cfg.CurrentGoal())
103+ if goal == nil || i.limitGoalBeforeRun(goal) {
104+ i.mu.Lock()
105+ i.busy = false
106+ i.mu.Unlock()
107+ i.resetCompactHandoff()
108+ i.invalidate()
109+ return
110+ }
111+ run, err := i.startGoalRun(goal)
112+ if err != nil || run == nil {
113+ i.mu.Lock()
114+ i.busy = false
115+ i.mu.Unlock()
116+ if err != nil {
117+ i.ReportError(err)
118+ }
119+ i.invalidate()
120+ return
121+ }
122+ i.startGoalContinuation(parent, message, run)
123+}
124+
85 func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {125 func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) {
86 goalMessage, goalActive := i.goalContinuationMessage()126 goalMessage, goalActive := i.goalContinuationMessage()
87 i.mu.Lock()127 i.mu.Lock()
M packages/agent/modes/goal_runtime.go +9 -4
@@ -124,9 +124,11 @@ func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
124124 }
125125
126126 // finishGoalRun records accounting exactly once and returns whether the
127-// controller should attempt another autonomous continuation. A stale run is
128-// discarded without changing a newer goal.
129-func (i *Interactive) finishGoalRun() bool {
127+// controller should attempt another autonomous continuation. Context-limited
128+// turns retain their prior progress classification because output truncation
129+// is not evidence that the agent stopped acting. A stale run is discarded
130+// without changing a newer goal.
131+func (i *Interactive) finishGoalRun(contextLimited bool) bool {
130132 i.mu.Lock()
131133 run := i.goalRun
132134 i.goalRun = nil
@@ -170,7 +172,10 @@ func (i *Interactive) finishGoalRun() bool {
170172 i.setGoalStatus(goal)
171173 return false
172174 }
173- if run.hadTool {
175+ if contextLimited {
176+ // The compact handoff provisions a fresh turn below. Do not let an
177+ // execution-capacity boundary consume the no-progress allowance.
178+ } else if run.hadTool {
174179 goal.ConsecutiveNoProgressTurns = 0
175180 } else {
176181 goal.ConsecutiveNoProgressTurns++
@@ -124,9 +124,11 @@ func (i *Interactive) observeGoalRun(ev core.AgentEvent) {
124 }124 }
125 125
126 // finishGoalRun records accounting exactly once and returns whether the126 // finishGoalRun records accounting exactly once and returns whether the
127-// controller should attempt another autonomous continuation. A stale run is127+// controller should attempt another autonomous continuation. Context-limited
128-// discarded without changing a newer goal.128+// turns retain their prior progress classification because output truncation
129-func (i *Interactive) finishGoalRun() bool {129+// is not evidence that the agent stopped acting. A stale run is discarded
130+// without changing a newer goal.
131+func (i *Interactive) finishGoalRun(contextLimited bool) bool {
130 i.mu.Lock()132 i.mu.Lock()
131 run := i.goalRun133 run := i.goalRun
132 i.goalRun = nil134 i.goalRun = nil
@@ -170,7 +172,10 @@ func (i *Interactive) finishGoalRun() bool {
170 i.setGoalStatus(goal)172 i.setGoalStatus(goal)
171 return false173 return false
172 }174 }
173- if run.hadTool {175+ if contextLimited {
176+ // The compact handoff provisions a fresh turn below. Do not let an
177+ // execution-capacity boundary consume the no-progress allowance.
178+ } else if run.hadTool {
174 goal.ConsecutiveNoProgressTurns = 0179 goal.ConsecutiveNoProgressTurns = 0
175 } else {180 } else {
176 goal.ConsecutiveNoProgressTurns++181 goal.ConsecutiveNoProgressTurns++
M packages/agent/modes/goal_runtime_test.go +35 -4
@@ -108,7 +108,7 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
108108 if err != nil || first == nil {
109109 t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110110 }
111- if !interactive.finishGoalRun() {
111+ if !interactive.finishGoalRun(false) {
112112 t.Fatal("first non-progress run should receive one corrective continuation")
113113 }
114114 if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
@@ -119,7 +119,7 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
119119 if err != nil || second == nil {
120120 t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121121 }
122- if interactive.finishGoalRun() {
122+ if interactive.finishGoalRun(false) {
123123 t.Fatal("second non-progress run scheduled another continuation")
124124 }
125125 got := current()
@@ -131,6 +131,37 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
131131 }
132132 }
133133
134+func TestGoalRuntimeContextLimitDoesNotConsumeNoProgressAllowance(t *testing.T) {
135+ var mu sync.Mutex
136+ goal := &core.SessionGoal{
137+ ID: "goal-1",
138+ Objective: "finish the work",
139+ Status: core.GoalActive,
140+ ConsecutiveNoProgressTurns: 1,
141+ }
142+ current := func() *core.SessionGoal {
143+ mu.Lock()
144+ defer mu.Unlock()
145+ return cloneSessionGoal(goal)
146+ }
147+ persist := func(next *core.SessionGoal) error {
148+ mu.Lock()
149+ goal = cloneSessionGoal(next)
150+ mu.Unlock()
151+ return nil
152+ }
153+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
154+ if run, err := interactive.startGoalRun(current()); err != nil || run == nil {
155+ t.Fatalf("start goal run = (%v, %v)", run, err)
156+ }
157+ if !interactive.finishGoalRun(true) {
158+ t.Fatal("context-limited run did not request a fresh continuation")
159+ }
160+ if got := current(); got.Status != core.GoalActive || got.ConsecutiveNoProgressTurns != 1 {
161+ t.Fatalf("context-limited goal = %#v", got)
162+ }
163+}
164+
134165 func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135166 for _, test := range []struct {
136167 name string
@@ -162,7 +193,7 @@ func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
162193 }
163194 interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164195 interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165- if got := interactive.finishGoalRun(); got != test.wantNext {
196+ if got := interactive.finishGoalRun(false); got != test.wantNext {
166197 t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167198 }
168199 got := current()
@@ -219,7 +250,7 @@ func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
219250 if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220251 t.Fatal(err)
221252 }
222- if interactive.finishGoalRun() {
253+ if interactive.finishGoalRun(false) {
223254 t.Fatal("stale run scheduled a continuation")
224255 }
225256 if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
@@ -108,7 +108,7 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
108 if err != nil || first == nil {108 if err != nil || first == nil {
109 t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)109 t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110 }110 }
111- if !interactive.finishGoalRun() {111+ if !interactive.finishGoalRun(false) {
112 t.Fatal("first non-progress run should receive one corrective continuation")112 t.Fatal("first non-progress run should receive one corrective continuation")
113 }113 }
114 if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {114 if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
@@ -119,7 +119,7 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
119 if err != nil || second == nil {119 if err != nil || second == nil {
120 t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)120 t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121 }121 }
122- if interactive.finishGoalRun() {122+ if interactive.finishGoalRun(false) {
123 t.Fatal("second non-progress run scheduled another continuation")123 t.Fatal("second non-progress run scheduled another continuation")
124 }124 }
125 got := current()125 got := current()
@@ -131,6 +131,37 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
131 }131 }
132 }132 }
133 133
134+func TestGoalRuntimeContextLimitDoesNotConsumeNoProgressAllowance(t *testing.T) {
135+ var mu sync.Mutex
136+ goal := &core.SessionGoal{
137+ ID: "goal-1",
138+ Objective: "finish the work",
139+ Status: core.GoalActive,
140+ ConsecutiveNoProgressTurns: 1,
141+ }
142+ current := func() *core.SessionGoal {
143+ mu.Lock()
144+ defer mu.Unlock()
145+ return cloneSessionGoal(goal)
146+ }
147+ persist := func(next *core.SessionGoal) error {
148+ mu.Lock()
149+ goal = cloneSessionGoal(next)
150+ mu.Unlock()
151+ return nil
152+ }
153+ interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
154+ if run, err := interactive.startGoalRun(current()); err != nil || run == nil {
155+ t.Fatalf("start goal run = (%v, %v)", run, err)
156+ }
157+ if !interactive.finishGoalRun(true) {
158+ t.Fatal("context-limited run did not request a fresh continuation")
159+ }
160+ if got := current(); got.Status != core.GoalActive || got.ConsecutiveNoProgressTurns != 1 {
161+ t.Fatalf("context-limited goal = %#v", got)
162+ }
163+}
164+
134 func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {165 func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135 for _, test := range []struct {166 for _, test := range []struct {
136 name string167 name string
@@ -162,7 +193,7 @@ func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
162 }193 }
163 interactive.observeGoalRun(core.EvToolCall{Name: "bash"})194 interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164 interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})195 interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165- if got := interactive.finishGoalRun(); got != test.wantNext {196+ if got := interactive.finishGoalRun(false); got != test.wantNext {
166 t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)197 t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167 }198 }
168 got := current()199 got := current()
@@ -219,7 +250,7 @@ func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
219 if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {250 if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220 t.Fatal(err)251 t.Fatal(err)
221 }252 }
222- if interactive.finishGoalRun() {253+ if interactive.finishGoalRun(false) {
223 t.Fatal("stale run scheduled a continuation")254 t.Fatal("stale run scheduled a continuation")
224 }255 }
225 if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {256 if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
M packages/agent/modes/interactive.go +14 -0
@@ -566,6 +566,8 @@ const (
566566 compactContinuationNone compactContinuationReason = iota
567567 compactContinuationStructuralTail
568568 compactContinuationForcedLength
569+ compactContinuationGoalCompactionPending
570+ compactContinuationGoal
569571 compactContinuationStatusRescue
570572 )
571573
@@ -598,6 +600,14 @@ func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
598600 if persisted.RescueAttempts == 0 {
599601 return compactContinuationState{reason: compactContinuationForcedLength}
600602 }
603+ case "goal_compaction_pending":
604+ if persisted.RescueAttempts == 0 {
605+ return compactContinuationState{reason: compactContinuationGoalCompactionPending}
606+ }
607+ case "goal":
608+ if persisted.RescueAttempts == 0 {
609+ return compactContinuationState{reason: compactContinuationGoal}
610+ }
601611 case "status_rescue":
602612 if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
603613 return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
@@ -613,6 +623,10 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
613623 reason = "structural_tail"
614624 case compactContinuationForcedLength:
615625 reason = "forced_length"
626+ case compactContinuationGoalCompactionPending:
627+ reason = "goal_compaction_pending"
628+ case compactContinuationGoal:
629+ reason = "goal"
616630 case compactContinuationStatusRescue:
617631 reason = "status_rescue"
618632 default:
@@ -566,6 +566,8 @@ const (
566 compactContinuationNone compactContinuationReason = iota566 compactContinuationNone compactContinuationReason = iota
567 compactContinuationStructuralTail567 compactContinuationStructuralTail
568 compactContinuationForcedLength568 compactContinuationForcedLength
569+ compactContinuationGoalCompactionPending
570+ compactContinuationGoal
569 compactContinuationStatusRescue571 compactContinuationStatusRescue
570 )572 )
571 573
@@ -598,6 +600,14 @@ func decodeCompactHandoff(raw json.RawMessage) compactContinuationState {
598 if persisted.RescueAttempts == 0 {600 if persisted.RescueAttempts == 0 {
599 return compactContinuationState{reason: compactContinuationForcedLength}601 return compactContinuationState{reason: compactContinuationForcedLength}
600 }602 }
603+ case "goal_compaction_pending":
604+ if persisted.RescueAttempts == 0 {
605+ return compactContinuationState{reason: compactContinuationGoalCompactionPending}
606+ }
607+ case "goal":
608+ if persisted.RescueAttempts == 0 {
609+ return compactContinuationState{reason: compactContinuationGoal}
610+ }
601 case "status_rescue":611 case "status_rescue":
602 if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {612 if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations {
603 return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}613 return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts}
@@ -613,6 +623,10 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage {
613 reason = "structural_tail"623 reason = "structural_tail"
614 case compactContinuationForcedLength:624 case compactContinuationForcedLength:
615 reason = "forced_length"625 reason = "forced_length"
626+ case compactContinuationGoalCompactionPending:
627+ reason = "goal_compaction_pending"
628+ case compactContinuationGoal:
629+ reason = "goal"
616 case compactContinuationStatusRescue:630 case compactContinuationStatusRescue:
617 reason = "status_rescue"631 reason = "status_rescue"
618 default:632 default:
M packages/agent/modes/interactive_compaction.go +21 -22
@@ -113,17 +113,23 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
113113 var next core.QueuedMessage
114114 var hasNext bool
115115 continueQueued := false
116+ resumeGoalCompaction := false
117+ resumeGoal := false
116118 var resume compactHandoffResume
117119 var handoff json.RawMessage
118120 var persistHandoff bool
119121 switch {
120- case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
122+ case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && len(i.queued) > 0:
121123 next, i.queued = i.queued[0], i.queued[1:]
122124 hasNext = true
123125 handoff, persistHandoff = i.resetCompactContinuationLocked()
124- case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
126+ case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && ag.QueuedMessageCount() > 0:
125127 continueQueued = true
126128 handoff, persistHandoff = i.resetCompactContinuationLocked()
129+ case state.reason == compactContinuationGoalCompactionPending:
130+ resumeGoalCompaction = true
131+ case state.reason == compactContinuationGoal:
132+ resumeGoal = true
127133 default:
128134 resume = classifyCompactHandoffResume(ag.Messages())
129135 if resume == compactHandoffDiscard {
@@ -132,7 +138,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
132138 }
133139 // Reserve the turn slot before persisting or dispatching so concurrent input
134140 // can only queue behind the restored handoff, never start a competing turn.
135- starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
141+ starting := hasNext || continueQueued || resumeGoalCompaction || resumeGoal || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
136142 i.busy = starting
137143 i.mu.Unlock()
138144 if persistHandoff {
@@ -147,6 +153,10 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
147153 i.startTurnWithImages(parent, next.Text, next.Images)
148154 case continueQueued:
149155 i.startTurnRequest(parent, "", nil, true, false)
156+ case resumeGoalCompaction:
157+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery, force: true})
158+ case resumeGoal:
159+ i.startReservedGoalContinuation(parent)
150160 case resume == compactHandoffContinueExisting:
151161 i.startTurnRequest(parent, "", nil, true, false)
152162 case resume == compactHandoffAppendPrompt:
@@ -217,7 +227,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
217227 i.cancelTurn = cancel
218228 var initialHandoff json.RawMessage
219229 persistInitialHandoff := false
220- if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
230+ if request.origin == compactOriginManual || (request.origin == compactOriginRecovery && i.compactContinuation.reason != compactContinuationGoalCompactionPending) {
221231 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
222232 }
223233 i.statusErr = ""
@@ -255,7 +265,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
255265 }
256266 summary, err := i.agent.Compact(ctx, keepTail, sink)
257267 _ = summary
258- goalMessage, goalActive := i.goalContinuationMessage()
268+ _, goalActive := i.goalContinuationMessage()
259269 i.mu.Lock()
260270 // Keep busy/compacting asserted while cleanup and queue selection run.
261271 // Completion updates can arrive in this window; clearing busy before
@@ -327,6 +337,11 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
327337 i.pendingCompactImages = nil
328338 i.hasPendingCompactPrompt = false
329339 hasNext = true
340+ case continuationReason == compactContinuationForcedLength && goalActive:
341+ // Preserve the goal as the durable handoff owner so the fresh
342+ // turn uses goal accounting and can be resumed after a restart.
343+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
344+ continueGoal = !i.coordinatorHasPendingWorkers()
330345 case continuationReason == compactContinuationForcedLength:
331346 // Forced truncated-output continuation keeps its existing
332347 // priority over an explicitly queued prompt.
@@ -388,23 +403,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
388403 case continueAutomatically:
389404 i.startAutoCompactContinuation(p)
390405 case continueGoal:
391- var run *goalContinuationRun
392- if i.cfg.CurrentGoal != nil {
393- goal := copySessionGoal(i.cfg.CurrentGoal())
394- if !i.limitGoalBeforeRun(goal) {
395- run, err = i.startGoalRun(goal)
396- }
397- }
398- if err != nil || run == nil {
399- i.mu.Lock()
400- i.busy = false
401- i.mu.Unlock()
402- if err != nil {
403- i.ReportError(err)
404- }
405- return
406- }
407- i.startGoalContinuation(p, goalMessage, run)
406+ i.startReservedGoalContinuation(p)
408407 }
409408 }
410409 }()
@@ -113,17 +113,23 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
113 var next core.QueuedMessage113 var next core.QueuedMessage
114 var hasNext bool114 var hasNext bool
115 continueQueued := false115 continueQueued := false
116+ resumeGoalCompaction := false
117+ resumeGoal := false
116 var resume compactHandoffResume118 var resume compactHandoffResume
117 var handoff json.RawMessage119 var handoff json.RawMessage
118 var persistHandoff bool120 var persistHandoff bool
119 switch {121 switch {
120- case state.reason != compactContinuationForcedLength && len(i.queued) > 0:122+ case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && len(i.queued) > 0:
121 next, i.queued = i.queued[0], i.queued[1:]123 next, i.queued = i.queued[0], i.queued[1:]
122 hasNext = true124 hasNext = true
123 handoff, persistHandoff = i.resetCompactContinuationLocked()125 handoff, persistHandoff = i.resetCompactContinuationLocked()
124- case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:126+ case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && ag.QueuedMessageCount() > 0:
125 continueQueued = true127 continueQueued = true
126 handoff, persistHandoff = i.resetCompactContinuationLocked()128 handoff, persistHandoff = i.resetCompactContinuationLocked()
129+ case state.reason == compactContinuationGoalCompactionPending:
130+ resumeGoalCompaction = true
131+ case state.reason == compactContinuationGoal:
132+ resumeGoal = true
127 default:133 default:
128 resume = classifyCompactHandoffResume(ag.Messages())134 resume = classifyCompactHandoffResume(ag.Messages())
129 if resume == compactHandoffDiscard {135 if resume == compactHandoffDiscard {
@@ -132,7 +138,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
132 }138 }
133 // Reserve the turn slot before persisting or dispatching so concurrent input139 // Reserve the turn slot before persisting or dispatching so concurrent input
134 // can only queue behind the restored handoff, never start a competing turn.140 // can only queue behind the restored handoff, never start a competing turn.
135- starting := hasNext || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt141+ starting := hasNext || continueQueued || resumeGoalCompaction || resumeGoal || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
136 i.busy = starting142 i.busy = starting
137 i.mu.Unlock()143 i.mu.Unlock()
138 if persistHandoff {144 if persistHandoff {
@@ -147,6 +153,10 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
147 i.startTurnWithImages(parent, next.Text, next.Images)153 i.startTurnWithImages(parent, next.Text, next.Images)
148 case continueQueued:154 case continueQueued:
149 i.startTurnRequest(parent, "", nil, true, false)155 i.startTurnRequest(parent, "", nil, true, false)
156+ case resumeGoalCompaction:
157+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery, force: true})
158+ case resumeGoal:
159+ i.startReservedGoalContinuation(parent)
150 case resume == compactHandoffContinueExisting:160 case resume == compactHandoffContinueExisting:
151 i.startTurnRequest(parent, "", nil, true, false)161 i.startTurnRequest(parent, "", nil, true, false)
152 case resume == compactHandoffAppendPrompt:162 case resume == compactHandoffAppendPrompt:
@@ -217,7 +227,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
217 i.cancelTurn = cancel227 i.cancelTurn = cancel
218 var initialHandoff json.RawMessage228 var initialHandoff json.RawMessage
219 persistInitialHandoff := false229 persistInitialHandoff := false
220- if request.origin == compactOriginManual || request.origin == compactOriginRecovery {230+ if request.origin == compactOriginManual || (request.origin == compactOriginRecovery && i.compactContinuation.reason != compactContinuationGoalCompactionPending) {
221 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()231 initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
222 }232 }
223 i.statusErr = ""233 i.statusErr = ""
@@ -255,7 +265,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
255 }265 }
256 summary, err := i.agent.Compact(ctx, keepTail, sink)266 summary, err := i.agent.Compact(ctx, keepTail, sink)
257 _ = summary267 _ = summary
258- goalMessage, goalActive := i.goalContinuationMessage()268+ _, goalActive := i.goalContinuationMessage()
259 i.mu.Lock()269 i.mu.Lock()
260 // Keep busy/compacting asserted while cleanup and queue selection run.270 // Keep busy/compacting asserted while cleanup and queue selection run.
261 // Completion updates can arrive in this window; clearing busy before271 // Completion updates can arrive in this window; clearing busy before
@@ -327,6 +337,11 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
327 i.pendingCompactImages = nil337 i.pendingCompactImages = nil
328 i.hasPendingCompactPrompt = false338 i.hasPendingCompactPrompt = false
329 hasNext = true339 hasNext = true
340+ case continuationReason == compactContinuationForcedLength && goalActive:
341+ // Preserve the goal as the durable handoff owner so the fresh
342+ // turn uses goal accounting and can be resumed after a restart.
343+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoal})
344+ continueGoal = !i.coordinatorHasPendingWorkers()
330 case continuationReason == compactContinuationForcedLength:345 case continuationReason == compactContinuationForcedLength:
331 // Forced truncated-output continuation keeps its existing346 // Forced truncated-output continuation keeps its existing
332 // priority over an explicitly queued prompt.347 // priority over an explicitly queued prompt.
@@ -388,23 +403,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
388 case continueAutomatically:403 case continueAutomatically:
389 i.startAutoCompactContinuation(p)404 i.startAutoCompactContinuation(p)
390 case continueGoal:405 case continueGoal:
391- var run *goalContinuationRun406+ i.startReservedGoalContinuation(p)
392- if i.cfg.CurrentGoal != nil {
393- goal := copySessionGoal(i.cfg.CurrentGoal())
394- if !i.limitGoalBeforeRun(goal) {
395- run, err = i.startGoalRun(goal)
396- }
397- }
398- if err != nil || run == nil {
399- i.mu.Lock()
400- i.busy = false
401- i.mu.Unlock()
402- if err != nil {
403- i.ReportError(err)
404- }
405- return
406- }
407- i.startGoalContinuation(p, goalMessage, run)
408 }407 }
409 }408 }
410 }()409 }()
M packages/agent/modes/interactive_events.go +5 -1
@@ -172,7 +172,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
172172 i.bumpToolRevisionLocked(tc)
173173 }
174174 if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok {
175- if update.Status == core.GoalActive {
175+ if update.Status == core.GoalSuperseded && i.cfg.CurrentGoal != nil {
176+ if current := i.cfg.CurrentGoal(); current != nil {
177+ i.goalStatus = current.Status
178+ }
179+ } else if update.Status == core.GoalActive {
176180 // A manager may advance a terminal goal to the next persisted
177181 // goal in the same mission. Do not let a late tool result resume
178182 // a goal explicitly paused by the user.
@@ -172,7 +172,11 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
172 i.bumpToolRevisionLocked(tc)172 i.bumpToolRevisionLocked(tc)
173 }173 }
174 if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok {174 if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok {
175- if update.Status == core.GoalActive {175+ if update.Status == core.GoalSuperseded && i.cfg.CurrentGoal != nil {
176+ if current := i.cfg.CurrentGoal(); current != nil {
177+ i.goalStatus = current.Status
178+ }
179+ } else if update.Status == core.GoalActive {
176 // A manager may advance a terminal goal to the next persisted180 // A manager may advance a terminal goal to the next persisted
177 // goal in the same mission. Do not let a late tool result resume181 // goal in the same mission. Do not let a late tool result resume
178 // a goal explicitly paused by the user.182 // a goal explicitly paused by the user.
M packages/agent/modes/interactive_turn.go +14 -2
@@ -336,7 +336,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
336336 if terminalGoalError {
337337 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
338338 }
339- continueGoal := i.finishGoalRun()
339+ goalContextLimited := lastStop == provider.StopLength
340+ continueGoal := i.finishGoalRun(goalContextLimited)
340341 i.mu.Lock()
341342 awaitingPre := i.awaitingStartupPre
342343 // A newer explicit prompt may have cleared the handoff while the
@@ -372,10 +373,21 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
372373 agentQueued = i.agent.QueuedMessageCount()
373374 }
374375 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
375- shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
376+ // A truncated autonomous goal needs fresh execution capacity even when
377+ // context-usage metadata is absent or below the configured threshold.
378+ // Route it through compaction rather than immediately reusing the same
379+ // exhausted transcript.
380+ goalNeedsFreshContext := continueGoal && goalContextLimited
381+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && (goalNeedsFreshContext || i.shouldAutoCompactLocked())
376382 continueStatusRescue := false
377383 var handoff json.RawMessage
378384 var persistHandoff bool
385+ if goalNeedsFreshContext {
386+ // Persist ownership before compaction starts. If the process exits
387+ // while condensing, session resume repeats compaction before it
388+ // provisions a fresh leased goal turn.
389+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoalCompactionPending})
390+ }
379391 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
380392 followUpMessages := i.agent.Messages()
381393 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
@@ -336,7 +336,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
336 if terminalGoalError {336 if terminalGoalError {
337 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")337 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
338 }338 }
339- continueGoal := i.finishGoalRun()339+ goalContextLimited := lastStop == provider.StopLength
340+ continueGoal := i.finishGoalRun(goalContextLimited)
340 i.mu.Lock()341 i.mu.Lock()
341 awaitingPre := i.awaitingStartupPre342 awaitingPre := i.awaitingStartupPre
342 // A newer explicit prompt may have cleared the handoff while the343 // A newer explicit prompt may have cleared the handoff while the
@@ -372,10 +373,21 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
372 agentQueued = i.agent.QueuedMessageCount()373 agentQueued = i.agent.QueuedMessageCount()
373 }374 }
374 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil375 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
375- shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()376+ // A truncated autonomous goal needs fresh execution capacity even when
377+ // context-usage metadata is absent or below the configured threshold.
378+ // Route it through compaction rather than immediately reusing the same
379+ // exhausted transcript.
380+ goalNeedsFreshContext := continueGoal && goalContextLimited
381+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && (goalNeedsFreshContext || i.shouldAutoCompactLocked())
376 continueStatusRescue := false382 continueStatusRescue := false
377 var handoff json.RawMessage383 var handoff json.RawMessage
378 var persistHandoff bool384 var persistHandoff bool
385+ if goalNeedsFreshContext {
386+ // Persist ownership before compaction starts. If the process exits
387+ // while condensing, session resume repeats compaction before it
388+ // provisions a fresh leased goal turn.
389+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: compactContinuationGoalCompactionPending})
390+ }
379 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {391 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
380 followUpMessages := i.agent.Messages()392 followUpMessages := i.agent.Messages()
381 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)393 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
M packages/agent/tools/update_goal.go +22 -5
@@ -13,7 +13,7 @@ import (
1313
1414 const (
1515 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
1717 )
1818
1919 // UpdateGoalTool lets the main agent start a mission, set its next concrete
@@ -25,6 +25,7 @@ type UpdateGoalTool struct{}
2525 type updateGoalArgs struct {
2626 Status string `json:"status"`
2727 Objective string `json:"objective,omitempty"`
28+ GoalID string `json:"goal_id,omitempty"`
2829 MissionID string `json:"mission_id,omitempty"`
2930 Reason string `json:"reason,omitempty"`
3031 }
@@ -33,6 +34,7 @@ type updateGoalArgs struct {
3334 type GoalUpdate struct {
3435 Status core.GoalStatus
3536 Objective string
37+ GoalID string
3638 MissionID string
3739 Reason string
3840 }
@@ -40,7 +42,7 @@ type GoalUpdate struct {
4042 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
4143
4244 func (t *UpdateGoalTool) Description() string {
43- return "Start your own mission when none is active, set the next concrete goal within an active mission, or mark the active goal complete or blocked. Do not broaden an existing mission."
45+ return "Start your own mission when none is active, set the next concrete goal within an active mission, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work."
4446 }
4547
4648 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -57,6 +59,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
5759 }
5860 args.Status = strings.TrimSpace(args.Status)
5961 args.Objective = strings.TrimSpace(args.Objective)
62+ args.GoalID = strings.TrimSpace(args.GoalID)
6063 args.MissionID = strings.TrimSpace(args.MissionID)
6164 args.Reason = strings.TrimSpace(args.Reason)
6265
@@ -71,6 +74,17 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
7174 update.MissionID = args.MissionID
7275 case "complete":
7376 update.Status = core.GoalDone
77+ case "superseded":
78+ if args.Objective == "" {
79+ return goalToolError("objective is required when superseding a goal"), nil
80+ }
81+ if args.GoalID == "" {
82+ return goalToolError("goal_id is required when superseding a goal"), nil
83+ }
84+ update.Status = core.GoalSuperseded
85+ update.Objective = args.Objective
86+ update.GoalID = args.GoalID
87+ update.MissionID = args.MissionID
7488 case "blocked":
7589 if args.Reason == "" {
7690 return goalToolError("reason is required when blocking a goal"), nil
@@ -78,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
7892 update.Status = core.GoalBlocked
7993 update.Reason = args.Reason
8094 default:
81- return goalToolError("status must be active, complete, or blocked"), nil
95+ return goalToolError("status must be active, complete, blocked, or superseded"), nil
8296 }
8397
8498 text := "autonomous goal marked " + string(update.Status)
@@ -100,10 +114,13 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
100114 if !ok {
101115 return GoalUpdate{}, false
102116 }
103- if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked {
117+ if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
118+ return GoalUpdate{}, false
119+ }
120+ if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" {
104121 return GoalUpdate{}, false
105122 }
106- if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" {
123+ if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {
107124 return GoalUpdate{}, false
108125 }
109126 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
@@ -13,7 +13,7 @@ import (
13 13
14 const (14 const (
15 UpdateGoalToolName = "update_goal"15 UpdateGoalToolName = "update_goal"
16- updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`16+ updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}`
17 )17 )
18 18
19 // UpdateGoalTool lets the main agent start a mission, set its next concrete19 // UpdateGoalTool lets the main agent start a mission, set its next concrete
@@ -25,6 +25,7 @@ type UpdateGoalTool struct{}
25 type updateGoalArgs struct {25 type updateGoalArgs struct {
26 Status string `json:"status"`26 Status string `json:"status"`
27 Objective string `json:"objective,omitempty"`27 Objective string `json:"objective,omitempty"`
28+ GoalID string `json:"goal_id,omitempty"`
28 MissionID string `json:"mission_id,omitempty"`29 MissionID string `json:"mission_id,omitempty"`
29 Reason string `json:"reason,omitempty"`30 Reason string `json:"reason,omitempty"`
30 }31 }
@@ -33,6 +34,7 @@ type updateGoalArgs struct {
33 type GoalUpdate struct {34 type GoalUpdate struct {
34 Status core.GoalStatus35 Status core.GoalStatus
35 Objective string36 Objective string
37+ GoalID string
36 MissionID string38 MissionID string
37 Reason string39 Reason string
38 }40 }
@@ -40,7 +42,7 @@ type GoalUpdate struct {
40 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }42 func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName }
41 43
42 func (t *UpdateGoalTool) Description() string {44 func (t *UpdateGoalTool) Description() string {
43- return "Start your own mission when none is active, set the next concrete goal within an active mission, or mark the active goal complete or blocked. Do not broaden an existing mission."45+ return "Start your own mission when none is active, set the next concrete goal within an active mission, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work."
44 }46 }
45 47
46 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }48 func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) }
@@ -57,6 +59,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
57 }59 }
58 args.Status = strings.TrimSpace(args.Status)60 args.Status = strings.TrimSpace(args.Status)
59 args.Objective = strings.TrimSpace(args.Objective)61 args.Objective = strings.TrimSpace(args.Objective)
62+ args.GoalID = strings.TrimSpace(args.GoalID)
60 args.MissionID = strings.TrimSpace(args.MissionID)63 args.MissionID = strings.TrimSpace(args.MissionID)
61 args.Reason = strings.TrimSpace(args.Reason)64 args.Reason = strings.TrimSpace(args.Reason)
62 65
@@ -71,6 +74,17 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
71 update.MissionID = args.MissionID74 update.MissionID = args.MissionID
72 case "complete":75 case "complete":
73 update.Status = core.GoalDone76 update.Status = core.GoalDone
77+ case "superseded":
78+ if args.Objective == "" {
79+ return goalToolError("objective is required when superseding a goal"), nil
80+ }
81+ if args.GoalID == "" {
82+ return goalToolError("goal_id is required when superseding a goal"), nil
83+ }
84+ update.Status = core.GoalSuperseded
85+ update.Objective = args.Objective
86+ update.GoalID = args.GoalID
87+ update.MissionID = args.MissionID
74 case "blocked":88 case "blocked":
75 if args.Reason == "" {89 if args.Reason == "" {
76 return goalToolError("reason is required when blocking a goal"), nil90 return goalToolError("reason is required when blocking a goal"), nil
@@ -78,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(
78 update.Status = core.GoalBlocked92 update.Status = core.GoalBlocked
79 update.Reason = args.Reason93 update.Reason = args.Reason
80 default:94 default:
81- return goalToolError("status must be active, complete, or blocked"), nil95+ return goalToolError("status must be active, complete, blocked, or superseded"), nil
82 }96 }
83 97
84 text := "autonomous goal marked " + string(update.Status)98 text := "autonomous goal marked " + string(update.Status)
@@ -100,10 +114,13 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) {
100 if !ok {114 if !ok {
101 return GoalUpdate{}, false115 return GoalUpdate{}, false
102 }116 }
103- if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked {117+ if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded {
118+ return GoalUpdate{}, false
119+ }
120+ if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" {
104 return GoalUpdate{}, false121 return GoalUpdate{}, false
105 }122 }
106- if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" {123+ if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" {
107 return GoalUpdate{}, false124 return GoalUpdate{}, false
108 }125 }
109 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {126 if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
M packages/agent/tools/update_goal_test.go +26 -0
@@ -45,6 +45,21 @@ func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
4545 }
4646 }
4747
48+func TestUpdateGoalToolReturnsPersistableSupersedingGoal(t *testing.T) {
49+ tool := &UpdateGoalTool{}
50+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"superseded","objective":"use the supported API","goal_id":"goal-1","mission_id":"mission-1"}`), nil)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ update, ok := GoalUpdateFromResult(result)
55+ if !ok {
56+ t.Fatalf("details = %#v, want superseding goal update", result.Details)
57+ }
58+ if update.Status != core.GoalSuperseded || update.Objective != "use the supported API" || update.GoalID != "goal-1" || update.MissionID != "mission-1" {
59+ t.Fatalf("update = %#v", update)
60+ }
61+}
62+
4863 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
4964 tool := &UpdateGoalTool{}
5065 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
@@ -73,6 +88,17 @@ func TestGoalUpdateFromResultRejectsBlockedWithoutReason(t *testing.T) {
7388 }
7489 }
7590
91+func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) {
92+ tool := &UpdateGoalTool{}
93+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"superseded"}`), nil)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if !result.IsError {
98+ t.Fatalf("result = %#v, want tool error", result)
99+ }
100+}
101+
76102 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
77103 tool := &UpdateGoalTool{}
78104 raw := json.RawMessage(`{"status":"active"}`)
@@ -45,6 +45,21 @@ func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) {
45 }45 }
46 }46 }
47 47
48+func TestUpdateGoalToolReturnsPersistableSupersedingGoal(t *testing.T) {
49+ tool := &UpdateGoalTool{}
50+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"superseded","objective":"use the supported API","goal_id":"goal-1","mission_id":"mission-1"}`), nil)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ update, ok := GoalUpdateFromResult(result)
55+ if !ok {
56+ t.Fatalf("details = %#v, want superseding goal update", result.Details)
57+ }
58+ if update.Status != core.GoalSuperseded || update.Objective != "use the supported API" || update.GoalID != "goal-1" || update.MissionID != "mission-1" {
59+ t.Fatalf("update = %#v", update)
60+ }
61+}
62+
48 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {63 func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) {
49 tool := &UpdateGoalTool{}64 tool := &UpdateGoalTool{}
50 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)65 result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil)
@@ -73,6 +88,17 @@ func TestGoalUpdateFromResultRejectsBlockedWithoutReason(t *testing.T) {
73 }88 }
74 }89 }
75 90
91+func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) {
92+ tool := &UpdateGoalTool{}
93+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"superseded"}`), nil)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ if !result.IsError {
98+ t.Fatalf("result = %#v, want tool error", result)
99+ }
100+}
101+
76 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {102 func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
77 tool := &UpdateGoalTool{}103 tool := &UpdateGoalTool{}
78 raw := json.RawMessage(`{"status":"active"}`)104 raw := json.RawMessage(`{"status":"active"}`)
M packages/core/core_test.go +59 -0
@@ -336,6 +336,65 @@ func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
336336 }
337337 }
338338
339+func TestSessionSupersedeGoalArchivesAndReplacesAtomically(t *testing.T) {
340+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ defer sess.Close()
345+ if err := sess.UpdateGoal(&SessionGoal{Objective: "use the legacy API", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
346+ t.Fatal(err)
347+ }
348+ missionID := sess.Meta.Mission.ID
349+ originalID := sess.Meta.Goal.ID
350+
351+ if err := sess.SupersedeGoal(&SessionGoal{Objective: "use the supported API", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
352+ t.Fatal(err)
353+ }
354+ if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != GoalActive || got.MissionID != missionID || got.ID == originalID {
355+ t.Fatalf("replacement goal = %#v", got)
356+ }
357+ if len(sess.Meta.GoalHistory) != 1 {
358+ t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
359+ }
360+ archived := sess.Meta.GoalHistory[0]
361+ if archived.ID != originalID || archived.Status != GoalSuperseded || archived.Reason == "" {
362+ t.Fatalf("archived goal = %#v", archived)
363+ }
364+ if sess.Meta.Mission.TransitionCount != 1 || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
365+ t.Fatalf("mission = %#v", sess.Meta.Mission)
366+ }
367+}
368+
369+func TestSessionSupersedeGoalRejectsInvalidReplacement(t *testing.T) {
370+ for _, test := range []struct {
371+ name string
372+ replacement *SessionGoal
373+ }{
374+ {name: "blank objective", replacement: &SessionGoal{Status: GoalActive}},
375+ {name: "reused id", replacement: &SessionGoal{ID: "old-goal", Objective: "replacement", Status: GoalActive}},
376+ {name: "other mission", replacement: &SessionGoal{MissionID: "other", Objective: "replacement", Status: GoalActive}},
377+ } {
378+ t.Run(test.name, func(t *testing.T) {
379+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
380+ if err != nil {
381+ t.Fatal(err)
382+ }
383+ defer sess.Close()
384+ if err := sess.UpdateGoal(&SessionGoal{ID: "old-goal", Objective: "current", Status: GoalActive}); err != nil {
385+ t.Fatal(err)
386+ }
387+ before := *sess.Meta.Goal
388+ if err := sess.SupersedeGoal(test.replacement); err == nil {
389+ t.Fatal("invalid replacement was accepted")
390+ }
391+ if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
392+ t.Fatalf("state changed after rejection: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
393+ }
394+ })
395+ }
396+}
397+
339398 func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
340399 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
341400 if err != nil {
@@ -336,6 +336,65 @@ func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) {
336 }336 }
337 }337 }
338 338
339+func TestSessionSupersedeGoalArchivesAndReplacesAtomically(t *testing.T) {
340+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
341+ if err != nil {
342+ t.Fatal(err)
343+ }
344+ defer sess.Close()
345+ if err := sess.UpdateGoal(&SessionGoal{Objective: "use the legacy API", Status: GoalActive, Owner: GoalOwnerUser}); err != nil {
346+ t.Fatal(err)
347+ }
348+ missionID := sess.Meta.Mission.ID
349+ originalID := sess.Meta.Goal.ID
350+
351+ if err := sess.SupersedeGoal(&SessionGoal{Objective: "use the supported API", Status: GoalActive, Owner: GoalOwnerManager}); err != nil {
352+ t.Fatal(err)
353+ }
354+ if got := sess.Meta.Goal; got == nil || got.Objective != "use the supported API" || got.Status != GoalActive || got.MissionID != missionID || got.ID == originalID {
355+ t.Fatalf("replacement goal = %#v", got)
356+ }
357+ if len(sess.Meta.GoalHistory) != 1 {
358+ t.Fatalf("goal history = %#v", sess.Meta.GoalHistory)
359+ }
360+ archived := sess.Meta.GoalHistory[0]
361+ if archived.ID != originalID || archived.Status != GoalSuperseded || archived.Reason == "" {
362+ t.Fatalf("archived goal = %#v", archived)
363+ }
364+ if sess.Meta.Mission.TransitionCount != 1 || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID {
365+ t.Fatalf("mission = %#v", sess.Meta.Mission)
366+ }
367+}
368+
369+func TestSessionSupersedeGoalRejectsInvalidReplacement(t *testing.T) {
370+ for _, test := range []struct {
371+ name string
372+ replacement *SessionGoal
373+ }{
374+ {name: "blank objective", replacement: &SessionGoal{Status: GoalActive}},
375+ {name: "reused id", replacement: &SessionGoal{ID: "old-goal", Objective: "replacement", Status: GoalActive}},
376+ {name: "other mission", replacement: &SessionGoal{MissionID: "other", Objective: "replacement", Status: GoalActive}},
377+ } {
378+ t.Run(test.name, func(t *testing.T) {
379+ sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
380+ if err != nil {
381+ t.Fatal(err)
382+ }
383+ defer sess.Close()
384+ if err := sess.UpdateGoal(&SessionGoal{ID: "old-goal", Objective: "current", Status: GoalActive}); err != nil {
385+ t.Fatal(err)
386+ }
387+ before := *sess.Meta.Goal
388+ if err := sess.SupersedeGoal(test.replacement); err == nil {
389+ t.Fatal("invalid replacement was accepted")
390+ }
391+ if *sess.Meta.Goal != before || len(sess.Meta.GoalHistory) != 0 {
392+ t.Fatalf("state changed after rejection: goal=%#v history=%#v", sess.Meta.Goal, sess.Meta.GoalHistory)
393+ }
394+ })
395+ }
396+}
397+
339 func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {398 func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) {
340 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")399 sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test")
341 if err != nil {400 if err != nil {
M packages/core/session.go +36 -1
@@ -65,6 +65,7 @@ const (
6565 GoalDone GoalStatus = "done"
6666 GoalBudgetLimited GoalStatus = "budget_limited"
6767 GoalStalled GoalStatus = "stalled"
68+ GoalSuperseded GoalStatus = "superseded"
6869 )
6970
7071 // GoalOwner identifies who established a session goal. Missing owners in
@@ -1308,6 +1309,40 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error {
13081309 return nil
13091310 }
13101311
1312+// SupersedeGoal atomically archives the active goal as superseded and starts
1313+// its replacement in the same mission. Mission assignment enforces the normal
1314+// manager transition bound.
1315+func (s *Session) SupersedeGoal(replacement *SessionGoal) error {
1316+ if s == nil {
1317+ return nil
1318+ }
1319+ if s.Meta.Goal == nil || s.Meta.Goal.Status != GoalActive || replacement == nil || replacement.Status != GoalActive {
1320+ return errors.New("supersede goal requires an active goal and replacement")
1321+ }
1322+ if strings.TrimSpace(replacement.Objective) == "" {
1323+ return errors.New("supersede goal requires a replacement objective")
1324+ }
1325+ if replacement.ID != "" {
1326+ return errors.New("supersede goal replacement must not reuse a goal ID")
1327+ }
1328+ if replacement.MissionID != "" && (s.Meta.Mission == nil || replacement.MissionID != s.Meta.Mission.ID) {
1329+ return errors.New("supersede goal replacement does not belong to the active mission")
1330+ }
1331+ replacement = cloneSessionGoal(replacement)
1332+ replacement.Objective = strings.TrimSpace(replacement.Objective)
1333+ replacement.Owner = GoalOwnerManager
1334+ original := cloneSessionGoal(s.Meta.Goal)
1335+ superseded := *original
1336+ superseded.Status = GoalSuperseded
1337+ superseded.Reason = "superseded by a new goal"
1338+ s.Meta.Goal = &superseded
1339+ if err := s.UpdateGoal(replacement); err != nil {
1340+ s.Meta.Goal = original
1341+ return fmt.Errorf("supersede goal: %w", err)
1342+ }
1343+ return nil
1344+}
1345+
13111346 // UpdateGoalRuntime persists mutable execution state without treating each
13121347 // continuation as a mission transition. The goal ID must match the current
13131348 // goal so stale workers cannot overwrite a newer user or manager goal.
@@ -1375,7 +1410,7 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
13751410 case GoalPaused:
13761411 mission.Status = MissionPaused
13771412 mission.Reason = ""
1378- case GoalBlocked, GoalBudgetLimited, GoalStalled:
1413+ case GoalBlocked, GoalBudgetLimited, GoalStalled, GoalSuperseded:
13791414 mission.Status = MissionBlocked
13801415 mission.Reason = goal.Reason
13811416 }
@@ -65,6 +65,7 @@ const (
65 GoalDone GoalStatus = "done"65 GoalDone GoalStatus = "done"
66 GoalBudgetLimited GoalStatus = "budget_limited"66 GoalBudgetLimited GoalStatus = "budget_limited"
67 GoalStalled GoalStatus = "stalled"67 GoalStalled GoalStatus = "stalled"
68+ GoalSuperseded GoalStatus = "superseded"
68 )69 )
69 70
70 // GoalOwner identifies who established a session goal. Missing owners in71 // GoalOwner identifies who established a session goal. Missing owners in
@@ -1308,6 +1309,40 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error {
1308 return nil1309 return nil
1309 }1310 }
1310 1311
1312+// SupersedeGoal atomically archives the active goal as superseded and starts
1313+// its replacement in the same mission. Mission assignment enforces the normal
1314+// manager transition bound.
1315+func (s *Session) SupersedeGoal(replacement *SessionGoal) error {
1316+ if s == nil {
1317+ return nil
1318+ }
1319+ if s.Meta.Goal == nil || s.Meta.Goal.Status != GoalActive || replacement == nil || replacement.Status != GoalActive {
1320+ return errors.New("supersede goal requires an active goal and replacement")
1321+ }
1322+ if strings.TrimSpace(replacement.Objective) == "" {
1323+ return errors.New("supersede goal requires a replacement objective")
1324+ }
1325+ if replacement.ID != "" {
1326+ return errors.New("supersede goal replacement must not reuse a goal ID")
1327+ }
1328+ if replacement.MissionID != "" && (s.Meta.Mission == nil || replacement.MissionID != s.Meta.Mission.ID) {
1329+ return errors.New("supersede goal replacement does not belong to the active mission")
1330+ }
1331+ replacement = cloneSessionGoal(replacement)
1332+ replacement.Objective = strings.TrimSpace(replacement.Objective)
1333+ replacement.Owner = GoalOwnerManager
1334+ original := cloneSessionGoal(s.Meta.Goal)
1335+ superseded := *original
1336+ superseded.Status = GoalSuperseded
1337+ superseded.Reason = "superseded by a new goal"
1338+ s.Meta.Goal = &superseded
1339+ if err := s.UpdateGoal(replacement); err != nil {
1340+ s.Meta.Goal = original
1341+ return fmt.Errorf("supersede goal: %w", err)
1342+ }
1343+ return nil
1344+}
1345+
1311 // UpdateGoalRuntime persists mutable execution state without treating each1346 // UpdateGoalRuntime persists mutable execution state without treating each
1312 // continuation as a mission transition. The goal ID must match the current1347 // continuation as a mission transition. The goal ID must match the current
1313 // goal so stale workers cannot overwrite a newer user or manager goal.1348 // goal so stale workers cannot overwrite a newer user or manager goal.
@@ -1375,7 +1410,7 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error {
1375 case GoalPaused:1410 case GoalPaused:
1376 mission.Status = MissionPaused1411 mission.Status = MissionPaused
1377 mission.Reason = ""1412 mission.Reason = ""
1378- case GoalBlocked, GoalBudgetLimited, GoalStalled:1413+ case GoalBlocked, GoalBudgetLimited, GoalStalled, GoalSuperseded:
1379 mission.Status = MissionBlocked1414 mission.Status = MissionBlocked
1380 mission.Reason = goal.Reason1415 mission.Reason = goal.Reason
1381 }1416 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close