Skip to content

Commit e7b4c37

Browse files
feat: add task binding lifecycle and continuity helpers (#33)
1 parent b234943 commit e7b4c37

7 files changed

Lines changed: 210 additions & 14 deletions

File tree

src/core/task-binding.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function decideContinuity({ hasActiveTask = false, continuationSignal = false, distinctObjectiveSignal = false, alternativeRequested = false, ambiguous = false } = {}) {
2+
if (!hasActiveTask) {
3+
return { decision: "create-new", reason: "no-active-task" };
4+
}
5+
6+
if (distinctObjectiveSignal || alternativeRequested) {
7+
return { decision: "create-new", reason: alternativeRequested ? "alternative-requested" : "distinct-objective" };
8+
}
9+
10+
if (ambiguous) {
11+
return { decision: "ask-user", reason: "ambiguous-continuity" };
12+
}
13+
14+
if (continuationSignal) {
15+
return { decision: "reuse-active", reason: "clear-continuation" };
16+
}
17+
18+
return { decision: "ask-user", reason: "insufficient-signal" };
19+
}
20+
21+
export function inferTaskLifecycleTransition({ currentStatus = "inactive", explicitSignal = "none" } = {}) {
22+
if (explicitSignal === "activate") return "active";
23+
if (explicitSignal === "deactivate") return "inactive";
24+
if (explicitSignal === "complete") return "completed";
25+
if (explicitSignal === "block") return "blocked";
26+
return currentStatus;
27+
}

src/core/worktree-service.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33

44
import { parse } from "jsonc-parser";
5+
import { inferTaskLifecycleTransition } from "./task-binding.js";
56

67
export const DEFAULTS = {
78
branchPrefix: "wt/",
@@ -364,7 +365,13 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
364365
if (!sessionID || !stateStore) return;
365366
const state = await stateStore.loadSessionState(repoRoot, sessionID);
366367
const next = stateStore.setActiveTask(
367-
stateStore.upsertTask(state, { title: prepared.title, branch: prepared.branch, worktree_path: prepared.worktree_path, status: "active" }),
368+
stateStore.upsertTask(state, {
369+
task_id: prepared.branch,
370+
title: prepared.title,
371+
branch: prepared.branch,
372+
worktree_path: prepared.worktree_path,
373+
status: inferTaskLifecycleTransition({ explicitSignal: "activate" }),
374+
}),
368375
prepared.branch,
369376
);
370377
await stateStore.saveSessionState(repoRoot, sessionID, next);
@@ -373,7 +380,12 @@ export function createWorktreeWorkflowService({ directory, git, stateStore }) {
373380
if (!sessionID || !stateStore || removed.length === 0) return;
374381
let state = await stateStore.loadSessionState(repoRoot, sessionID);
375382
for (const item of removed) {
376-
state = stateStore.upsertTask(state, { branch: item.branch, worktree_path: item.path, status: "cleaned" });
383+
state = stateStore.upsertTask(state, {
384+
task_id: item.branch,
385+
branch: item.branch,
386+
worktree_path: item.path,
387+
status: inferTaskLifecycleTransition({ explicitSignal: "complete" }),
388+
});
377389
if (stateStore.getActiveTask(state) === item.branch) {
378390
state = stateStore.setActiveTask(state, null);
379391
}

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { tool } from "@opencode-ai/plugin";
22

33
import { createWorktreeWorkflowService, __internalService, isMissingGitRepositoryError, isMissingRemoteError } from "./core/worktree-service.js";
4+
import { decideContinuity, inferTaskLifecycleTransition } from "./core/task-binding.js";
45
import { createRuntimeStateStore } from "./runtime/state-store.js";
56

67
function publishStructuredResult(context, result) {
@@ -27,6 +28,8 @@ export const __internal = {
2728
...__internalService,
2829
isMissingGitRepositoryError,
2930
isMissingRemoteError,
31+
decideContinuity,
32+
inferTaskLifecycleTransition,
3033
};
3134

3235
export const WorktreeWorkflowPlugin = async ({ $, directory }) => {

src/runtime/state-store.js

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,88 @@ import path from "node:path";
55

66
const STORE_VERSION = 1;
77

8+
const LIFECYCLE_VALUES = new Set(["active", "inactive", "completed", "blocked"]);
9+
10+
function normalizeLifecycleStatus(value) {
11+
if (value === "cleaned") return "completed";
12+
return LIFECYCLE_VALUES.has(value) ? value : "inactive";
13+
}
14+
15+
function createDefaultState(repoRoot, sessionID) {
16+
return {
17+
schema_version: STORE_VERSION,
18+
repo_root: path.resolve(repoRoot),
19+
session_id: sessionID,
20+
active_task_id: null,
21+
tasks: [],
22+
};
23+
}
24+
25+
function findTaskIndex(tasks, taskPatch) {
26+
if (taskPatch?.task_id) {
27+
const byID = tasks.findIndex((task) => task?.task_id === taskPatch.task_id);
28+
if (byID !== -1) return byID;
29+
}
30+
31+
return tasks.findIndex((task) => {
32+
if (taskPatch?.branch && task?.branch === taskPatch.branch) return true;
33+
if (taskPatch?.worktree_path && task?.worktree_path === taskPatch.worktree_path) return true;
34+
return false;
35+
});
36+
}
37+
38+
function normalizeLoadedState(parsed, repoRoot, sessionID) {
39+
if (!parsed || typeof parsed !== "object") return createDefaultState(repoRoot, sessionID);
40+
41+
const state = createDefaultState(repoRoot, sessionID);
42+
const incomingTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
43+
const legacyActiveTask = typeof parsed.active_task === "string" && parsed.active_task ? parsed.active_task : null;
44+
const declaredActiveTask = typeof parsed.active_task_id === "string" && parsed.active_task_id ? parsed.active_task_id : null;
45+
46+
state.tasks = incomingTasks.map((task, index) => {
47+
const normalized = {
48+
...(task && typeof task === "object" ? task : {}),
49+
task_id: task?.task_id || task?.branch || task?.worktree_path || `task-${index + 1}`,
50+
status: normalizeLifecycleStatus(task?.status),
51+
};
52+
return normalized;
53+
});
54+
55+
let activeTaskID = declaredActiveTask;
56+
if (!activeTaskID && legacyActiveTask) {
57+
const legacyMatch = state.tasks.find((task) => task.task_id === legacyActiveTask || task.branch === legacyActiveTask);
58+
activeTaskID = legacyMatch?.task_id ?? null;
59+
}
60+
if (!activeTaskID) {
61+
const activeFromStatus = state.tasks.find((task) => task.status === "active");
62+
activeTaskID = activeFromStatus?.task_id ?? null;
63+
}
64+
if (activeTaskID) {
65+
const activeTask = state.tasks.find((task) => task.task_id === activeTaskID);
66+
if (!activeTask || activeTask.status === "completed" || activeTask.status === "blocked") {
67+
activeTaskID = null;
68+
}
69+
}
70+
71+
state.active_task_id = activeTaskID;
72+
if (activeTaskID) {
73+
state.tasks = state.tasks.map((task) => {
74+
if (task.status === "completed" || task.status === "blocked") return task;
75+
return {
76+
...task,
77+
status: task.task_id === activeTaskID ? "active" : "inactive",
78+
};
79+
});
80+
} else {
81+
state.tasks = state.tasks.map((task) => {
82+
if (task.status === "active") return { ...task, status: "inactive" };
83+
return task;
84+
});
85+
}
86+
87+
return state;
88+
}
89+
890
function defaultStateDir(env = process.env, platform = process.platform) {
991
if (env.OPENCODE_WORKTREE_STATE_DIR) {
1092
return path.resolve(env.OPENCODE_WORKTREE_STATE_DIR);
@@ -44,13 +126,10 @@ export function createRuntimeStateStore({ stateDir = defaultStateDir(), now = ()
44126
try {
45127
const raw = await fs.readFile(filePath, "utf8");
46128
const parsed = JSON.parse(raw);
47-
if (!parsed || typeof parsed !== "object") {
48-
return { schema_version: STORE_VERSION, repo_root: path.resolve(repoRoot), session_id: sessionID, active_task: null, tasks: [] };
49-
}
50-
return parsed;
129+
return normalizeLoadedState(parsed, repoRoot, sessionID);
51130
} catch (error) {
52131
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
53-
return { schema_version: STORE_VERSION, repo_root: path.resolve(repoRoot), session_id: sessionID, active_task: null, tasks: [] };
132+
return createDefaultState(repoRoot, sessionID);
54133
}
55134
throw error;
56135
}
@@ -63,24 +142,40 @@ export function createRuntimeStateStore({ stateDir = defaultStateDir(), now = ()
63142
}
64143

65144
function getActiveTask(state) {
66-
return state?.active_task ?? null;
145+
return state?.active_task_id ?? null;
67146
}
68147

69-
function setActiveTask(state, activeTask) {
148+
function setActiveTask(state, activeTaskID) {
149+
const tasks = Array.isArray(state?.tasks) ? state.tasks : [];
150+
const nextTasks = tasks.map((task) => {
151+
if (!task || typeof task !== "object") return task;
152+
if (task.status === "completed" || task.status === "blocked") return task;
153+
if (!activeTaskID) {
154+
return task.status === "active" ? { ...task, status: "inactive" } : task;
155+
}
156+
return {
157+
...task,
158+
status: task.task_id === activeTaskID ? "active" : "inactive",
159+
};
160+
});
161+
70162
return {
71163
...state,
72-
active_task: activeTask,
164+
active_task_id: activeTaskID,
165+
tasks: nextTasks,
73166
};
74167
}
75168

76169
function upsertTask(state, taskPatch) {
77170
const timestamp = now();
78171
const tasks = Array.isArray(state?.tasks) ? [...state.tasks] : [];
79-
const index = tasks.findIndex((task) => task?.branch === taskPatch?.branch || task?.worktree_path === taskPatch?.worktree_path);
172+
const index = findTaskIndex(tasks, taskPatch);
80173

81174
if (index === -1) {
82175
tasks.push({
83176
...taskPatch,
177+
task_id: taskPatch?.task_id || taskPatch?.branch || taskPatch?.worktree_path || `task-${tasks.length + 1}`,
178+
status: normalizeLifecycleStatus(taskPatch?.status),
84179
created_at: timestamp,
85180
last_used_at: timestamp,
86181
});
@@ -89,6 +184,8 @@ export function createRuntimeStateStore({ stateDir = defaultStateDir(), now = ()
89184
tasks[index] = {
90185
...existing,
91186
...taskPatch,
187+
task_id: existing.task_id || taskPatch?.task_id || taskPatch?.branch || taskPatch?.worktree_path || `task-${index + 1}`,
188+
status: normalizeLifecycleStatus(taskPatch?.status ?? existing.status),
92189
created_at: existing.created_at || timestamp,
93190
last_used_at: timestamp,
94191
};

test/base-branch.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ test("worktree_cleanup apply updates runtime state for removed task", async () =
143143
const stateFilePath = path.join(fixture.stateDir, "sessions", stateFiles[0]);
144144
const state = JSON.parse(await fs.readFile(stateFilePath, "utf8"));
145145
const task = state.tasks.find((item) => item.branch === prepared.result.branch);
146-
assert.equal(task.status, "cleaned");
146+
assert.equal(task.status, "completed");
147147
} finally {
148148
process.env.OPENCODE_WORKTREE_STATE_DIR = prev;
149149
await fixture.cleanup();

test/runtime-state-store.test.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,41 @@ test("upsertTask preserves created_at and refreshes last_used_at", () => {
3131
let tick = 0;
3232
const store = createRuntimeStateStore({ stateDir: "/tmp/unused", now: () => `2026-03-27T00:00:0${++tick}Z` });
3333
const first = store.upsertTask({ tasks: [] }, { branch: "wt/a", worktree_path: "/tmp/a", status: "active" });
34-
const second = store.upsertTask(first, { branch: "wt/a", status: "cleaned" });
34+
const second = store.upsertTask(first, { branch: "wt/a", status: "completed" });
3535
assert.equal(second.tasks[0].created_at, "2026-03-27T00:00:01Z");
3636
assert.equal(second.tasks[0].last_used_at, "2026-03-27T00:00:02Z");
37-
assert.equal(second.tasks[0].status, "cleaned");
37+
assert.equal(second.tasks[0].status, "completed");
38+
});
39+
40+
test("setActiveTask enforces single-active invariant", () => {
41+
const store = createRuntimeStateStore({ stateDir: "/tmp/unused" });
42+
const withA = store.upsertTask({ tasks: [] }, { task_id: "wt/a", branch: "wt/a", status: "active" });
43+
const withB = store.upsertTask(withA, { task_id: "wt/b", branch: "wt/b", status: "inactive" });
44+
const activatedB = store.setActiveTask(withB, "wt/b");
45+
46+
assert.equal(store.getActiveTask(activatedB), "wt/b");
47+
assert.equal(activatedB.tasks.find((task) => task.task_id === "wt/a")?.status, "inactive");
48+
assert.equal(activatedB.tasks.find((task) => task.task_id === "wt/b")?.status, "active");
49+
});
50+
51+
test("loadSessionState migrates legacy active_task and cleaned status", async () => {
52+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "wt-state-store-legacy-"));
53+
try {
54+
const store = createRuntimeStateStore({ stateDir: root });
55+
const legacy = {
56+
schema_version: 1,
57+
repo_root: "/repo/path",
58+
session_id: "session-legacy",
59+
active_task: "wt/task",
60+
tasks: [{ branch: "wt/task", worktree_path: "/tmp/wt/task", status: "cleaned" }],
61+
};
62+
await store.saveSessionState("/repo/path", "session-legacy", legacy);
63+
64+
const loaded = await store.loadSessionState("/repo/path", "session-legacy");
65+
assert.equal(loaded.active_task_id, null);
66+
assert.equal(loaded.tasks[0].task_id, "wt/task");
67+
assert.equal(loaded.tasks[0].status, "completed");
68+
} finally {
69+
await fs.rm(root, { recursive: true, force: true });
70+
}
3871
});

test/task-binding.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { __internal } from "../src/index.js";
5+
6+
test("decideContinuity reuses active task for clear continuation", () => {
7+
const decision = __internal.decideContinuity({ hasActiveTask: true, continuationSignal: true });
8+
assert.deepEqual(decision, { decision: "reuse-active", reason: "clear-continuation" });
9+
});
10+
11+
test("decideContinuity asks user when ambiguous", () => {
12+
const decision = __internal.decideContinuity({ hasActiveTask: true, ambiguous: true });
13+
assert.deepEqual(decision, { decision: "ask-user", reason: "ambiguous-continuity" });
14+
});
15+
16+
test("decideContinuity creates new for distinct objective", () => {
17+
const decision = __internal.decideContinuity({ hasActiveTask: true, distinctObjectiveSignal: true });
18+
assert.deepEqual(decision, { decision: "create-new", reason: "distinct-objective" });
19+
});
20+
21+
test("inferTaskLifecycleTransition only changes on explicit signal", () => {
22+
assert.equal(__internal.inferTaskLifecycleTransition({ currentStatus: "active", explicitSignal: "none" }), "active");
23+
assert.equal(__internal.inferTaskLifecycleTransition({ currentStatus: "active", explicitSignal: "complete" }), "completed");
24+
});

0 commit comments

Comments
 (0)