taskchef 7.3.0 → 7.5.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,8 +38,9 @@ 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 = 5;
42
- const PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 6;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 5;
43
+ const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
43
44
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
44
45
  const PROJECT_FIELDS = new Set([
45
46
  "name",
@@ -63,7 +64,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
63
64
  "updatedAt",
64
65
  "updatedBy",
65
66
  ]);
66
- const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
67
+ const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
68
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
67
69
  const RECORD_DISPATCH_FIELDS = new Set([
68
70
  "id",
69
71
  "project",
@@ -75,7 +77,7 @@ const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
75
77
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
76
78
  const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
77
79
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
78
- const LAST_RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
80
+ const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
79
81
 
80
82
  function requireExactFields(value, fields, name) {
81
83
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -221,7 +223,9 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
221
223
  if (dispatches.length === 0) return;
222
224
  const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
223
225
  const content = await readFile(dispatchPath, "utf8");
224
- const appended = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
226
+ const appended = dispatches
227
+ .map((dispatch) => `${JSON.stringify(schema6Task(dispatch))}\n`)
228
+ .join("");
225
229
  await writeTextAtomic(dispatchPath, `${content}${appended}`);
226
230
  }
227
231
 
@@ -679,7 +683,8 @@ export async function removeProject(workspaceRoot, name) {
679
683
 
680
684
  async function validateDispatchShape(dispatch, name = "task") {
681
685
  const supportedVersions = [
682
- PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION,
686
+ FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
687
+ PREVIOUS_TASK_SCHEMA_VERSION,
683
688
  CURRENT_TASK_SCHEMA_VERSION,
684
689
  ];
685
690
  if (!supportedVersions.includes(dispatch?.schemaVersion)) {
@@ -687,9 +692,11 @@ async function validateDispatchShape(dispatch, name = "task") {
687
692
  }
688
693
  requireExactFields(
689
694
  dispatch,
690
- dispatch.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
695
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
691
696
  ? DISPATCH_FIELDS
692
- : STATEFUL_DISPATCH_FIELDS,
697
+ : dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION
698
+ ? SCHEMA_5_DISPATCH_FIELDS
699
+ : STATEFUL_DISPATCH_FIELDS,
693
700
  name,
694
701
  );
695
702
  const id = requireSafeId(dispatch.id, `${name}.id`);
@@ -700,38 +707,48 @@ async function validateDispatchShape(dispatch, name = "task") {
700
707
  });
701
708
  const turnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
702
709
  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 = {
710
+ const normalizeResult = (result, resultName) => {
711
+ requireExactFields(result, RESULT_FIELDS, resultName);
712
+ const normalizedResult = {
708
713
  status: requireEnum(
709
- dispatch.lastResult.status,
714
+ result.status,
710
715
  RESULT_STATUSES,
711
- `${name}.lastResult.status`,
716
+ `${resultName}.status`,
712
717
  ),
713
718
  summary: optionalString(
714
- dispatch.lastResult.summary,
715
- `${name}.lastResult.summary`,
719
+ result.summary,
720
+ `${resultName}.summary`,
716
721
  { maxLength: MAX_RESULT_SUMMARY_LENGTH },
717
722
  ),
718
723
  turnId: optionalString(
719
- dispatch.lastResult.turnId,
720
- `${name}.lastResult.turnId`,
724
+ result.turnId,
725
+ `${resultName}.turnId`,
721
726
  { maxLength: 256 },
722
727
  ),
723
728
  updatedAt: requireTimestamp(
724
- dispatch.lastResult.updatedAt,
725
- `${name}.lastResult.updatedAt`,
729
+ result.updatedAt,
730
+ `${resultName}.updatedAt`,
726
731
  ),
727
- };
728
- if (lastResult.summary === null) {
729
- throw new Error(`${name}.lastResult.summary must be a non-empty string`);
730
- }
732
+ };
733
+ if (normalizedResult.summary === null) {
734
+ throw new Error(`${resultName}.summary must be a non-empty string`);
735
+ }
736
+ return normalizedResult;
737
+ };
738
+ let results = [];
739
+ if (dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
740
+ if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
741
+ results = dispatch.results.map((result, index) => (
742
+ normalizeResult(result, `${name}.results[${index}]`)
743
+ ));
744
+ } else if (dispatch.schemaVersion === PREVIOUS_TASK_SCHEMA_VERSION) {
745
+ if (dispatch.lastResult !== null) {
746
+ results = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
731
747
  }
732
748
  } else if (RESULT_STATUSES.has(status)) {
733
- lastResult = { status, summary, turnId, updatedAt };
749
+ results = [{ status, summary, turnId, updatedAt }];
734
750
  }
751
+ const lastResult = results.at(-1) ?? null;
735
752
  const normalized = {
736
753
  schemaVersion: dispatch.schemaVersion,
737
754
  id,
@@ -747,22 +764,25 @@ async function validateDispatchShape(dispatch, name = "task") {
747
764
  turnId,
748
765
  updatedAt,
749
766
  updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
767
+ results,
750
768
  lastResult,
751
769
  };
752
770
  const isSelfLinkingRecord = normalized.threadId !== null
753
771
  && parseTaskChefMarker(normalized.instruction) === normalized.id;
754
772
  if (isSelfLinkingRecord) {
755
773
  if (normalized.turnId !== null) {
756
- normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
774
+ normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
757
775
  }
758
- if (normalized.lastResult?.turnId != null) {
759
- normalizeCodexThreadId(
760
- normalized.lastResult.turnId,
761
- `${name}.lastResult.turnId`,
762
- );
776
+ for (const [index, result] of normalized.results.entries()) {
777
+ if (result.turnId != null) {
778
+ result.turnId = normalizeCodexThreadId(
779
+ result.turnId,
780
+ `${name}.results[${index}].turnId`,
781
+ );
782
+ }
763
783
  }
764
784
  }
765
- if (normalized.schemaVersion >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION) {
785
+ if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
766
786
  if (normalized.threadId === null) {
767
787
  const isLinkPending = normalized.status === "working"
768
788
  && normalized.summary === null
@@ -782,8 +802,9 @@ async function validateDispatchShape(dispatch, name = "task") {
782
802
  if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
783
803
  throw new Error(`${name}.turnId is required for a linked semantic state`);
784
804
  }
785
- if (normalized.lastResult !== null && normalized.lastResult.turnId === null) {
786
- throw new Error(`${name}.lastResult.turnId is required for a linked result`);
805
+ const resultWithoutTurn = normalized.results.findIndex((result) => result.turnId === null);
806
+ if (resultWithoutTurn !== -1) {
807
+ throw new Error(`${name}.results[${resultWithoutTurn}].turnId is required for a linked result`);
787
808
  }
788
809
  }
789
810
  }
@@ -793,7 +814,7 @@ async function validateDispatchShape(dispatch, name = "task") {
793
814
  if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
794
815
  throw new Error(`${name}.summary is required for status ${normalized.status}`);
795
816
  }
796
- if (normalized.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
817
+ if (normalized.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION) {
797
818
  if (RESULT_STATUSES.has(normalized.status)) {
798
819
  if (
799
820
  normalized.lastResult === null
@@ -829,6 +850,34 @@ async function validateDispatchShape(dispatch, name = "task") {
829
850
  throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
830
851
  }
831
852
  }
853
+ const seenResultTurns = new Set();
854
+ const nullTurnKey = Symbol("null turn");
855
+ for (const [index, result] of normalized.results.entries()) {
856
+ const turnKey = result.turnId ?? nullTurnKey;
857
+ if (seenResultTurns.has(turnKey)) {
858
+ throw new Error(`${name}.results contains duplicate turnId: ${result.turnId ?? "null"}`);
859
+ }
860
+ seenResultTurns.add(turnKey);
861
+ if (Date.parse(result.updatedAt) < Date.parse(normalized.createdAt)) {
862
+ throw new Error(`${name}.results[${index}].updatedAt must not be earlier than createdAt`);
863
+ }
864
+ if (Date.parse(result.updatedAt) > Date.parse(normalized.updatedAt)) {
865
+ throw new Error(`${name}.results[${index}].updatedAt must not be later than updatedAt`);
866
+ }
867
+ if (
868
+ index > 0
869
+ && Date.parse(result.updatedAt) < Date.parse(normalized.results[index - 1].updatedAt)
870
+ ) {
871
+ throw new Error(`${name}.results must be ordered by updatedAt`);
872
+ }
873
+ if (
874
+ isSelfLinkingRecord
875
+ && index > 0
876
+ && result.turnId <= normalized.results[index - 1].turnId
877
+ ) {
878
+ throw new Error(`${name}.results must be ordered by turnId`);
879
+ }
880
+ }
832
881
  if (
833
882
  Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
834
883
  ) {
@@ -911,6 +960,70 @@ export async function parseTaskLogContent(workspaceRoot, content) {
911
960
  return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
912
961
  }
913
962
 
963
+ function schema6Task(dispatch, patch = {}) {
964
+ const { lastResult: _lastResult, ...persisted } = dispatch;
965
+ return {
966
+ ...persisted,
967
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
968
+ results: dispatch.results ?? [],
969
+ ...patch,
970
+ };
971
+ }
972
+
973
+ export async function migrateTaskLog(workspaceRoot, {
974
+ now = () => new Date().toISOString(),
975
+ writeTaskLog = writeTextAtomic,
976
+ } = {}) {
977
+ const root = await realpath(path.resolve(workspaceRoot));
978
+ return withWorkspaceLock(root, async () => {
979
+ const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
980
+ if (!(await managedRegularFileExists(dispatchPath))) {
981
+ throw new Error(`task log does not exist: ${dispatchPath}`);
982
+ }
983
+ const original = await readFile(dispatchPath, "utf8");
984
+ const records = await parseDispatchRecordsUnlocked(root, original);
985
+ const migratedCount = records.filter(
986
+ (record) => record.raw.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION,
987
+ ).length;
988
+ if (migratedCount === 0) {
989
+ return {
990
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
991
+ action: "unchanged",
992
+ taskCount: records.length,
993
+ migratedCount: 0,
994
+ backupPath: null,
995
+ };
996
+ }
997
+ const lines = records.map((record) => JSON.stringify(schema6Task(record.normalized)));
998
+ const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
999
+ await parseDispatchRecordsUnlocked(root, migrated);
1000
+ const timestamp = requireTimestamp(now(), "migration timestamp")
1001
+ .replaceAll(":", "-")
1002
+ .replaceAll(".", "-");
1003
+ const backupPath = `${dispatchPath}.pre-v6-${timestamp}-${randomUUID()}.bak`;
1004
+ await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1005
+ if (await readFile(backupPath, "utf8") !== original) {
1006
+ throw new Error(`task log backup validation failed: ${backupPath}`);
1007
+ }
1008
+ try {
1009
+ await writeTaskLog(dispatchPath, migrated);
1010
+ await parseDispatchRecordsUnlocked(root, await readFile(dispatchPath, "utf8"));
1011
+ } catch (error) {
1012
+ throw new Error(
1013
+ `task log migration failed after recovery backup ${backupPath}: ${error.message}`,
1014
+ { cause: error },
1015
+ );
1016
+ }
1017
+ return {
1018
+ schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1019
+ action: "migrated",
1020
+ taskCount: records.length,
1021
+ migratedCount,
1022
+ backupPath,
1023
+ };
1024
+ });
1025
+ }
1026
+
914
1027
  export async function recordTask(workspaceRoot, input, { now } = {}) {
915
1028
  requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
916
1029
  const root = await realpath(path.resolve(workspaceRoot));
@@ -933,7 +1046,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
933
1046
  turnId: null,
934
1047
  updatedAt: createdAt,
935
1048
  updatedBy: "dispatcher",
936
- lastResult: null,
1049
+ results: [],
937
1050
  });
938
1051
  const existing = await readDispatchesUnlocked(root);
939
1052
  if (existing.some((item) => item.id === dispatch.id)) {
@@ -975,13 +1088,11 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
975
1088
  throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
976
1089
  }
977
1090
  if (dispatch.threadId === durableThreadId) return dispatch;
978
- const canonical = await validateDispatchShape({
979
- ...dispatch,
980
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1091
+ const canonical = await validateDispatchShape(schema6Task(dispatch, {
981
1092
  threadId: durableThreadId,
982
1093
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
983
1094
  updatedBy: "mcp",
984
- });
1095
+ }));
985
1096
  const lines = records.map((record, recordIndex) => recordIndex === index
986
1097
  ? dispatchLineWithState(canonical, {})
987
1098
  : record.line);
@@ -1006,13 +1117,11 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1006
1117
  ))) {
1007
1118
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
1008
1119
  }
1009
- const linked = await validateDispatchShape({
1010
- ...dispatch,
1011
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1120
+ const linked = await validateDispatchShape(schema6Task(dispatch, {
1012
1121
  threadId: durableThreadId,
1013
1122
  updatedAt: transitionTimestamp(now, dispatch.updatedAt),
1014
1123
  updatedBy: "mcp",
1015
- });
1124
+ }));
1016
1125
  const lines = records.map((record, recordIndex) => recordIndex === index
1017
1126
  ? dispatchLineWithState(linked, {})
1018
1127
  : record.line);
@@ -1022,11 +1131,7 @@ export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
1022
1131
  }
1023
1132
 
1024
1133
  function dispatchLineWithState(dispatch, patch) {
1025
- return JSON.stringify({
1026
- ...dispatch,
1027
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1028
- ...patch,
1029
- });
1134
+ return JSON.stringify(schema6Task(dispatch, patch));
1030
1135
  }
1031
1136
 
1032
1137
  function normalizeTaskStateInput(input, { allowWorking }) {
@@ -1108,7 +1213,7 @@ async function reportTaskStateInternal(
1108
1213
  if (!compatibilityAlias) {
1109
1214
  const rawSchemaVersion = records[index].raw.schemaVersion;
1110
1215
  const hasCurrentMarker = rawSchemaVersion
1111
- >= PREVIOUS_SELF_LINKING_TASK_SCHEMA_VERSION
1216
+ >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION
1112
1217
  && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
1113
1218
  const isFreshCreationFailure = hasCurrentMarker
1114
1219
  && dispatch.status === "working"
@@ -1147,31 +1252,24 @@ async function reportTaskStateInternal(
1147
1252
  }
1148
1253
  }
1149
1254
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1150
- const updated = await validateDispatchShape({
1151
- ...dispatch,
1152
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1255
+ const updated = await validateDispatchShape(schema6Task(dispatch, {
1153
1256
  status,
1154
1257
  summary: null,
1155
1258
  turnId: stateTurnId,
1156
1259
  updatedAt,
1157
1260
  updatedBy: "mcp",
1158
- lastResult: dispatch.lastResult,
1159
- });
1261
+ }));
1160
1262
  const lines = records.map((record, recordIndex) => recordIndex === index
1161
1263
  ? dispatchLineWithState(updated, {})
1162
1264
  : record.line);
1163
1265
  await writeDispatchLinesAtomic(root, lines);
1164
1266
  return updated;
1165
1267
  }
1166
- if (
1167
- dispatch.status === status
1168
- && dispatch.turnId === stateTurnId
1169
- && dispatch.summary === summary
1170
- && sameLastResult(dispatch.lastResult, { status, summary, turnId: stateTurnId })
1171
- ) {
1268
+ const priorTurnResult = dispatch.results.find((result) => result.turnId === stateTurnId);
1269
+ if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
1172
1270
  return dispatch;
1173
1271
  }
1174
- if (dispatch.turnId === stateTurnId && dispatch.status !== "working") {
1272
+ if (priorTurnResult || (dispatch.turnId === stateTurnId && dispatch.status !== "working")) {
1175
1273
  throw new Error(`task turn already has a different semantic result: ${id}`);
1176
1274
  }
1177
1275
  if (compatibilityAlias) {
@@ -1190,16 +1288,14 @@ async function reportTaskStateInternal(
1190
1288
  }
1191
1289
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1192
1290
  const lastResult = { status, summary, turnId: stateTurnId, updatedAt };
1193
- const candidate = {
1194
- ...dispatch,
1195
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1291
+ const candidate = schema6Task(dispatch, {
1196
1292
  status,
1197
1293
  summary,
1198
1294
  turnId: stateTurnId,
1199
1295
  updatedAt,
1200
1296
  updatedBy: "mcp",
1201
- lastResult,
1202
- };
1297
+ results: [...dispatch.results, lastResult],
1298
+ });
1203
1299
  const updated = await validateDispatchShape(candidate);
1204
1300
  const lines = records.map((record, recordIndex) => recordIndex === index
1205
1301
  ? dispatchLineWithState(updated, {})