Skip to content

Commit 98bb51f

Browse files
committed
Fix overlay and task sync across worktrees
1 parent 0a31fa8 commit 98bb51f

4 files changed

Lines changed: 323 additions & 26 deletions

File tree

src/cli/commands/task.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ interface RuntimePaths {
112112
eventsPath: string;
113113
}
114114

115+
type PersistedTaskContractStatus = 'pending' | 'in_progress' | 'done';
116+
115117
interface TaskFixAction {
116118
task_id: string;
117119
action: 'reset' | 'block' | 'migrate';
@@ -308,6 +310,26 @@ async function writeRuntimeState(runtimeFilePath: string, runtimeState: RuntimeS
308310
await writeJsonAtomic(runtimeFilePath, runtimeState);
309311
}
310312

313+
async function syncTaskContractStatus(task: Pick<ParsedTask, 'task_file_path'>, status: PersistedTaskContractStatus): Promise<void> {
314+
if (!task.task_file_path) {
315+
return;
316+
}
317+
318+
let currentContent: string;
319+
try {
320+
currentContent = await fs.readFile(task.task_file_path, 'utf-8');
321+
} catch {
322+
return;
323+
}
324+
325+
const nextContent = currentContent.replace(/^status:\s*.+$/m, `status: ${status}`);
326+
if (nextContent === currentContent) {
327+
return;
328+
}
329+
330+
await fs.writeFile(task.task_file_path, nextContent, 'utf-8');
331+
}
332+
311333
function createEmptyRuntimeState(): RuntimeState {
312334
return {
313335
changes: {},
@@ -1927,6 +1949,7 @@ async function startTask(taskId: string, command = 'task start'): Promise<TaskCo
19271949
}
19281950

19291951
if (existingTaskState?.status === 'in_progress') {
1952+
await syncTaskContractStatus(matchedTask, 'in_progress');
19301953
return {
19311954
ok: true,
19321955
data: {
@@ -1987,6 +2010,7 @@ async function startTask(taskId: string, command = 'task start'): Promise<TaskCo
19872010
if (persistResult) {
19882011
return persistResult;
19892012
}
2013+
await syncTaskContractStatus(matchedTask, 'in_progress');
19902014
await appendEvent(runtimePaths.eventsPath, 'task.started', taskRef, { command });
19912015
if (matchedTask.change_id) {
19922016
await syncChangeMetrics(matchedTask.change_id);
@@ -2047,6 +2071,7 @@ async function resumeTask(taskId: string, command = 'task resume'): Promise<Task
20472071
}
20482072

20492073
if (existingTaskState?.status === 'in_progress') {
2074+
await syncTaskContractStatus(matchedTask, 'in_progress');
20502075
return {
20512076
ok: true,
20522077
data: {
@@ -2112,6 +2137,7 @@ async function resumeTask(taskId: string, command = 'task resume'): Promise<Task
21122137
if (persistResult) {
21132138
return persistResult;
21142139
}
2140+
await syncTaskContractStatus(matchedTask, 'in_progress');
21152141
await appendEvent(runtimePaths.eventsPath, 'task.resumed', taskRef, { command });
21162142
if (matchedTask.change_id) {
21172143
await syncChangeMetrics(matchedTask.change_id);
@@ -2323,6 +2349,7 @@ async function completeTask(taskId: string, command = 'task review complete'): P
23232349
});
23242350

23252351
await writeRuntimeState(runtimePaths.tasksPath, runtimeState);
2352+
await syncTaskContractStatus(matchedTask, 'done');
23262353
await appendEvent(runtimePaths.eventsPath, 'task.approved', taskRef, { command, workflowPhase: 'review' });
23272354
const overlay = await ensureOverlayFromMergedTasks({ command });
23282355

@@ -2415,6 +2442,7 @@ async function completeTasks(taskIds: string[], command = 'task review complete'
24152442
updated_at: timestamp,
24162443
};
24172444
setRuntimeTaskState(runtimeState, taskRef, nextTaskState);
2445+
await syncTaskContractStatus(task, 'done');
24182446

24192447
await appendEvent(runtimePaths.eventsPath, 'task.approved', taskRef, { command, workflowPhase: 'review' });
24202448
completedTasks.push(buildRuntimeTaskSnapshot(task, getRuntimeTaskState(runtimeState, taskRef)!));
@@ -2483,6 +2511,7 @@ async function approveTask(taskId: string, command = 'task review approve'): Pro
24832511
});
24842512

24852513
await writeRuntimeState(runtimePaths.tasksPath, runtimeState);
2514+
await syncTaskContractStatus(matchedTask, 'done');
24862515
await appendEvent(runtimePaths.eventsPath, 'task.approved', taskRef, { command, workflowPhase: 'review' });
24872516
const overlay = await ensureOverlayFromMergedTasks({ command });
24882517

@@ -2694,6 +2723,7 @@ async function reopenTask(taskId: string, reason?: string, command = 'task revie
26942723
if (persistResult) {
26952724
return persistResult;
26962725
}
2726+
await syncTaskContractStatus(mergedTask, 'in_progress');
26972727
await appendEvent(runtimePaths.eventsPath, 'task.reopened', taskRef, {
26982728
command,
26992729
workflowPhase: 'review',
@@ -2753,6 +2783,9 @@ async function resetTask(taskId: string, command = 'task repair reset'): Promise
27532783
deleteRuntimeTaskState(runtimeState, resolvedTaskRef);
27542784

27552785
await writeRuntimeState(runtimePaths.tasksPath, runtimeState);
2786+
if (matchedTask) {
2787+
await syncTaskContractStatus(matchedTask, 'pending');
2788+
}
27562789
await appendEvent(runtimePaths.eventsPath, 'task.reset', resolvedTaskRef, { command, workflowPhase: 'runtime' });
27572790
const overlay = await ensureOverlayFromMergedTasks({ command });
27582791

src/cli/overlay-runtime.ts

Lines changed: 150 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { loadChangeGraph } from './graph';
2020
import { getTaskRef, toQualifiedTaskId } from './task-identity';
2121
import { formatTitleFromSlug } from './commands/scaffold';
22+
import { readExecutionRootsState, type ExecutionRootsState } from './execution-roots';
2223
import { resolveProjectIdentity } from './project-identity';
2324
import { resolveSuperplanRoot, resolveWorkspaceRoot } from './workspace-root';
2425

@@ -59,6 +60,20 @@ interface SetOverlayVisibilityOptions {
5960
workspacePath?: string;
6061
}
6162

63+
interface OverlayRuntimeTaskEntry {
64+
status?: string;
65+
}
66+
67+
interface OverlayRuntimeChangeEntry {
68+
active_task_ref?: string | null;
69+
tasks?: Record<string, OverlayRuntimeTaskEntry>;
70+
}
71+
72+
interface OverlayRuntimeStateFile {
73+
changes?: Record<string, OverlayRuntimeChangeEntry>;
74+
tasks?: Record<string, OverlayRuntimeTaskEntry>;
75+
}
76+
6277
async function pathExists(targetPath: string): Promise<boolean> {
6378
try {
6479
await fs.access(targetPath);
@@ -81,6 +96,13 @@ async function readOverlaySnapshot(paths: OverlayRuntimePaths): Promise<OverlayS
8196
}
8297
}
8398

99+
async function writeOverlaySnapshot(paths: OverlayRuntimePaths, snapshot: OverlaySnapshot): Promise<void> {
100+
await fs.mkdir(paths.runtime_dir, { recursive: true });
101+
const tempSnapshotPath = `${paths.snapshot_path}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
102+
await fs.writeFile(tempSnapshotPath, JSON.stringify(snapshot, null, 2), 'utf-8');
103+
await fs.rename(tempSnapshotPath, paths.snapshot_path);
104+
}
105+
84106
function getPriorityRank(priority: TaskPriority): number {
85107
if (priority === 'high') {
86108
return 0;
@@ -202,6 +224,101 @@ function createAlertEvents(previousEvents: OverlayEvent[], alertKinds: OverlayEv
202224
return [...previousEvents, ...newEvents].slice(-MAX_OVERLAY_EVENTS);
203225
}
204226

227+
function getOverlaySnapshotWorkspacePaths(workspacePath: string): string[] {
228+
const workspaceRoot = resolveWorkspaceRoot(workspacePath);
229+
const projectRoot = resolveProjectIdentity(workspacePath).project_root;
230+
return [...new Set([workspaceRoot, projectRoot])];
231+
}
232+
233+
async function readOverlayRuntimeTaskRefs(workspacePath: string): Promise<string[]> {
234+
const tasksPath = path.join(resolveSuperplanRoot(workspacePath), 'runtime', 'tasks.json');
235+
try {
236+
const parsed = JSON.parse(await fs.readFile(tasksPath, 'utf-8')) as OverlayRuntimeStateFile;
237+
if (parsed && typeof parsed === 'object' && parsed.changes && typeof parsed.changes === 'object') {
238+
const refs = new Set<string>();
239+
for (const [changeId, changeState] of Object.entries(parsed.changes)) {
240+
const activeTaskRef = typeof changeState?.active_task_ref === 'string'
241+
? changeState.active_task_ref.trim()
242+
: '';
243+
if (activeTaskRef) {
244+
refs.add(activeTaskRef);
245+
continue;
246+
}
247+
248+
if (!changeState?.tasks || typeof changeState.tasks !== 'object') {
249+
continue;
250+
}
251+
252+
for (const [taskId, taskState] of Object.entries(changeState.tasks)) {
253+
if (taskState?.status === 'in_progress') {
254+
refs.add(toQualifiedTaskId(changeId, taskId));
255+
}
256+
}
257+
}
258+
259+
return [...refs].sort((left, right) => left.localeCompare(right));
260+
}
261+
262+
if (parsed && typeof parsed === 'object' && parsed.tasks && typeof parsed.tasks === 'object') {
263+
return Object.entries(parsed.tasks)
264+
.filter(([taskRef, taskState]) => taskRef.includes('/') && taskState?.status === 'in_progress')
265+
.map(([taskRef]) => taskRef)
266+
.sort((left, right) => left.localeCompare(right));
267+
}
268+
} catch {}
269+
270+
return [];
271+
}
272+
273+
function getAttachedChangeIdForWorkspace(
274+
executionRootsState: ExecutionRootsState,
275+
workspacePath: string,
276+
): string | null {
277+
const workspaceRoot = resolveWorkspaceRoot(workspacePath);
278+
return Object.values(executionRootsState.roots)
279+
.find(record => record.path === workspaceRoot)
280+
?.attached_change_id ?? null;
281+
}
282+
283+
function getActiveOverlayTaskForWorkspace(
284+
board: OverlaySnapshot['board'],
285+
options: {
286+
activeTaskRefs: string[];
287+
attachedChangeId: string | null;
288+
},
289+
): OverlayTaskSummary | null {
290+
const taskByRef = new Map<string, OverlayTaskSummary>();
291+
for (const task of board.in_progress) {
292+
if (task.task_ref) {
293+
taskByRef.set(task.task_ref, task);
294+
}
295+
}
296+
297+
if (options.attachedChangeId) {
298+
const attachedActiveTaskRef = options.activeTaskRefs.find(taskRef => taskRef.startsWith(`${options.attachedChangeId}/`));
299+
if (attachedActiveTaskRef) {
300+
const attachedActiveTask = taskByRef.get(attachedActiveTaskRef);
301+
if (attachedActiveTask) {
302+
return attachedActiveTask;
303+
}
304+
}
305+
306+
const attachedTask = board.in_progress.find(task => task.change_id === options.attachedChangeId);
307+
if (attachedTask) {
308+
return attachedTask;
309+
}
310+
}
311+
312+
for (const taskRef of options.activeTaskRefs) {
313+
const activeTask = taskByRef.get(taskRef);
314+
if (activeTask) {
315+
return activeTask;
316+
}
317+
}
318+
319+
return board.in_progress[0] ?? null;
320+
}
321+
205322
async function getTrackedChangeTaskIds(changeDir: string): Promise<string[]> {
206323
const tasksDir = path.join(changeDir, 'tasks');
207324
let taskEntries: Array<{ isFile(): boolean; name: string }> = [];
@@ -371,11 +488,9 @@ export async function refreshOverlaySnapshot(
371488
tasks: OverlayTaskSource[],
372489
options: RefreshOverlaySnapshotOptions = {},
373490
): Promise<{ paths: OverlayRuntimePaths; snapshot: OverlaySnapshot }> {
374-
const workspacePath = options.workspacePath ?? resolveWorkspaceRoot();
491+
const workspacePath = resolveWorkspaceRoot(options.workspacePath ?? resolveWorkspaceRoot());
375492
const projectIdentity = resolveProjectIdentity(workspacePath);
376-
const paths = getOverlayRuntimePaths(workspacePath);
377493
const timestamp = new Date().toISOString();
378-
const previousSnapshot = await readOverlaySnapshot(paths);
379494
const sortedTasks = [...tasks].sort(sortTasks);
380495
const trackedChanges = await collectTrackedChanges(workspacePath, sortedTasks);
381496
const focusedChange = toFocusedChange(trackedChanges[0]);
@@ -389,31 +504,40 @@ export async function refreshOverlaySnapshot(
389504
blocked: sortedTasks.filter(task => task.status === 'blocked').map(toOverlayTaskSummary),
390505
needs_feedback: sortedTasks.filter(task => task.status === 'needs_feedback').map(toOverlayTaskSummary),
391506
};
507+
const [executionRootsState, activeTaskRefs] = await Promise.all([
508+
readExecutionRootsState(workspacePath),
509+
readOverlayRuntimeTaskRefs(workspacePath),
510+
]);
511+
const targetWorkspacePaths = getOverlaySnapshotWorkspacePaths(workspacePath);
512+
const snapshots = await Promise.all(targetWorkspacePaths.map(async targetWorkspacePath => {
513+
const paths = getOverlayRuntimePaths(targetWorkspacePath);
514+
const previousSnapshot = await readOverlaySnapshot(paths);
515+
const snapshot = createOverlaySnapshot({
516+
project_id: projectIdentity.project_id,
517+
project_name: path.basename(projectIdentity.project_root) || path.basename(targetWorkspacePath) || 'root',
518+
project_path: projectIdentity.project_root,
519+
workspace_path: targetWorkspacePath,
520+
session_id: `workspace:${targetWorkspacePath}`,
521+
updated_at: timestamp,
522+
tracked_changes: trackedChanges.map(toOverlayTrackedChange),
523+
focused_change: focusedChange,
524+
active_task: getActiveOverlayTaskForWorkspace(board, {
525+
activeTaskRefs,
526+
attachedChangeId: getAttachedChangeIdForWorkspace(executionRootsState, targetWorkspacePath),
527+
}),
528+
board,
529+
attention_state: attentionState,
530+
events: createAlertEvents(previousSnapshot?.events ?? [], alertKinds, timestamp),
531+
});
392532

393-
const snapshot = createOverlaySnapshot({
394-
project_id: projectIdentity.project_id,
395-
project_name: path.basename(projectIdentity.project_root) || path.basename(workspacePath) || 'root',
396-
project_path: projectIdentity.project_root,
397-
workspace_path: workspacePath,
398-
session_id: `workspace:${workspacePath}`,
399-
updated_at: timestamp,
400-
tracked_changes: trackedChanges.map(toOverlayTrackedChange),
401-
focused_change: focusedChange,
402-
active_task: board.in_progress[0] ?? null,
403-
board,
404-
attention_state: attentionState,
405-
events: createAlertEvents(previousSnapshot?.events ?? [], alertKinds, timestamp),
406-
});
407-
408-
await fs.mkdir(paths.runtime_dir, { recursive: true });
409-
const tempSnapshotPath = `${paths.snapshot_path}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
410-
await fs.writeFile(tempSnapshotPath, JSON.stringify(snapshot, null, 2), 'utf-8');
411-
await fs.rename(tempSnapshotPath, paths.snapshot_path);
533+
await writeOverlaySnapshot(paths, snapshot);
534+
return {
535+
paths,
536+
snapshot,
537+
};
538+
}));
412539

413-
return {
414-
paths,
415-
snapshot,
416-
};
540+
return snapshots.find(item => item.snapshot.workspace_path === workspacePath) ?? snapshots[0];
417541
}
418542

419543
export async function setOverlayVisibilityRequest(

0 commit comments

Comments
 (0)