taskchef 6.1.3 → 7.1.0

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
@@ -5,17 +5,14 @@ import {
5
5
  lstat,
6
6
  mkdir,
7
7
  readFile,
8
- readdir,
9
8
  realpath,
10
9
  link,
11
10
  rename,
12
- rmdir,
13
11
  stat,
14
12
  unlink,
15
13
  writeFile,
16
14
  } from "node:fs/promises";
17
15
  import { randomUUID } from "node:crypto";
18
- import { fileURLToPath } from "node:url";
19
16
  import path from "node:path";
20
17
  import { promisify } from "node:util";
21
18
  import lockfile from "proper-lockfile";
@@ -37,32 +34,22 @@ const DISPATCHER_INSTRUCTIONS_URL = new URL(
37
34
  );
38
35
  const DISPATCHER_INSTRUCTIONS_START = "<!-- taskchef:dispatcher-instructions:start -->";
39
36
  const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -->";
40
- const TASKCHEF_SKILL_NAMES = [
41
- "taskchef-bootstrap",
42
- "taskchef-delegate",
43
- "taskchef-report",
44
- ];
45
- const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
46
- const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
47
37
  const DISPATCH_FILE_NAME = "tasks.jsonl";
48
38
  const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
49
39
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
50
40
  const CURRENT_CONFIG_SCHEMA_VERSION = 2;
51
- const CURRENT_TASK_SCHEMA_VERSION = 4;
52
- const PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION = 3;
53
- const LEGACY_SCHEMA_VERSION = 1;
54
- const PREVIOUS_SCHEMA_VERSION = 2;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 5;
42
+ const PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
55
43
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
56
44
  const PROJECT_FIELDS = new Set([
57
45
  "name",
58
46
  "path",
59
47
  "isGitRepository",
60
- "githubRepo",
61
48
  "githubRepos",
62
49
  "description",
63
50
  ]);
64
51
  const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
