taskchef 7.20.0 → 7.21.1

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,9 +38,11 @@ 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 = 9;
42
- const PREVIOUS_TASK_SCHEMA_VERSION = 8;
41
+ const CURRENT_TASK_SCHEMA_VERSION = 10;
42
+ const PREVIOUS_TASK_SCHEMA_VERSION = 9;
43
+ const TURN_REF_TASK_SCHEMA_VERSION = 9;
43
44
  const FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION = 7;
45
+ const INTERRUPTED_TURN_TASK_SCHEMA_VERSION = 8;
44
46
  const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
45
47
  const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
46
48
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard"]);
@@ -81,14 +83,36 @@ const RECORD_DISPATCH_FIELDS = new Set([
81
83
  const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
82
84
  const TURN_RESULT_STATUSES = new Set([...RESULT_STATUSES, "interrupted"]);
83
85
  const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
84
- const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp"]);
86
+ const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp", "dashboard"]);
85
87
  const MAX_RESULT_SUMMARY_LENGTH = 2_000;
86
88
  const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
87
89
  const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
88
90
  const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
89
91
  const LEGACY_TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
90
- const TURN_FIELDS = new Set(["turnRef", "turnId", "requestSummary", "startedAt", "result"]);
92
+ const SCHEMA_9_TURN_FIELDS = new Set([
93
+ "turnRef", "turnId", "requestSummary", "startedAt", "result",
94
+ ]);
95
+ const TURN_FIELDS = new Set([...SCHEMA_9_TURN_FIELDS, "provenance"]);
96
+ const TURN_PROVENANCE_KINDS = new Set(["legacy", "mcp", "dashboard_manual"]);
97
+ const SIMPLE_TURN_PROVENANCE_FIELDS = new Set(["kind"]);
98
+ const MANUAL_TURN_PROVENANCE_FIELDS = new Set([
99
+ "kind",
100
+ "actionId",
101
+ "fromStatus",
102
+ "toStatus",
103
+ "expectedTurnRef",
104
+ "expectedThreadId",
105
+ "expectedUpdatedAt",
106
+ ]);
91
107
  const INTERRUPTED_TURN_SUMMARY = "Turn interrupted before a terminal report.";
108
+ const MANUAL_TRANSITION_STATUSES = new Set(["working", "needs_input"]);
109
+ const MANUAL_TARGET_STATUSES = new Set(["completed", "failed"]);
110
+ const MANUAL_TRANSITION_FIELDS = new Set([
111
+ "actionId", "expected", "targetStatus",
112
+ ]);
113
+ const MANUAL_EXPECTED_FIELDS = new Set([
114
+ "status", "turnRef", "threadId", "updatedAt",
115
+ ]);
92
116
 
93
117
  function requireExactFields(value, fields, name) {
94
118
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -148,6 +172,31 @@ function normalizeExecutorTurnRef(value, name = "turnRef") {
148
172
  return normalized;
149
173
  }
150
174
 
175
+ function normalizeTurnProvenance(value, name) {
176
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
177
+ throw new Error(`${name} must be an object`);
178
+ }
179
+ const kind = requireEnum(value.kind, TURN_PROVENANCE_KINDS, `${name}.kind`);
180
+ if (kind !== "dashboard_manual") {
181
+ requireExactFields(value, SIMPLE_TURN_PROVENANCE_FIELDS, name);
182
+ return { kind };
183
+ }
184
+ requireExactFields(value, MANUAL_TURN_PROVENANCE_FIELDS, name);
185
+ return {
186
+ kind,
187
+ actionId: normalizeExecutorTurnRef(value.actionId, `${name}.actionId`),
188
+ fromStatus: requireEnum(value.fromStatus, MANUAL_TRANSITION_STATUSES, `${name}.fromStatus`),
189
+ toStatus: requireEnum(value.toStatus, MANUAL_TARGET_STATUSES, `${name}.toStatus`),
190
+ expectedTurnRef: value.expectedTurnRef === null
191
+ ? null
192
+ : normalizeTurnRef(value.expectedTurnRef, `${name}.expectedTurnRef`),
193
+ expectedThreadId: value.expectedThreadId === null
194
+ ? null
195
+ : normalizeDurableThreadId(value.expectedThreadId, `${name}.expectedThreadId`),
196
+ expectedUpdatedAt: requireTimestamp(value.expectedUpdatedAt, `${name}.expectedUpdatedAt`),
197
+ };
198
+ }
199
+
151
200
  function requireEnum(value, allowed, name) {
152
201
  if (!allowed.has(value)) {
153
202
  throw new Error(`${name} must be one of: ${[...allowed].join(", ")}`);
@@ -738,6 +787,7 @@ async function validateDispatchShape(dispatch, name = "task") {
738
787
  5,
739
788
  LEGACY_RESULTS_TASK_SCHEMA_VERSION,
740
789
  FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION,
790
+ INTERRUPTED_TURN_TASK_SCHEMA_VERSION,
741
791
  PREVIOUS_TASK_SCHEMA_VERSION,
742
792
  CURRENT_TASK_SCHEMA_VERSION,
743
793
  ];
@@ -746,7 +796,7 @@ async function validateDispatchShape(dispatch, name = "task") {
746
796
  }
747
797
  requireExactFields(
748
798
  dispatch,
749
- dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
799
+ dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
750
800
  ? DISPATCH_FIELDS
751
801
  : dispatch.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
752
802
  ? LEGACY_TURN_DISPATCH_FIELDS
@@ -765,7 +815,7 @@ async function validateDispatchShape(dispatch, name = "task") {
765
815
  });
766
816
  const rawTurnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
767
817
  const turnId = rawTurnId === null ? null : normalizeTurnRef(rawTurnId, `${name}.turnId`);
768
- const storedTurnRef = dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
818
+ const storedTurnRef = dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
769
819
  ? (dispatch.turnRef === null ? null : normalizeTurnRef(dispatch.turnRef, `${name}.turnRef`))
770
820
  : null;
771
821
  const updatedAt = requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`);
@@ -805,7 +855,7 @@ async function validateDispatchShape(dispatch, name = "task") {
805
855
  const normalizedResult = {
806
856
  status: requireEnum(
807
857
  result.status,
808
- dispatch.schemaVersion >= PREVIOUS_TASK_SCHEMA_VERSION
858
+ dispatch.schemaVersion >= INTERRUPTED_TURN_TASK_SCHEMA_VERSION
809
859
  ? TURN_RESULT_STATUSES
810
860
  : RESULT_STATUSES,
811
861
  `${resultName}.status`,
@@ -829,7 +879,11 @@ async function validateDispatchShape(dispatch, name = "task") {
829
879
  const normalizeTurn = (turn, turnName) => {
830
880
  requireExactFields(
831
881
  turn,
832
- dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION ? TURN_FIELDS : LEGACY_TURN_FIELDS,
882
+ dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
883
+ ? TURN_FIELDS
884
+ : dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
885
+ ? SCHEMA_9_TURN_FIELDS
886
+ : LEGACY_TURN_FIELDS,
833
887
  turnName,
834
888
  );
835
889
  const rawTurnId = optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 });
@@ -837,7 +891,7 @@ async function validateDispatchShape(dispatch, name = "task") {
837
891
  ? null
838
892
  : normalizeTurnRef(rawTurnId, `${turnName}.turnId`);
839
893
  return {
840
- turnRef: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
894
+ turnRef: dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
841
895
  ? normalizeTurnRef(turn.turnRef, `${turnName}.turnRef`)
842
896
  : normalizedTurnId,
843
897
  turnId: normalizedTurnId,
@@ -846,6 +900,9 @@ async function validateDispatchShape(dispatch, name = "task") {
846
900
  }),
847
901
  startedAt: requireTimestamp(turn.startedAt, `${turnName}.startedAt`),
848
902
  result: normalizeTurnResult(turn.result, `${turnName}.result`),
903
+ provenance: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
904
+ ? normalizeTurnProvenance(turn.provenance, `${turnName}.provenance`)
905
+ : null,
849
906
  };
850
907
  };
851
908
  let legacyResults = [];
@@ -876,6 +933,7 @@ async function validateDispatchShape(dispatch, name = "task") {
876
933
  summary: result.summary,
877
934
  updatedAt: result.updatedAt,
878
935
  },
936
+ provenance: null,
879
937
  }));
880
938
  if (
881
939
  status === "working"
@@ -885,7 +943,14 @@ async function validateDispatchShape(dispatch, name = "task") {
885
943
  || turns.at(-1)?.result !== null
886
944
  )
887
945
  ) {
888
- turns.push({ turnRef: turnId, turnId, requestSummary: null, startedAt: updatedAt, result: null });
946
+ turns.push({
947
+ turnRef: turnId,
948
+ turnId,
949
+ requestSummary: null,
950
+ startedAt: updatedAt,
951
+ result: null,
952
+ provenance: null,
953
+ });
889
954
  }
890
955
  }
891
956
  const results = turns.flatMap((turn) => (
@@ -894,6 +959,9 @@ async function validateDispatchShape(dispatch, name = "task") {
894
959
  ...turn.result,
895
960
  turnRef: turn.turnRef,
896
961
  turnId: turn.turnId,
962
+ ...(turn.provenance?.kind === "dashboard_manual"
963
+ ? { provenance: turn.provenance }
964
+ : {}),
897
965
  }]);
898
966
  const lastResult = results.at(-1) ?? null;
899
967
  const latestTurn = turns.at(-1) ?? null;
@@ -909,7 +977,7 @@ async function validateDispatchShape(dispatch, name = "task") {
909
977
  createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
910
978
  status,
911
979
  summary,
912
- turnRef: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
980
+ turnRef: dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
913
981
  ? storedTurnRef
914
982
  : turns.at(-1)?.turnRef ?? null,
915
983
  turnId,
@@ -920,7 +988,10 @@ async function validateDispatchShape(dispatch, name = "task") {
920
988
  results,
921
989
  lastResult,
922
990
  };
923
- if (normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
991
+ if (normalized.updatedBy === "dashboard" && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
992
+ throw new Error(`${name}.updatedBy dashboard requires schema ${CURRENT_TASK_SCHEMA_VERSION}`);
993
+ }
994
+ if (normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION) {
924
995
  if (normalized.turnId !== null && normalized.turnRef !== normalizeTurnRef(normalized.turnId)) {
925
996
  throw new Error(`${name}.turnRef must equal turnId when turnId is present`);
926
997
  }
@@ -944,6 +1015,82 @@ async function validateDispatchShape(dispatch, name = "task") {
944
1015
  }
945
1016
  }
946
1017
  }
1018
+ if (normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
1019
+ const actionIds = new Set();
1020
+ for (const [index, turn] of normalized.turns.entries()) {
1021
+ const provenance = turn.provenance;
1022
+ if (provenance.kind !== "dashboard_manual") continue;
1023
+ const turnName = `${name}.turns[${index}]`;
1024
+ const predecessor = normalized.turns[index - 1] ?? null;
1025
+ if (actionIds.has(provenance.actionId)) {
1026
+ throw new Error(`${name} has duplicate manual transition actionId: ${provenance.actionId}`);
1027
+ }
1028
+ actionIds.add(provenance.actionId);
1029
+ if (turn.turnId !== null) {
1030
+ throw new Error(`${turnName}.turnId must be null for a manual dashboard turn`);
1031
+ }
1032
+ if (provenance.expectedThreadId !== normalized.threadId) {
1033
+ throw new Error(`${turnName}.provenance.expectedThreadId must match the task threadId`);
1034
+ }
1035
+ if (provenance.expectedTurnRef !== (predecessor?.turnRef ?? null)) {
1036
+ throw new Error(`${turnName}.provenance.expectedTurnRef must match the prior turn`);
1037
+ }
1038
+ const validPriorState = provenance.fromStatus === "needs_input"
1039
+ ? predecessor?.result?.status === "needs_input"
1040
+ : predecessor === null || predecessor.result?.status === "interrupted";
1041
+ if (!validPriorState) {
1042
+ throw new Error(`${turnName}.provenance.fromStatus does not match the prior turn`);
1043
+ }
1044
+ if (
1045
+ provenance.fromStatus === "working"
1046
+ && predecessor !== null
1047
+ && predecessor.result.updatedAt !== turn.startedAt
1048
+ ) {
1049
+ throw new Error(`${turnName} must share its timestamp with the interrupted prior turn`);
1050
+ }
1051
+ const expectedPriorTimestamp = provenance.fromStatus === "needs_input"
1052
+ ? predecessor.result.updatedAt
1053
+ : predecessor?.startedAt ?? null;
1054
+ if (
1055
+ Date.parse(provenance.expectedUpdatedAt) < Date.parse(normalized.createdAt)
1056
+ || (
1057
+ expectedPriorTimestamp !== null
1058
+ && provenance.expectedUpdatedAt !== expectedPriorTimestamp
1059
+ )
1060
+ ) {
1061
+ throw new Error(`${turnName}.provenance.expectedUpdatedAt does not match prior state`);
1062
+ }
1063
+ if (turn.requestSummary !== manualTransitionRequestSummary(
1064
+ provenance.fromStatus,
1065
+ provenance.toStatus,
1066
+ )) {
1067
+ throw new Error(`${turnName}.requestSummary must use the manual transition summary`);
1068
+ }
1069
+ if (
1070
+ turn.result?.status !== provenance.toStatus
1071
+ || turn.result?.summary !== manualTransitionSummary(provenance.toStatus)
1072
+ ) {
1073
+ throw new Error(`${turnName}.result must match its manual transition provenance`);
1074
+ }
1075
+ if (
1076
+ turn.startedAt !== turn.result.updatedAt
1077
+ || Date.parse(turn.startedAt) < Date.parse(provenance.expectedUpdatedAt)
1078
+ ) {
1079
+ throw new Error(`${turnName} has invalid manual transition timestamps`);
1080
+ }
1081
+ }
1082
+ if (normalized.updatedBy === "dashboard") {
1083
+ if (normalized.latestTurn?.provenance?.kind !== "dashboard_manual") {
1084
+ throw new Error(`${name}.updatedBy dashboard requires a latest manual dashboard turn`);
1085
+ }
1086
+ }
1087
+ if (
1088
+ normalized.latestTurn?.provenance?.kind === "dashboard_manual"
1089
+ && normalized.updatedBy !== "dashboard"
1090
+ ) {
1091
+ throw new Error(`${name} latest manual dashboard turn requires updatedBy dashboard`);
1092
+ }
1093
+ }
947
1094
  const isSelfLinkingRecord = normalized.threadId !== null
948
1095
  && parseTaskChefMarker(normalized.instruction) === normalized.id;
949
1096
  if (isSelfLinkingRecord) {
@@ -958,7 +1105,7 @@ async function validateDispatchShape(dispatch, name = "task") {
958
1105
  );
959
1106
  }
960
1107
  if (turn.turnRef !== null) {
961
- turn.turnRef = normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
1108
+ turn.turnRef = normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
962
1109
  ? normalizeExecutorTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`)
963
1110
  : normalizeTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`);
964
1111
  }
@@ -969,6 +1116,9 @@ async function validateDispatchShape(dispatch, name = "task") {
969
1116
  ...turn.result,
970
1117
  turnRef: turn.turnRef,
971
1118
  turnId: turn.turnId,
1119
+ ...(turn.provenance?.kind === "dashboard_manual"
1120
+ ? { provenance: turn.provenance }
1121
+ : {}),
972
1122
  }]);
973
1123
  normalized.lastResult = normalized.results.at(-1) ?? null;
974
1124
  normalized.latestTurn = normalized.turns.at(-1) ?? null;
@@ -984,17 +1134,25 @@ async function validateDispatchShape(dispatch, name = "task") {
984
1134
  const isCreationFailure = normalized.status === "failed"
985
1135
  && normalized.summary !== null
986
1136
  && (
987
- normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1137
+ normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
988
1138
  || normalized.turnRef !== null
989
1139
  )
990
1140
  && normalized.turnId === null
991
1141
  && normalized.lastResult?.status === "failed"
992
1142
  && normalized.lastResult.turnId === null
993
1143
  && normalized.updatedBy === "mcp";
994
- if (!isLinkPending && !isCreationFailure) {
1144
+ const isManualTerminal = normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
1145
+ && RESULT_STATUSES.has(normalized.status)
1146
+ && normalized.summary !== null
1147
+ && normalized.turnId === null
1148
+ && normalized.lastResult?.status === normalized.status
1149
+ && normalized.lastResult.turnId === null
1150
+ && normalized.updatedBy === "dashboard"
1151
+ && normalized.latestTurn?.provenance?.kind === "dashboard_manual";
1152
+ if (!isLinkPending && !isCreationFailure && !isManualTerminal) {
995
1153
  throw new Error(`${name} has an invalid unlinked lifecycle state`);
996
1154
  }
997
- if (isCreationFailure && normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
1155
+ if ((isCreationFailure || isManualTerminal) && normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION) {
998
1156
  normalized.turnRef = normalizeExecutorTurnRef(normalized.turnRef, `${name}.turnRef`);
999
1157
  for (const [index, turn] of normalized.turns.entries()) {
1000
1158
  turn.turnRef = normalizeExecutorTurnRef(
@@ -1005,12 +1163,12 @@ async function validateDispatchShape(dispatch, name = "task") {
1005
1163
  }
1006
1164
  } else {
1007
1165
  if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
1008
- if (normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
1166
+ if (normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION) {
1009
1167
  throw new Error(`${name}.turnId is required for a linked semantic state`);
1010
1168
  }
1011
1169
  }
1012
1170
  const resultWithoutTurn = normalized.results.findIndex((result) => (
1013
- normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION && result.turnId === null
1171
+ normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION && result.turnId === null
1014
1172
  ));
1015
1173
  if (resultWithoutTurn !== -1) {
1016
1174
  throw new Error(`${name}.results[${resultWithoutTurn}].turnId is required for a linked result`);
@@ -1051,7 +1209,7 @@ async function validateDispatchShape(dispatch, name = "task") {
1051
1209
  if (
1052
1210
  normalized.status === "working"
1053
1211
  && isSelfLinkingRecord
1054
- && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1212
+ && normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
1055
1213
  && normalized.lastResult?.turnId != null
1056
1214
  && (normalized.turnId === null || normalized.turnId <= normalized.lastResult.turnId)
1057
1215
  ) {
@@ -1060,7 +1218,7 @@ async function validateDispatchShape(dispatch, name = "task") {
1060
1218
  }
1061
1219
  if (
1062
1220
  normalized.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
1063
- && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1221
+ && normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
1064
1222
  && normalized.turnId !== normalized.turnRef
1065
1223
  ) {
1066
1224
  throw new Error(`${name}.turnId must match the latest legacy turn identity`);
@@ -1121,7 +1279,7 @@ async function validateDispatchShape(dispatch, name = "task") {
1121
1279
  throw new Error(`${name}.turns must be ordered by startedAt`);
1122
1280
  }
1123
1281
  if (
1124
- normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION
1282
+ normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
1125
1283
  && isSelfLinkingRecord
1126
1284
  && index > 0
1127
1285
  && turn.turnId <= normalized.turns[index - 1].turnId
@@ -1129,7 +1287,7 @@ async function validateDispatchShape(dispatch, name = "task") {
1129
1287
  throw new Error(`${name}.turns must be ordered by turnId`);
1130
1288
  }
1131
1289
  if (
1132
- normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
1290
+ normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
1133
1291
  && isSelfLinkingRecord
1134
1292
  && turn.turnId !== null
1135
1293
  ) {
@@ -1188,6 +1346,7 @@ async function parseDispatchRecordsUnlocked(root, content) {
1188
1346
  }
1189
1347
  const ids = new Set();
1190
1348
  const threadIds = new Set();
1349
+ const manualActionIds = new Set();
1191
1350
  for (const { normalized: dispatch } of records) {
1192
1351
  if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
1193
1352
  const threadKey = dispatch.threadId === null ? null : threadIdentityKey(dispatch.threadId);
@@ -1196,6 +1355,13 @@ async function parseDispatchRecordsUnlocked(root, content) {
1196
1355
  }
1197
1356
  ids.add(dispatch.id);
1198
1357
  if (threadKey !== null) threadIds.add(threadKey);
1358
+ for (const turn of dispatch.turns) {
1359
+ if (turn.provenance?.kind !== "dashboard_manual") continue;
1360
+ if (manualActionIds.has(turn.provenance.actionId)) {
1361
+ throw new Error(`duplicate manual transition actionId: ${turn.provenance.actionId}`);
1362
+ }
1363
+ manualActionIds.add(turn.provenance.actionId);
1364
+ }
1199
1365
  }
1200
1366
  return records;
1201
1367
  }
@@ -1241,6 +1407,7 @@ function currentSchemaTask(dispatch, patch = {}) {
1241
1407
  const sourceTurns = patch.turns ?? dispatch.turns ?? [];
1242
1408
  const turns = sourceTurns.map((turn) => ({
1243
1409
  ...turn,
1410
+ provenance: turn.provenance ?? { kind: "legacy" },
1244
1411
  turnRef: turn.turnRef === null || turn.turnRef === undefined
1245
1412
  ? (turn.turnId === null || turn.turnId === undefined ? randomUUID() : turn.turnId)
1246
1413
  : turn.turnRef,
@@ -1305,7 +1472,7 @@ export async function migrateTaskLog(workspaceRoot, {
1305
1472
  const timestamp = requireTimestamp(now(), "migration timestamp")
1306
1473
  .replaceAll(":", "-")
1307
1474
  .replaceAll(".", "-");
1308
- const backupPath = `${dispatchPath}.pre-v9-${timestamp}-${randomUUID()}.bak`;
1475
+ const backupPath = `${dispatchPath}.pre-v10-${timestamp}-${randomUUID()}.bak`;
1309
1476
  await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
1310
1477
  if (await readFile(backupPath, "utf8") !== original) {
1311
1478
  throw new Error(`task log backup validation failed: ${backupPath}`);
@@ -1491,6 +1658,64 @@ function normalizeTaskStateInput(input, { allowWorking }) {
1491
1658
  return { id, threadId, turnRef, turnId, status, summary, requestSummary };
1492
1659
  }
1493
1660
 
1661
+ function manualTransitionSummary(status) {
1662
+ return `Manually marked ${status} from the TaskChef dashboard.`;
1663
+ }
1664
+
1665
+ function manualTransitionRequestSummary(fromStatus, toStatus) {
1666
+ return `Manual dashboard transition from ${fromStatus} to ${toStatus}.`;
1667
+ }
1668
+
1669
+ function taskOperationError(code, message, task = null) {
1670
+ const error = new Error(message);
1671
+ error.code = code;
1672
+ error.task = task;
1673
+ return error;
1674
+ }
1675
+
1676
+ function normalizeManualTransitionInput(input) {
1677
+ requireExactFields(input, MANUAL_TRANSITION_FIELDS, "manual transition");
1678
+ requireExactFields(input.expected, MANUAL_EXPECTED_FIELDS, "manual transition.expected");
1679
+ const expectedTurnRef = input.expected.turnRef === null
1680
+ ? null
1681
+ : normalizeTurnRef(input.expected.turnRef, "manual transition.expected.turnRef");
1682
+ const expectedThreadId = input.expected.threadId === null
1683
+ ? null
1684
+ : normalizeDurableThreadId(
1685
+ input.expected.threadId,
1686
+ "manual transition.expected.threadId",
1687
+ );
1688
+ return {
1689
+ actionId: normalizeExecutorTurnRef(input.actionId, "manual transition.actionId"),
1690
+ expected: {
1691
+ status: requireEnum(
1692
+ input.expected.status,
1693
+ TASK_STATUSES,
1694
+ "manual transition.expected.status",
1695
+ ),
1696
+ turnRef: expectedTurnRef,
1697
+ threadId: expectedThreadId,
1698
+ updatedAt: requireTimestamp(
1699
+ input.expected.updatedAt,
1700
+ "manual transition.expected.updatedAt",
1701
+ ),
1702
+ },
1703
+ targetStatus: requireEnum(
1704
+ input.targetStatus,
1705
+ MANUAL_TARGET_STATUSES,
1706
+ "manual transition.targetStatus",
1707
+ ),
1708
+ };
1709
+ }
1710
+
1711
+ function sameManualAction(provenance, input) {
1712
+ return provenance.fromStatus === input.expected.status
1713
+ && provenance.toStatus === input.targetStatus
1714
+ && provenance.expectedTurnRef === input.expected.turnRef
1715
+ && provenance.expectedThreadId === input.expected.threadId
1716
+ && provenance.expectedUpdatedAt === input.expected.updatedAt;
1717
+ }
1718
+
1494
1719
  function sameLastResult(lastResult, { status, summary, turnRef, turnId }) {
1495
1720
  return lastResult !== null
1496
1721
  && lastResult.status === status
@@ -1652,6 +1877,7 @@ async function reportTaskStateInternal(
1652
1877
  requestSummary,
1653
1878
  startedAt: updatedAt,
1654
1879
  result: null,
1880
+ provenance: { kind: "mcp" },
1655
1881
  }];
1656
1882
  const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
1657
1883
  status,
@@ -1706,6 +1932,7 @@ async function reportTaskStateInternal(
1706
1932
  requestSummary: null,
1707
1933
  startedAt: updatedAt,
1708
1934
  result: turnResult,
1935
+ provenance: { kind: "mcp" },
1709
1936
  }];
1710
1937
  } else {
1711
1938
  turns = dispatch.turns.map((turn, turnIndex) => turnIndex === currentTurnIndex
@@ -1741,6 +1968,127 @@ export async function reportTaskResult(workspaceRoot, input, options = {}) {
1741
1968
  });
1742
1969
  }
1743
1970
 
1971
+ export async function manuallyTransitionTask(
1972
+ workspaceRoot,
1973
+ taskId,
1974
+ input,
1975
+ { now, writeTaskLines = writeDispatchLinesAtomic } = {},
1976
+ ) {
1977
+ const id = requireSafeId(taskId, "taskId");
1978
+ let normalizedInput;
1979
+ try {
1980
+ normalizedInput = normalizeManualTransitionInput(input);
1981
+ } catch {
1982
+ throw taskOperationError("invalid_request", "manual transition request is invalid");
1983
+ }
1984
+ const root = await realpath(path.resolve(workspaceRoot));
1985
+ return withWorkspaceLock(root, async () => {
1986
+ const records = await readDispatchRecordsUnlocked(root);
1987
+ const dispatches = records.map((record) => record.normalized);
1988
+ const index = dispatches.findIndex((dispatch) => dispatch.id === id);
1989
+ if (index === -1) {
1990
+ throw taskOperationError("task_not_found", `task not found: ${id}`);
1991
+ }
1992
+ const actionOwner = dispatches.find((dispatch) => dispatch.turns.some((turn) => (
1993
+ turn.provenance?.kind === "dashboard_manual"
1994
+ && turn.provenance.actionId === normalizedInput.actionId
1995
+ )));
1996
+ if (actionOwner) {
1997
+ const manualTurn = actionOwner.turns.find((turn) => (
1998
+ turn.provenance?.kind === "dashboard_manual"
1999
+ && turn.provenance.actionId === normalizedInput.actionId
2000
+ ));
2001
+ if (actionOwner.id !== id || !sameManualAction(manualTurn.provenance, normalizedInput)) {
2002
+ throw taskOperationError(
2003
+ "idempotency_conflict",
2004
+ `manual transition actionId is already used: ${normalizedInput.actionId}`,
2005
+ dispatches[index],
2006
+ );
2007
+ }
2008
+ return { task: dispatches[index], idempotent: true };
2009
+ }
2010
+
2011
+ const dispatch = dispatches[index];
2012
+ if (!MANUAL_TRANSITION_STATUSES.has(dispatch.status)) {
2013
+ throw taskOperationError(
2014
+ "invalid_transition",
2015
+ `task status cannot be changed manually from ${dispatch.status}: ${id}`,
2016
+ dispatch,
2017
+ );
2018
+ }
2019
+ const expected = normalizedInput.expected;
2020
+ if (
2021
+ dispatch.status !== expected.status
2022
+ || dispatch.turnRef !== expected.turnRef
2023
+ || dispatch.threadId !== expected.threadId
2024
+ || dispatch.updatedAt !== expected.updatedAt
2025
+ ) {
2026
+ throw taskOperationError(
2027
+ "stale_task",
2028
+ `task changed after the dashboard loaded it: ${id}`,
2029
+ dispatch,
2030
+ );
2031
+ }
2032
+
2033
+ const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
2034
+ const interruptedTurns = (
2035
+ dispatch.status === "working"
2036
+ && dispatch.latestTurn?.result === null
2037
+ )
2038
+ ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
2039
+ ? {
2040
+ ...turn,
2041
+ result: {
2042
+ status: "interrupted",
2043
+ summary: INTERRUPTED_TURN_SUMMARY,
2044
+ updatedAt,
2045
+ },
2046
+ }
2047
+ : turn)
2048
+ : dispatch.turns;
2049
+ const turnRef = randomUUID();
2050
+ const provenance = {
2051
+ kind: "dashboard_manual",
2052
+ actionId: normalizedInput.actionId,
2053
+ fromStatus: dispatch.status,
2054
+ toStatus: normalizedInput.targetStatus,
2055
+ expectedTurnRef: expected.turnRef,
2056
+ expectedThreadId: expected.threadId,
2057
+ expectedUpdatedAt: expected.updatedAt,
2058
+ };
2059
+ const summary = manualTransitionSummary(normalizedInput.targetStatus);
2060
+ const turns = [...interruptedTurns, {
2061
+ turnRef,
2062
+ turnId: null,
2063
+ requestSummary: manualTransitionRequestSummary(
2064
+ dispatch.status,
2065
+ normalizedInput.targetStatus,
2066
+ ),
2067
+ startedAt: updatedAt,
2068
+ result: {
2069
+ status: normalizedInput.targetStatus,
2070
+ summary,
2071
+ updatedAt,
2072
+ },
2073
+ provenance,
2074
+ }];
2075
+ const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
2076
+ status: normalizedInput.targetStatus,
2077
+ summary,
2078
+ turnRef,
2079
+ turnId: null,
2080
+ updatedAt,
2081
+ updatedBy: "dashboard",
2082
+ turns,
2083
+ }));
2084
+ const lines = records.map((record, recordIndex) => recordIndex === index
2085
+ ? dispatchLineWithState(updated, {})
2086
+ : record.line);
2087
+ await writeTaskLines(root, lines);
2088
+ return { task: updated, idempotent: false };
2089
+ });
2090
+ }
2091
+
1744
2092
  export async function readTask(workspaceRoot, taskId) {
1745
2093
  const id = requireSafeId(taskId, "taskId");
1746
2094
  const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);