taskchef 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.js CHANGED
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
  import path from "node:path";
19
19
  import { promisify } from "node:util";
20
20
  import lockfile from "proper-lockfile";
21
+ import { normalizeDurableThreadId, parseTaskChefMarker } from "./delegation.js";
21
22
 
22
23
  const execFile = promisify(execFileCallback);
23
24
  const DISPATCHER_INSTRUCTIONS_URL = new URL(
@@ -164,6 +165,12 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
164
165
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
165
166
  }
166
167
 
168
+ async function writeDispatchesAtomic(workspaceRoot, dispatches) {
169
+ const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
170
+ const content = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
171
+ await writeTextAtomic(dispatchPath, content);
172
+ }
173
+
167
174
  async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
168
175
  await mkdir(path.dirname(filePath), { recursive: true });
169
176
  if (exclusive && (await pathExists(filePath))) {
@@ -558,14 +565,12 @@ export async function initializeWorkspace(workspaceRoot) {
558
565
  ? await readConfig(root, { checkPaths: false })
559
566
  : { schemaVersion: 1, projects: [] };
560
567
  const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
561
- const existingDispatches = configExists && (await managedRegularFileExists(dispatchPath))
562
- ? await readDispatchesUnlocked(root)
563
- : [];
564
- const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
568
+ if (configExists && (await managedRegularFileExists(dispatchPath))) {
569
+ await readDispatchesUnlocked(root);
570
+ }
565
571
  const { legacySkills } = await ensureWorkspaceSkills(root);
566
572
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
567
573
  const tasks = await ensureDispatchFile(root);
568
- const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
569
574
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
570
575
  if (!configExists) await unlink(configPath).catch(() => {});
571
576
  throw error;
@@ -576,7 +581,6 @@ export async function initializeWorkspace(workspaceRoot) {
576
581
  tasks,
577
582
  instructions,
578
583
  legacySkills,
579
- legacyTasks,
580
584
  };
581
585
  }
582
586
 
@@ -656,15 +660,24 @@ async function validateDispatchShape(dispatch, name = "task") {
656
660
  if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
657
661
  const id = requireSafeId(dispatch.id, `${name}.id`);
658
662
  const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
659
- return {
663
+ const normalized = {
660
664
  schemaVersion: 1,
661
665
  id,
662
666
  project,
663
667
  title: requireString(dispatch.title, `${name}.title`).trim(),
664
668
  instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
665
- threadId: requireString(dispatch.threadId, `${name}.threadId`).trim(),
669
+ threadId: dispatch.threadId === null
670
+ ? null
671
+ : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
666
672
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
667
673
  };
674
+ if (
675
+ normalized.threadId === null &&
676
+ parseTaskChefMarker(normalized.instruction) !== normalized.id
677
+ ) {
678
+ throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
679
+ }
680
+ return normalized;
668
681
  }
669
682
 
670
683
  async function readDispatchesUnlocked(root) {
@@ -695,11 +708,11 @@ async function readDispatchesUnlocked(root) {
695
708
  const threadIds = new Set();
696
709
  for (const dispatch of dispatches) {
697
710
  if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
698
- if (threadIds.has(dispatch.threadId)) {
711
+ if (dispatch.threadId !== null && threadIds.has(dispatch.threadId)) {
699
712
  throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
700
713
  }
701
714
  ids.add(dispatch.id);
702
- threadIds.add(dispatch.threadId);
715
+ if (dispatch.threadId !== null) threadIds.add(dispatch.threadId);
703
716
  }
704
717
  return dispatches;
705
718
  }
@@ -730,7 +743,10 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
730
743
  if (existing.some((item) => item.id === dispatch.id)) {
731
744
  throw new Error(`task already exists: ${dispatch.id}`);
732
745
  }
733
- if (existing.some((item) => item.threadId === dispatch.threadId)) {
746
+ if (
747
+ dispatch.threadId !== null &&
748
+ existing.some((item) => item.threadId === dispatch.threadId)
749
+ ) {
734
750
  throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
735
751
  }
736
752
  await appendDispatchesAtomic(root, [dispatch]);
@@ -738,6 +754,32 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
738
754
  return dispatch;
739
755
  }
740
756
 
757
+ export async function resolveTask(workspaceRoot, taskId, threadId) {
758
+ const id = requireSafeId(taskId, "taskId");
759
+ const durableThreadId = normalizeDurableThreadId(threadId);
760
+ const root = await realpath(path.resolve(workspaceRoot));
761
+ return withDispatchLock(root, async () => {
762
+ const dispatches = await readDispatchesUnlocked(root);
763
+ const index = dispatches.findIndex((dispatch) => dispatch.id === id);
764
+ if (index === -1) throw new Error(`task not found: ${id}`);
765
+ const dispatch = dispatches[index];
766
+ if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
767
+ throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
768
+ }
769
+ if (dispatch.threadId === durableThreadId) return dispatch;
770
+ if (dispatch.threadId !== null) {
771
+ throw new Error(`task already has a different threadId: ${id}`);
772
+ }
773
+ if (dispatches.some((item) => item.threadId === durableThreadId)) {
774
+ throw new Error(`threadId is already recorded: ${durableThreadId}`);
775
+ }
776
+ const resolved = { ...dispatch, threadId: durableThreadId };
777
+ dispatches[index] = resolved;
778
+ await writeDispatchesAtomic(root, dispatches);
779
+ return resolved;
780
+ });
781
+ }
782
+
741
783
  export async function readTask(workspaceRoot, taskId) {
742
784
  const id = requireSafeId(taskId, "taskId");
743
785
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
@@ -775,161 +817,6 @@ export async function buildTaskSummary(workspaceRoot) {
775
817
  };
776
818
  }
777
819
 
778
- const LEGACY_TASK_FIELDS = new Set([
779
- "schemaVersion",
780
- "id",
781
- "project",
782
- "title",
783
- "instruction",
784
- "status",
785
- "threadId",
786
- "result",
787
- "createdAt",
788
- "updatedAt",
789
- ]);
790
-
791
- function validateLegacyTask(task, taskId) {
792
- requireExactFields(task, LEGACY_TASK_FIELDS, `legacy task ${taskId}`);
793
- if (task.schemaVersion !== 1) throw new Error(`legacy task ${taskId} has unsupported schemaVersion`);
794
- if (requireSafeId(task.id) !== taskId) throw new Error(`legacy task ID does not match directory: ${taskId}`);
795
- requireString(task.project, `legacy task ${taskId}.project`);
796
- requireString(task.title, `legacy task ${taskId}.title`);
797
- requireString(task.instruction, `legacy task ${taskId}.instruction`);
798
- requireTimestamp(task.createdAt, `legacy task ${taskId}.createdAt`);
799
- if (task.threadId === null) {
800
- throw new Error(`legacy pending task ${taskId} has no executor thread and cannot be migrated`);
801
- }
802
- requireString(task.threadId, `legacy task ${taskId}.threadId`);
803
- return task;
804
- }
805
-
806
- async function inspectLegacyTaskRecords(workspaceRoot, config, existingDispatches = []) {
807
- const tasksPath = path.join(workspaceRoot, "tasks");
808
- const details = await lstat(tasksPath).catch((error) => {
809
- if (error.code === "ENOENT") return null;
810
- throw error;
811
- });
812
- if (details === null) return { tasksPath, entries: [], records: [], action: "not-found" };
813
- if (details.isSymbolicLink() || !details.isDirectory()) {
814
- throw new Error(`legacy tasks path is not a real directory: ${tasksPath}`);
815
- }
816
- const entries = await readdir(tasksPath, { withFileTypes: true });
817
- const records = [];
818
- const ids = new Set();
819
- const threadIds = new Set();
820
- for (const entry of entries) {
821
- if (!entry.isDirectory() || !SAFE_ID.test(entry.name)) {
822
- throw new Error(`unexpected legacy task entry: ${entry.name}`);
823
- }
824
- const taskDirectory = path.join(tasksPath, entry.name);
825
- const taskEntries = await readdir(taskDirectory, { withFileTypes: true });
826
- if (taskEntries.length === 0) {
827
- records.push({ dispatch: null, taskPath: null, taskDirectory, taskId: entry.name });
828
- continue;
829
- }
830
- if (
831
- taskEntries.length !== 1 ||
832
- taskEntries[0].name !== "task.json" ||
833
- !taskEntries[0].isFile()
834
- ) {
835
- throw new Error(`legacy task directory must contain only task.json: ${entry.name}`);
836
- }
837
- const taskPath = path.join(taskDirectory, "task.json");
838
- const task = validateLegacyTask(JSON.parse(await readFile(taskPath, "utf8")), entry.name);
839
- const existing = existingDispatches.find((dispatch) => dispatch.id === task.id);
840
- let dispatch;
841
- if (existing) {
842
- if (task.project.trim() !== existing.project.path) {
843
- throw new Error(`legacy task conflicts with task history: ${task.id}`);
844
- }
845
- const comparable = {
846
- title: task.title.trim(),
847
- instruction: task.instruction.trim(),
848
- threadId: task.threadId.trim(),
849
- createdAt: task.createdAt,
850
- };
851
- for (const [field, value] of Object.entries(comparable)) {
852
- if (existing[field] !== value) {
853
- throw new Error(`legacy task conflicts with task history: ${task.id}`);
854
- }
855
- }
856
- dispatch = existing;
857
- } else {
858
- const project = config.projects.find((candidate) => candidate.path === task.project) ??
859
- await inspectProject({ path: task.project });
860
- dispatch = await validateDispatchShape({
861
- schemaVersion: 1,
862
- id: task.id,
863
- project,
864
- title: task.title,
865
- instruction: task.instruction,
866
- threadId: task.threadId,
867
- createdAt: task.createdAt,
868
- });
869
- }
870
- if (ids.has(dispatch.id)) throw new Error(`duplicate legacy task ID: ${dispatch.id}`);
871
- if (threadIds.has(dispatch.threadId)) {
872
- throw new Error(`duplicate legacy task threadId: ${dispatch.threadId}`);
873
- }
874
- ids.add(dispatch.id);
875
- threadIds.add(dispatch.threadId);
876
- records.push({ dispatch, taskPath, taskDirectory, taskId: task.id });
877
- }
878
- return {
879
- tasksPath,
880
- entries,
881
- records,
882
- action: entries.length === 0 ? "removed-empty" : "migrated",
883
- };
884
- }
885
-
886
- async function migrateLegacyTaskRecords(workspaceRoot, inspection) {
887
- if (inspection.action === "not-found") return { action: "not-found", migratedCount: 0 };
888
- const migrations = await withDispatchLock(workspaceRoot, async () => {
889
- const existing = await readDispatchesUnlocked(workspaceRoot);
890
- const existingById = new Map(existing.map((dispatch) => [dispatch.id, dispatch]));
891
- const threadIds = new Set(existing.map((dispatch) => dispatch.threadId));
892
- const pending = [];
893
- for (const { dispatch, taskPath, taskDirectory, taskId } of inspection.records) {
894
- if (dispatch === null) {
895
- if (!existingById.has(taskId)) {
896
- throw new Error(`empty legacy task directory has no matching dispatch: ${taskId}`);
897
- }
898
- pending.push({ dispatch: null, taskPath, taskDirectory });
899
- continue;
900
- }
901
- const previous = existingById.get(dispatch.id);
902
- if (previous && JSON.stringify(previous) !== JSON.stringify(dispatch)) {
903
- throw new Error(`legacy task conflicts with dispatch: ${dispatch.id}`);
904
- }
905
- if (!previous && threadIds.has(dispatch.threadId)) {
906
- throw new Error(`legacy task threadId is already recorded: ${dispatch.threadId}`);
907
- }
908
- if (!previous) {
909
- pending.push({ dispatch, taskPath, taskDirectory });
910
- existingById.set(dispatch.id, dispatch);
911
- threadIds.add(dispatch.threadId);
912
- } else {
913
- pending.push({ dispatch: null, taskPath, taskDirectory });
914
- }
915
- }
916
- await appendDispatchesAtomic(
917
- workspaceRoot,
918
- pending.filter((item) => item.dispatch).map((item) => item.dispatch),
919
- );
920
- return pending;
921
- });
922
- for (const migration of migrations) {
923
- if (migration.taskPath !== null) await unlink(migration.taskPath);
924
- await rmdir(migration.taskDirectory);
925
- }
926
- await rmdir(inspection.tasksPath);
927
- return {
928
- action: inspection.action,
929
- migratedCount: migrations.filter((item) => item.dispatch).length,
930
- };
931
- }
932
-
933
820
  export async function doctorWorkspace(workspaceRoot) {
934
821
  const root = path.resolve(workspaceRoot);
935
822
  const checks = [];
@@ -965,11 +852,5 @@ export async function doctorWorkspace(workspaceRoot) {
965
852
  }
966
853
  return "no legacy TaskChef skill links";
967
854
  });
968
- await check("legacy-tasks", async () => {
969
- if (await pathExists(path.join(root, "tasks"))) {
970
- throw new Error("legacy tasks directory remains; run workspace init to migrate it");
971
- }
972
- return "no legacy task records";
973
- });
974
855
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
975
856
  }