taskchef 7.14.1 → 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/.codex-plugin/plugin.json +1 -1
- package/README.md +10 -5
- package/docs/firstmate-taskchef-comparison.md +5 -5
- package/docs/spec.md +44 -29
- package/docs/workflows.md +38 -29
- package/package.json +1 -1
- package/skills/taskchef-delegate/SKILL.md +4 -2
- package/skills/taskchef-executor/SKILL.md +18 -12
- package/src/cli.js +8 -3
- package/src/dashboard/app.js +23 -18
- package/src/dashboard/github-links.js +160 -24
- package/src/dashboard/state.js +61 -9
- package/src/dashboard.js +5 -0
- package/src/delegation.js +18 -3
- package/src/mcp.js +9 -2
- package/src/workspace.js +282 -109
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 =
|
|
42
|
-
const PREVIOUS_TASK_SCHEMA_VERSION =
|
|
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
|
|
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
|
|
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
|
|
724
|
+
dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
|
|
705
725
|
? DISPATCH_FIELDS
|
|
706
|
-
: dispatch.schemaVersion
|
|
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
|
|
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:
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 >=
|
|
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 <
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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
|
|
971
|
-
|
|
1070
|
+
const seenTurnRefs = new Set();
|
|
1071
|
+
let lastNativeTurnRef = null;
|
|
972
1072
|
for (const [index, turn] of normalized.turns.entries()) {
|
|
973
|
-
const turnKey = turn.
|
|
974
|
-
const
|
|
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 (
|
|
984
|
-
throw new Error(`${name}.turns contains duplicate
|
|
1082
|
+
if (seenTurnRefs.has(turnKey) && !isLegacyOpaqueTurnReuse) {
|
|
1083
|
+
throw new Error(`${name}.turns contains duplicate turnRef: ${turn.turnRef ?? "null"}`);
|
|
985
1084
|
}
|
|
986
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
1362
|
-
|
|
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 (
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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 (
|
|
1392
|
-
throw new Error(`task state
|
|
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" &&
|
|
1397
|
-
const recordedTurn = dispatch.turns.
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
)
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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.
|
|
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, {
|
|
1484
|
-
|
|
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.
|
|
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.
|
|
1492
|
-
if (!matchesWorkingTurn &&
|
|
1493
|
-
|
|
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 (
|
|
1501
|
-
|
|
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.
|
|
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
|
},
|