Skip to content

Commit e883d9d

Browse files
oxgeneralclaude
andcommitted
fix: resolve goal completion deadlock and paused→achieved transition
Three bugs fixed: 1. Agent deadlock: the pending-tasks guard blocked `achieved` when the agent's own [auto] task was in_progress — autonomous tasks are now excluded from the check since they are the mechanism for achieving the goal, not a blocker. 2. State machine: added `paused → achieved` transition so goals can be completed directly from paused state. 3. TUI: C-key now passes `{ force: true }` to cancel cancellable pending tasks, with an informative message. Callback signature updated to forward opts through to GoalService. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 856532b commit e883d9d

File tree

5 files changed

+75
-13
lines changed

5 files changed

+75
-13
lines changed

src/application/goal-service.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* Goal service — business logic for goal lifecycle.
33
*
44
* Goals are persistent objectives that drive autonomous agent work.
5-
* State machine: active → achieved | abandoned
6-
* active ↔ paused
5+
* State machine: active → achieved | abandoned | paused
6+
* paused → active | achieved | abandoned
77
*
88
* Side effect: assigning an agent to a goal auto-enables autonomous mode;
99
* removing the last active goal from an agent auto-disables it.
@@ -22,7 +22,7 @@ import type { TaskService } from './task-service.js';
2222

2323
const VALID_TRANSITIONS: Record<GoalStatus, GoalStatus[]> = {
2424
active: ['paused', 'achieved', 'abandoned'],
25-
paused: ['active', 'abandoned'],
25+
paused: ['active', 'achieved', 'abandoned'],
2626
achieved: [],
2727
abandoned: [],
2828
};
@@ -82,10 +82,14 @@ export class GoalService {
8282
);
8383
}
8484

