taskchef 7.20.0 → 7.21.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/.codex-plugin/plugin.json +1 -1
- package/README.md +26 -11
- package/docs/spec.md +57 -15
- package/docs/workflows.md +11 -9
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/dashboard/actions.js +81 -0
- package/src/dashboard/app.js +220 -13
- package/src/dashboard/index.html +13 -4
- package/src/dashboard/state.js +60 -3
- package/src/dashboard/styles.css +17 -4
- package/src/dashboard.js +116 -0
- package/src/mcp.js +19 -1
- package/src/usage-tracker.js +68 -13
- package/src/workspace.js +368 -20
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 =
|
|
42
|
-
const PREVIOUS_TASK_SCHEMA_VERSION =
|
|
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
|
|
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
|
|
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
|
|
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 >=
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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.
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
) {
|
|
@@ -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-
|
|
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);
|