taskchef 7.14.2 → 7.15.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 = 8;
42
- const PREVIOUS_TASK_SCHEMA_VERSION = 7;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 9;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 8;
43
+ const FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION = 7;
43
44
  const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
44
45
  const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
45
46
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
@@ -67,7 +68,8 @@ const STATEFUL_DISPATCH_FIELDS = new Set([
67
68
  ]);
68
69
  const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
69
70
  const SCHEMA_6_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
70
- const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
71
+ const LEGACY_TURN_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
72
+ const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turnRef", "turns"]);
71
73
  const RECORD_DISPATCH_FIELDS = new Set([
72
74
  "id",
73
75
  "project",
@@ -83,7 +85,8 @@ const MAX_RESULT_SUMMARY_LENGTH = 2_000;
83
85
  const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
84
86
  const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
85
87
  const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
86
- const TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
88
+ const LEGACY_TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
89
+ const TURN_FIELDS = new Set(["turnRef", "turnId", "requestSummary", "startedAt", "result"]);
87
90
  const INTERRUPTED_TURN_SUMMARY = "Turn interrupted before a terminal report.";
88
91
 
89
92
  function requireExactFields(value, fields, name) {
@@ -128,6 +131,22 @@ function optionalString(value, name, { maxLength = null } = {}) {
128
131
  return normalized;
129
132
  }
130
133
 
134
+ function normalizeTurnRef(value, name = "turnRef") {
135
+ const normalized = requireString(value, name).trim();
136
+ if (normalized.length > 256) throw new Error(`${name} must be at most 256 characters`);
137
+ return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(normalized)
138
+ ? normalized.toLowerCase()
139
+ : normalized;
140
+ }
141
+
142
+ function normalizeExecutorTurnRef(value, name = "turnRef") {
143
+ const normalized = normalizeTurnRef(value, name);
144
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
145
+ throw new Error(`${name} must be a UUID for a self-linked task`);
146
+ }
147
+ return normalized;
148
+ }
149
+
131
150
  function requireEnum(value, allowed, name) {
132
151
  if (!allowed.has(value)) {
133
152
  throw new Error(`${name} must be one of: ${[...allowed].join(", ")}`);
@@ -693,6 +712,7 @@ async function validateDispatchShape(dispatch, name = "task") {
693
712
  FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
694
713
  5,
695
714
  LEGACY_RESULTS_TASK_SCHEMA_VERSION,
715
+ FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION,
696
716
  PREVIOUS_TASK_SCHEMA_VERSION,
697
717
  CURRENT_TASK_SCHEMA_VERSION,
698
718
  ];
@@ -701,9 +721,11 @@ async function validateDispatchShape(dispatch, name = "task") {
701
721
  }
702
722
  requireExactFields(
703
723
  dispatch,
704
- dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION
724
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
705
725
  ? DISPATCH_FIELDS
706
- : dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION
726
+ : dispatch.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
727
+ ? LEGACY_TURN_DISPATCH_FIELDS
728
+ : dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION
707
729
  ? SCHEMA_6_DISPATCH_FIELDS
708
730
  : dispatch.schemaVersion === 5
709
731
  ? SCHEMA_5_DISPATCH_FIELDS
@@ -716,7 +738,11 @@ async function validateDispatchShape(dispatch, name = "task") {
716
738
  const summary = optionalString(dispatch.summary, `${name}.summary`, {
717
739
  maxLength: MAX_RESULT_SUMMARY_LENGTH,
718
740
  });
719
- const turnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
741
+ const rawTurnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
742
+ const turnId = rawTurnId === null ? null : normalizeTurnRef(rawTurnId, `${name}.turnId`);
743
+ const storedTurnRef = dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
744
+ ? (dispatch.turnRef === null ? null : normalizeTurnRef(dispatch.turnRef, `${name}.turnRef`))
745
+ : null;
720
746
  const updatedAt = requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`);
721
747
  const normalizeResult = (result, resultName) => {
722
748
  requireExactFields(result, RESULT_FIELDS, resultName);
@@ -731,11 +757,13 @@ async function validateDispatchShape(dispatch, name = "task") {
731
757
  `${resultName}.summary`,
732
758
  { maxLength: MAX_RESULT_SUMMARY_LENGTH },
733
759
  ),
734
- turnId: optionalString(
735
- result.turnId,
736
- `${resultName}.turnId`,
737
- { maxLength: 256 },
738
- ),
760
+ turnId: result.turnId === null
761
+ ? null
762
+ : normalizeTurnRef(optionalString(
763
+ result.turnId,
764
+ `${resultName}.turnId`,
765
+ { maxLength: 256 },
766
+ ), `${resultName}.turnId`),
739
767
  updatedAt: requireTimestamp(
740
768
  result.updatedAt,
741
769
  `${resultName}.updatedAt`,
@@ -752,7 +780,7 @@ async function validateDispatchShape(dispatch, name = "task") {
752
780
  const normalizedResult = {
753
781
  status: requireEnum(
754
782
  result.status,
755
- dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
783
+ dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION
756
784
  ? TURN_RESULT_STATUSES
757
785
  : RESULT_STATUSES,
758
786
  `${resultName}.status`,
@@ -774,9 +802,20 @@ async function validateDispatchShape(dispatch, name = "task") {
774
802
  return normalizedResult;
775
803
  };
776
804
  const normalizeTurn = (turn, turnName) => {
777
- requireExactFields(turn, TURN_FIELDS, turnName);
805
+ requireExactFields(
806
+ turn,
807
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION ? TURN_FIELDS : LEGACY_TURN_FIELDS,
808
+ turnName,
809
+ );
810
+ const rawTurnId = optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 });
811
+ const normalizedTurnId = rawTurnId === null
812
+ ? null
813
+ : normalizeTurnRef(rawTurnId, `${turnName}.turnId`);
778
814
  return {
779
- turnId: optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 }),
815
+ turnRef: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
816
+ ? normalizeTurnRef(turn.turnRef, `${turnName}.turnRef`)
817
+ : normalizedTurnId,
818
+ turnId: normalizedTurnId,
780
819
  requestSummary: optionalString(turn.requestSummary, `${turnName}.requestSummary`, {
781
820
  maxLength: MAX_REQUEST_SUMMARY_LENGTH,
782
821
  }),
@@ -786,7 +825,7 @@ async function validateDispatchShape(dispatch, name = "task") {
786
825
  };
787
826
  let legacyResults = [];
788
827
  let turns = [];
789
- if (dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION) {
828
+ if (dispatch.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION) {
790
829
  if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
791
830
  turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
792
831
  } else if (dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION) {
@@ -801,8 +840,9 @@ async function validateDispatchShape(dispatch, name = "task") {
801
840
  } else if (RESULT_STATUSES.has(status)) {
802
841
  legacyResults = [{ status, summary, turnId, updatedAt }];
803
842
  }
804
- if (dispatch.schemaVersion < PREVIOUS_TASK_SCHEMA_VERSION) {
843
+ if (dispatch.schemaVersion < FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION) {
805
844
  turns = legacyResults.map((result) => ({
845
+ turnRef: result.turnId,
806
846
  turnId: result.turnId,
807
847
  requestSummary: null,
808
848
  startedAt: result.updatedAt,
@@ -820,13 +860,14 @@ async function validateDispatchShape(dispatch, name = "task") {
820
860
  || turns.at(-1)?.result !== null
821
861
  )
822
862
  ) {
823
- turns.push({ turnId, requestSummary: null, startedAt: updatedAt, result: null });
863
+ turns.push({ turnRef: turnId, turnId, requestSummary: null, startedAt: updatedAt, result: null });
824
864
  }
825
865
  }
826
866
  const results = turns.flatMap((turn) => (
827
867
  turn.result === null || !RESULT_STATUSES.has(turn.result.status)
828
868
  ) ? [] : [{
829
869
  ...turn.result,
870
+ turnRef: turn.turnRef,
830
871
  turnId: turn.turnId,
831
872
  }]);
832
873
  const lastResult = results.at(-1) ?? null;
@@ -843,6 +884,9 @@ async function validateDispatchShape(dispatch, name = "task") {
843
884
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
844
885
  status,
845
886
  summary,
887
+ turnRef: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
888
+ ? storedTurnRef
889
+ : turns.at(-1)?.turnRef ?? null,
846
890
  turnId,
847
891
  updatedAt,
848
892
  updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
@@ -851,6 +895,30 @@ async function validateDispatchShape(dispatch, name = "task") {
851
895
  results,
852
896
  lastResult,
853
897
  };
898
+ if (normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
899
+ if (normalized.turnId !== null && normalized.turnRef !== normalizeTurnRef(normalized.turnId)) {
900
+ throw new Error(`${name}.turnRef must equal turnId when turnId is present`);
901
+ }
902
+ if (normalized.latestTurn === null) {
903
+ if (normalized.turnRef !== null) {
904
+ throw new Error(`${name}.turnRef must be null before the first lifecycle turn`);
905
+ }
906
+ } else if (normalized.turnRef !== normalized.latestTurn.turnRef) {
907
+ throw new Error(`${name}.turnRef must match latestTurn.turnRef`);
908
+ }
909
+ for (const [index, turn] of normalized.turns.entries()) {
910
+ if (turn.turnId === null) {
911
+ turn.turnRef = normalizeExecutorTurnRef(
912
+ turn.turnRef,
913
+ `${name}.turns[${index}].turnRef`,
914
+ );
915
+ } else if (turn.turnRef !== normalizeTurnRef(turn.turnId)) {
916
+ throw new Error(
917
+ `${name}.turns[${index}].turnRef must equal turnId when turnId is present`,
918
+ );
919
+ }
920
+ }
921
+ }
854
922
  const isSelfLinkingRecord = normalized.threadId !== null
855
923
  && parseTaskChefMarker(normalized.instruction) === normalized.id;
856
924
  if (isSelfLinkingRecord) {
@@ -864,11 +932,17 @@ async function validateDispatchShape(dispatch, name = "task") {
864
932
  `${name}.turns[${index}].turnId`,
865
933
  );
866
934
  }
935
+ if (turn.turnRef !== null) {
936
+ turn.turnRef = normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
937
+ ? normalizeExecutorTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`)
938
+ : normalizeTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`);
939
+ }
867
940
  }
868
941
  normalized.results = normalized.turns.flatMap((turn) => (
869
942
  turn.result === null || !RESULT_STATUSES.has(turn.result.status)
870
943
  ) ? [] : [{
871
944
  ...turn.result,
945
+ turnRef: turn.turnRef,
872
946
  turnId: turn.turnId,
873
947
  }]);
874
948
  normalized.lastResult = normalized.results.at(-1) ?? null;
@@ -878,11 +952,16 @@ async function validateDispatchShape(dispatch, name = "task") {
878
952
  if (normalized.threadId === null) {
879
953
  const isLinkPending = normalized.status === "working"
880
954
  && normalized.summary === null
955
+ && normalized.turnRef === null
881
956
  && normalized.turnId === null
882
957
  && normalized.lastResult === null
883
958
  && normalized.updatedBy === "dispatcher";
884
959
  const isCreationFailure = normalized.status === "failed"
885
960
  && normalized.summary !== null
961
+ && (
962
+ normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
963
+ || normalized.turnRef !== null
964
+ )
886
965
  && normalized.turnId === null
887
966
  && normalized.lastResult?.status === "failed"
888
967
  && normalized.lastResult.turnId === null
@@ -890,11 +969,24 @@ async function validateDispatchShape(dispatch, name = "task") {
890
969
  if (!isLinkPending && !isCreationFailure) {
891
970
  throw new Error(`${name} has an invalid unlinked lifecycle state`);
892
971
  }
972
+ if (isCreationFailure && normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
973
+ normalized.turnRef = normalizeExecutorTurnRef(normalized.turnRef, `${name}.turnRef`);
974
+ for (const [index, turn] of normalized.turns.entries()) {
975
+ turn.turnRef = normalizeExecutorTurnRef(
976
+ turn.turnRef,
977
+ `${name}.turns[${index}].turnRef`,
978
+ );
979
+ }
980
+ }
893
981
  } else {
894
982
  if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
895
- throw new Error(`${name}.turnId is required for a linked semantic state`);
983
+ if (normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
984
+ throw new Error(`${name}.turnId is required for a linked semantic state`);
985
+ }
896
986
  }
897
- const resultWithoutTurn = normalized.results.findIndex((result) => result.turnId === null);
987
+ const resultWithoutTurn = normalized.results.findIndex((result) => (
988
+ normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION && result.turnId === null
989
+ ));
898
990
  if (resultWithoutTurn !== -1) {
899
991
  throw new Error(`${name}.results[${resultWithoutTurn}].turnId is required for a linked result`);
900
992
  }
@@ -912,6 +1004,7 @@ async function validateDispatchShape(dispatch, name = "task") {
912
1004
  normalized.lastResult === null
913
1005
  || normalized.lastResult.status !== normalized.status
914
1006
  || normalized.lastResult.summary !== normalized.summary
1007
+ || normalized.lastResult.turnRef !== normalized.turnRef
915
1008
  || normalized.lastResult.turnId !== normalized.turnId
916
1009
  || normalized.lastResult.updatedAt !== normalized.updatedAt
917
1010
  ) {
@@ -933,22 +1026,28 @@ async function validateDispatchShape(dispatch, name = "task") {
933
1026
  if (
934
1027
  normalized.status === "working"
935
1028
  && isSelfLinkingRecord
1029
+ && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
936
1030
  && normalized.lastResult?.turnId != null
937
- && (
938
- normalized.turnId === null
939
- || normalized.turnId <= normalized.lastResult.turnId
940
- )
1031
+ && (normalized.turnId === null || normalized.turnId <= normalized.lastResult.turnId)
941
1032
  ) {
942
1033
  throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
943
1034
  }
944
1035
  }
1036
+ if (
1037
+ normalized.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
1038
+ && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1039
+ && normalized.turnId !== normalized.turnRef
1040
+ ) {
1041
+ throw new Error(`${name}.turnId must match the latest legacy turn identity`);
1042
+ }
945
1043
  if (normalized.status === "working") {
946
- if (normalized.turnId === null) {
1044
+ if (normalized.turnRef === null) {
947
1045
  if (normalized.turns.length !== 0) {
948
1046
  throw new Error(`${name}.turns must be empty before the first working turn`);
949
1047
  }
950
1048
  } else if (
951
1049
  normalized.latestTurn === null
1050
+ || normalized.latestTurn.turnRef !== normalized.turnRef
952
1051
  || normalized.latestTurn.turnId !== normalized.turnId
953
1052
  || normalized.latestTurn.result !== null
954
1053
  ) {
@@ -958,6 +1057,7 @@ async function validateDispatchShape(dispatch, name = "task") {
958
1057
  if (RESULT_STATUSES.has(normalized.status)) {
959
1058
  if (
960
1059
  normalized.latestTurn === null
1060
+ || normalized.latestTurn.turnRef !== normalized.turnRef
961
1061
  || normalized.latestTurn.turnId !== normalized.turnId
962
1062
  || normalized.latestTurn.result === null
963
1063
  || normalized.latestTurn.result.status !== normalized.status
@@ -967,23 +1067,22 @@ async function validateDispatchShape(dispatch, name = "task") {
967
1067
  throw new Error(`${name}.latestTurn must match the current semantic state`);
968
1068
  }
969
1069
  }
970
- const seenTurnIds = new Set();
971
- const nullTurnKey = Symbol("null turn");
1070
+ const seenTurnRefs = new Set();
1071
+ let lastNativeTurnRef = null;
972
1072
  for (const [index, turn] of normalized.turns.entries()) {
973
- const turnKey = turn.turnId ?? nullTurnKey;
974
- const isLegacyOpaqueWorkingReuse = (
1073
+ const turnKey = turn.turnRef;
1074
+ const isLegacyOpaqueTurnReuse = (
975
1075
  !isSelfLinkingRecord
976
- && normalized.status === "working"
977
1076
  && index === normalized.turns.length - 1
978
- && turn.result === null
979
1077
  && index > 0
980
1078
  && normalized.turns[index - 1].turnId === turn.turnId
1079
+ && normalized.turns[index - 1].turnRef === turn.turnRef
981
1080
  && normalized.turns[index - 1].result !== null
982
1081
  );
983
- if (seenTurnIds.has(turnKey) && !isLegacyOpaqueWorkingReuse) {
984
- throw new Error(`${name}.turns contains duplicate turnId: ${turn.turnId ?? "null"}`);
1082
+ if (seenTurnRefs.has(turnKey) && !isLegacyOpaqueTurnReuse) {
1083
+ throw new Error(`${name}.turns contains duplicate turnRef: ${turn.turnRef ?? "null"}`);
985
1084
  }
986
- seenTurnIds.add(turnKey);
1085
+ seenTurnRefs.add(turnKey);
987
1086
  if (Date.parse(turn.startedAt) < Date.parse(normalized.createdAt)) {
988
1087
  throw new Error(`${name}.turns[${index}].startedAt must not be earlier than createdAt`);
989
1088
  }
@@ -997,12 +1096,23 @@ async function validateDispatchShape(dispatch, name = "task") {
997
1096
  throw new Error(`${name}.turns must be ordered by startedAt`);
998
1097
  }
999
1098
  if (
1000
- isSelfLinkingRecord
1099
+ normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1100
+ && isSelfLinkingRecord
1001
1101
  && index > 0
1002
1102
  && turn.turnId <= normalized.turns[index - 1].turnId
1003
1103
  ) {
1004
1104
  throw new Error(`${name}.turns must be ordered by turnId`);
1005
1105
  }
1106
+ if (
1107
+ normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
1108
+ && isSelfLinkingRecord
1109
+ && turn.turnId !== null
1110
+ ) {
1111
+ if (lastNativeTurnRef !== null && turn.turnRef <= lastNativeTurnRef) {
1112
+ throw new Error(`${name}.native-backed turnRefs must be strictly increasing`);
1113
+ }
1114
+ lastNativeTurnRef = turn.turnRef;
1115
+ }
1006
1116
  if (turn.result !== null) {
1007
1117
  if (Date.parse(turn.result.updatedAt) < Date.parse(turn.startedAt)) {
1008
1118
  throw new Error(`${name}.turns[${index}].result.updatedAt must not be earlier than startedAt`);
@@ -1103,11 +1213,20 @@ function currentSchemaTask(dispatch, patch = {}) {
1103
1213
  lastResult: _lastResult,
1104
1214
  ...persisted
1105
1215
  } = dispatch;
1216
+ const sourceTurns = patch.turns ?? dispatch.turns ?? [];
1217
+ const turns = sourceTurns.map((turn) => ({
1218
+ ...turn,
1219
+ turnRef: turn.turnRef === null || turn.turnRef === undefined
1220
+ ? (turn.turnId === null || turn.turnId === undefined ? randomUUID() : turn.turnId)
1221
+ : turn.turnRef,
1222
+ }));
1223
+ const latestTurn = turns.at(-1) ?? null;
1106
1224
  return {
1107
1225
  ...persisted,
1108
1226
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1109
- turns: dispatch.turns ?? [],
1110
1227
  ...patch,
1228
+ turnRef: "turnRef" in patch ? patch.turnRef : latestTurn?.turnRef ?? null,
1229
+ turns,
1111
1230
  };
1112
1231
  }
1113
1232
 
@@ -1127,21 +1246,41 @@ export async function migrateTaskLog(workspaceRoot, {
1127
1246
  (record) => record.raw.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION,
1128
1247
  ).length;
1129
1248
  if (migratedCount === 0) {
1249
+ const turnCount = records.reduce((count, record) => count + record.normalized.turns.length, 0);
1130
1250
  return {
1131
1251
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1132
1252
  action: "unchanged",
1133
1253
  taskCount: records.length,
1254
+ turnCount,
1134
1255
  migratedCount: 0,
1256
+ nativeTurnRefCount: records.reduce((count, record) => count + record.normalized.turns.filter(
1257
+ (turn) => turn.turnId !== null && turn.turnRef === turn.turnId,
1258
+ ).length, 0),
1259
+ fallbackTurnRefCount: records.reduce((count, record) => count + record.normalized.turns.filter(
1260
+ (turn) => turn.turnId === null,
1261
+ ).length, 0),
1135
1262
  backupPath: null,
1136
1263
  };
1137
1264
  }
1138
- const lines = records.map((record) => JSON.stringify(currentSchemaTask(record.normalized)));
1265
+ const beforeTurnCount = records.reduce((count, record) => count + record.normalized.turns.length, 0);
1266
+ const migratedTasks = records.map((record) => currentSchemaTask(record.normalized));
1267
+ const lines = migratedTasks.map((task) => JSON.stringify(task));
1139
1268
  const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
1140
- await parseDispatchRecordsUnlocked(root, migrated);
1269
+ const validated = await parseDispatchRecordsUnlocked(root, migrated);
1270
+ const afterTurnCount = validated.reduce((count, record) => count + record.normalized.turns.length, 0);
1271
+ if (validated.length !== records.length || afterTurnCount !== beforeTurnCount) {
1272
+ throw new Error("task log migration changed task or turn counts before replacement");
1273
+ }
1274
+ const nativeTurnRefCount = migratedTasks.reduce((count, task) => count + task.turns.filter(
1275
+ (turn) => turn.turnId !== null && turn.turnRef === turn.turnId,
1276
+ ).length, 0);
1277
+ const fallbackTurnRefCount = migratedTasks.reduce((count, task) => count + task.turns.filter(
1278
+ (turn) => turn.turnId === null,
1279
+ ).length, 0);
1141
1280
  const timestamp = requireTimestamp(now(), "migration timestamp")
1142
1281
  .replaceAll(":", "-")
1143
1282
  .replaceAll(".", "-");
1144
- const backupPath = `${dispatchPath}.pre-v8-${timestamp}-${randomUUID()}.bak`;
1283
+ const backupPath = `${dispatchPath}.pre-v9-${timestamp}-${randomUUID()}.bak`;
1145
1284
  await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1146
1285
  if (await readFile(backupPath, "utf8") !== original) {
1147
1286
  throw new Error(`task log backup validation failed: ${backupPath}`);
@@ -1159,7 +1298,10 @@ export async function migrateTaskLog(workspaceRoot, {
1159
1298
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
1160
1299
  action: "migrated",
1161
1300
  taskCount: records.length,
1301
+ turnCount: afterTurnCount,
1162
1302
  migratedCount,
1303
+ nativeTurnRefCount,
1304
+ fallbackTurnRefCount,
1163
1305
  backupPath,
1164
1306
  };
1165
1307
  });
@@ -1184,6 +1326,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
1184
1326
  createdAt,
1185
1327
  status: "working",
1186
1328
  summary: null,
1329
+ turnRef: null,
1187
1330
  turnId: null,
1188
1331
  updatedAt: createdAt,
1189
1332
  updatedBy: "dispatcher",
@@ -1277,7 +1420,7 @@ function dispatchLineWithState(dispatch, patch) {
1277
1420
 
1278
1421
  function normalizeTaskStateInput(input, { allowWorking }) {
1279
1422
  const fields = new Set([
1280
- "taskId", "threadId", "turnId", "status", "summary", "requestSummary",
1423
+ "taskId", "threadId", "turnRef", "turnId", "status", "summary", "requestSummary",
1281
1424
  ]);
1282
1425
  if (!input || typeof input !== "object" || Array.isArray(input)) {
1283
1426
  throw new Error("task state must be an object");
@@ -1291,7 +1434,13 @@ function normalizeTaskStateInput(input, { allowWorking }) {
1291
1434
  const threadId = input.threadId === null
1292
1435
  ? null
1293
1436
  : normalizeDurableThreadId(input.threadId, "threadId");
1294
- const turnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
1437
+ const rawTurnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
1438
+ const turnId = rawTurnId === null ? null : normalizeTurnRef(rawTurnId, "turnId");
1439
+ const turnRefInput = "turnRef" in input ? input.turnRef : turnId;
1440
+ const turnRef = turnRefInput === null ? null : normalizeTurnRef(turnRefInput);
1441
+ if (turnId !== null && turnRef !== normalizeTurnRef(turnId, "turnId as turnRef")) {
1442
+ throw new Error("turnRef must equal turnId when turnId is present");
1443
+ }
1295
1444
  const status = requireEnum(
1296
1445
  input.status,
1297
1446
  allowWorking ? TASK_STATUSES : RESULT_STATUSES,
@@ -1314,13 +1463,14 @@ function normalizeTaskStateInput(input, { allowWorking }) {
1314
1463
  if (status !== "working" && requestSummary !== null) {
1315
1464
  throw new Error(`requestSummary is accepted only for status working`);
1316
1465
  }
1317
- return { id, threadId, turnId, status, summary, requestSummary };
1466
+ return { id, threadId, turnRef, turnId, status, summary, requestSummary };
1318
1467
  }
1319
1468
 
1320
- function sameLastResult(lastResult, { status, summary, turnId }) {
1469
+ function sameLastResult(lastResult, { status, summary, turnRef, turnId }) {
1321
1470
  return lastResult !== null
1322
1471
  && lastResult.status === status
1323
1472
  && lastResult.summary === summary
1473
+ && lastResult.turnRef === turnRef
1324
1474
  && lastResult.turnId === turnId;
1325
1475
  }
1326
1476
 
@@ -1329,7 +1479,10 @@ async function reportTaskStateInternal(
1329
1479
  input,
1330
1480
  { now, compatibilityAlias = false } = {},
1331
1481
  ) {
1332
- const { id, threadId, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
1482
+ const turnRefWasProvided = input !== null
1483
+ && typeof input === "object"
1484
+ && Object.prototype.hasOwnProperty.call(input, "turnRef");
1485
+ const { id, threadId, turnRef, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
1333
1486
  allowWorking: !compatibilityAlias,
1334
1487
  });
1335
1488
  const root = await realpath(path.resolve(workspaceRoot));
@@ -1350,66 +1503,86 @@ async function reportTaskStateInternal(
1350
1503
  ) {
1351
1504
  throw new Error(`report_state accepts only self-linked task records: ${id}`);
1352
1505
  }
1506
+ let stateTurnRef = turnRef;
1353
1507
  let stateTurnId = turnId;
1354
1508
  if (isSelfLinkingJourney) {
1355
1509
  if (dispatch.updatedBy === "dispatcher") {
1356
1510
  throw new Error(`self-linking task must link before reporting a result: ${id}`);
1357
1511
  }
1358
- stateTurnId = normalizeCodexThreadId(turnId, "turnId");
1512
+ stateTurnId = turnId === null ? null : normalizeCodexThreadId(turnId, "turnId");
1513
+ stateTurnRef = normalizeExecutorTurnRef(turnRef, "turnRef");
1359
1514
  }
1360
1515
  if (dispatch.threadId === null) {
1361
- if (threadId !== null || stateTurnId !== null || status !== "failed") {
1362
- throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
1516
+ const rawSchemaVersion = records[index].raw.schemaVersion;
1517
+ const hasCurrentMarker = rawSchemaVersion
1518
+ >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION
1519
+ && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
1520
+ const isIdenticalOmittedRefCreationFailureRetry = !turnRefWasProvided
1521
+ && hasCurrentMarker
1522
+ && threadId === null
1523
+ && dispatch.status === "failed"
1524
+ && dispatch.turnId === null
1525
+ && dispatch.updatedBy === "mcp"
1526
+ && dispatch.lastResult?.status === status
1527
+ && dispatch.lastResult?.summary === summary
1528
+ && dispatch.lastResult?.turnId === null;
1529
+ if (isIdenticalOmittedRefCreationFailureRetry) return dispatch;
1530
+ if (stateTurnRef !== null) {
1531
+ stateTurnRef = normalizeExecutorTurnRef(stateTurnRef, "turnRef");
1363
1532
  }
1364
- if (!compatibilityAlias) {
1365
- const rawSchemaVersion = records[index].raw.schemaVersion;
1366
- const hasCurrentMarker = rawSchemaVersion
1367
- >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION
1368
- && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
1369
- const isFreshCreationFailure = hasCurrentMarker
1370
- && dispatch.status === "working"
1371
- && dispatch.turnId === null
1372
- && dispatch.lastResult === null
1373
- && dispatch.updatedBy === "dispatcher";
1374
- const isIdenticalCreationFailureRetry = hasCurrentMarker
1375
- && dispatch.status === "failed"
1376
- && dispatch.turnId === null
1377
- && dispatch.updatedBy === "mcp"
1378
- && sameLastResult(dispatch.lastResult, {
1379
- status,
1380
- summary,
1381
- turnId: stateTurnId,
1382
- });
1383
- if (!isFreshCreationFailure && !isIdenticalCreationFailureRetry) {
1384
- throw new Error(`report_state unlinked failure requires a fresh link-pending task: ${id}`);
1385
- }
1533
+ if (threadId !== null || stateTurnId !== null || stateTurnRef === null || status !== "failed") {
1534
+ throw new Error(`task without a durable threadId accepts only failed with a turnRef and null thread/turn IDs: ${id}`);
1535
+ }
1536
+ const isFreshCreationFailure = hasCurrentMarker
1537
+ && dispatch.status === "working"
1538
+ && dispatch.turnRef === null
1539
+ && dispatch.lastResult === null
1540
+ && dispatch.updatedBy === "dispatcher";
1541
+ const isIdenticalCreationFailureRetry = hasCurrentMarker
1542
+ && dispatch.status === "failed"
1543
+ && dispatch.turnRef === stateTurnRef
1544
+ && dispatch.updatedBy === "mcp"
1545
+ && sameLastResult(dispatch.lastResult, {
1546
+ status,
1547
+ summary,
1548
+ turnRef: stateTurnRef,
1549
+ turnId: stateTurnId,
1550
+ });
1551
+ if (!isFreshCreationFailure && !isIdenticalCreationFailureRetry) {
1552
+ throw new Error(`report_state unlinked failure requires a fresh link-pending task: ${id}`);
1386
1553
  }
1387
1554
  } else {
1388
1555
  if (threadIdentityKey(threadId) !== threadIdentityKey(dispatch.threadId)) {
1389
1556
  throw new Error(`task result threadId does not match recorded threadId: ${id}`);
1390
1557
  }
1391
- if (stateTurnId === null) {
1392
- throw new Error(`task state turnId is required for a linked task: ${id}`);
1558
+ if (stateTurnRef === null) {
1559
+ throw new Error(`task state turnRef is required for a linked task: ${id}`);
1393
1560
  }
1394
1561
  }
1395
1562
  if (status === "working") {
1396
- const sameWorkingTurn = dispatch.status === "working" && stateTurnId === dispatch.turnId;
1397
- const recordedTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
1563
+ const sameWorkingTurn = dispatch.status === "working" && stateTurnRef === dispatch.turnRef;
1564
+ const recordedTurn = dispatch.turns.findLast((turn) => turn.turnRef === stateTurnRef);
1398
1565
  if (!sameWorkingTurn && recordedTurn) {
1399
- if (
1400
- requestSummary !== null
1401
- && recordedTurn.requestSummary !== requestSummary
1402
- ) {
1403
- throw new Error(`working turn already has a different requestSummary: ${id}`);
1404
- }
1405
- if (
1406
- recordedTurn.requestSummary !== null
1407
- || recordedTurn.result?.status === "interrupted"
1408
- ) {
1409
- return dispatch;
1410
- }
1566
+ throw new Error(`working turnRef is stale: ${id}`);
1567
+ }
1568
+ const latestNativeTurnRef = isSelfLinkingJourney && stateTurnId !== null
1569
+ ? dispatch.turns.reduce((latest, turn) => (
1570
+ turn.turnId !== null && (latest === null || turn.turnRef > latest)
1571
+ ? turn.turnRef
1572
+ : latest
1573
+ ), null)
1574
+ : null;
1575
+ if (
1576
+ !sameWorkingTurn
1577
+ && latestNativeTurnRef !== null
1578
+ && stateTurnRef <= latestNativeTurnRef
1579
+ ) {
1580
+ throw new Error(`working native turnRef is stale: ${id}`);
1411
1581
  }
1412
1582
  if (sameWorkingTurn) {
1583
+ if (dispatch.turnId !== stateTurnId) {
1584
+ throw new Error(`working turnRef already has different Codex turn metadata: ${id}`);
1585
+ }
1413
1586
  const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
1414
1587
  if (
1415
1588
  requestSummary !== null
@@ -1425,14 +1598,6 @@ async function reportTaskStateInternal(
1425
1598
  return dispatch;
1426
1599
  }
1427
1600
  }
1428
- if (isSelfLinkingJourney) {
1429
- if (!sameWorkingTurn && dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1430
- throw new Error(`working turnId must be newer than the current task turnId: ${id}`);
1431
- }
1432
- if (!sameWorkingTurn && dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1433
- throw new Error(`working turnId must be newer than the last result turnId: ${id}`);
1434
- }
1435
- }
1436
1601
  const updatedAt = sameWorkingTurn
1437
1602
  ? dispatch.updatedAt
1438
1603
  : transitionTimestamp(now, dispatch.updatedAt);
@@ -1457,6 +1622,7 @@ async function reportTaskStateInternal(
1457
1622
  ? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
1458
1623
  : turn)
1459
1624
  : [...recoveredTurns, {
1625
+ turnRef: stateTurnRef,
1460
1626
  turnId: stateTurnId,
1461
1627
  requestSummary,
1462
1628
  startedAt: updatedAt,
@@ -1465,6 +1631,7 @@ async function reportTaskStateInternal(
1465
1631
  const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
1466
1632
  status,
1467
1633
  summary: null,
1634
+ turnRef: stateTurnRef,
1468
1635
  turnId: stateTurnId,
1469
1636
  updatedAt,
1470
1637
  updatedBy: "mcp",
@@ -1476,36 +1643,40 @@ async function reportTaskStateInternal(
1476
1643
  await writeDispatchLinesAtomic(root, lines);
1477
1644
  return updated;
1478
1645
  }
1479
- const priorTurn = dispatch.turns.find((turn) => turn.turnId === stateTurnId);
1646
+ const priorTurn = dispatch.turns.findLast((turn) => turn.turnRef === stateTurnRef);
1480
1647
  const priorTurnResult = priorTurn?.result === null || priorTurn === undefined
1481
1648
  ? null
1482
- : { ...priorTurn.result, turnId: priorTurn.turnId };
1483
- if (priorTurnResult && sameLastResult(priorTurnResult, { status, summary, turnId: stateTurnId })) {
1484
- return dispatch;
1649
+ : { ...priorTurn.result, turnRef: priorTurn.turnRef, turnId: priorTurn.turnId };
1650
+ if (priorTurnResult && sameLastResult(priorTurnResult, {
1651
+ status, summary, turnRef: stateTurnRef, turnId: stateTurnId,
1652
+ })) {
1653
+ if (dispatch.turnRef === stateTurnRef) return dispatch;
1654
+ throw new Error(`task result turnRef is stale: ${id}`);
1485
1655
  }
1486
- if (priorTurnResult || (dispatch.turnId === stateTurnId && dispatch.status !== "working")) {
1656
+ if (priorTurnResult || (dispatch.turnRef === stateTurnRef && dispatch.status !== "working")) {
1487
1657
  throw new Error(`task turn already has a different semantic result: ${id}`);
1488
1658
  }
1489
1659
  if (compatibilityAlias) {
1490
1660
  const matchesWorkingTurn = dispatch.status === "working"
1491
- && dispatch.turnId === stateTurnId;
1492
- if (!matchesWorkingTurn && isSelfLinkingJourney) {
1493
- if (dispatch.turnId !== null && stateTurnId <= dispatch.turnId) {
1494
- throw new Error(`task result turnId must be newer than the stored turnId: ${id}`);
1495
- }
1496
- if (dispatch.lastResult?.turnId != null && stateTurnId <= dispatch.lastResult.turnId) {
1497
- throw new Error(`task result turnId must be newer than the last result turnId: ${id}`);
1498
- }
1661
+ && dispatch.turnRef === stateTurnRef;
1662
+ if (!matchesWorkingTurn && dispatch.turns.some((turn) => turn.turnRef === stateTurnRef)) {
1663
+ throw new Error(`task result turnRef is stale: ${id}`);
1499
1664
  }
1500
- } else if (dispatch.status !== "working" || dispatch.turnId !== stateTurnId) {
1501
- throw new Error(`task result must match the current working turnId: ${id}`);
1665
+ } else if (
1666
+ dispatch.threadId !== null
1667
+ && (dispatch.status !== "working" || dispatch.turnRef !== stateTurnRef)
1668
+ ) {
1669
+ throw new Error(`task result must match the current working turnRef: ${id}`);
1502
1670
  }
1503
1671
  const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
1504
1672
  const turnResult = { status, summary, updatedAt };
1505
- const currentTurnIndex = dispatch.turns.findIndex((turn) => turn.turnId === stateTurnId);
1673
+ const currentTurnIndex = dispatch.turns.findLastIndex(
1674
+ (turn) => turn.turnRef === stateTurnRef,
1675
+ );
1506
1676
  let turns;
1507
1677
  if (currentTurnIndex === -1) {
1508
1678
  turns = [...dispatch.turns, {
1679
+ turnRef: stateTurnRef,
1509
1680
  turnId: stateTurnId,
1510
1681
  requestSummary: null,
1511
1682
  startedAt: updatedAt,
@@ -1519,6 +1690,7 @@ async function reportTaskStateInternal(
1519
1690
  const candidate = currentSchemaTask(dispatch, {
1520
1691
  status,
1521
1692
  summary,
1693
+ turnRef: stateTurnRef,
1522
1694
  turnId: stateTurnId,
1523
1695
  updatedAt,
1524
1696
  updatedBy: "mcp",
@@ -1661,6 +1833,7 @@ function taskBriefEntry(task) {
1661
1833
  lastOutcome: task.lastResult === null ? null : {
1662
1834
  status: task.lastResult.status,
1663
1835
  summary: task.lastResult.summary,
1836
+ turnRef: task.lastResult.turnRef,
1664
1837
  turnId: task.lastResult.turnId,
1665
1838
  updatedAt: task.lastResult.updatedAt,
1666
1839
  },