taskchef 7.0.0 → 7.2.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
@@ -38,7 +38,8 @@ const DISPATCH_FILE_NAME = "tasks.jsonl";
38
38
  const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
39
39
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
40
40
  const CURRENT_CONFIG_SCHEMA_VERSION = 2;
41
- const CURRENT_TASK_SCHEMA_VERSION = 4;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 5;
42
+ const PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
42
43
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
43
44
  const PROJECT_FIELDS = new Set([
44
45
  "name",
@@ -48,7 +49,7 @@ const PROJECT_FIELDS = new Set([
48
49
  "description",
49
50
  ]);
50
51
  const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
51
- const DISPATCH_FIELDS = new Set([
52
+ const STATEFUL_DISPATCH_FIELDS = new Set([
52
53
  "schemaVersion",
53
54
  "id",
54
55
  "project",
@@ -62,6 +63,7 @@ const DISPATCH_FIELDS = new Set([
62
63
  "updatedAt",
63
64
  "updatedBy",
64
65
  ]);
66
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
65
67
  const RECORD_DISPATCH_FIELDS = new Set([
66
68
  "id",
67
69
  "project",
@@ -73,6 +75,7 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
73
75
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
74
76
  const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
75
77
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
78
+ const LAST_RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
76
79
 
77
80
  function requireExactFields(value, fields, name) {
78
81
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -100,6 +103,13 @@ function requireTimestamp(value, name) {
100
103
  return value;
101
104
  }
102
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
+
103
113
  function optionalString(value, name, { maxLength = null } = {}) {
104
114
  if (value === null) return null;
105
115
  const normalized = requireString(value, name).trim();
@@ -668,14 +678,62 @@ export async function removeProject(workspaceRoot, name) {
668
678
  }
669
679
 
670
680
  async function validateDispatchShape(dispatch, name = "task") {
671
- if (dispatch?.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION) {
681
+ const supportedVersions = [
682
+ PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION,
683
+ CURRENT_TASK_SCHEMA_VERSION,
684
+ ];
685
+ if (!supportedVersions.includes(dispatch?.schemaVersion)) {
672
686
  throw new Error(`unsupported ${name} schemaVersion`);
673
687
  }
674
- requireExactFields(dispatch, DISPATCH_FIELDS, name);
688
+ requireExactFields(
689
+ dispatch,
690
+ dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
691
+ ? DISPATCH_FIELDS
692
+ : STATEFUL_DISPATCH_FIELDS,
693
+ name,
694
+ );
675
695
  const id = requireSafeId(dispatch.id, `${name}.id`);
676
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,
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
+ }
677
735
  const normalized = {
678
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
736
+ schemaVersion: dispatch.schemaVersion,
679
737
  id,
680
738
  project,
681
739
  title: requireString(dispatch.title, `${name}.title`).trim(),
@@ -684,20 +742,93 @@ async function validateDispatchShape(dispatch, name = "task") {
684
742
  ? null
685
743
  : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
686
744
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
687
- status: requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`),
688
- summary: optionalString(dispatch.summary, `${name}.summary`, {
689
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
690
- }),
691
- turnId: optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 }),
692
- updatedAt: requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`),
745
+ status,
746
+ summary,
747
+ turnId,
748
+ updatedAt,
693
749
  updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
750
+ lastResult,
694
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
+ }
695
790
  if (normalized.status === "working" && normalized.summary !== null) {
696
791
  throw new Error(`${name}.summary must be null while status is working`);
697
792
  }
698
793
  if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
699
794
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
700
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
+ }
701
832
  if (
702
833
  Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
703
834
  ) {
@@ -802,6 +933,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
802
933
  turnId: null,
803
934
  updatedAt: createdAt,
804
935
  updatedBy: "dispatcher",
936
+ lastResult: null,
805
937
  });
806
938
  const existing = await readDispatchesUnlocked(root);
807
939
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -845,8 +977,9 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
845
977
  if (dispatch.threadId === durableThreadId) return dispatch;
846
978
  const canonical = await validateDispatchShape({
847
979
  ...dispatch,
980
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
848
981
  threadId: durableThreadId,
849
- updatedAt: now ?? new Date().toISOString(),
982
+ updatedAt: transitionTimestamp(now, dispatch.updatedAt),
850
983
  updatedBy: "mcp",
851
984
  });
852
985
  const lines = records.map((record, recordIndex) => recordIndex === index
@@ -875,8 +1008,9 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
875
1008
  }
876
1009
  const linked = await validateDispatchShape({
877
1010
  ...dispatch,
1011
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
878
1012
  threadId: durableThreadId,
879
- updatedAt: now ?? new Date().toISOString(),
1013
+ updatedAt: transitionTimestamp(now, dispatch.updatedAt),
880
1014
  updatedBy: "mcp",
881
1015
  });
882
1016
  const lines = records.map((record, recordIndex) => recordIndex === index
@@ -895,22 +1029,53 @@ function dispatchLineWithState(dispatch, patch) {
895
1029
  });
896
1030
  }
897
1031
 
898
- export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
899
- requireExactFields(
900
- input,
901
- new Set(["taskId", "threadId", "turnId", "status", "summary"]),
902
- "task result",
903
- );
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
+ }
904
1042
  const id = requireSafeId(input.taskId, "taskId");
905
1043
  const threadId = input.threadId === null
906
1044
  ? null
907
1045
  : normalizeDurableThreadId(input.threadId, "threadId");
908
1046
  const turnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
909
- const status = requireEnum(input.status, RESULT_STATUSES, "status");
910
- 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", {
911
1053
  maxLength: MAX_RESULT_SUMMARY_LENGTH,
912
1054
  });
913
- 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
+ });
914
1079
  const root = await realpath(path.resolve(workspaceRoot));
915
1080
  return withWorkspaceLock(root, async () => {
916
1081
  const records = await readDispatchRecordsUnlocked(root);
@@ -922,45 +1087,120 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
922
1087
  dispatch.threadId !== null
923
1088
  && parseTaskChefMarker(dispatch.instruction) === dispatch.id
924
1089
  );
925
- 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;
926
1098
  if (isSelfLinkingJourney) {
927
1099
  if (dispatch.updatedBy === "dispatcher") {
928
1100
  throw new Error(`self-linking task must link before reporting a result: ${id}`);
929
1101
  }
930
- resultTurnId = normalizeCodexThreadId(turnId, "turnId");
1102
+ stateTurnId = normalizeCodexThreadId(turnId, "turnId");
931
1103
  }
932
1104
  if (dispatch.threadId === null) {
933
- if (threadId !== null || resultTurnId !== null || status !== "failed") {
1105
+ if (threadId !== null || stateTurnId !== null || status !== "failed") {
934
1106
  throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
935
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
+ }
936
1131
  } else {
937
1132
  if (threadIdentityKey(threadId) !== threadIdentityKey(dispatch.threadId)) {
938
1133
  throw new Error(`task result threadId does not match recorded threadId: ${id}`);
939
1134
  }
940
- if (resultTurnId === null) {
941
- 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}`);
942
1137
  }
943
1138
  }
944
- if (dispatch.updatedBy === "mcp" && resultTurnId === dispatch.turnId) {
945
- if (status === dispatch.status && summary === dispatch.summary) return dispatch;
946
- 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;
947
1165
  }
948
1166
  if (
949
- isSelfLinkingJourney
950
- && dispatch.turnId !== null
951
- && resultTurnId <= dispatch.turnId
1167
+ dispatch.status === status
1168
+ && dispatch.turnId === stateTurnId
1169
+ && dispatch.summary === summary
1170
+ && sameLastResult(dispatch.lastResult, { status, summary, turnId: stateTurnId })
952
1171
  ) {
953
- 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}`);
954
1176
  }
955
- 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 = {
956
1194
  ...dispatch,
957
1195
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
958
1196
  status,
959
1197
  summary,
960
- turnId: resultTurnId,
961
- updatedAt: now ?? new Date().toISOString(),
1198
+ turnId: stateTurnId,
1199
+ updatedAt,
962
1200
  updatedBy: "mcp",
963
- });
1201
+ lastResult,
1202
+ };
1203
+ const updated = await validateDispatchShape(candidate);
964
1204
  const lines = records.map((record, recordIndex) => recordIndex === index
965
1205
  ? dispatchLineWithState(updated, {})
966
1206
  : record.line);
@@ -969,6 +1209,17 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
969
1209
  });
970
1210
  }
971
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
+
972
1223
  export async function readTask(workspaceRoot, taskId) {
973
1224
  const id = requireSafeId(taskId, "taskId");
974
1225
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);