taskchef 5.12.0 → 6.1.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 +5 -2
- package/BACKLOG.md +6 -15
- package/README.md +30 -40
- package/SPEC.md +92 -369
- package/assets/e2e-benchmark-example.json +37 -33
- package/assets/taskchef-dark.svg +9 -0
- package/assets/taskchef.svg +9 -0
- package/docs/delegation-design.md +219 -170
- package/index.js +3 -11
- package/package.json +2 -2
- package/scripts/e2e-benchmark.js +101 -412
- package/skills/taskchef-bootstrap/SKILL.md +2 -3
- package/skills/taskchef-delegate/SKILL.md +60 -138
- package/skills/taskchef-report/SKILL.md +12 -8
- package/src/cli.js +2 -1
- package/src/delegation.js +24 -201
- package/src/mcp.js +10 -10
- package/src/workspace.js +128 -61
- package/hooks/hooks.json +0 -18
- package/hooks/taskchef-initial-prompt.js +0 -17
- package/src/hook.js +0 -96
package/src/mcp.js
CHANGED
|
@@ -2,9 +2,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
4
|
prepareDispatch,
|
|
5
|
+
linkTask,
|
|
5
6
|
recordTask,
|
|
6
7
|
reportTaskResult,
|
|
7
|
-
resolveTask,
|
|
8
8
|
} from "./workspace.js";
|
|
9
9
|
import { parseTaskChefMarker } from "./delegation.js";
|
|
10
10
|
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
@@ -54,13 +54,13 @@ export function createTaskChefMcpServer({
|
|
|
54
54
|
prepare = prepareDispatch,
|
|
55
55
|
record = recordTask,
|
|
56
56
|
reportResult = reportTaskResult,
|
|
57
|
-
|
|
57
|
+
link = linkTask,
|
|
58
58
|
} = {}) {
|
|
59
59
|
const server = new McpServer(
|
|
60
60
|
{ name: "taskchef", version: "1.0.0" },
|
|
61
61
|
{
|
|
62
62
|
instructions:
|
|
63
|
-
"Prepare with prepare_dispatch, call record_task before creating the Codex task, then create it natively
|
|
63
|
+
"Prepare with prepare_dispatch, call record_task before creating the Codex task, then create it natively and return immediately. The executor must call link_task before other work and report_result before ending.",
|
|
64
64
|
},
|
|
65
65
|
);
|
|
66
66
|
|
|
@@ -89,13 +89,13 @@ export function createTaskChefMcpServer({
|
|
|
89
89
|
{
|
|
90
90
|
title: "Record TaskChef task",
|
|
91
91
|
description:
|
|
92
|
-
"Atomically append one prepared TaskChef task before creating its Codex executor. Pass the exact marked instruction and
|
|
92
|
+
"Atomically append one prepared TaskChef task before creating its Codex executor. Pass the exact marked instruction and null threadId; only the executor may self-link it.",
|
|
93
93
|
inputSchema: {
|
|
94
94
|
id: z.string().min(1),
|
|
95
95
|
project: z.string().min(1),
|
|
96
96
|
title: z.string().min(1),
|
|
97
97
|
instruction: z.string().min(1),
|
|
98
|
-
threadId: z.
|
|
98
|
+
threadId: z.null(),
|
|
99
99
|
},
|
|
100
100
|
outputSchema: { task: taskSchema },
|
|
101
101
|
annotations: {
|
|
@@ -114,11 +114,11 @@ export function createTaskChefMcpServer({
|
|
|
114
114
|
);
|
|
115
115
|
|
|
116
116
|
server.registerTool(
|
|
117
|
-
"
|
|
117
|
+
"link_task",
|
|
118
118
|
{
|
|
119
|
-
title: "
|
|
119
|
+
title: "Link TaskChef executor",
|
|
120
120
|
description:
|
|
121
|
-
"
|
|
121
|
+
"Register this executor's asserted durable Codex thread ID. The atomic one-way transition is idempotent and rejects conflicts or thread reuse.",
|
|
122
122
|
inputSchema: {
|
|
123
123
|
taskId: z.string().min(1),
|
|
124
124
|
threadId: z.string().min(1),
|
|
@@ -131,8 +131,8 @@ export function createTaskChefMcpServer({
|
|
|
131
131
|
},
|
|
132
132
|
},
|
|
133
133
|
async ({ taskId, threadId }) => {
|
|
134
|
-
const task = await
|
|
135
|
-
return toolResult("task", task, `
|
|
134
|
+
const task = await link(workspace, taskId, threadId);
|
|
135
|
+
return toolResult("task", task, `Linked TaskChef task ${task.id}.`);
|
|
136
136
|
},
|
|
137
137
|
);
|
|
138
138
|
|
package/src/workspace.js
CHANGED
|
@@ -20,6 +20,7 @@ import path from "node:path";
|
|
|
20
20
|
import { promisify } from "node:util";
|
|
21
21
|
import lockfile from "proper-lockfile";
|
|
22
22
|
import {
|
|
23
|
+
normalizeCodexThreadId,
|
|
23
24
|
normalizeDurableThreadId,
|
|
24
25
|
parseTaskChefMarker,
|
|
25
26
|
taskChefMarker,
|
|
@@ -47,7 +48,8 @@ const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
|
47
48
|
const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
|
|
48
49
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
49
50
|
const CURRENT_CONFIG_SCHEMA_VERSION = 2;
|
|
50
|
-
const CURRENT_TASK_SCHEMA_VERSION =
|
|
51
|
+
const CURRENT_TASK_SCHEMA_VERSION = 4;
|
|
52
|
+
const PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION = 3;
|
|
51
53
|
const LEGACY_SCHEMA_VERSION = 1;
|
|
52
54
|
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
53
55
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
@@ -786,6 +788,7 @@ async function validateDispatchShape(
|
|
|
786
788
|
const supportedVersions = [
|
|
787
789
|
LEGACY_SCHEMA_VERSION,
|
|
788
790
|
PREVIOUS_SCHEMA_VERSION,
|
|
791
|
+
PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION,
|
|
789
792
|
CURRENT_TASK_SCHEMA_VERSION,
|
|
790
793
|
];
|
|
791
794
|
if (!supportedVersions.includes(dispatch?.schemaVersion)) {
|
|
@@ -793,7 +796,7 @@ async function validateDispatchShape(
|
|
|
793
796
|
}
|
|
794
797
|
requireExactFields(
|
|
795
798
|
dispatch,
|
|
796
|
-
dispatch.schemaVersion
|
|
799
|
+
dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
797
800
|
? DISPATCH_FIELDS
|
|
798
801
|
: LEGACY_DISPATCH_FIELDS,
|
|
799
802
|
name,
|
|
@@ -804,7 +807,7 @@ async function validateDispatchShape(
|
|
|
804
807
|
allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
805
808
|
});
|
|
806
809
|
const normalized = {
|
|
807
|
-
schemaVersion:
|
|
810
|
+
schemaVersion: dispatch.schemaVersion,
|
|
808
811
|
id,
|
|
809
812
|
project,
|
|
810
813
|
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
@@ -813,21 +816,21 @@ async function validateDispatchShape(
|
|
|
813
816
|
? null
|
|
814
817
|
: normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
|
|
815
818
|
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
816
|
-
status: dispatch.schemaVersion
|
|
819
|
+
status: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
817
820
|
? requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`)
|
|
818
821
|
: null,
|
|
819
|
-
summary: dispatch.schemaVersion
|
|
822
|
+
summary: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
820
823
|
? optionalString(dispatch.summary, `${name}.summary`, {
|
|
821
824
|
maxLength: MAX_RESULT_SUMMARY_LENGTH,
|
|
822
825
|
})
|
|
823
826
|
: null,
|
|
824
|
-
turnId: dispatch.schemaVersion
|
|
827
|
+
turnId: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
825
828
|
? optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 })
|
|
826
829
|
: null,
|
|
827
|
-
updatedAt: dispatch.schemaVersion
|
|
830
|
+
updatedAt: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
828
831
|
? requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`)
|
|
829
832
|
: null,
|
|
830
|
-
updatedBy: dispatch.schemaVersion
|
|
833
|
+
updatedBy: dispatch.schemaVersion >= PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION
|
|
831
834
|
? requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`)
|
|
832
835
|
: null,
|
|
833
836
|
};
|
|
@@ -881,15 +884,24 @@ async function parseDispatchRecordsUnlocked(root, content) {
|
|
|
881
884
|
const threadIds = new Set();
|
|
882
885
|
for (const { normalized: dispatch } of records) {
|
|
883
886
|
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
884
|
-
|
|
887
|
+
const threadKey = dispatch.threadId === null ? null : threadIdentityKey(dispatch.threadId);
|
|
888
|
+
if (threadKey !== null && threadIds.has(threadKey)) {
|
|
885
889
|
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
886
890
|
}
|
|
887
891
|
ids.add(dispatch.id);
|
|
888
|
-
if (
|
|
892
|
+
if (threadKey !== null) threadIds.add(threadKey);
|
|
889
893
|
}
|
|
890
894
|
return records;
|
|
891
895
|
}
|
|
892
896
|
|
|
897
|
+
function threadIdentityKey(threadId) {
|
|
898
|
+
try {
|
|
899
|
+
return `codex:${normalizeCodexThreadId(threadId)}`;
|
|
900
|
+
} catch {
|
|
901
|
+
return `opaque:${threadId}`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
893
905
|
async function readDispatchRecordsUnlocked(root) {
|
|
894
906
|
const filePath = path.join(root, DISPATCH_FILE_NAME);
|
|
895
907
|
if (!(await managedRegularFileExists(filePath))) {
|
|
@@ -942,10 +954,19 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
942
954
|
}
|
|
943
955
|
if (
|
|
944
956
|
dispatch.threadId !== null &&
|
|
945
|
-
existing.some((item) =>
|
|
957
|
+
existing.some((item) => (
|
|
958
|
+
item.threadId !== null
|
|
959
|
+
&& threadIdentityKey(item.threadId) === threadIdentityKey(dispatch.threadId)
|
|
960
|
+
))
|
|
946
961
|
) {
|
|
947
962
|
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
948
963
|
}
|
|
964
|
+
if (
|
|
965
|
+
dispatch.threadId !== null
|
|
966
|
+
&& parseTaskChefMarker(dispatch.instruction) === dispatch.id
|
|
967
|
+
) {
|
|
968
|
+
throw new Error("a marked self-linking task must be recorded with threadId: null");
|
|
969
|
+
}
|
|
949
970
|
await appendDispatchesAtomic(root, [dispatch]);
|
|
950
971
|
return dispatch;
|
|
951
972
|
});
|
|
@@ -960,6 +981,10 @@ export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {})
|
|
|
960
981
|
const dispatches = records.map((record) => record.normalized);
|
|
961
982
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
962
983
|
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
984
|
+
const currentRecord = records[index];
|
|
985
|
+
if (currentRecord.raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION) {
|
|
986
|
+
throw new Error(`task resolve is only available for legacy pre-self-linking records: ${id}`);
|
|
987
|
+
}
|
|
963
988
|
const dispatch = dispatches[index];
|
|
964
989
|
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
965
990
|
if (dispatch.threadId !== null) {
|
|
@@ -968,86 +993,101 @@ export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {})
|
|
|
968
993
|
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
969
994
|
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
970
995
|
}
|
|
971
|
-
if (dispatches.some((item) =>
|
|
996
|
+
if (dispatches.some((item) => (
|
|
997
|
+
item.threadId !== null
|
|
998
|
+
&& threadIdentityKey(item.threadId) === threadIdentityKey(durableThreadId)
|
|
999
|
+
))) {
|
|
972
1000
|
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
973
1001
|
}
|
|
974
|
-
const
|
|
975
|
-
const
|
|
976
|
-
?
|
|
977
|
-
...
|
|
1002
|
+
const statefulLegacy = currentRecord.raw.schemaVersion === PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
|
|
1003
|
+
const rawResolved = statefulLegacy
|
|
1004
|
+
? {
|
|
1005
|
+
...currentRecord.raw,
|
|
978
1006
|
threadId: durableThreadId,
|
|
979
1007
|
updatedAt: now ?? new Date().toISOString(),
|
|
980
1008
|
updatedBy: "dispatcher",
|
|
981
|
-
}
|
|
1009
|
+
}
|
|
1010
|
+
: { ...currentRecord.raw, threadId: durableThreadId };
|
|
1011
|
+
const resolved = statefulLegacy
|
|
1012
|
+
? await validateDispatchShape(rawResolved)
|
|
982
1013
|
: { ...dispatch, threadId: durableThreadId };
|
|
983
1014
|
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
984
|
-
?
|
|
985
|
-
? dispatchLineWithState(resolved, {})
|
|
986
|
-
: JSON.stringify({ ...record.raw, threadId: durableThreadId })
|
|
1015
|
+
? JSON.stringify(rawResolved)
|
|
987
1016
|
: record.line);
|
|
988
1017
|
await writeDispatchLinesAtomic(root, lines);
|
|
989
1018
|
return resolved;
|
|
990
1019
|
});
|
|
991
1020
|
}
|
|
992
1021
|
|
|
993
|
-
function
|
|
994
|
-
return JSON.stringify({
|
|
995
|
-
...dispatch,
|
|
996
|
-
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
997
|
-
...patch,
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
export async function startTaskFromHook(
|
|
1002
|
-
workspaceRoot,
|
|
1003
|
-
taskId,
|
|
1004
|
-
threadId,
|
|
1005
|
-
turnId,
|
|
1006
|
-
{ now } = {},
|
|
1007
|
-
) {
|
|
1022
|
+
export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
|
|
1008
1023
|
const id = requireSafeId(taskId, "taskId");
|
|
1009
|
-
const durableThreadId =
|
|
1010
|
-
const currentTurnId = optionalString(turnId, "turnId", { maxLength: 256 });
|
|
1024
|
+
const durableThreadId = normalizeCodexThreadId(threadId);
|
|
1011
1025
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
1012
1026
|
return withWorkspaceLock(root, async () => {
|
|
1013
1027
|
const records = await readDispatchRecordsUnlocked(root);
|
|
1014
1028
|
const dispatches = records.map((record) => record.normalized);
|
|
1015
1029
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
1016
1030
|
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
1031
|
+
const currentRecord = records[index];
|
|
1032
|
+
if (currentRecord.raw.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
|
|
1033
|
+
throw new Error(`link_task accepts only self-linking task records: ${id}`);
|
|
1034
|
+
}
|
|
1017
1035
|
const dispatch = dispatches[index];
|
|
1018
|
-
|
|
1019
|
-
|
|
1036
|
+
const sameIdentity = dispatch.threadId?.toLowerCase() === durableThreadId;
|
|
1037
|
+
if (sameIdentity && dispatch.updatedBy === "mcp") {
|
|
1038
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
1039
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
1040
|
+
}
|
|
1041
|
+
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
1042
|
+
const canonical = await validateDispatchShape({
|
|
1043
|
+
...dispatch,
|
|
1044
|
+
threadId: durableThreadId,
|
|
1045
|
+
updatedAt: now ?? new Date().toISOString(),
|
|
1046
|
+
updatedBy: "mcp",
|
|
1047
|
+
});
|
|
1048
|
+
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1049
|
+
? dispatchLineWithState(canonical, {})
|
|
1050
|
+
: record.line);
|
|
1051
|
+
await writeDispatchLinesAtomic(root, lines);
|
|
1052
|
+
return canonical;
|
|
1020
1053
|
}
|
|
1021
|
-
if (dispatch.threadId !== null && dispatch.
|
|
1054
|
+
if (dispatch.threadId !== null && dispatch.updatedBy === "mcp") {
|
|
1022
1055
|
throw new Error(`task already has a different threadId: ${id}`);
|
|
1023
1056
|
}
|
|
1024
1057
|
if (
|
|
1025
|
-
dispatch.threadId
|
|
1026
|
-
|
|
1058
|
+
dispatch.threadId !== null
|
|
1059
|
+
|| dispatch.status !== "working"
|
|
1060
|
+
|| dispatch.updatedBy !== "dispatcher"
|
|
1027
1061
|
) {
|
|
1028
|
-
throw new Error(`
|
|
1062
|
+
throw new Error(`task is not an eligible link-pending dispatcher record: ${id}`);
|
|
1029
1063
|
}
|
|
1030
|
-
if (dispatch.
|
|
1031
|
-
|
|
1064
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
1065
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
1032
1066
|
}
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1067
|
+
if (dispatches.some((item) => (
|
|
1068
|
+
item.id !== id && item.threadId?.toLowerCase() === durableThreadId
|
|
1069
|
+
))) {
|
|
1070
|
+
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
1035
1071
|
}
|
|
1036
|
-
const
|
|
1037
|
-
const started = await validateDispatchShape({
|
|
1072
|
+
const linked = await validateDispatchShape({
|
|
1038
1073
|
...dispatch,
|
|
1039
1074
|
threadId: durableThreadId,
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
turnId: currentTurnId,
|
|
1043
|
-
updatedAt,
|
|
1044
|
-
updatedBy: "hook",
|
|
1075
|
+
updatedAt: now ?? new Date().toISOString(),
|
|
1076
|
+
updatedBy: "mcp",
|
|
1045
1077
|
});
|
|
1046
1078
|
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1047
|
-
? dispatchLineWithState(
|
|
1079
|
+
? dispatchLineWithState(linked, {})
|
|
1048
1080
|
: record.line);
|
|
1049
1081
|
await writeDispatchLinesAtomic(root, lines);
|
|
1050
|
-
return
|
|
1082
|
+
return linked;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function dispatchLineWithState(dispatch, patch) {
|
|
1087
|
+
return JSON.stringify({
|
|
1088
|
+
...dispatch,
|
|
1089
|
+
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|
|
1090
|
+
...patch,
|
|
1051
1091
|
});
|
|
1052
1092
|
}
|
|
1053
1093
|
|
|
@@ -1074,28 +1114,55 @@ export async function reportTaskResult(workspaceRoot, input, { now } = {}) {
|
|
|
1074
1114
|
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
1075
1115
|
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
1076
1116
|
const dispatch = dispatches[index];
|
|
1117
|
+
const isSelfLinkingJourney = (
|
|
1118
|
+
records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
|
|
1119
|
+
&& dispatch.threadId !== null
|
|
1120
|
+
&& parseTaskChefMarker(dispatch.instruction) === dispatch.id
|
|
1121
|
+
);
|
|
1122
|
+
let resultTurnId = turnId;
|
|
1123
|
+
if (isSelfLinkingJourney) {
|
|
1124
|
+
if (dispatch.updatedBy === "dispatcher") {
|
|
1125
|
+
throw new Error(`self-linking task must link before reporting a result: ${id}`);
|
|
1126
|
+
}
|
|
1127
|
+
resultTurnId = normalizeCodexThreadId(turnId, "turnId");
|
|
1128
|
+
}
|
|
1077
1129
|
if (dispatch.threadId === null) {
|
|
1078
|
-
if (threadId !== null ||
|
|
1130
|
+
if (threadId !== null || resultTurnId !== null || status !== "failed") {
|
|
1079
1131
|
throw new Error(`task without a durable threadId accepts only failed with null thread/turn IDs: ${id}`);
|
|
1080
1132
|
}
|
|
1081
1133
|
} else {
|
|
1082
|
-
if (threadId !== dispatch.threadId) {
|
|
1134
|
+
if (threadIdentityKey(threadId) !== threadIdentityKey(dispatch.threadId)) {
|
|
1083
1135
|
throw new Error(`task result threadId does not match recorded threadId: ${id}`);
|
|
1084
1136
|
}
|
|
1085
|
-
if (
|
|
1137
|
+
if (resultTurnId === null) {
|
|
1086
1138
|
throw new Error(`task result turnId is required for a linked task: ${id}`);
|
|
1087
1139
|
}
|
|
1088
1140
|
}
|
|
1141
|
+
if (dispatch.updatedBy === "mcp" && resultTurnId === dispatch.turnId) {
|
|
1142
|
+
if (status === dispatch.status && summary === dispatch.summary) return dispatch;
|
|
1143
|
+
throw new Error(`task turn already has a different semantic result: ${id}`);
|
|
1144
|
+
}
|
|
1145
|
+
if (
|
|
1146
|
+
isSelfLinkingJourney
|
|
1147
|
+
&& dispatch.turnId !== null
|
|
1148
|
+
&& resultTurnId <= dispatch.turnId
|
|
1149
|
+
) {
|
|
1150
|
+
throw new Error(`task result turnId must be newer than the stored turnId: ${id}`);
|
|
1151
|
+
}
|
|
1152
|
+
const persistedSchemaVersion = records[index].raw.schemaVersion >= CURRENT_TASK_SCHEMA_VERSION
|
|
1153
|
+
? CURRENT_TASK_SCHEMA_VERSION
|
|
1154
|
+
: PREVIOUS_STATEFUL_TASK_SCHEMA_VERSION;
|
|
1089
1155
|
const updated = await validateDispatchShape({
|
|
1090
1156
|
...dispatch,
|
|
1157
|
+
schemaVersion: persistedSchemaVersion,
|
|
1091
1158
|
status,
|
|
1092
1159
|
summary,
|
|
1093
|
-
turnId,
|
|
1160
|
+
turnId: resultTurnId,
|
|
1094
1161
|
updatedAt: now ?? new Date().toISOString(),
|
|
1095
1162
|
updatedBy: "mcp",
|
|
1096
1163
|
});
|
|
1097
1164
|
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
1098
|
-
? dispatchLineWithState(updated, {})
|
|
1165
|
+
? dispatchLineWithState(updated, { schemaVersion: persistedSchemaVersion })
|
|
1099
1166
|
: record.line);
|
|
1100
1167
|
await writeDispatchLinesAtomic(root, lines);
|
|
1101
1168
|
return updated;
|
package/hooks/hooks.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"description": "Link TaskChef executor identity and provide the current callback turn.",
|
|
3
|
-
"hooks": {
|
|
4
|
-
"UserPromptSubmit": [
|
|
5
|
-
{
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "node \"$PLUGIN_ROOT/hooks/taskchef-initial-prompt.js\"",
|
|
10
|
-
"commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\taskchef-initial-prompt.js\"",
|
|
11
|
-
"timeout": 40,
|
|
12
|
-
"statusMessage": "Linking TaskChef task"
|
|
13
|
-
}
|
|
14
|
-
]
|
|
15
|
-
}
|
|
16
|
-
]
|
|
17
|
-
}
|
|
18
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { handleInitialPromptHook } from "../src/hook.js";
|
|
4
|
-
|
|
5
|
-
let raw = "";
|
|
6
|
-
process.stdin.setEncoding("utf8");
|
|
7
|
-
for await (const chunk of process.stdin) raw += chunk;
|
|
8
|
-
|
|
9
|
-
try {
|
|
10
|
-
const input = JSON.parse(raw);
|
|
11
|
-
process.stdout.write(`${JSON.stringify(await handleInitialPromptHook(input))}\n`);
|
|
12
|
-
} catch (error) {
|
|
13
|
-
process.stdout.write(`${JSON.stringify({
|
|
14
|
-
continue: true,
|
|
15
|
-
systemMessage: `TaskChef initial identity hook failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
16
|
-
})}\n`);
|
|
17
|
-
}
|
package/src/hook.js
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { parseTaskChefMarker } from "./delegation.js";
|
|
2
|
-
import { listTasks, startTaskFromHook } from "./workspace.js";
|
|
3
|
-
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
4
|
-
|
|
5
|
-
// The final hook check trails the dispatcher's 30-second resolver checkpoint
|
|
6
|
-
// so a simultaneous final match is visible before the hook fails closed.
|
|
7
|
-
export const INITIAL_LINK_CHECKPOINTS_MS = Object.freeze([0, 250, 1_000, 3_000, 10_000, 32_000]);
|
|
8
|
-
|
|
9
|
-
function nonEmptyString(value) {
|
|
10
|
-
return typeof value === "string" && value.trim().length > 0
|
|
11
|
-
? value.trim()
|
|
12
|
-
: null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function hookContext(taskId, threadId, turnId) {
|
|
16
|
-
return {
|
|
17
|
-
continue: true,
|
|
18
|
-
hookSpecificOutput: {
|
|
19
|
-
hookEventName: "UserPromptSubmit",
|
|
20
|
-
additionalContext: [
|
|
21
|
-
`This is TaskChef task ${taskId} in root thread ${threadId}, current turn ${turnId}.`,
|
|
22
|
-
"Before ending the task, call the TaskChef report_result MCP tool with the semantic outcome needs_input, completed, or failed and a concise summary.",
|
|
23
|
-
"Pass this exact task ID, root thread ID, and current turn ID to report_result.",
|
|
24
|
-
"Use needs_input only for a decision or information the user must provide; native approval prompts are reported from live Codex state.",
|
|
25
|
-
"Do not include secrets, transcripts, or raw command output in the summary.",
|
|
26
|
-
].join(" "),
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function waitForLinkedTask(root, taskId, {
|
|
32
|
-
checkpoints = INITIAL_LINK_CHECKPOINTS_MS,
|
|
33
|
-
listTaskSnapshots = listTasks,
|
|
34
|
-
waitImpl = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
35
|
-
} = {}) {
|
|
36
|
-
let previousCheckpoint = 0;
|
|
37
|
-
for (const checkpoint of checkpoints) {
|
|
38
|
-
if (!Number.isFinite(checkpoint) || checkpoint < previousCheckpoint) {
|
|
39
|
-
throw new Error("initial-link checkpoints must be finite, non-decreasing milliseconds");
|
|
40
|
-
}
|
|
41
|
-
const delay = checkpoint - previousCheckpoint;
|
|
42
|
-
if (delay > 0) await waitImpl(delay);
|
|
43
|
-
previousCheckpoint = checkpoint;
|
|
44
|
-
const task = (await listTaskSnapshots(root)).find((item) => item.id === taskId);
|
|
45
|
-
if (task?.threadId) return task;
|
|
46
|
-
}
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function handleInitialPromptHook(input, {
|
|
51
|
-
workspace = null,
|
|
52
|
-
resolveWorkspace = () => resolveWorkspacePath().workspace,
|
|
53
|
-
startTask = startTaskFromHook,
|
|
54
|
-
listTaskSnapshots = listTasks,
|
|
55
|
-
linkCheckpoints = INITIAL_LINK_CHECKPOINTS_MS,
|
|
56
|
-
waitImpl = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
57
|
-
} = {}) {
|
|
58
|
-
if (input?.hook_event_name !== "UserPromptSubmit") return { continue: true };
|
|
59
|
-
const taskId = parseTaskChefMarker(input.prompt);
|
|
60
|
-
const threadId = nonEmptyString(input.session_id);
|
|
61
|
-
const turnId = nonEmptyString(input.turn_id);
|
|
62
|
-
if (turnId === null || (taskId === null && threadId === null)) {
|
|
63
|
-
if (taskId === null) return { continue: true };
|
|
64
|
-
return {
|
|
65
|
-
continue: true,
|
|
66
|
-
systemMessage: "TaskChef could not prepare this initial task because the hook payload lacked a turn ID.",
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
if (taskId !== null) {
|
|
70
|
-
const root = workspace ?? resolveWorkspace();
|
|
71
|
-
const linked = await waitForLinkedTask(root, taskId, {
|
|
72
|
-
checkpoints: linkCheckpoints,
|
|
73
|
-
listTaskSnapshots,
|
|
74
|
-
waitImpl,
|
|
75
|
-
});
|
|
76
|
-
if (linked === null) {
|
|
77
|
-
return {
|
|
78
|
-
continue: true,
|
|
79
|
-
systemMessage: "TaskChef left this task unresolved because its durable child task ID could not be verified. Do not report a semantic result until the recorded identity is repaired.",
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
await startTask(root, taskId, linked.threadId, turnId);
|
|
83
|
-
return hookContext(taskId, linked.threadId, turnId);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const root = workspace ?? resolveWorkspace();
|
|
88
|
-
const task = (await listTaskSnapshots(root)).find((item) => item.threadId === threadId);
|
|
89
|
-
return task === undefined
|
|
90
|
-
? { continue: true }
|
|
91
|
-
: hookContext(task.id, threadId, turnId);
|
|
92
|
-
} catch {
|
|
93
|
-
// An unrelated prompt must not surface TaskChef workspace setup failures.
|
|
94
|
-
return { continue: true };
|
|
95
|
-
}
|
|
96
|
-
}
|