cclaw-cli 0.48.16 → 0.48.18
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/dist/content/node-hooks.js +176 -13
- package/dist/fs-utils.js +56 -24
- package/dist/run-archive.js +145 -138
- package/dist/run-persistence.d.ts +13 -0
- package/dist/run-persistence.js +16 -2
- package/package.json +1 -1
|
@@ -53,19 +53,173 @@ function safeParseJson(raw, fallback = {}) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// === atomic/locked state I/O =========================================
|
|
57
|
+
//
|
|
58
|
+
// The generated hook script runs OUTSIDE the cclaw CLI process, so it
|
|
59
|
+
// cannot import \`fs-utils.ts\`. These helpers mirror \`writeFileSafe\` and
|
|
60
|
+
// \`withDirectoryLock\` just enough to keep hook-owned state files
|
|
61
|
+
// atomic and free of interleaved concurrent writes.
|
|
62
|
+
|
|
63
|
+
function hookSleep(ms) {
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function withDirectoryLockInline(lockPath, fn, options = {}) {
|
|
68
|
+
const retries = Number.isFinite(options.retries) ? options.retries : 200;
|
|
69
|
+
const retryDelayMs = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 20;
|
|
70
|
+
const staleAfterMs = Number.isFinite(options.staleAfterMs) ? options.staleAfterMs : 60000;
|
|
71
|
+
try {
|
|
72
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
73
|
+
} catch {
|
|
74
|
+
// parent may already exist
|
|
75
|
+
}
|
|
76
|
+
let acquired = false;
|
|
77
|
+
let lastError = null;
|
|
78
|
+
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
79
|
+
try {
|
|
80
|
+
await fs.mkdir(lockPath);
|
|
81
|
+
acquired = true;
|
|
82
|
+
break;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
lastError = error;
|
|
85
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
86
|
+
if (code !== "EEXIST") {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const stat = await fs.stat(lockPath);
|
|
91
|
+
if (Date.now() - stat.mtimeMs > staleAfterMs) {
|
|
92
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// lock vanished between retries
|
|
97
|
+
}
|
|
98
|
+
await hookSleep(retryDelayMs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!acquired) {
|
|
102
|
+
const details = lastError instanceof Error ? lastError.message : String(lastError);
|
|
103
|
+
throw new Error(
|
|
104
|
+
"cclaw hook: failed to acquire lock " + lockPath + " (attempts=" + retries + ", lastError=" + details + ")"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return await fn();
|
|
109
|
+
} finally {
|
|
110
|
+
await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function writeFileAtomic(filePath, content, options = {}) {
|
|
115
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
116
|
+
const tempPath = path.join(
|
|
117
|
+
path.dirname(filePath),
|
|
118
|
+
"." + path.basename(filePath) + ".tmp-" + process.pid + "-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8)
|
|
119
|
+
);
|
|
120
|
+
await fs.writeFile(tempPath, content, { encoding: "utf8" });
|
|
121
|
+
// Windows' fs.rename can fail transiently with EPERM/EBUSY/EACCES when the
|
|
122
|
+
// destination file is held open by another process (antivirus, indexer,
|
|
123
|
+
// or a sibling hook invocation racing on the same file). Retry with tiny
|
|
124
|
+
// backoff before falling back to copyFile.
|
|
125
|
+
const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
const maxAttempts = 6;
|
|
128
|
+
while (true) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.rename(tempPath, filePath);
|
|
131
|
+
if (options.mode !== undefined) {
|
|
132
|
+
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
137
|
+
if (code === "EXDEV") {
|
|
138
|
+
try {
|
|
139
|
+
await fs.copyFile(tempPath, filePath);
|
|
140
|
+
} finally {
|
|
141
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
if (options.mode !== undefined) {
|
|
144
|
+
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (renameRetryableCodes.has(code) && attempt < maxAttempts) {
|
|
149
|
+
attempt += 1;
|
|
150
|
+
await hookSleep(10 * attempt + Math.floor(Math.random() * 10));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (renameRetryableCodes.has(code)) {
|
|
154
|
+
// Last-resort fallback: copy-then-unlink. Not atomic, but the
|
|
155
|
+
// directory lock around this call already serializes writers.
|
|
156
|
+
try {
|
|
157
|
+
await fs.copyFile(tempPath, filePath);
|
|
158
|
+
if (options.mode !== undefined) {
|
|
159
|
+
await fs.chmod(filePath, options.mode).catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
} finally {
|
|
163
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function lockPathFor(filePath) {
|
|
173
|
+
return filePath + ".lock";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function recordHookError(root, stage, detail) {
|
|
177
|
+
try {
|
|
178
|
+
const errorsPath = path.join(root, RUNTIME_ROOT, "state", "hook-errors.jsonl");
|
|
179
|
+
await fs.mkdir(path.dirname(errorsPath), { recursive: true });
|
|
180
|
+
const payload = JSON.stringify({
|
|
181
|
+
ts: new Date().toISOString(),
|
|
182
|
+
stage: typeof stage === "string" ? stage : "unknown",
|
|
183
|
+
detail: typeof detail === "string" ? detail : String(detail)
|
|
184
|
+
});
|
|
185
|
+
await fs.appendFile(errorsPath, payload + "\\n", "utf8");
|
|
186
|
+
} catch {
|
|
187
|
+
// diagnostics must never cascade
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readJsonFile(filePath, fallback = {}, options = {}) {
|
|
57
192
|
try {
|
|
58
193
|
const raw = await fs.readFile(filePath, "utf8");
|
|
59
|
-
|
|
194
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
195
|
+
return fallback;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(raw);
|
|
199
|
+
return parsed === undefined ? fallback : parsed;
|
|
200
|
+
} catch (parseErr) {
|
|
201
|
+
// Emit a diagnostic breadcrumb instead of silently returning fallback.
|
|
202
|
+
// The hook must still continue (soft-fail), but the corruption is
|
|
203
|
+
// now visible in \`state/hook-errors.jsonl\` and to \`cclaw doctor\`.
|
|
204
|
+
if (options.root) {
|
|
205
|
+
await recordHookError(
|
|
206
|
+
options.root,
|
|
207
|
+
options.stage || "read-json",
|
|
208
|
+
"corrupt-json file=" + filePath + " error=" + (parseErr instanceof Error ? parseErr.message : String(parseErr))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return fallback;
|
|
212
|
+
}
|
|
60
213
|
} catch {
|
|
61
214
|
return fallback;
|
|
62
215
|
}
|
|
63
216
|
}
|
|
64
217
|
|
|
65
218
|
async function writeJsonFile(filePath, value) {
|
|
66
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
67
219
|
const next = JSON.stringify(value, null, 2) + "\\n";
|
|
68
|
-
await
|
|
220
|
+
await withDirectoryLockInline(lockPathFor(filePath), async () => {
|
|
221
|
+
await writeFileAtomic(filePath, next);
|
|
222
|
+
});
|
|
69
223
|
}
|
|
70
224
|
|
|
71
225
|
async function fileExists(filePath) {
|
|
@@ -86,8 +240,17 @@ async function readTextFile(filePath, fallback = "") {
|
|
|
86
240
|
}
|
|
87
241
|
|
|
88
242
|
async function appendJsonLine(filePath, value) {
|
|
89
|
-
|
|
90
|
-
await
|
|
243
|
+
const payload = JSON.stringify(value) + "\\n";
|
|
244
|
+
await withDirectoryLockInline(lockPathFor(filePath), async () => {
|
|
245
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
246
|
+
await fs.appendFile(filePath, payload, "utf8");
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function writeTextFileAtomic(filePath, content) {
|
|
251
|
+
await withDirectoryLockInline(lockPathFor(filePath), async () => {
|
|
252
|
+
await writeFileAtomic(filePath, content);
|
|
253
|
+
});
|
|
91
254
|
}
|
|
92
255
|
|
|
93
256
|
async function readStdin() {
|
|
@@ -532,7 +695,10 @@ function extractCodePathsFromText(value) {
|
|
|
532
695
|
|
|
533
696
|
async function readFlowState(root) {
|
|
534
697
|
const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
|
|
535
|
-
|
|
698
|
+
// Loud-on-corrupt: if flow-state.json exists but fails JSON.parse, log
|
|
699
|
+
// a breadcrumb into state/hook-errors.jsonl before falling back to an
|
|
700
|
+
// empty object. Silent fallbacks used to mask stale CLI+hook drift.
|
|
701
|
+
const parsed = await readJsonFile(statePath, {}, { root, stage: "read-flow-state" });
|
|
536
702
|
const obj = toObject(parsed) || {};
|
|
537
703
|
const completed = Array.isArray(obj.completedStages) ? obj.completedStages : [];
|
|
538
704
|
return {
|
|
@@ -602,11 +768,9 @@ async function buildKnowledgeDigest(root, currentStage) {
|
|
|
602
768
|
});
|
|
603
769
|
const body =
|
|
604
770
|
relevant.length > 0 ? relevant.join("\\n") : "(no matching entries for current stage)";
|
|
605
|
-
await
|
|
606
|
-
await fs.writeFile(
|
|
771
|
+
await writeTextFileAtomic(
|
|
607
772
|
digestFile,
|
|
608
|
-
"# Knowledge digest (auto-generated)\\n\\n" + body + "\\n"
|
|
609
|
-
"utf8"
|
|
773
|
+
"# Knowledge digest (auto-generated)\\n\\n" + body + "\\n"
|
|
610
774
|
);
|
|
611
775
|
return {
|
|
612
776
|
digestLines: relevant,
|
|
@@ -1069,8 +1233,7 @@ async function handlePreCompact(runtime) {
|
|
|
1069
1233
|
digest.push("", "## Knowledge tail", knowledgeTail);
|
|
1070
1234
|
}
|
|
1071
1235
|
const digestFile = path.join(stateDir, "session-digest.md");
|
|
1072
|
-
await
|
|
1073
|
-
await fs.writeFile(digestFile, digest.join("\\n") + "\\n", "utf8");
|
|
1236
|
+
await writeTextFileAtomic(digestFile, digest.join("\\n") + "\\n");
|
|
1074
1237
|
return 0;
|
|
1075
1238
|
}
|
|
1076
1239
|
|
package/dist/fs-utils.js
CHANGED
|
@@ -75,35 +75,67 @@ export async function writeFileSafe(filePath, content, options = {}) {
|
|
|
75
75
|
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
76
76
|
const targetMode = options.mode;
|
|
77
77
|
await fs.writeFile(tempPath, content, { encoding: "utf8", ...(targetMode !== undefined ? { mode: targetMode } : {}) });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// On Windows, `fs.rename` can fail transiently with EPERM / EBUSY / EACCES
|
|
79
|
+
// when the target file is briefly held open by another process (antivirus,
|
|
80
|
+
// search indexer, or a concurrent cclaw hook). Retry with small backoff
|
|
81
|
+
// before falling back to a non-atomic copy + unlink.
|
|
82
|
+
const renameRetryableCodes = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
const maxAttempts = 6;
|
|
85
|
+
// eslint-disable-next-line no-constant-condition
|
|
86
|
+
while (true) {
|
|
87
|
+
try {
|
|
88
|
+
await fs.rename(tempPath, filePath);
|
|
89
|
+
if (targetMode !== undefined) {
|
|
90
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
82
93
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
catch (error) {
|
|
95
|
+
const code = error?.code;
|
|
96
|
+
// `rename` fails with EXDEV when the temp file and target live on
|
|
97
|
+
// different filesystems (container bind mounts, tmpfs + rootfs,
|
|
98
|
+
// cross-volume setups). Fall back to copy + unlink so atomic writes
|
|
99
|
+
// still work — copyFile is not fully atomic but is the best we can
|
|
100
|
+
// do across devices, and we remove the temp even if copy fails.
|
|
101
|
+
if (code === "EXDEV") {
|
|
102
|
+
try {
|
|
103
|
+
await fs.copyFile(tempPath, filePath);
|
|
104
|
+
if (targetMode !== undefined) {
|
|
105
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
96
110
|
}
|
|
111
|
+
return;
|
|
97
112
|
}
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
if (code !== undefined && renameRetryableCodes.has(code) && attempt < maxAttempts) {
|
|
114
|
+
attempt += 1;
|
|
115
|
+
const waitMs = 10 * attempt + Math.floor(Math.random() * 10);
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
117
|
+
continue;
|
|
100
118
|
}
|
|
101
|
-
|
|
119
|
+
if (code !== undefined && renameRetryableCodes.has(code)) {
|
|
120
|
+
// Last-resort fallback on Windows: copy-then-unlink. Not atomic,
|
|
121
|
+
// but the caller is expected to have serialized via a directory
|
|
122
|
+
// lock so this only loses atomicity under extreme contention.
|
|
123
|
+
try {
|
|
124
|
+
await fs.copyFile(tempPath, filePath);
|
|
125
|
+
if (targetMode !== undefined) {
|
|
126
|
+
await fs.chmod(filePath, targetMode).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Other errors: try to clean up the temp to avoid littering the
|
|
135
|
+
// directory with orphaned `.tmp-<pid>-*` files, then rethrow.
|
|
136
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
137
|
+
throw error;
|
|
102
138
|
}
|
|
103
|
-
// Other errors: try to clean up the temp to avoid littering the
|
|
104
|
-
// directory with orphaned `.tmp-<pid>-*` files, then rethrow.
|
|
105
|
-
await fs.unlink(tempPath).catch(() => undefined);
|
|
106
|
-
throw error;
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
141
|
export async function exists(filePath) {
|
package/dist/run-archive.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.j
|
|
|
6
6
|
import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
|
|
7
7
|
import { readKnowledgeSafely } from "./knowledge-store.js";
|
|
8
8
|
import { evaluateRetroGate } from "./retro-gate.js";
|
|
9
|
-
import { ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
|
|
9
|
+
import { ensureRunSystem, flowStateLockPathFor, readFlowState, writeFlowState } from "./run-persistence.js";
|
|
10
10
|
const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
|
|
11
11
|
const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
|
|
12
12
|
const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
|
|
@@ -161,150 +161,157 @@ export async function listRuns(projectRoot) {
|
|
|
161
161
|
}
|
|
162
162
|
export async function archiveRun(projectRoot, featureName, options = {}) {
|
|
163
163
|
await ensureRunSystem(projectRoot);
|
|
164
|
+
// Hold BOTH archive.lock and flow-state.lock for the entire archive:
|
|
165
|
+
// the outer archive lock serializes two concurrent archives; the
|
|
166
|
+
// inner flow-state lock prevents CLI / hook paths from mutating
|
|
167
|
+
// flow-state between the archive snapshot and the subsequent reset,
|
|
168
|
+
// which used to cause lost-update races.
|
|
164
169
|
return withDirectoryLock(archiveLockPath(projectRoot), async () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const feature = (featureName?.trim() && featureName.trim().length > 0)
|
|
171
|
-
? featureName.trim()
|
|
172
|
-
: await inferFeatureNameFromArtifacts(projectRoot);
|
|
173
|
-
const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
|
|
174
|
-
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
175
|
-
const archivePath = path.join(runsDir, archiveId);
|
|
176
|
-
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
177
|
-
let sourceState = await readFlowState(projectRoot);
|
|
178
|
-
const retroGate = await evaluateRetroGate(projectRoot, sourceState);
|
|
179
|
-
const shipCompleted = sourceState.completedStages.includes("ship");
|
|
180
|
-
const skipRetro = options.skipRetro === true;
|
|
181
|
-
const skipRetroReason = options.skipRetroReason?.trim();
|
|
182
|
-
if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
|
|
183
|
-
throw new Error("archive --skip-retro requires --retro-reason=<text>.");
|
|
184
|
-
}
|
|
185
|
-
const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
|
|
186
|
-
typeof sourceState.closeout.retroSkipReason === "string" &&
|
|
187
|
-
sourceState.closeout.retroSkipReason.trim().length > 0;
|
|
188
|
-
const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
|
|
189
|
-
const inShipCloseout = sourceState.currentStage === "ship";
|
|
190
|
-
if (inShipCloseout && skipRetro) {
|
|
191
|
-
throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
|
|
192
|
-
"Complete closeout to ready_to_archive via /cc-next.");
|
|
193
|
-
}
|
|
194
|
-
if (inShipCloseout && !readyForArchive) {
|
|
195
|
-
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
196
|
-
"Resume /cc-next until closeout reaches ready_to_archive.");
|
|
197
|
-
}
|
|
198
|
-
if (shipCompleted && !readyForArchive && !skipRetro) {
|
|
199
|
-
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
200
|
-
"Resume /cc-next until closeout reaches ready_to_archive, " +
|
|
201
|
-
"or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
|
|
202
|
-
}
|
|
203
|
-
if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
|
|
204
|
-
throw new Error("Archive blocked: retro gate is required after ship completion. " +
|
|
205
|
-
"Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
|
|
206
|
-
}
|
|
207
|
-
if (retroGate.completed) {
|
|
208
|
-
const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
|
|
209
|
-
sourceState = {
|
|
210
|
-
...sourceState,
|
|
211
|
-
retro: {
|
|
212
|
-
required: retroGate.required,
|
|
213
|
-
completedAt,
|
|
214
|
-
compoundEntries: retroGate.compoundEntries
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
await writeFlowState(projectRoot, sourceState, { allowReset: true });
|
|
218
|
-
}
|
|
219
|
-
const retroSummary = {
|
|
220
|
-
required: retroGate.required,
|
|
221
|
-
completed: retroGate.completed,
|
|
222
|
-
skipped: skipRetro || retroSkippedInCloseout,
|
|
223
|
-
skipReason: skipRetro
|
|
224
|
-
? skipRetroReason
|
|
225
|
-
: retroSkippedInCloseout
|
|
226
|
-
? sourceState.closeout.retroSkipReason
|
|
227
|
-
: undefined,
|
|
228
|
-
compoundEntries: retroGate.compoundEntries
|
|
229
|
-
};
|
|
230
|
-
await ensureDir(archivePath);
|
|
231
|
-
// Drop an `.archive-in-progress` sentinel immediately so that a crash
|
|
232
|
-
// between the artifact rename and the final manifest write leaves a
|
|
233
|
-
// recoverable marker (doctor surfaces these; re-running archive on an
|
|
234
|
-
// orphan attempts to complete or roll back). The sentinel is removed
|
|
235
|
-
// only after the manifest lands successfully.
|
|
236
|
-
const sentinelPath = path.join(archivePath, ".archive-in-progress");
|
|
237
|
-
const archivedAt = new Date().toISOString();
|
|
238
|
-
await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
|
|
239
|
-
const stateBeforeReset = sourceState;
|
|
240
|
-
let artifactsMoved = false;
|
|
241
|
-
let stateReset = false;
|
|
242
|
-
try {
|
|
243
|
-
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
244
|
-
artifactsMoved = true;
|
|
170
|
+
return withDirectoryLock(flowStateLockPathFor(projectRoot), async () => {
|
|
171
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
172
|
+
const artifactsDir = activeArtifactsPath(projectRoot);
|
|
173
|
+
const runsDir = runsRoot(projectRoot);
|
|
174
|
+
await ensureDir(runsDir);
|
|
245
175
|
await ensureDir(artifactsDir);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
176
|
+
const feature = (featureName?.trim() && featureName.trim().length > 0)
|
|
177
|
+
? featureName.trim()
|
|
178
|
+
: await inferFeatureNameFromArtifacts(projectRoot);
|
|
179
|
+
const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
|
|
180
|
+
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
181
|
+
const archivePath = path.join(runsDir, archiveId);
|
|
182
|
+
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
183
|
+
let sourceState = await readFlowState(projectRoot);
|
|
184
|
+
const retroGate = await evaluateRetroGate(projectRoot, sourceState);
|
|
185
|
+
const shipCompleted = sourceState.completedStages.includes("ship");
|
|
186
|
+
const skipRetro = options.skipRetro === true;
|
|
187
|
+
const skipRetroReason = options.skipRetroReason?.trim();
|
|
188
|
+
if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
|
|
189
|
+
throw new Error("archive --skip-retro requires --retro-reason=<text>.");
|
|
190
|
+
}
|
|
191
|
+
const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
|
|
192
|
+
typeof sourceState.closeout.retroSkipReason === "string" &&
|
|
193
|
+
sourceState.closeout.retroSkipReason.trim().length > 0;
|
|
194
|
+
const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
|
|
195
|
+
const inShipCloseout = sourceState.currentStage === "ship";
|
|
196
|
+
if (inShipCloseout && skipRetro) {
|
|
197
|
+
throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
|
|
198
|
+
"Complete closeout to ready_to_archive via /cc-next.");
|
|
199
|
+
}
|
|
200
|
+
if (inShipCloseout && !readyForArchive) {
|
|
201
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
202
|
+
"Resume /cc-next until closeout reaches ready_to_archive.");
|
|
203
|
+
}
|
|
204
|
+
if (shipCompleted && !readyForArchive && !skipRetro) {
|
|
205
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
206
|
+
"Resume /cc-next until closeout reaches ready_to_archive, " +
|
|
207
|
+
"or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
|
|
208
|
+
}
|
|
209
|
+
if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
|
|
210
|
+
throw new Error("Archive blocked: retro gate is required after ship completion. " +
|
|
211
|
+
"Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
|
|
212
|
+
}
|
|
213
|
+
if (retroGate.completed) {
|
|
214
|
+
const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
|
|
215
|
+
sourceState = {
|
|
216
|
+
...sourceState,
|
|
217
|
+
retro: {
|
|
218
|
+
required: retroGate.required,
|
|
219
|
+
completedAt,
|
|
220
|
+
compoundEntries: retroGate.compoundEntries
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await writeFlowState(projectRoot, sourceState, { allowReset: true, skipLock: true });
|
|
224
|
+
}
|
|
225
|
+
const retroSummary = {
|
|
226
|
+
required: retroGate.required,
|
|
227
|
+
completed: retroGate.completed,
|
|
228
|
+
skipped: skipRetro || retroSkippedInCloseout,
|
|
229
|
+
skipReason: skipRetro
|
|
230
|
+
? skipRetroReason
|
|
231
|
+
: retroSkippedInCloseout
|
|
232
|
+
? sourceState.closeout.retroSkipReason
|
|
233
|
+
: undefined,
|
|
234
|
+
compoundEntries: retroGate.compoundEntries
|
|
279
235
|
};
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
236
|
+
await ensureDir(archivePath);
|
|
237
|
+
// Drop an `.archive-in-progress` sentinel immediately so that a crash
|
|
238
|
+
// between the artifact rename and the final manifest write leaves a
|
|
239
|
+
// recoverable marker (doctor surfaces these; re-running archive on an
|
|
240
|
+
// orphan attempts to complete or roll back). The sentinel is removed
|
|
241
|
+
// only after the manifest lands successfully.
|
|
242
|
+
const sentinelPath = path.join(archivePath, ".archive-in-progress");
|
|
243
|
+
const archivedAt = new Date().toISOString();
|
|
244
|
+
await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
|
|
245
|
+
const stateBeforeReset = sourceState;
|
|
246
|
+
let artifactsMoved = false;
|
|
247
|
+
let stateReset = false;
|
|
248
|
+
try {
|
|
249
|
+
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
250
|
+
artifactsMoved = true;
|
|
251
|
+
await ensureDir(artifactsDir);
|
|
252
|
+
const archiveStatePath = path.join(archivePath, "state");
|
|
253
|
+
const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
|
|
254
|
+
const resetState = createInitialFlowState();
|
|
255
|
+
await writeFlowState(projectRoot, resetState, { allowReset: true, skipLock: true });
|
|
256
|
+
stateReset = true;
|
|
257
|
+
await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
|
|
258
|
+
const manifest = {
|
|
259
|
+
version: 1,
|
|
260
|
+
archiveId,
|
|
261
|
+
archivedAt,
|
|
262
|
+
featureName: feature,
|
|
263
|
+
activeFeature,
|
|
264
|
+
sourceRunId: sourceState.activeRunId,
|
|
265
|
+
sourceCurrentStage: sourceState.currentStage,
|
|
266
|
+
sourceCompletedStages: sourceState.completedStages,
|
|
267
|
+
snapshottedStateFiles,
|
|
268
|
+
retro: retroSummary
|
|
269
|
+
};
|
|
270
|
+
await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
271
|
+
// Manifest landed — sentinel is no longer needed.
|
|
272
|
+
await fs.unlink(sentinelPath).catch(() => undefined);
|
|
273
|
+
const knowledgeStats = await readKnowledgeStats(projectRoot);
|
|
274
|
+
await syncActiveFeatureSnapshot(projectRoot);
|
|
275
|
+
return {
|
|
276
|
+
archiveId,
|
|
277
|
+
archivePath,
|
|
278
|
+
archivedAt,
|
|
279
|
+
featureName: feature,
|
|
280
|
+
activeFeature,
|
|
281
|
+
resetState,
|
|
282
|
+
snapshottedStateFiles,
|
|
283
|
+
knowledge: knowledgeStats,
|
|
284
|
+
retro: retroSummary
|
|
285
|
+
};
|
|
295
286
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
287
|
+
catch (err) {
|
|
288
|
+
// Best-effort rollback: if artifacts were moved but the subsequent
|
|
289
|
+
// steps failed, put artifacts back so the user is not left without
|
|
290
|
+
// a working run. The sentinel is intentionally left behind for
|
|
291
|
+
// inspection; doctor surfaces it.
|
|
292
|
+
if (artifactsMoved) {
|
|
293
|
+
try {
|
|
294
|
+
await fs.rm(artifactsDir, { recursive: true, force: true });
|
|
295
|
+
await fs.rename(archiveArtifactsPath, artifactsDir);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Rollback failed — sentinel + orphaned archive dir will be
|
|
299
|
+
// surfaced by doctor and can be reconciled manually.
|
|
300
|
+
}
|
|
300
301
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
if (stateReset) {
|
|
303
|
+
try {
|
|
304
|
+
await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true, skipLock: true });
|
|
305
|
+
await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// If rollback of state fails, keep sentinel + archive remnants for
|
|
309
|
+
// manual reconciliation.
|
|
310
|
+
}
|
|
304
311
|
}
|
|
312
|
+
throw err;
|
|
305
313
|
}
|
|
306
|
-
|
|
307
|
-
}
|
|
314
|
+
});
|
|
308
315
|
}, {
|
|
309
316
|
retries: 400,
|
|
310
317
|
retryDelayMs: 25,
|
|
@@ -11,6 +11,13 @@ export interface WriteFlowStateOptions {
|
|
|
11
11
|
* bootstrap, or explicit recovery; never set from normal stage handlers.
|
|
12
12
|
*/
|
|
13
13
|
allowReset?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When true, skip the internal directory-lock acquisition. The caller
|
|
16
|
+
* MUST already hold `flowStateLockPath(projectRoot)` for the duration
|
|
17
|
+
* of this call. Used by run-archive to keep the full archive +
|
|
18
|
+
* flow-state reset inside one atomic lock window.
|
|
19
|
+
*/
|
|
20
|
+
skipLock?: boolean;
|
|
14
21
|
}
|
|
15
22
|
export interface ReadFlowStateOptions {
|
|
16
23
|
/**
|
|
@@ -26,6 +33,12 @@ export declare class CorruptFlowStateError extends Error {
|
|
|
26
33
|
}
|
|
27
34
|
export declare function readFlowState(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
|
|
28
35
|
export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Exposed path helper so callers that need to serialize a multi-step
|
|
38
|
+
* state operation with flow-state writes (e.g. run archival) can
|
|
39
|
+
* acquire the SAME lock directory used internally by `writeFlowState`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function flowStateLockPathFor(projectRoot: string): string;
|
|
29
42
|
interface EnsureRunSystemOptions {
|
|
30
43
|
createIfMissing?: boolean;
|
|
31
44
|
}
|
package/dist/run-persistence.js
CHANGED
|
@@ -362,7 +362,7 @@ export async function readFlowState(projectRoot, options = {}) {
|
|
|
362
362
|
}
|
|
363
363
|
export async function writeFlowState(projectRoot, state, options = {}) {
|
|
364
364
|
await ensureFeatureSystem(projectRoot);
|
|
365
|
-
|
|
365
|
+
const doWrite = async () => {
|
|
366
366
|
const statePath = flowStatePath(projectRoot);
|
|
367
367
|
if (!options.allowReset && (await exists(statePath))) {
|
|
368
368
|
try {
|
|
@@ -382,9 +382,23 @@ export async function writeFlowState(projectRoot, state, options = {}) {
|
|
|
382
382
|
}
|
|
383
383
|
const safe = coerceFlowState({ ...state });
|
|
384
384
|
await writeFileSafe(statePath, `${JSON.stringify(safe, null, 2)}\n`, { mode: 0o600 });
|
|
385
|
-
}
|
|
385
|
+
};
|
|
386
|
+
if (options.skipLock) {
|
|
387
|
+
await doWrite();
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
await withDirectoryLock(flowStateLockPath(projectRoot), doWrite);
|
|
391
|
+
}
|
|
386
392
|
await syncActiveFeatureSnapshot(projectRoot);
|
|
387
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Exposed path helper so callers that need to serialize a multi-step
|
|
396
|
+
* state operation with flow-state writes (e.g. run archival) can
|
|
397
|
+
* acquire the SAME lock directory used internally by `writeFlowState`.
|
|
398
|
+
*/
|
|
399
|
+
export function flowStateLockPathFor(projectRoot) {
|
|
400
|
+
return flowStateLockPath(projectRoot);
|
|
401
|
+
}
|
|
388
402
|
export async function ensureRunSystem(projectRoot, _options = {}) {
|
|
389
403
|
await ensureFeatureSystem(projectRoot);
|
|
390
404
|
await ensureDir(runsRoot(projectRoot));
|