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 Unverified
B5690EEEBB952194The 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 | |||
| 268 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | ||
| @@ -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 | |||
| 705 | 705 | var ( | |
| 706 | 706 | errGoalActiveReplacement = errors.New("active goal replacement") | |
| 707 | 707 | errGoalMissionMismatch = errors.New("goal mission mismatch") | |
| 708 | + | errGoalStaleUpdate = errors.New("stale goal update") | |
| 708 | 709 | errGoalStorageUnavailable = errors.New("goal storage unavailable") | |
| 709 | 710 | errGoalStateWrite = errors.New("goal state write") | |
| 710 | 711 | ) | |
| @@ -740,6 +741,8 @@ func safeGoalCommitMessage(err error) string { | |||
| 740 | 741 | return "goal transition rejected: the current goal is still active" | |
| 741 | 742 | case errors.Is(err, errGoalMissionMismatch): | |
| 742 | 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 | 746 | case errors.Is(err, errGoalStorageUnavailable): | |
| 744 | 747 | return "goal state unavailable: session persistence is disabled" | |
| 745 | 748 | case errors.Is(err, errGoalStateWrite): | |
| @@ -757,6 +760,26 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||
| 757 | 760 | if !ok { | |
| 758 | 761 | 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 | 783 | if update.Status == core.GoalActive { | |
| 761 | 784 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |
| 762 | 785 | 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 nil | 761 | 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 errGoalActiveReplacement | 785 | return errGoalActiveReplacement | ||
M packages/agent/cli_session_test.go +44 -0
| @@ -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 | 349 | func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) { | |
| 306 | 350 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 307 | 351 | 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) { | |||
| 316 | 316 | raw: `{"version":1,"reason":"forced_length"}`, | |
| 317 | 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 | 330 | name: "invalid status attempt", raw: `{"version":1,"reason":"status_rescue","rescue_attempts":3}`}, | |
| 321 | 331 | {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 { | |||
| 69 | 69 | 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 | 120 | func (c *autoCompactPacerClient) Name() string { return "auto-compact-pacer-test" } | |
| 73 | 121 | ||
| 74 | 122 | func (c *autoCompactPacerClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| @@ -141,6 +189,173 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) | |||
| 141 | 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 | 359 | func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) { | |
| 145 | 360 | var goalMu sync.Mutex | |
| 146 | 361 | goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive} | |
| @@ -69,6 +69,54 @@ type autoCompactPacerClient struct { | |||||
| 69 | calls int | 69 | 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.Mutex | 360 | 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) { | |||
| 30 | 30 | return provider.Message{}, false | |
| 31 | 31 | } | |
| 32 | 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 | 34 | if goal.ConsecutiveNoProgressTurns > 0 { | |
| 35 | 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 | 40 | if goal.MissionID != "" { | |
| 38 | 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 | 85 | 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 | 125 | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) { | |
| 86 | 126 | goalMessage, goalActive := i.goalContinuationMessage() | |
| 87 | 127 | i.mu.Lock() | |
| @@ -30,10 +30,13 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||||
| 30 | return provider.Message{}, false | 30 | 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 true | 85 | 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) { | |||
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | 126 | // 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 { | |
| 130 | 132 | i.mu.Lock() | |
| 131 | 133 | run := i.goalRun | |
| 132 | 134 | i.goalRun = nil | |
| @@ -170,7 +172,10 @@ func (i *Interactive) finishGoalRun() bool { | |||
| 170 | 172 | i.setGoalStatus(goal) | |
| 171 | 173 | 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 | 179 | goal.ConsecutiveNoProgressTurns = 0 | |
| 175 | 180 | } else { | |
| 176 | 181 | 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 the | 126 | // finishGoalRun records accounting exactly once and returns whether the | ||
| 127 | - | // controller should attempt another autonomous continuation. A stale run is | 127 | + | // 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.goalRun | 133 | run := i.goalRun | ||
| 132 | i.goalRun = nil | 134 | i.goalRun = nil | ||
| @@ -170,7 +172,10 @@ func (i *Interactive) finishGoalRun() bool { | |||||
| 170 | i.setGoalStatus(goal) | 172 | i.setGoalStatus(goal) | ||
| 171 | return false | 173 | 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 = 0 | 179 | 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) { | |||
| 108 | 108 | if err != nil || first == nil { | |
| 109 | 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 | 112 | t.Fatal("first non-progress run should receive one corrective continuation") | |
| 113 | 113 | } | |
| 114 | 114 | if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" { | |
| @@ -119,7 +119,7 @@ func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) { | |||
| 119 | 119 | if err != nil || second == nil { | |
| 120 | 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 | 123 | t.Fatal("second non-progress run scheduled another continuation") | |
| 124 | 124 | } | |
| 125 | 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 | 165 | func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) { | |
| 135 | 166 | for _, test := range []struct { | |
| 136 | 167 | name string | |
| @@ -162,7 +193,7 @@ func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) { | |||
| 162 | 193 | } | |
| 163 | 194 | interactive.observeGoalRun(core.EvToolCall{Name: "bash"}) | |
| 164 | 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 | 197 | t.Fatalf("finish goal run = %t, want %t", got, test.wantNext) | |
| 167 | 198 | } | |
| 168 | 199 | got := current() | |
| @@ -219,7 +250,7 @@ func TestGoalRuntimeRejectsStaleRun(t *testing.T) { | |||
| 219 | 250 | if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil { | |
| 220 | 251 | t.Fatal(err) | |
| 221 | 252 | } | |
| 222 | - | if interactive.finishGoalRun() { | |
| 253 | + | if interactive.finishGoalRun(false) { | |
| 223 | 254 | t.Fatal("stale run scheduled a continuation") | |
| 224 | 255 | } | |
| 225 | 256 | 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 string | 167 | 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 ( | |||
| 566 | 566 | compactContinuationNone compactContinuationReason = iota | |
| 567 | 567 | compactContinuationStructuralTail | |
| 568 | 568 | compactContinuationForcedLength | |
| 569 | + | compactContinuationGoalCompactionPending | |
| 570 | + | compactContinuationGoal | |
| 569 | 571 | compactContinuationStatusRescue | |
| 570 | 572 | ) | |
| 571 | 573 | ||
| @@ -598,6 +600,14 @@ func decodeCompactHandoff(raw json.RawMessage) compactContinuationState { | |||
| 598 | 600 | if persisted.RescueAttempts == 0 { | |
| 599 | 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 | 611 | case "status_rescue": | |
| 602 | 612 | if persisted.RescueAttempts >= 1 && persisted.RescueAttempts <= maxStatusRescueContinuations { | |
| 603 | 613 | return compactContinuationState{reason: compactContinuationStatusRescue, rescueAttempts: persisted.RescueAttempts} | |
| @@ -613,6 +623,10 @@ func encodeCompactHandoff(state compactContinuationState) json.RawMessage { | |||
| 613 | 623 | reason = "structural_tail" | |
| 614 | 624 | case compactContinuationForcedLength: | |
| 615 | 625 | reason = "forced_length" | |
| 626 | + | case compactContinuationGoalCompactionPending: | |
| 627 | + | reason = "goal_compaction_pending" | |
| 628 | + | case compactContinuationGoal: | |
| 629 | + | reason = "goal" | |
| 616 | 630 | case compactContinuationStatusRescue: | |
| 617 | 631 | reason = "status_rescue" | |
| 618 | 632 | default: | |
| @@ -566,6 +566,8 @@ const ( | |||||
| 566 | compactContinuationNone compactContinuationReason = iota | 566 | compactContinuationNone compactContinuationReason = iota | ||
| 567 | compactContinuationStructuralTail | 567 | compactContinuationStructuralTail | ||
| 568 | compactContinuationForcedLength | 568 | compactContinuationForcedLength | ||
| 569 | + | compactContinuationGoalCompactionPending | |||
| 570 | + | compactContinuationGoal | |||
| 569 | compactContinuationStatusRescue | 571 | 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) { | |||
| 113 | 113 | var next core.QueuedMessage | |
| 114 | 114 | var hasNext bool | |
| 115 | 115 | continueQueued := false | |
| 116 | + | resumeGoalCompaction := false | |
| 117 | + | resumeGoal := false | |
| 116 | 118 | var resume compactHandoffResume | |
| 117 | 119 | var handoff json.RawMessage | |
| 118 | 120 | var persistHandoff bool | |
| 119 | 121 | switch { | |
| 120 | - | case state.reason != compactContinuationForcedLength && len(i.queued) > 0: | |
| 122 | + | case state.reason != compactContinuationForcedLength && state.reason != compactContinuationGoalCompactionPending && len(i.queued) > 0: | |
| 121 | 123 | next, i.queued = i.queued[0], i.queued[1:] | |
| 122 | 124 | hasNext = true | |
| 123 | 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 | 127 | continueQueued = true | |
| 126 | 128 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 129 | + | case state.reason == compactContinuationGoalCompactionPending: | |
| 130 | + | resumeGoalCompaction = true | |
| 131 | + | case state.reason == compactContinuationGoal: | |
| 132 | + | resumeGoal = true | |
| 127 | 133 | default: | |
| 128 | 134 | resume = classifyCompactHandoffResume(ag.Messages()) | |
| 129 | 135 | if resume == compactHandoffDiscard { | |
| @@ -132,7 +138,7 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 132 | 138 | } | |
| 133 | 139 | // Reserve the turn slot before persisting or dispatching so concurrent input | |
| 134 | 140 | // 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 | |
| 136 | 142 | i.busy = starting | |
| 137 | 143 | i.mu.Unlock() | |
| 138 | 144 | if persistHandoff { | |
| @@ -147,6 +153,10 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||
| 147 | 153 | i.startTurnWithImages(parent, next.Text, next.Images) | |
| 148 | 154 | case continueQueued: | |
| 149 | 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 | 160 | case resume == compactHandoffContinueExisting: | |
| 151 | 161 | i.startTurnRequest(parent, "", nil, true, false) | |
| 152 | 162 | case resume == compactHandoffAppendPrompt: | |
| @@ -217,7 +227,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 217 | 227 | i.cancelTurn = cancel | |
| 218 | 228 | var initialHandoff json.RawMessage | |
| 219 | 229 | persistInitialHandoff := false | |
| 220 | - | if request.origin == compactOriginManual || request.origin == compactOriginRecovery { | |
| 230 | + | if request.origin == compactOriginManual || (request.origin == compactOriginRecovery && i.compactContinuation.reason != compactContinuationGoalCompactionPending) { | |
| 221 | 231 | initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked() | |
| 222 | 232 | } | |
| 223 | 233 | i.statusErr = "" | |
| @@ -255,7 +265,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 255 | 265 | } | |
| 256 | 266 | summary, err := i.agent.Compact(ctx, keepTail, sink) | |
| 257 | 267 | _ = summary | |
| 258 | - | goalMessage, goalActive := i.goalContinuationMessage() | |
| 268 | + | _, goalActive := i.goalContinuationMessage() | |
| 259 | 269 | i.mu.Lock() | |
| 260 | 270 | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 261 | 271 | // Completion updates can arrive in this window; clearing busy before | |
| @@ -327,6 +337,11 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 327 | 337 | i.pendingCompactImages = nil | |
| 328 | 338 | i.hasPendingCompactPrompt = false | |
| 329 | 339 | 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 | 345 | case continuationReason == compactContinuationForcedLength: | |
| 331 | 346 | // Forced truncated-output continuation keeps its existing | |
| 332 | 347 | // priority over an explicitly queued prompt. | |
| @@ -388,23 +403,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 388 | 403 | case continueAutomatically: | |
| 389 | 404 | i.startAutoCompactContinuation(p) | |
| 390 | 405 | 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) | |
| 408 | 407 | } | |
| 409 | 408 | } | |
| 410 | 409 | }() | |
| @@ -113,17 +113,23 @@ func (i *Interactive) startRestoredCompactHandoff(parent context.Context) { | |||||
| 113 | var next core.QueuedMessage | 113 | var next core.QueuedMessage | ||
| 114 | var hasNext bool | 114 | var hasNext bool | ||
| 115 | continueQueued := false | 115 | continueQueued := false | ||
| 116 | + | resumeGoalCompaction := false | |||
| 117 | + | resumeGoal := false | |||
| 116 | var resume compactHandoffResume | 118 | var resume compactHandoffResume | ||
| 117 | var handoff json.RawMessage | 119 | var handoff json.RawMessage | ||
| 118 | var persistHandoff bool | 120 | 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 = true | 124 | 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 = true | 127 | 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 input | 139 | // 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 == compactHandoffAppendPrompt | 141 | + | starting := hasNext || continueQueued || resumeGoalCompaction || resumeGoal || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt |
| 136 | i.busy = starting | 142 | 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 = cancel | 227 | i.cancelTurn = cancel | ||
| 218 | var initialHandoff json.RawMessage | 228 | var initialHandoff json.RawMessage | ||
| 219 | persistInitialHandoff := false | 229 | 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 | _ = summary | 267 | _ = 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 before | 271 | // 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 = nil | 337 | i.pendingCompactImages = nil | ||
| 328 | i.hasPendingCompactPrompt = false | 338 | i.hasPendingCompactPrompt = false | ||
| 329 | hasNext = true | 339 | 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 existing | 346 | // 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 *goalContinuationRun | 406 | + | 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) { | |||
| 172 | 172 | i.bumpToolRevisionLocked(tc) | |
| 173 | 173 | } | |
| 174 | 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 | 180 | // A manager may advance a terminal goal to the next persisted | |
| 177 | 181 | // goal in the same mission. Do not let a late tool result resume | |
| 178 | 182 | // 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 persisted | 180 | // A manager may advance a terminal goal to the next persisted | ||
| 177 | // goal in the same mission. Do not let a late tool result resume | 181 | // 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 | |||
| 336 | 336 | if terminalGoalError { | |
| 337 | 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 | 341 | i.mu.Lock() | |
| 341 | 342 | awaitingPre := i.awaitingStartupPre | |
| 342 | 343 | // 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 | 373 | agentQueued = i.agent.QueuedMessageCount() | |
| 373 | 374 | } | |
| 374 | 375 | 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 | 382 | continueStatusRescue := false | |
| 377 | 383 | var handoff json.RawMessage | |
| 378 | 384 | 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 | 391 | if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil { | |
| 380 | 392 | followUpMessages := i.agent.Messages() | |
| 381 | 393 | 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.awaitingStartupPre | 342 | awaitingPre := i.awaitingStartupPre | ||
| 342 | // A newer explicit prompt may have cleared the handoff while the | 343 | // 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() == nil | 375 | 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 := false | 382 | continueStatusRescue := false | ||
| 377 | var handoff json.RawMessage | 383 | var handoff json.RawMessage | ||
| 378 | var persistHandoff bool | 384 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | 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 | 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | |
| @@ -25,6 +25,7 @@ type UpdateGoalTool struct{} | |||
| 25 | 25 | type updateGoalArgs struct { | |
| 26 | 26 | Status string `json:"status"` | |
| 27 | 27 | Objective string `json:"objective,omitempty"` | |
| 28 | + | GoalID string `json:"goal_id,omitempty"` | |
| 28 | 29 | MissionID string `json:"mission_id,omitempty"` | |
| 29 | 30 | Reason string `json:"reason,omitempty"` | |
| 30 | 31 | } | |
| @@ -33,6 +34,7 @@ type updateGoalArgs struct { | |||
| 33 | 34 | type GoalUpdate struct { | |
| 34 | 35 | Status core.GoalStatus | |
| 35 | 36 | Objective string | |
| 37 | + | GoalID string | |
| 36 | 38 | MissionID string | |
| 37 | 39 | Reason string | |
| 38 | 40 | } | |
| @@ -40,7 +42,7 @@ type GoalUpdate struct { | |||
| 40 | 42 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |
| 41 | 43 | ||
| 42 | 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 | 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 | 60 | args.Status = strings.TrimSpace(args.Status) | |
| 59 | 61 | args.Objective = strings.TrimSpace(args.Objective) | |
| 62 | + | args.GoalID = strings.TrimSpace(args.GoalID) | |
| 60 | 63 | args.MissionID = strings.TrimSpace(args.MissionID) | |
| 61 | 64 | args.Reason = strings.TrimSpace(args.Reason) | |
| 62 | 65 | ||
| @@ -71,6 +74,17 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||
| 71 | 74 | update.MissionID = args.MissionID | |
| 72 | 75 | case "complete": | |
| 73 | 76 | 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 | 88 | case "blocked": | |
| 75 | 89 | if args.Reason == "" { | |
| 76 | 90 | 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 | 92 | update.Status = core.GoalBlocked | |
| 79 | 93 | update.Reason = args.Reason | |
| 80 | 94 | default: | |
| 81 | - | return goalToolError("status must be active, complete, or blocked"), nil | |
| 95 | + | return goalToolError("status must be active, complete, blocked, or superseded"), nil | |
| 82 | 96 | } | |
| 83 | 97 | ||
| 84 | 98 | text := "autonomous goal marked " + string(update.Status) | |
| @@ -100,10 +114,13 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||
| 100 | 114 | if !ok { | |
| 101 | 115 | 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 | 121 | 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 | 124 | return GoalUpdate{}, false | |
| 108 | 125 | } | |
| 109 | 126 | 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 concrete | 19 | // 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.GoalStatus | 35 | Status core.GoalStatus | ||
| 35 | Objective string | 36 | Objective string | ||
| 37 | + | GoalID string | |||
| 36 | MissionID string | 38 | MissionID string | ||
| 37 | Reason string | 39 | 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.MissionID | 74 | update.MissionID = args.MissionID | ||
| 72 | case "complete": | 75 | case "complete": | ||
| 73 | update.Status = core.GoalDone | 76 | 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"), nil | 90 | 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.GoalBlocked | 92 | update.Status = core.GoalBlocked | ||
| 79 | update.Reason = args.Reason | 93 | update.Reason = args.Reason | ||
| 80 | default: | 94 | default: | ||
| 81 | - | return goalToolError("status must be active, complete, or blocked"), nil | 95 | + | 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{}, false | 115 | 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{}, false | 121 | 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{}, false | 124 | 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) { | |||
| 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 | 63 | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | |
| 49 | 64 | tool := &UpdateGoalTool{} | |
| 50 | 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 | 102 | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | |
| 77 | 103 | tool := &UpdateGoalTool{} | |
| 78 | 104 | 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) { | |||
| 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 | 398 | func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) { | |
| 340 | 399 | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 341 | 400 | 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 ( | |||
| 65 | 65 | GoalDone GoalStatus = "done" | |
| 66 | 66 | GoalBudgetLimited GoalStatus = "budget_limited" | |
| 67 | 67 | GoalStalled GoalStatus = "stalled" | |
| 68 | + | GoalSuperseded GoalStatus = "superseded" | |
| 68 | 69 | ) | |
| 69 | 70 | ||
| 70 | 71 | // GoalOwner identifies who established a session goal. Missing owners in | |
| @@ -1308,6 +1309,40 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||
| 1308 | 1309 | 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 | 1346 | // UpdateGoalRuntime persists mutable execution state without treating each | |
| 1312 | 1347 | // continuation as a mission transition. The goal ID must match the current | |
| 1313 | 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 | 1410 | case GoalPaused: | |
| 1376 | 1411 | mission.Status = MissionPaused | |
| 1377 | 1412 | mission.Reason = "" | |
| 1378 | - | case GoalBlocked, GoalBudgetLimited, GoalStalled: | |
| 1413 | + | case GoalBlocked, GoalBudgetLimited, GoalStalled, GoalSuperseded: | |
| 1379 | 1414 | mission.Status = MissionBlocked | |
| 1380 | 1415 | mission.Reason = goal.Reason | |
| 1381 | 1416 | } | |
| @@ -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 in | 71 | // GoalOwner identifies who established a session goal. Missing owners in | ||
| @@ -1308,6 +1309,40 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||||
| 1308 | return nil | 1309 | 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 each | 1346 | // UpdateGoalRuntime persists mutable execution state without treating each | ||
| 1312 | // continuation as a mission transition. The goal ID must match the current | 1347 | // 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 = MissionPaused | 1411 | mission.Status = MissionPaused | ||
| 1377 | mission.Reason = "" | 1412 | mission.Reason = "" | ||
| 1378 | - | case GoalBlocked, GoalBudgetLimited, GoalStalled: | 1413 | + | case GoalBlocked, GoalBudgetLimited, GoalStalled, GoalSuperseded: |
| 1379 | mission.Status = MissionBlocked | 1414 | mission.Status = MissionBlocked | ||
| 1380 | mission.Reason = goal.Reason | 1415 | mission.Reason = goal.Reason | ||
| 1381 | } | 1416 | } | ||