65
- const DISPATCH_FIELDS = new Set([
52
+ const STATEFUL_DISPATCH_FIELDS = new Set([
66
53
  "schemaVersion",
67
54
  "id",
68
55
  "project",
@@ -76,15 +63,7 @@ const DISPATCH_FIELDS = new Set([
76
63
  "updatedAt",
77
64
  "updatedBy",
78
65
  ]);
79
- const LEGACY_DISPATCH_FIELDS = new Set([
80
- "schemaVersion",
81
- "id",
82
- "project",
83
- "title",
84
- "instruction",
85
- "threadId",
86
- "createdAt",
87
- ]);
66
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
88
67
  const RECORD_DISPATCH_FIELDS = new Set([
89
68
  "id",
90
69
  "project",
@@ -94,8 +73,9 @@ const RECORD_DISPATCH_FIELDS = new Set([
94
73
  ]);
95
74
  const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
96
75
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
97
- const TASK_UPDATE_SOURCES = new Set(["dispatcher", "hook", "mcp"]);
76
+ const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
98
77
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
78
+ const LAST_RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
99
79
 
100
80
  function requireExactFields(value, fields, name) {
101
81
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -123,6 +103,13 @@ function requireTimestamp(value, name) {
123
103
  return value;
124
104
  }
125
105
 
106
+ function transitionTimestamp(now, currentUpdatedAt) {
107
+ const candidate = requireTimestamp(now ?? new Date().toISOString(), "transition timestamp");
108
+ return Date.parse(candidate) < Date.parse(currentUpdatedAt)
109
+ ? currentUpdatedAt
110
+ : candidate;
111
+ }
112
+
126
113
  function optionalString(value, name, { maxLength = null } = {}) {
127
114
  if (value === null) return null;
128
115
  const normalized = requireString(value, name).trim();
@@ -347,75 +334,6 @@ export async function ensureWorkspaceInstructions(workspaceRoot) {
347
334
  return { path: filePath, action };
348
335
  }
349
336
 
350
- async function findLegacySkillLinks(workspaceRoot) {
351
- const agentsDirectory = path.join(workspaceRoot, ".agents");
352
- const agentsDetails = await lstat(agentsDirectory).catch((error) => {
353
- if (error.code === "ENOENT") return null;
354
- throw error;
355
- });
356
- if (agentsDetails === null) return { agentsDirectory: null, skillsDirectory: null, links: [] };
357
- if (agentsDetails.isSymbolicLink() || !agentsDetails.isDirectory()) {
358
- return { agentsDirectory: null, skillsDirectory: null, links: [] };
359
- }
360
-
361
- const skillsDirectory = path.join(agentsDirectory, "skills");
362
- const skillsDetails = await lstat(skillsDirectory).catch((error) => {
363
- if (error.code === "ENOENT") return null;
364
- throw error;
365
- });
366
- if (skillsDetails === null) return { agentsDirectory, skillsDirectory, links: [] };
367
- if (skillsDetails.isSymbolicLink() || !skillsDetails.isDirectory()) {
368
- return { agentsDirectory, skillsDirectory: null, links: [] };
369
- }
370
-
371
- const links = [];
372
- for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
373
- const skillPath = path.join(skillsDirectory, skillName);
374
- const details = await lstat(skillPath).catch((error) => {
375
- if (error.code === "ENOENT") return null;
376
- throw error;
377
- });
378
- if (details === null) continue;
379
- if (!details.isSymbolicLink()) {
380
- throw new Error(`legacy TaskChef skill path is not a symlink: ${skillPath}`);
381
- }
382
- links.push({ name: skillName, path: skillPath });
383
- }
384
- return { agentsDirectory, skillsDirectory, links };
385
- }
386
-
387
- async function removeLegacySkillLinks(workspaceRoot) {
388
- const legacy = await findLegacySkillLinks(workspaceRoot);
389
- for (const link of legacy.links) await unlink(link.path);
390
- const removedDirectories = [];
391
- if (legacy.skillsDirectory && (await readdir(legacy.skillsDirectory)).length === 0) {
392
- await rmdir(legacy.skillsDirectory);
393
- removedDirectories.push(legacy.skillsDirectory);
394
- }
395
- if (legacy.agentsDirectory && (await readdir(legacy.agentsDirectory).catch((error) => {
396
- if (error.code === "ENOENT") return null;
397
- throw error;
398
- }))?.length === 0) {
399
- await rmdir(legacy.agentsDirectory);
400
- removedDirectories.push(legacy.agentsDirectory);
401
- }
402
- return { removed: legacy.links, removedDirectories };
403
- }
404
-
405
- export async function ensureWorkspaceSkills(workspaceRoot) {
406
- const root = await realpath(path.resolve(workspaceRoot));
407
- const legacySkills = await removeLegacySkillLinks(root);
408
- return {
409
- directory: null,
410
- skills: TASKCHEF_SKILL_NAMES.map((name) => ({
411
- name,
412
- path: path.join(SKILLS_SOURCE_ROOT, name),
413
- action: "provided-by-plugin",
414
- })),
415
- legacySkills,
416
- };
417
- }
418
-
419
337
  export async function canonicalGitRoot(projectPath) {
420
338
  const requested = await canonicalDirectory(projectPath);
421
339
  const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
@@ -445,7 +363,7 @@ export async function canonicalDirectory(projectPath) {
445
363
  async function normalizeProject(
446
364
  project,
447
365
  index,
448
- { checkPath = true, allowLegacyGithubRepo = false } = {},
366
+ { checkPath = true } = {},
449
367
  ) {
450
368
  const field = `projects[${index}]`;
451
369
  if (!project || typeof project !== "object" || Array.isArray(project)) {
@@ -453,12 +371,7 @@ async function normalizeProject(
453
371
  }
454
372
  const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
455
373
  if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
456
- const repositoryField = allowLegacyGithubRepo ? "githubRepo" : "githubRepos";
457
- const unsupportedRepositoryField = allowLegacyGithubRepo ? "githubRepos" : "githubRepo";
458
- if (unsupportedRepositoryField in project) {
459
- throw new Error(`${field} has unsupported field: ${unsupportedRepositoryField}`);
460
- }
461
- for (const required of ["name", "path", "isGitRepository", repositoryField]) {
374
+ for (const required of ["name", "path", "isGitRepository", "githubRepos"]) {
462
375
  if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
463
376
  }
464
377
  const name = requireString(project.name, `${field}.name`).trim();
@@ -476,9 +389,7 @@ async function normalizeProject(
476
389
  throw new Error(`${field}.path must be a normalized absolute path`);
477
390
  }
478
391
  }
479
- const githubRepos = normalizeGithubRepositories(project[repositoryField], `${field}.${repositoryField}`, {
480
- allowLegacyScalar: allowLegacyGithubRepo,
481
- });
392
+ const githubRepos = normalizeGithubRepositories(project.githubRepos, `${field}.githubRepos`);
482
393
  const normalized = {
483
394
  name,
484
395
  path: projectPath,
@@ -496,14 +407,13 @@ async function normalizeProject(
496
407
 
497
408
  async function normalizeProjects(
498
409
  projects,
499
- { checkPaths = true, allowLegacyGithubRepo = false } = {},
410
+ { checkPaths = true } = {},
500
411
  ) {
501
412
  if (!Array.isArray(projects)) throw new Error("projects must be an array");
502
413
  const normalized = [];
503
414
  for (const [index, project] of projects.entries()) {
504
415
  normalized.push(await normalizeProject(project, index, {
505
416
  checkPath: checkPaths,
506
- allowLegacyGithubRepo,
507
417
  }));
508
418
  }
509
419
  if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
@@ -594,15 +504,12 @@ async function inspectProject(input, index = 0) {
594
504
 
595
505
  export async function validateConfig(config, { checkPaths = true } = {}) {
596
506
  requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
597
- if (![LEGACY_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION].includes(config.schemaVersion)) {
507
+ if (config.schemaVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
598
508
  throw new Error("unsupported configuration schemaVersion");
599
509
  }
600
510
  return {
601
511
  schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
602
- projects: await normalizeProjects(config.projects, {
603
- checkPaths,
604
- allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
605
- }),
512
+ projects: await normalizeProjects(config.projects, { checkPaths }),
606
513
  };
607
514
  }
608
515
 
@@ -630,9 +537,6 @@ export async function initializeWorkspace(workspaceRoot) {
630
537
  return withWorkspaceLock(root, async () => {
631
538
  const configPath = path.join(root, "taskchef.json");
632
539
  const configExists = await managedRegularFileExists(configPath);
633
- const storedConfigVersion = configExists
634
- ? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
635
- : null;
636
540
  const config = configExists
637
541
  ? await readConfig(root, { checkPaths: false })
638
542
  : { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION, projects: [] };
@@ -640,11 +544,7 @@ export async function initializeWorkspace(workspaceRoot) {
640
544
  if (configExists && (await managedRegularFileExists(dispatchPath))) {
641
545
  await readDispatchesUnlocked(root);
642
546
  }
643
- const { legacySkills } = await ensureWorkspaceSkills(root);
644
547
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
645
- else if (storedConfigVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
646
- await writeJsonAtomic(configPath, config);
647
- }
648
548
  const tasks = await ensureDispatchFile(root);
649
549
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
650
550
  if (!configExists) await unlink(configPath).catch(() => {});
@@ -656,14 +556,11 @@ export async function initializeWorkspace(workspaceRoot) {
656
556
  workspace: root,
657
557
  config: {
658
558
  path: configPath,
659
- action: !configExists
660
- ? "created"
661
- : storedConfigVersion === CURRENT_CONFIG_SCHEMA_VERSION ? "unchanged" : "migrated",
559
+ action: configExists ? "unchanged" : "created",
662
560
  value: config,
663
561
  },
664
562
  tasks,
665
563
  instructions,
666
- legacySkills,
667
564
  };
668
565
  });
669
566
  }
@@ -780,15 +677,9 @@ export async function removeProject(workspaceRoot, name) {
780
677
  });
781
678
  }
782
679
 
783
- async function validateDispatchShape(
784
- dispatch,
785
- name = "task",
786
- { allowLegacyHeading = false } = {},
787
- ) {
680
+ async function validateDispatchShape(dispatch, name = "task") {
788
681
  const supportedVersions = [
789
- LEGACY_SCHEMA_VERSION,
790
- PREVIOUS_SCHEMA_VERSION,
791
- PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION,
682
+ PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION,
792
683
  CURRENT_TASK_SCHEMA_VERSION,
793
684
  ];
794
685
  if (!supportedVersions.includes(dispatch?.schemaVersion)) {
@@ -796,16 +687,51 @@ async function validateDispatchShape(
796
687
  }
797
688
  requireExactFields(
798
689
  dispatch,
799
- dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
690
+ dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
800
691
  ? DISPATCH_FIELDS
801
- : LEGACY_DISPATCH_FIELDS,
692
+ : STATEFUL_DISPATCH_FIELDS,
802
693
  name,
803
694
  );
804
695
  const id = requireSafeId(dispatch.id, `${name}.id`);
805
- const project = await normalizeProject(dispatch.project, 0, {
806
- checkPath: false,
807
- allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
696
+ const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
697
+ const status = requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`);
698
+ const summary = optionalString(dispatch.summary, `${name}.summary`, {
699
+ maxLength: MAX_RESULT_SUMMARY_LENGTH,
808
700
  });
701
+ const turnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
702
+ const updatedAt = requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`);
703
+ let lastResult = null;
704
+ if (dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
705
+ if (dispatch.lastResult !== null) {
706
+ requireExactFields(dispatch.lastResult, LAST_RESULT_FIELDS, `${name}.lastResult`);
707
+ lastResult = {
708
+ status: requireEnum(
709
+ dispatch.lastResult.status,
710
+ RESULT_STATUSES,
711
+ `${name}.lastResult.status`,
712
+ ),
713
+ summary: optionalString(
714
+ dispatch.lastResult.summary,
715
+ `${name}.lastResult.summary`,
716
+ { maxLength: MAX_RESULT_SUMMARY_LENGTH },
717
+ ),
718
+ turnId: optionalString(
719
+ dispatch.lastResult.turnId,
720
+ `${name}.lastResult.turnId`,
721
+ { maxLength: 256 },
722
+ ),
723
+ updatedAt: requireTimestamp(
724
+ dispatch.lastResult.updatedAt,
725
+ `${name}.lastResult.updatedAt`,
726
+ ),
727
+ };
728
+ if (lastResult.summary === null) {
729
+ throw new Error(`${name}.lastResult.summary must be a non-empty string`);
730
+ }
731
+ }
732
+ } else if (RESULT_STATUSES.has(status)) {
733
+ lastResult = { status, summary, turnId, updatedAt };
734
+ }
809
735
  const normalized = {
810
736
  schemaVersion: dispatch.schemaVersion,
811
737
  id,
@@ -816,39 +742,101 @@ async function validateDispatchShape(
816
742
  ? null
817
743
  : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
818
744
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
819
- status: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
820
- ? requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`)
821
- : null,
822
- summary: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
823
- ? optionalString(dispatch.summary, `${name}.summary`, {
824
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
825
- })
826
- : null,
827
- turnId: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
828
- ? optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 })
829
- : null,
830
- updatedAt: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
831
- ? requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`)
832
- : null,
833
- updatedBy: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
834
- ? requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`)
835
- : null,
745
+ status,
746
+ summary,
747
+ turnId,
748
+ updatedAt,
749
+ updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
750
+ lastResult,
836
751
  };
752
+ const isSelfLinkingRecord = normalized.threadId !== null
753
+ && parseTaskChefMarker(normalized.instruction) === normalized.id;
754
+ if (isSelfLinkingRecord) {
755
+ if (normalized.turnId !== null) {
756
+ normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
757
+ }
758
+ if (normalized.lastResult?.turnId != null) {
759
+ normalizeCodexThreadId(
760
+ normalized.lastResult.turnId,
761
+ `${name}.lastResult.turnId`,
762
+ );
763
+ }
764
+ }
765
+ if (normalized.schemaVersion >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION) {
766
+ if (normalized.threadId === null) {
767
+ const isLinkPending = normalized.status === "working"
768
+ && normalized.summary === null
769
+ && normalized.turnId === null
770
+ && normalized.lastResult === null
771
+ && normalized.updatedBy === "dispatcher";
772
+ const isCreationFailure = normalized.status === "failed"
773
+ && normalized.summary !== null
774
+ && normalized.turnId === null
775
+ && normalized.lastResult?.status === "failed"
776
+ && normalized.lastResult.turnId === null
777
+ && normalized.updatedBy === "mcp";
778
+ if (!isLinkPending && !isCreationFailure) {
779
+ throw new Error(`${name} has an invalid unlinked lifecycle state`);
780
+ }
781
+ } else {
782
+ if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
783
+ throw new Error(`${name}.turnId is required for a linked semantic state`);
784
+ }
785
+ if (normalized.lastResult !== null && normalized.lastResult.turnId === null) {
786
+ throw new Error(`${name}.lastResult.turnId is required for a linked result`);
787
+ }
788
+ }
789
+ }
837
790
  if (normalized.status === "working" && normalized.summary !== null) {
838
791
  throw new Error(`${name}.summary must be null while status is working`);
839
792
  }
840
793
  if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
841
794
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
842
795
  }
796
+ if (normalized.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
797
+ if (RESULT_STATUSES.has(normalized.status)) {
798
+ if (
799
+ normalized.lastResult === null
800
+ || normalized.lastResult.status !== normalized.status
801
+ || normalized.lastResult.summary !== normalized.summary
802
+ || normalized.lastResult.turnId !== normalized.turnId
803
+ || normalized.lastResult.updatedAt !== normalized.updatedAt
804
+ ) {
805
+ throw new Error(`${name}.lastResult must match the current semantic state`);
806
+ }
807
+ }
808
+ if (
809
+ normalized.lastResult !== null
810
+ && Date.parse(normalized.lastResult.updatedAt) < Date.parse(normalized.createdAt)
811
+ ) {
812
+ throw new Error(`${name}.lastResult.updatedAt must not be earlier than createdAt`);
813
+ }
814
+ if (
815
+ normalized.lastResult !== null
816
+ && Date.parse(normalized.lastResult.updatedAt) > Date.parse(normalized.updatedAt)
817
+ ) {
818
+ throw new Error(`${name}.lastResult.updatedAt must not be later than updatedAt`);
819
+ }
820
+ if (
821
+ normalized.status === "working"
822
+ && isSelfLinkingRecord
823
+ && normalized.lastResult?.turnId != null
824
+ && (
825
+ normalized.turnId === null
826
+ || normalized.turnId <= normalized.lastResult.turnId
827
+ )
828
+ ) {
829
+ throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
830
+ }
831
+ }
843
832
  if (
844
- normalized.updatedAt !== null
845
- && Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
833
+ Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
846
834
  ) {
847
835
  throw new Error(`${name}.updatedAt must not be earlier than createdAt`);
848
836
  }
849
837
  if (
850
838
  normalized.threadId === null &&
851
- parseTaskChefMarker(normalized.instruction, { allowLegacyHeading }) !== normalized.id
839
+ parseTaskChefMarker(normalized.instruction) !== normalized.id
852
840
  ) {
853
841
  throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
854
842
  }
@@ -875,9 +863,7 @@ async function parseDispatchRecordsUnlocked(root, content) {
875
863
  records.push({
876
864
  line,
877
865
  raw: value,
878
- normalized: await validateDispatchShape(value, `task line ${index + 1}`, {
879
- allowLegacyHeading: true,
880
- }),
866
+ normalized: await validateDispatchShape(value, `task line ${index + 1}`),
881
867
  });
882
868
  }
883
869
  const ids = new Set();
@@ -947,6 +933,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
947
933
  turnId: null,
948
934
  updatedAt: createdAt,
949
935
  updatedBy: "dispatcher",
936
+ lastResult: null,
950
937
  });
951
938
  const existing = await readDispatchesUnlocked(root);
952
939
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -972,53 +959,6 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
972
959
  });
973
960
  }
974
961
 
975
- export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {}) {
976
- const id = requireSafeId(taskId, "taskId");
977
- const durableThreadId = normalizeDurableThreadId(threadId);
978
- const root = await realpath(path.resolve(workspaceRoot));
979
- return withWorkspaceLock(root, async () => {
980
- const records = await readDispatchRecordsUnlocked(root);
981
- const dispatches = records.map((record) => record.normalized);
982
- const index = dispatches.findIndex((dispatch) => dispatch.id === id);
983
- if (index === -1) throw new Error(`task not found: ${id}`);
984
- const currentRecord = records[index];
985
- if (currentRecord.raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
986
- throw new Error(`task resolve is only available for legacy pre-self-linking records: ${id}`);
987
- }
988
- const dispatch = dispatches[index];
989
- if (dispatch.threadId === durableThreadId) return dispatch;
990
- if (dispatch.threadId !== null) {
991
- throw new Error(`task already has a different threadId: ${id}`);
992
- }
993
- if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
994
- throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
995
- }
996
- if (dispatches.some((item) => (
997
- item.threadId !== null
998
- && threadIdentityKey(item.threadId) === threadIdentityKey(durableThreadId)
999
- ))) {
1000
- throw new Error(`threadId is already recorded: ${durableThreadId}`);
1001
- }
1002
- const statefulLegacy = currentRecord.raw.schemaVersion === PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
1003
- const rawResolved = statefulLegacy
1004
- ? {
1005
- ...currentRecord.raw,
1006
- threadId: durableThreadId,
1007
- updatedAt: now ?? new Date().toISOString(),
1008
- updatedBy: "dispatcher",
1009
- }
1010
- : { ...currentRecord.raw, threadId: durableThreadId };
1011
- const resolved = statefulLegacy
1012
- ? await validateDispatchShape(rawResolved)
1013
- : { ...dispatch, threadId: durableThreadId };
1014
- const lines = records.map((record, recordIndex) => recordIndex === index
1015
- ? JSON.stringify(rawResolved)
1016
- : record.line);
1017
- await writeDispatchLinesAtomic(root, lines);
1018
- return resolved;
1019
- });
1020
- }
1021
-
1022
962
  export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1023
963
  const id = requireSafeId(taskId, "taskId");
1024
964
  const durableThreadId = normalizeCodexThreadId(threadId);
@@ -1028,10 +968,6 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1028
968
  const dispatches = records.map((record) => record.normalized);
1029
969
  const index = dispatches.findIndex((dispatch) => dispatch.id === id);
1030
970
  if (index === -1) throw new Error(`task not found: ${id}`);
1031
- const currentRecord = records[index];
1032
- if (currentRecord.raw.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
1033
- throw new Error(`link_task accepts only self-linking task records: ${id}`);
1034
- }
1035
971
  const dispatch = dispatches[index];
1036
972
  const sameIdentity = dispatch.threadId?.toLowerCase() === durableThreadId;
1037
973
  if (sameIdentity && dispatch.updatedBy === "mcp") {
@@ -1041,8 +977,9 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1041
977
  if (dispatch.threadId === durableThreadId) return dispatch;
1042
978
  const canonical = await validateDispatchShape({
1043
979
  ...dispatch,
980
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1044
981
  threadId: durableThreadId,
1045
- updatedAt: now ?? new Date().toISOString(),
982
+ updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1046
983
  updatedBy: "mcp",
1047
984
  });
1048
985
  const lines = records.map((record, recordIndex) => recordIndex === index
@@ -1071,8 +1008,9 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1071
1008
  }
1072
1009
  const linked = await validateDispatchShape({
1073
1010
  ...dispatch,
1011
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1074
1012
  threadId: durableThreadId,
1075
- updatedAt: now ?? new Date().toISOString(),
1013
+ updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1076
1014
  updatedBy: "mcp",
1077
1015
  });
1078
1016
  const lines = records.map((record, recordIndex) => recordIndex === index
@@ -1091,22 +1029,53 @@ function dispatchLineWithState(dispatch, patch) {
1091
1029
  });
1092
1030
  }
1093
1031
 
1094
- export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1095
- requireExactFields(
1096
- input,
1097
- new Set(["taskId", "threadId", "turnId", "status", "summary"]),
1098
- "task result",
1099
- );
1032
+ function normalizeTaskStateInput(input, { allowWorking }) {
1033
+ const fields = new Set(["taskId", "threadId", "turnId", "status", "summary"]);
1034
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1035
+ throw new Error("task state must be an object");
1036
+ }
1037
+ const unexpected = Object.keys(input).find((key) => !fields.has(key));
1038
+ if (unexpected) throw new Error(`task state has unsupported field: ${unexpected}`);
1039
+ for (const field of ["taskId", "threadId", "turnId", "status"]) {
1040
+ if (!(field in input)) throw new Error(`task state is missing field: ${field}`);
1041
+ }
1100
1042
  const id = requireSafeId(input.taskId, "taskId");
1101
1043
  const threadId = input.threadId === null
1102
1044
  ? null
1103
1045
  : normalizeDurableThreadId(input.threadId, "threadId");
1104
1046
  const turnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
1105
- const status = requireEnum(input.status, RESULT_STATUSES, "status");
1106
- const summary = optionalString(input.summary, "summary", {
1047
+ const status = requireEnum(
1048
+ input.status,
1049
+ allowWorking ? TASK_STATUSES : RESULT_STATUSES,
1050
+ "status",
1051
+ );
1052
+ const summary = optionalString("summary" in input ? input.summary : null, "summary", {
1107
1053
  maxLength: MAX_RESULT_SUMMARY_LENGTH,
1108
1054
  });
1109
- if (summary === null) throw new Error("summary must be a non-empty string");
1055
+ if (status === "working" && summary !== null) {
1056
+ throw new Error("summary must be null while status is working");
1057
+ }
1058
+ if (status !== "working" && summary === null) {
1059
+ throw new Error(`summary is required for status ${status}`);
1060
+ }
1061
+ return { id, threadId, turnId, status, summary };
1062
+ }
1063
+
1064
+ function sameLastResult(lastResult, { status, summary, turnId }) {
1065
+ return lastResult !== null
1066
+ && lastResult.status === status
1067
+ && lastResult.summary === summary
1068
+ && lastResult.turnId === turnId;
1069
+ }
1070
+
1071
+ async function reportTaskStateInternal(
1072
+ workspaceRoot,
1073
+ input,
1074
+ { now, compatibilityAlias = false } = {},
1075
+ ) {
1076
+ const { id, threadId, turnId, status, summary } = normalizeTaskStateInput(input, {
1077
+ allowWorking: !compatibilityAlias,
1078
+ });
1110
1079
  const root = await realpath(path.resolve(workspaceRoot));
1111
1080
  return withWorkspaceLock(root, async () => {
1112
1081
  const records = await readDispatchRecordsUnlocked(root);
@@ -1115,60 +1084,142 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
1115
1084
  if (index === -1) throw new Error(`task not found: ${id}`);
1116
1085
  const dispatch = dispatches[index];
1117
1086
  const isSelfLinkingJourney = (
1118
- records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
1119
- && dispatch.threadId !== null
1087
+ dispatch.threadId !== null
1120
1088
  && parseTaskChefMarker(dispatch.instruction) === dispatch.id
1121
1089
  );
1122
- let resultTurnId = turnId;
1090
+ if (
1091
+ !compatibilityAlias
1092
+ && dispatch.threadId !== null
1093
+ && !isSelfLinkingJourney
1094
+ ) {
1095
+ throw new Error(`report_state accepts only self-linked task records: ${id}`);
1096
+ }
1097
+ let stateTurnId = turnId;
1123
1098
  if (isSelfLinkingJourney) {
1124
1099
  if (dispatch.updatedBy === "dispatcher") {
1125
1100
  throw new Error(`self-linking task must link before reporting a result: ${id}`);
1126
1101
  }
1127
- resultTurnId = normalizeCodexThreadId(turnId, "turnId");
1102
+ stateTurnId = normalizeCodexThreadId(turnId, "turnId");
1128
1103
  }
1129
1104
  if (dispatch.threadId === null) {
1130
- if (threadId !== null || resultTurnId !== null || status !== "failed") {
1105
+ if (threadId !== null || stateTurnId !== null || status !== "failed") {
1131
1106
  throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
1132
1107
  }
1108
+ if (!compatibilityAlias) {
1109
+ const rawSchemaVersion = records[index].raw.schemaVersion;
1110
+ const hasCurrentMarker = rawSchemaVersion
1111
+ >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION
1112
+ && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
1113
+ const isFreshCreationFailure = hasCurrentMarker
1114
+ && dispatch.status === "working"
1115
+ && dispatch.turnId === null
1116
+ && dispatch.lastResult === null
1117
+ && dispatch.updatedBy === "dispatcher";
1118
+ const isIdenticalCreationFailureRetry = hasCurrentMarker
1119
+ && dispatch.status === "failed"
1120
+ && dispatch.turnId === null
1121
+ && dispatch.updatedBy === "mcp"
1122
+ && sameLastResult(dispatch.lastResult, {
1123
+ status,
1124
+ summary,
1125
+ turnId: stateTurnId,
1126
+ });
1127
+ if (!isFreshCreationFailure && !isIdenticalCreationFailureRetry) {
1128
+ throw new Error(`report_state unlinked failure requires a fresh link-pending task: ${id}`);
1129
+ }
1130
+ }
1133
1131
  } else {
1134
1132
  if (threadIdentityKey(threadId) !== threadIdentityKey(dispatch.threadId)) {
1135
1133
  throw new Error(`task result threadId does not match recorded threadId: ${id}`);
1136
1134
  }
1137
- if (resultTurnId === null) {
1138
- throw new Error(`task result turnId is required for a linked task: ${id}`);
1135
+ if (stateTurnId === null) {
1136
+ throw new Error(`task state turnId is required for a linked task: ${id}`);
1139
1137
  }
1140
1138
  }
1141
- if (dispatch.updatedBy === "mcp" && resultTurnId === dispatch.turnId) {
1142
- if (status === dispatch.status && summary === dispatch.summary) return dispatch;
1143
- throw new Error(`task turn already has a different semantic result: ${id}`);
1139
+ if (status === "working") {
1140
+ if (dispatch.status === "working" && stateTurnId === dispatch.turnId) return dispatch;
1141
+ if (isSelfLinkingJourney) {
1142
+ if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1143
+ throw new Error(`working turnId must be newer than the current task turnId: ${id}`);
1144
+ }
1145
+ if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1146
+ throw new Error(`working turnId must be newer than the last result turnId: ${id}`);
1147
+ }
1148
+ }
1149
+ const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1150
+ const updated = await validateDispatchShape({
1151
+ ...dispatch,
1152
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1153
+ status,
1154
+ summary: null,
1155
+ turnId: stateTurnId,
1156
+ updatedAt,
1157
+ updatedBy: "mcp",
1158
+ lastResult: dispatch.lastResult,
1159
+ });
1160
+ const lines = records.map((record, recordIndex) => recordIndex === index
1161
+ ? dispatchLineWithState(updated, {})
1162
+ : record.line);
1163
+ await writeDispatchLinesAtomic(root, lines);
1164
+ return updated;
1144
1165
  }
1145
1166
  if (
1146
- isSelfLinkingJourney
1147
- && dispatch.turnId !== null
1148
- && resultTurnId <= dispatch.turnId
1167
+ dispatch.status === status
1168
+ && dispatch.turnId === stateTurnId
1169
+ && dispatch.summary === summary
1170
+ && sameLastResult(dispatch.lastResult, { status, summary, turnId: stateTurnId })
1149
1171
  ) {
1150
- throw new Error(`task result turnId must be newer than the stored turnId: ${id}`);
1172
+ return dispatch;
1173
+ }
1174
+ if (dispatch.turnId === stateTurnId && dispatch.status !== "working") {
1175
+ throw new Error(`task turn already has a different semantic result: ${id}`);
1151
1176
  }
1152
- const persistedSchemaVersion = records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
1153
- ? CURRENT_TASK_SCHEMA_VERSION
1154
- : PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
1155
- const updated = await validateDispatchShape({
1177
+ if (compatibilityAlias) {
1178
+ const matchesWorkingTurn = dispatch.status === "working"
1179
+ && dispatch.turnId === stateTurnId;
1180
+ if (!matchesWorkingTurn && isSelfLinkingJourney) {
1181
+ if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1182
+ throw new Error(`task result turnId must be newer than the stored turnId: ${id}`);
1183
+ }
1184
+ if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1185
+ throw new Error(`task result turnId must be newer than the last result turnId: ${id}`);
1186
+ }
1187
+ }
1188
+ } else if (dispatch.status !== "working" || dispatch.turnId !== stateTurnId) {
1189
+ throw new Error(`task result must match the current working turnId: ${id}`);
1190
+ }
1191
+ const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1192
+ const lastResult = { status, summary, turnId: stateTurnId, updatedAt };
1193
+ const candidate = {
1156
1194
  ...dispatch,
1157
- schemaVersion: persistedSchemaVersion,
1195
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1158
1196
  status,
1159
1197
  summary,
1160
- turnId: resultTurnId,
1161
- updatedAt: now ?? new Date().toISOString(),
1198
+ turnId: stateTurnId,
1199
+ updatedAt,
1162
1200
  updatedBy: "mcp",
1163
- });
1201
+ lastResult,
1202
+ };
1203
+ const updated = await validateDispatchShape(candidate);
1164
1204
  const lines = records.map((record, recordIndex) => recordIndex === index
1165
- ? dispatchLineWithState(updated, { schemaVersion: persistedSchemaVersion })
1205
+ ? dispatchLineWithState(updated, {})
1166
1206
  : record.line);
1167
1207
  await writeDispatchLinesAtomic(root, lines);
1168
1208
  return updated;
1169
1209
  });
1170
1210
  }
1171
1211
 
1212
+ export async function reportTaskState(workspaceRoot, input, { now } = {}) {
1213
+ return reportTaskStateInternal(workspaceRoot, input, { now });
1214
+ }
1215
+
1216
+ export async function reportTaskResult(workspaceRoot, input, options = {}) {
1217
+ return reportTaskStateInternal(workspaceRoot, input, {
1218
+ ...options,
1219
+ compatibilityAlias: true,
1220
+ });
1221
+ }
1222
+
1172
1223
  export async function readTask(workspaceRoot, taskId) {
1173
1224
  const id = requireSafeId(taskId, "taskId");
1174
1225
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
@@ -1234,12 +1285,5 @@ export async function doctorWorkspace(workspaceRoot) {
1234
1285
  }
1235
1286
  return "managed AGENTS.md instructions current";
1236
1287
  });
1237
- await check("legacy-skill-links", async () => {
1238
- const legacy = await findLegacySkillLinks(root);
1239
- if (legacy.links.length > 0) {
1240
- throw new Error("legacy TaskChef skill links remain; run workspace init to remove them");
1241
- }
1242
- return "no legacy TaskChef skill links";
1243
- });
1244
1288
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
1245
1289
  }