cclaw-cli 0.48.16 → 0.48.17

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.
@@ -53,19 +53,145 @@ function safeParseJson(raw, fallback = {}) {
53
53
  }
54
54
  }
55
55
 
56
- async function readJsonFile(filePath, fallback = {}) {
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
+ try {
122
+ await fs.rename(tempPath, filePath);
123
+ if (options.mode !== undefined) {
124
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
125
+ }
126
+ } catch (error) {
127
+ const code = error && typeof error === "object" && "code" in error ? error.code : null;
128
+ if (code === "EXDEV") {
129
+ try {
130
+ await fs.copyFile(tempPath, filePath);
131
+ } finally {
132
+ await fs.unlink(tempPath).catch(() => undefined);
133
+ }
134
+ if (options.mode !== undefined) {
135
+ await fs.chmod(filePath, options.mode).catch(() => undefined);
136
+ }
137
+ return;
138
+ }
139
+ await fs.unlink(tempPath).catch(() => undefined);
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ function lockPathFor(filePath) {
145
+ return filePath + ".lock";
146
+ }
147
+
148
+ async function recordHookError(root, stage, detail) {
149
+ try {
150
+ const errorsPath = path.join(root, RUNTIME_ROOT, "state", "hook-errors.jsonl");
151
+ await fs.mkdir(path.dirname(errorsPath), { recursive: true });
152
+ const payload = JSON.stringify({
153
+ ts: new Date().toISOString(),
154
+ stage: typeof stage === "string" ? stage : "unknown",
155
+ detail: typeof detail === "string" ? detail : String(detail)
156
+ });
157
+ await fs.appendFile(errorsPath, payload + "\\n", "utf8");
158
+ } catch {
159
+ // diagnostics must never cascade
160
+ }
161
+ }
162
+
163
+ async function readJsonFile(filePath, fallback = {}, options = {}) {
57
164
  try {
58
165
  const raw = await fs.readFile(filePath, "utf8");
59
- return safeParseJson(raw, fallback);
166
+ if (typeof raw !== "string" || raw.trim().length === 0) {
167
+ return fallback;
168
+ }
169
+ try {
170
+ const parsed = JSON.parse(raw);
171
+ return parsed === undefined ? fallback : parsed;
172
+ } catch (parseErr) {
173
+ // Emit a diagnostic breadcrumb instead of silently returning fallback.
174
+ // The hook must still continue (soft-fail), but the corruption is
175
+ // now visible in \`state/hook-errors.jsonl\` and to \`cclaw doctor\`.
176
+ if (options.root) {
177
+ await recordHookError(
178
+ options.root,
179
+ options.stage || "read-json",
180
+ "corrupt-json file=" + filePath + " error=" + (parseErr instanceof Error ? parseErr.message : String(parseErr))
181
+ );
182
+ }
183
+ return fallback;
184
+ }
60
185
  } catch {
61
186
  return fallback;
62
187
  }
63
188
  }
64
189
 
65
190
  async function writeJsonFile(filePath, value) {
66
- await fs.mkdir(path.dirname(filePath), { recursive: true });
67
191
  const next = JSON.stringify(value, null, 2) + "\\n";
68
- await fs.writeFile(filePath, next, "utf8");
192
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
193
+ await writeFileAtomic(filePath, next);
194
+ });
69
195
  }
70
196
 
71
197
  async function fileExists(filePath) {
@@ -86,8 +212,17 @@ async function readTextFile(filePath, fallback = "") {
86
212
  }
87
213
 
88
214
  async function appendJsonLine(filePath, value) {
89
- await fs.mkdir(path.dirname(filePath), { recursive: true });
90
- await fs.appendFile(filePath, JSON.stringify(value) + "\\n", "utf8");
215
+ const payload = JSON.stringify(value) + "\\n";
216
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
217
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
218
+ await fs.appendFile(filePath, payload, "utf8");
219
+ });
220
+ }
221
+
222
+ async function writeTextFileAtomic(filePath, content) {
223
+ await withDirectoryLockInline(lockPathFor(filePath), async () => {
224
+ await writeFileAtomic(filePath, content);
225
+ });
91
226
  }
92
227
 