85-
// Guard: block achieved if child tasks are still pending
85+
// Guard: block achieved if child tasks are still pending.
86+
// Autonomous [auto] tasks are excluded — they are the mechanism for achieving
87+
// the goal and will be cleaned up by side effects after status change.
8688
if (newStatus === 'achieved' && this.taskService) {
8789
const childTasks = await this.taskService.list({ goalId: id });
88-
const pending = childTasks.filter((t) => !isTaskTerminal(t.status));
90+
const pending = childTasks.filter(
91+
(t) => !isTaskTerminal(t.status) && !t.labels?.includes(AUTONOMOUS_LABEL),
92+
);
8993
if (pending.length > 0) {
9094
if (opts?.force) {
9195
// Force mode: cancel tasks that are safe to cancel at storage level.

src/cli/commands/tui.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ export function registerTuiCommand(program: Command, container: Container): void
207207
return container.goalService.update(id, fields);
208208
};
209209

210-
const onUpdateGoalStatus = async (id: string, status: import('../../domain/goal.js').GoalStatus) => {
211-
return container.goalService.updateStatus(id, status);
210+
const onUpdateGoalStatus = async (id: string, status: import('../../domain/goal.js').GoalStatus, opts?: { force?: boolean }) => {
211+
return container.goalService.updateStatus(id, status, opts);
212212
};
213213

214214
const onDeleteGoal = async (id: string) => {

src/domain/goal.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* Goals have lower priority than tasks — agents work on goals only
66
* when no regular tasks are available.
77
*
8-
* State machine: active → achieved | abandoned
9-
* active ↔ paused
8+
* State machine: active → achieved | abandoned | paused
9+
* paused → active | achieved | abandoned
1010
*/
1111

1212
export const GOAL_STATUSES = ['active', 'paused', 'achieved', 'abandoned'] as const;

src/tui/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export interface AppProps {
182182
onRefreshGoals?: () => Promise<Goal[]>;
183183
onCreateGoal?: (input: { title: string; description?: string; assignee?: string }) => Promise<Goal>;
184184
onUpdateGoal?: (id: string, fields: { title?: string; description?: string; assignee?: string }) => Promise<Goal>;
185-
onUpdateGoalStatus?: (id: string, status: GoalStatus) => Promise<Goal>;
185+
onUpdateGoalStatus?: (id: string, status: GoalStatus, opts?: { force?: boolean }) => Promise<Goal>;
186186
onDeleteGoal?: (id: string) => Promise<void>;
187187
onGetGoalProgress?: (goalId: string) => Promise<string | undefined>;
188188
/** Callback to persist onboardingCompleted=true when onboarding finishes */
@@ -2038,11 +2038,11 @@ export function App({
20382038
return;
20392039
}
20402040

2041-
// C: mark goal as achieved / abandon
2041+
// C: mark goal as achieved (force: cancel cancellable pending tasks)
20422042
if ((input === 'c' || input === 'C') && activeView === 'goals' && selectedGoal && onUpdateGoalStatus) {
20432043
if (selectedGoal.status === 'active' || selectedGoal.status === 'paused') {
2044-
addMessage(`Marking goal "${selectedGoal.title}" as achieved...`, tuiColors.amber);
2045-
onUpdateGoalStatus(selectedGoal.id, 'achieved').then(
2044+
addMessage(`Marking goal "${selectedGoal.title}" as achieved (pending tasks will be cancelled)...`, tuiColors.amber);
2045+
onUpdateGoalStatus(selectedGoal.id, 'achieved', { force: true }).then(
20462046
() => { addMessage(`\u2713 Goal "${selectedGoal.title}" achieved`, tuiColors.green); refreshAll(); },
20472047
(err) => addMessage(`Failed: ${err instanceof Error ? err.message : String(err)}`, tuiColors.red),
20482048
);

test/unit/application/goal-service-autonomous.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,64 @@ describe('GoalService autonomous mode side effects', () => {
447447
}
448448
});
449449

450+
it('excludes autonomous [auto] tasks from pending check', async () => {
451+
const goal = makeGoal({ id: 'goal_auto1', status: 'active', assignee: 'agt_a' });
452+
const tasks = [
453+
makeTask({ id: 'tsk_auto', status: 'in_progress', goalId: 'goal_auto1', labels: ['autonomous'] }),
454+
makeTask({ id: 'tsk_done', status: 'done', goalId: 'goal_auto1' }),
455+
];
456+
const goalStore = createMockGoalStore([goal]);
457+
const agentService = createMockAgentService();
458+
const taskService = createMockTaskService(tasks);
459+
const svc = new GoalService(goalStore, eventBus, agentService as any, taskService as any);
460+
461+
// The [auto] task is in_progress but should be excluded — no deadlock
462+
const result = await svc.updateStatus('goal_auto1', 'achieved');
463+
expect(result.status).toBe('achieved');
464+
});
465+
466+
it('still blocks on non-autonomous in_progress tasks', async () => {
467+
const goal = makeGoal({ id: 'goal_block', status: 'active' });
468+
const tasks = [
469+
makeTask({ id: 'tsk_auto', status: 'in_progress', goalId: 'goal_block', labels: ['autonomous'] }),
470+
makeTask({ id: 'tsk_user', status: 'in_progress', goalId: 'goal_block', labels: [] }),
471+
];
472+
const goalStore = createMockGoalStore([goal]);
473+
const taskService = createMockTaskService(tasks);
474+
const svc = new GoalService(goalStore, eventBus, undefined, taskService as any);
475+
476+
await expect(svc.updateStatus('goal_block', 'achieved'))
477+
.rejects.toThrow(GoalHasPendingTasksError);
478+
});
479+
480+
it('paused → achieved is allowed and calls maybeDisableAutonomous', async () => {
481+
const goal = makeGoal({ id: 'goal_pa1', status: 'paused', assignee: 'agt_pa' });
482+
const goalStore = createMockGoalStore([goal]);
483+
const agentService = createMockAgentService();
484+
const taskService = createMockTaskService([]);
485+
const svc = new GoalService(goalStore, eventBus, agentService as any, taskService as any);
486+
487+
const result = await svc.updateStatus('goal_pa1', 'achieved');
488+
expect(result.status).toBe('achieved');
489+
expect(agentService.setAutonomous).toHaveBeenCalledWith('agt_pa', false);
490+
});
491+
492+
it('paused → achieved with force cancels pending non-auto tasks', async () => {
493+
const goal = makeGoal({ id: 'goal_pf1', status: 'paused' });
494+
const tasks = [
495+
makeTask({ id: 'tsk_pf1', status: 'todo', goalId: 'goal_pf1' }),
496+
makeTask({ id: 'tsk_pf2', status: 'review', goalId: 'goal_pf1' }),
497+
];
498+
const goalStore = createMockGoalStore([goal]);
499+
const taskService = createMockTaskService(tasks);
500+
const svc = new GoalService(goalStore, eventBus, undefined, taskService as any);
501+
502+
const result = await svc.updateStatus('goal_pf1', 'achieved', { force: true });
503+
expect(result.status).toBe('achieved');
504+
expect(taskService.cancel).toHaveBeenCalledWith('tsk_pf1');
505+
expect(taskService.cancel).toHaveBeenCalledWith('tsk_pf2');
506+
});
507+
450508
it('skips validation when taskService is not provided', async () => {
451509
const goal = makeGoal({ id: 'goal_notsk', status: 'active' });
452510
const goalStore = createMockGoalStore([goal]);

0 commit comments

Comments
 (0)