fix(scheduled-tasks): keep task fields a server build does not know
Every project-config write re-serialized normalized tasks, so a server that shares the config file but predates a field (goal, auto-accept) stripped it the first time any task ran. Untouched tasks now go back to disk verbatim, a state update swaps only `state`, and only a deliberately replaced task is serialized from the normalized shape.
This commit is contained in:
@@ -499,11 +499,20 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Normalized tasks for reading, plus the raw on-disk record of each one for
|
||||
// writing back. Normalization only keeps the fields THIS build knows, so a
|
||||
// write that re-serialized normalized tasks would strip every field added
|
||||
// by a newer build (or a newer UI) the moment an older server touched the
|
||||
// file — a goal or auto-accept setting silently lost after a task ran.
|
||||
// Writers therefore persist untouched tasks from `rawTasksByID` verbatim and
|
||||
// only serialize a normalized task where the task itself was deliberately
|
||||
// replaced.
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const parsed = await readRawProjectConfigFromDisk(projectID);
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
const rawTasksByID = new Map();
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
@@ -514,15 +523,31 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
refreshUpdatedAt: false,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
rawTasksByID.set(normalized.id, task);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
rawTasksByID,
|
||||
};
|
||||
};
|
||||
|
||||
// The list to write: tasks this write replaced go out normalized; every
|
||||
// other task goes out exactly as stored, fields unknown to this build
|
||||
// included. A state-only update counts as untouched — only its `state` is
|
||||
// swapped onto the stored record. Callers keep working with (and returning)
|
||||
// the normalized tasks; only the bytes on disk differ.
|
||||
const toStoredTasks = (config, tasks, { replacedIDs = new Set(), stateUpdatedID = null } = {}) => (
|
||||
tasks.map((task) => {
|
||||
if (replacedIDs.has(task.id)) return task;
|
||||
const stored = config.rawTasksByID.get(task.id);
|
||||
if (!stored) return task;
|
||||
return task.id === stateUpdatedID ? { ...stored, state: task.state } : stored;
|
||||
})
|
||||
);
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
@@ -607,7 +632,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
const nextConfig = {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs: new Set([normalizedTask.id]) }),
|
||||
};
|
||||
await writeProjectConfigToDisk(projectID, nextConfig);
|
||||
|
||||
@@ -633,7 +658,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
if (deleted) {
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -676,7 +701,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -737,7 +762,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -797,6 +822,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
const consumedLoopPaths = new Set();
|
||||
const nextTasks = [];
|
||||
const replacedIDs = new Set();
|
||||
for (const task of tasks) {
|
||||
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
|
||||
// The driving loop file was removed (or renamed) — unschedule.
|
||||
@@ -828,6 +854,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
},
|
||||
);
|
||||
nextTasks.push(adopted);
|
||||
replacedIDs.add(adopted.id);
|
||||
pendingLoops.delete(loop.definition.name);
|
||||
if (task.loopFile) {
|
||||
consumedLoopPaths.add(task.loopFile);
|
||||
@@ -863,6 +890,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
},
|
||||
);
|
||||
nextTasks.push(created);
|
||||
replacedIDs.add(created.id);
|
||||
} catch (error) {
|
||||
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
|
||||
}
|
||||
@@ -870,7 +898,7 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs }),
|
||||
});
|
||||
|
||||
return nextTasks;
|
||||
|
||||
Reference in New Issue
Block a user