93
228
  async function readStdin() {
@@ -532,7 +667,10 @@ function extractCodePathsFromText(value) {
532
667
 
533
668
  async function readFlowState(root) {
534
669
  const statePath = path.join(root, RUNTIME_ROOT, "state", "flow-state.json");
535
- const parsed = await readJsonFile(statePath, {});
670
+ // Loud-on-corrupt: if flow-state.json exists but fails JSON.parse, log
671
+ // a breadcrumb into state/hook-errors.jsonl before falling back to an
672
+ // empty object. Silent fallbacks used to mask stale CLI+hook drift.
673
+ const parsed = await readJsonFile(statePath, {}, { root, stage: "read-flow-state" });
536
674
  const obj = toObject(parsed) || {};
537
675
  const completed = Array.isArray(obj.completedStages) ? obj.completedStages : [];
538
676
  return {
@@ -602,11 +740,9 @@ async function buildKnowledgeDigest(root, currentStage) {
602
740
  });
603
741
  const body =
604
742
  relevant.length > 0 ? relevant.join("\\n") : "(no matching entries for current stage)";
605
- await fs.mkdir(path.dirname(digestFile), { recursive: true });
606
- await fs.writeFile(
743
+ await writeTextFileAtomic(
607
744
  digestFile,
608
- "# Knowledge digest (auto-generated)\\n\\n" + body + "\\n",
609
- "utf8"
745
+ "# Knowledge digest (auto-generated)\\n\\n" + body + "\\n"
610
746
  );
611
747
  return {
612
748
  digestLines: relevant,
@@ -1069,8 +1205,7 @@ async function handlePreCompact(runtime) {
1069
1205
  digest.push("", "## Knowledge tail", knowledgeTail);
1070
1206
  }
1071
1207
  const digestFile = path.join(stateDir, "session-digest.md");
1072
- await fs.mkdir(path.dirname(digestFile), { recursive: true });
1073
- await fs.writeFile(digestFile, digest.join("\\n") + "\\n", "utf8");
1208
+ await writeTextFileAtomic(digestFile, digest.join("\\n") + "\\n");
1074
1209
  return 0;
1075
1210
  }
1076
1211
 
@@ -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
- const activeFeature = await readActiveFeature(projectRoot);
166
- const artifactsDir = activeArtifactsPath(projectRoot);
167
- const runsDir = runsRoot(projectRoot);
168
- await ensureDir(runsDir);
169
- await ensureDir(artifactsDir);
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 archiveStatePath = path.join(archivePath, "state");
247
- const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
248
- const resetState = createInitialFlowState();
249
- await writeFlowState(projectRoot, resetState, { allowReset: true });
250
- stateReset = true;
251
- await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
252
- const manifest = {
253
- version: 1,
254
- archiveId,
255
- archivedAt,
256
- featureName: feature,
257
- activeFeature,
258
- sourceRunId: sourceState.activeRunId,
259
- sourceCurrentStage: sourceState.currentStage,
260
- sourceCompletedStages: sourceState.completedStages,
261
- snapshottedStateFiles,
262
- retro: retroSummary
263
- };
264
- await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
265
- // Manifest landed sentinel is no longer needed.
266
- await fs.unlink(sentinelPath).catch(() => undefined);
267
- const knowledgeStats = await readKnowledgeStats(projectRoot);
268
- await syncActiveFeatureSnapshot(projectRoot);
269
- return {
270
- archiveId,
271
- archivePath,
272
- archivedAt,
273
- featureName: feature,
274
- activeFeature,
275
- resetState,
276
- snapshottedStateFiles,
277
- knowledge: knowledgeStats,
278
- retro: retroSummary
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
- catch (err) {
282
- // Best-effort rollback: if artifacts were moved but the subsequent
283
- // steps failed, put artifacts back so the user is not left without
284
- // a working run. The sentinel is intentionally left behind for
285
- // inspection; doctor surfaces it.
286
- if (artifactsMoved) {
287
- try {
288
- await fs.rm(artifactsDir, { recursive: true, force: true });
289
- await fs.rename(archiveArtifactsPath, artifactsDir);
290
- }
291
- catch {
292
- // Rollback failed — sentinel + orphaned archive dir will be
293
- // surfaced by doctor and can be reconciled manually.
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
- if (stateReset) {
297
- try {
298
- await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true });
299
- await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
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
- catch {
302
- // If rollback of state fails, keep sentinel + archive remnants for
303
- // manual reconciliation.
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
- throw err;
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
  }
@@ -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
- await withDirectoryLock(flowStateLockPath(projectRoot), async () => {
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.48.16",
3
+ "version": "0.48.17",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {