fix(state): correct TOCTOU races, busy-wait, lock cleanup, and config locking (#1944)

cmdStateUpdateProgress, cmdStateAddDecision, cmdStateAddBlocker,
cmdStateResolveBlocker, cmdStateRecordSession, and cmdStateBeginPhase from
bare readFileSync+writeStateMd to readModifyWriteStateMd, eliminating the
TOCTOU window where two concurrent callers read the same content and the
second write clobbers the first.

Atomics.wait(), matching the pattern already used in withPlanningLock in
core.cjs.

and core.cjs and register a process.on('exit') handler to unlink them on
process exit. The exit event fires even when process.exit(1) is called
inside a locked region, eliminating stale lock files after errors.

read-modify-write body of setConfigValue in a planning lock, preventing
concurrent config-set calls from losing each other's writes.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-07 17:39:29 -04:00
committed by GitHub
parent 14fd090e47
commit 4dd35f6b69
4 changed files with 691 additions and 236 deletions

View File

@@ -4,7 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { output, error, planningRoot, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs'); const { output, error, planningRoot, withPlanningLock, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
const { const {
VALID_PROFILES, VALID_PROFILES,
getAgentToModelMapForProfile, getAgentToModelMapForProfile,
@@ -293,36 +293,38 @@ function cmdConfigEnsureSection(cwd, raw) {
function setConfigValue(cwd, keyPath, parsedValue) { function setConfigValue(cwd, keyPath, parsedValue) {
const configPath = path.join(planningRoot(cwd), 'config.json'); const configPath = path.join(planningRoot(cwd), 'config.json');
// Load existing config or start with empty object return withPlanningLock(cwd, () => {
let config = {}; // Load existing config or start with empty object
try { let config = {};
if (fs.existsSync(configPath)) { try {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
} catch (err) {
error('Failed to read config.json: ' + err.message);
} }
} catch (err) {
error('Failed to read config.json: ' + err.message);
}
// Set nested value using dot notation (e.g., "workflow.research") // Set nested value using dot notation (e.g., "workflow.research")
const keys = keyPath.split('.'); const keys = keyPath.split('.');
let current = config; let current = config;
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]; const key = keys[i];
if (current[key] === undefined || typeof current[key] !== 'object') { if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {}; current[key] = {};
}
current = current[key];
} }
current = current[key]; const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
} current[keys[keys.length - 1]] = parsedValue;
const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting
current[keys[keys.length - 1]] = parsedValue;
// Write back // Write back
try { try {
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
return { updated: true, key: keyPath, value: parsedValue, previousValue }; return { updated: true, key: keyPath, value: parsedValue, previousValue };
} catch (err) { } catch (err) {
error('Failed to write config.json: ' + err.message); error('Failed to write config.json: ' + err.message);
} }
});
} }
/** /**

View File

@@ -27,6 +27,16 @@ const WORKSTREAM_SESSION_ENV_KEYS = [
let cachedControllingTtyToken = null; let cachedControllingTtyToken = null;
let didProbeControllingTtyToken = false; let didProbeControllingTtyToken = false;
// Track all .planning/.lock files held by this process so they can be removed
// on exit. process.on('exit') fires even on process.exit(1), unlike try/finally
// which is skipped when error() calls process.exit(1) inside a locked region (#1916).
const _heldPlanningLocks = new Set();
process.on('exit', () => {
for (const lockPath of _heldPlanningLocks) {
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
}
});
// ─── Path helpers ──────────────────────────────────────────────────────────── // ─── Path helpers ────────────────────────────────────────────────────────────
/** Normalize a relative path to always use forward slashes (cross-platform). */ /** Normalize a relative path to always use forward slashes (cross-platform). */
@@ -604,10 +614,15 @@ function withPlanningLock(cwd, fn) {
acquired: new Date().toISOString(), acquired: new Date().toISOString(),
}), { flag: 'wx' }); }), { flag: 'wx' });
// Register for exit-time cleanup so process.exit(1) inside a locked region
// cannot leave a stale lock file (#1916).
_heldPlanningLocks.add(lockPath);
// Lock acquired — run the function // Lock acquired — run the function
try { try {
return fn(); return fn();
} finally { } finally {
_heldPlanningLocks.delete(lockPath);
try { fs.unlinkSync(lockPath); } catch { /* already released */ } try { fs.unlinkSync(lockPath); } catch { /* already released */ }
} }
} catch (err) { } catch (err) {

View File

@@ -12,6 +12,16 @@ function getStatePath(cwd) {
return planningPaths(cwd).state; return planningPaths(cwd).state;
} }
// Track all lock files held by this process so they can be removed on exit.
// process.on('exit') fires even on process.exit(1), unlike try/finally which is
// skipped when error() calls process.exit(1) inside a locked region (#1916).
const _heldStateLocks = new Set();
process.on('exit', () => {
for (const lockPath of _heldStateLocks) {
try { require('fs').unlinkSync(lockPath); } catch { /* already gone */ }
}
});
// Shared helper: extract a field value from STATE.md content. // Shared helper: extract a field value from STATE.md content.
// Supports both **Field:** bold and plain Field: format. // Supports both **Field:** bold and plain Field: format.
function stateExtractField(content, fieldName) { function stateExtractField(content, fieldName) {
@@ -184,18 +194,22 @@ function cmdStateUpdate(cwd, field, value) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
try { try {
let content = fs.readFileSync(statePath, 'utf-8'); let updated = false;
const fieldEscaped = escapeRegex(field); readModifyWriteStateMd(statePath, (content) => {
// Try **Field:** bold format first, then plain Field: format const fieldEscaped = escapeRegex(field);
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i'); // Try **Field:** bold format first, then plain Field: format
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im'); const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) { const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`); if (boldPattern.test(content)) {
writeStateMd(statePath, content, cwd); updated = true;
output({ updated: true }); return content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
} else if (plainPattern.test(content)) { } else if (plainPattern.test(content)) {
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`); updated = true;
writeStateMd(statePath, content, cwd); return content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
}
return content;
}, cwd);
if (updated) {
output({ updated: true }); output({ updated: true });
} else { } else {
output({ updated: false, reason: `Field "${field}" not found in STATE.md` }); output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
@@ -274,55 +288,67 @@ function cmdStateAdvancePlan(cwd, raw) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8');
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
let result = null;
// Try legacy separate fields first, then compound "Plan: X of Y" format readModifyWriteStateMd(statePath, (content) => {
const legacyPlan = stateExtractField(content, 'Current Plan'); // Try legacy separate fields first, then compound "Plan: X of Y" format
const legacyTotal = stateExtractField(content, 'Total Plans in Phase'); const legacyPlan = stateExtractField(content, 'Current Plan');
const planField = stateExtractField(content, 'Plan'); const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
const planField = stateExtractField(content, 'Plan');
let currentPlan, totalPlans; let currentPlan, totalPlans;
let useCompoundFormat = false; let useCompoundFormat = false;
if (legacyPlan && legacyTotal) { if (legacyPlan && legacyTotal) {
currentPlan = parseInt(legacyPlan, 10); currentPlan = parseInt(legacyPlan, 10);
totalPlans = parseInt(legacyTotal, 10); totalPlans = parseInt(legacyTotal, 10);
} else if (planField) { } else if (planField) {
// Compound format: "2 of 6 in current phase" or "2 of 6" // Compound format: "2 of 6 in current phase" or "2 of 6"
currentPlan = parseInt(planField, 10); currentPlan = parseInt(planField, 10);
const ofMatch = planField.match(/of\s+(\d+)/); const ofMatch = planField.match(/of\s+(\d+)/);
totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN; totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN;
useCompoundFormat = true; useCompoundFormat = true;
} }
if (isNaN(currentPlan) || isNaN(totalPlans)) { if (isNaN(currentPlan) || isNaN(totalPlans)) {
result = { error: true };
return content;
}
if (currentPlan >= totalPlans) {
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' };
} else {
const newPlan = currentPlan + 1;
let planDisplayValue;
if (useCompoundFormat) {
// Preserve compound format: "X of Y in current phase" → replace X only
planDisplayValue = planField.replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans };
}
return content;
}, cwd);
if (!result || result.error) {
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw); output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
return; return;
} }
if (currentPlan >= totalPlans) { if (result.advanced === false) {
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification'); output(result, raw, 'false');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Phase complete — ready for verification', lastActivity: today });
writeStateMd(statePath, content, cwd);
output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
} else { } else {
const newPlan = currentPlan + 1; output(result, raw, 'true');
let planDisplayValue;
if (useCompoundFormat) {
// Preserve compound format: "X of Y in current phase" → replace X only
planDisplayValue = planField.replace(/^\d+/, String(newPlan));
content = stateReplaceField(content, 'Plan', planDisplayValue) || content;
} else {
planDisplayValue = `${newPlan} of ${totalPlans}`;
content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
}
content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute');
content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today);
content = updateCurrentPositionFields(content, { status: 'Ready to execute', lastActivity: today, plan: planDisplayValue });
writeStateMd(statePath, content, cwd);
output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
} }
} }
@@ -330,7 +356,6 @@ function cmdStateRecordMetric(cwd, options, raw) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8');
const { phase, plan, duration, tasks, files } = options; const { phase, plan, duration, tasks, files } = options;
if (!phase || !plan || !duration) { if (!phase || !plan || !duration) {
@@ -338,22 +363,29 @@ function cmdStateRecordMetric(cwd, options, raw) {
return; return;
} }
// Find Performance Metrics section and its table let recorded = false;
const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; readModifyWriteStateMd(statePath, (content) => {
const metricsMatch = content.match(metricsPattern); // Find Performance Metrics section and its table
const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
const metricsMatch = content.match(metricsPattern);
if (metricsMatch) { if (metricsMatch) {
let tableBody = metricsMatch[2].trimEnd(); let tableBody = metricsMatch[2].trimEnd();
const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`; const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
if (tableBody.trim() === '' || tableBody.includes('None yet')) { if (tableBody.trim() === '' || tableBody.includes('None yet')) {
tableBody = newRow; tableBody = newRow;
} else { } else {
tableBody = tableBody + '\n' + newRow; tableBody = tableBody + '\n' + newRow;
}
recorded = true;
return content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`);
} }
return content;
}, cwd);
content = content.replace(metricsPattern, (_match, header) => `${header}${tableBody}\n`); if (recorded) {
writeStateMd(statePath, content, cwd);
output({ recorded: true, phase, plan, duration }, raw, 'true'); output({ recorded: true, phase, plan, duration }, raw, 'true');
} else { } else {
output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false'); output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
@@ -364,9 +396,7 @@ function cmdStateUpdateProgress(cwd, raw) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8'); // Count summaries across current milestone phases only (outside lock — read-only)
// Count summaries across current milestone phases only
const phasesDir = planningPaths(cwd).phases; const phasesDir = planningPaths(cwd).phases;
let totalPlans = 0; let totalPlans = 0;
let totalSummaries = 0; let totalSummaries = 0;
@@ -389,17 +419,26 @@ function cmdStateUpdateProgress(cwd, raw) {
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const progressStr = `[${bar}] ${percent}%`; const progressStr = `[${bar}] ${percent}%`;
// Try **Progress:** bold format first, then plain Progress: format let updated = false;
const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i; const _totalPlans = totalPlans;
const plainProgressPattern = /^(Progress:\s*).*/im; const _totalSummaries = totalSummaries;
if (boldProgressPattern.test(content)) {
content = content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`); readModifyWriteStateMd(statePath, (content) => {
writeStateMd(statePath, content, cwd); // Try **Progress:** bold format first, then plain Progress: format
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr); const boldProgressPattern = /(\*\*Progress:\*\*\s*).*/i;
} else if (plainProgressPattern.test(content)) { const plainProgressPattern = /^(Progress:\s*).*/im;
content = content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`); if (boldProgressPattern.test(content)) {
writeStateMd(statePath, content, cwd); updated = true;
output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr); return content.replace(boldProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
} else if (plainProgressPattern.test(content)) {
updated = true;
return content.replace(plainProgressPattern, (_match, prefix) => `${prefix}${progressStr}`);
}
return content;
}, cwd);
if (updated) {
output({ updated: true, percent, completed: _totalSummaries, total: _totalPlans, bar: progressStr }, raw, progressStr);
} else { } else {
output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false'); output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
} }
@@ -423,20 +462,26 @@ function cmdStateAddDecision(cwd, options, raw) {
if (!summaryText) { output({ error: 'summary required' }, raw); return; } if (!summaryText) { output({ error: 'summary required' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8');
const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? `${rationaleText}` : ''}`; const entry = `- [Phase ${phase || '?'}]: ${summaryText}${rationaleText ? `${rationaleText}` : ''}`;
let added = false;
// Find Decisions section (various heading patterns) readModifyWriteStateMd(statePath, (content) => {
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; // Find Decisions section (various heading patterns)
const match = content.match(sectionPattern); const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) { if (match) {
let sectionBody = match[2]; let sectionBody = match[2];
// Remove placeholders // Remove placeholders
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`); added = true;
writeStateMd(statePath, content, cwd); return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
}
return content;
}, cwd);
if (added) {
output({ added: true, decision: entry }, raw, 'true'); output({ added: true, decision: entry }, raw, 'true');
} else { } else {
output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false'); output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
@@ -458,18 +503,24 @@ function cmdStateAddBlocker(cwd, text, raw) {
if (!blockerText) { output({ error: 'text required' }, raw); return; } if (!blockerText) { output({ error: 'text required' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8');
const entry = `- ${blockerText}`; const entry = `- ${blockerText}`;
let added = false;
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; readModifyWriteStateMd(statePath, (content) => {
const match = content.match(sectionPattern); const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) { if (match) {
let sectionBody = match[2]; let sectionBody = match[2];
sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
content = content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`); added = true;
writeStateMd(statePath, content, cwd); return content.replace(sectionPattern, (_match, header) => `${header}${sectionBody}`);
}
return content;
}, cwd);
if (added) {
output({ added: true, blocker: blockerText }, raw, 'true'); output({ added: true, blocker: blockerText }, raw, 'true');
} else { } else {
output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
@@ -481,27 +532,33 @@ function cmdStateResolveBlocker(cwd, text, raw) {
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
if (!text) { output({ error: 'text required' }, raw); return; } if (!text) { output({ error: 'text required' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8'); let resolved = false;
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; readModifyWriteStateMd(statePath, (content) => {
const match = content.match(sectionPattern); const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
if (match) { if (match) {
const sectionBody = match[2]; const sectionBody = match[2];
const lines = sectionBody.split('\n'); const lines = sectionBody.split('\n');
const filtered = lines.filter(line => { const filtered = lines.filter(line => {
if (!line.startsWith('- ')) return true; if (!line.startsWith('- ')) return true;
return !line.toLowerCase().includes(text.toLowerCase()); return !line.toLowerCase().includes(text.toLowerCase());
}); });
let newBody = filtered.join('\n'); let newBody = filtered.join('\n');
// If section is now empty, add placeholder // If section is now empty, add placeholder
if (!newBody.trim() || !newBody.includes('- ')) { if (!newBody.trim() || !newBody.includes('- ')) {
newBody = 'None\n'; newBody = 'None\n';
}
resolved = true;
return content.replace(sectionPattern, (_match, header) => `${header}${newBody}`);
} }
return content;
}, cwd);
content = content.replace(sectionPattern, (_match, header) => `${header}${newBody}`); if (resolved) {
writeStateMd(statePath, content, cwd);
output({ resolved: true, blocker: text }, raw, 'true'); output({ resolved: true, blocker: text }, raw, 'true');
} else { } else {
output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false'); output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
@@ -512,31 +569,33 @@ function cmdStateRecordSession(cwd, options, raw) {
const statePath = planningPaths(cwd).state; const statePath = planningPaths(cwd).state;
if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
let content = fs.readFileSync(statePath, 'utf-8');
const now = new Date().toISOString(); const now = new Date().toISOString();
const updated = []; const updated = [];
// Update Last session / Last Date readModifyWriteStateMd(statePath, (content) => {
let result = stateReplaceField(content, 'Last session', now); // Update Last session / Last Date
if (result) { content = result; updated.push('Last session'); } let result = stateReplaceField(content, 'Last session', now);
result = stateReplaceField(content, 'Last Date', now); if (result) { content = result; updated.push('Last session'); }
if (result) { content = result; updated.push('Last Date'); } result = stateReplaceField(content, 'Last Date', now);
if (result) { content = result; updated.push('Last Date'); }
// Update Stopped at // Update Stopped at
if (options.stopped_at) { if (options.stopped_at) {
result = stateReplaceField(content, 'Stopped At', options.stopped_at); result = stateReplaceField(content, 'Stopped At', options.stopped_at);
if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at); if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
if (result) { content = result; updated.push('Stopped At'); } if (result) { content = result; updated.push('Stopped At'); }
} }
// Update Resume file // Update Resume file
const resumeFile = options.resume_file || 'None'; const resumeFile = options.resume_file || 'None';
result = stateReplaceField(content, 'Resume File', resumeFile); result = stateReplaceField(content, 'Resume File', resumeFile);
if (!result) result = stateReplaceField(content, 'Resume file', resumeFile); if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
if (result) { content = result; updated.push('Resume File'); } if (result) { content = result; updated.push('Resume File'); }
return content;
}, cwd);
if (updated.length > 0) { if (updated.length > 0) {
writeStateMd(statePath, content, cwd);
output({ recorded: true, updated }, raw, 'true'); output({ recorded: true, updated }, raw, 'true');
} else { } else {
output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false'); output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
@@ -805,6 +864,9 @@ function acquireStateLock(statePath) {
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY); const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
fs.writeSync(fd, String(process.pid)); fs.writeSync(fd, String(process.pid));
fs.closeSync(fd); fs.closeSync(fd);
// Register for exit-time cleanup so process.exit(1) inside a locked region
// cannot leave a stale lock file (#1916).
_heldStateLocks.add(lockPath);
return lockPath; return lockPath;
} catch (err) { } catch (err) {
if (err.code === 'EEXIST') { if (err.code === 'EEXIST') {
@@ -821,8 +883,7 @@ function acquireStateLock(statePath) {
return lockPath; return lockPath;
} }
const jitter = Math.floor(Math.random() * 50); const jitter = Math.floor(Math.random() * 50);
const start = Date.now(); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelay + jitter);
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
continue; continue;
} }
return lockPath; // non-EEXIST error — proceed without lock return lockPath; // non-EEXIST error — proceed without lock
@@ -832,6 +893,7 @@ function acquireStateLock(statePath) {
} }
function releaseStateLock(lockPath) { function releaseStateLock(lockPath) {
_heldStateLocks.delete(lockPath);
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ } try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
} }
@@ -913,96 +975,95 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) {
return; return;
} }
let content = fs.readFileSync(statePath, 'utf-8');
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const updated = []; const updated = [];
// Update Status field readModifyWriteStateMd(statePath, (content) => {
const statusValue = `Executing Phase ${phaseNumber}`; // Update Status field
let result = stateReplaceField(content, 'Status', statusValue); const statusValue = `Executing Phase ${phaseNumber}`;
if (result) { content = result; updated.push('Status'); } let result = stateReplaceField(content, 'Status', statusValue);
if (result) { content = result; updated.push('Status'); }
// Update Last Activity // Update Last Activity
result = stateReplaceField(content, 'Last Activity', today); result = stateReplaceField(content, 'Last Activity', today);
if (result) { content = result; updated.push('Last Activity'); } if (result) { content = result; updated.push('Last Activity'); }
// Update Last Activity Description if it exists // Update Last Activity Description if it exists
const activityDesc = `Phase ${phaseNumber} execution started`; const activityDesc = `Phase ${phaseNumber} execution started`;
result = stateReplaceField(content, 'Last Activity Description', activityDesc); result = stateReplaceField(content, 'Last Activity Description', activityDesc);
if (result) { content = result; updated.push('Last Activity Description'); } if (result) { content = result; updated.push('Last Activity Description'); }
// Update Current Phase // Update Current Phase
result = stateReplaceField(content, 'Current Phase', String(phaseNumber)); result = stateReplaceField(content, 'Current Phase', String(phaseNumber));
if (result) { content = result; updated.push('Current Phase'); } if (result) { content = result; updated.push('Current Phase'); }
// Update Current Phase Name // Update Current Phase Name
if (phaseName) { if (phaseName) {
result = stateReplaceField(content, 'Current Phase Name', phaseName); result = stateReplaceField(content, 'Current Phase Name', phaseName);
if (result) { content = result; updated.push('Current Phase Name'); } if (result) { content = result; updated.push('Current Phase Name'); }
}
// Update Current Plan to 1 (starting from the first plan)
result = stateReplaceField(content, 'Current Plan', '1');
if (result) { content = result; updated.push('Current Plan'); }
// Update Total Plans in Phase
if (planCount) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
}
// Update **Current focus:** body text line (#1104)
const focusLabel = phaseName ? `Phase ${phaseNumber}${phaseName}` : `Phase ${phaseNumber}`;
const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
if (focusPattern.test(content)) {
content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
updated.push('Current focus');
}
// Update ## Current Position section (#1104, #1365)
// Update individual fields within Current Position instead of replacing the
// entire section, so that Status, Last activity, and Progress are preserved.
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// Update or insert Phase line
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
posBody = newPhase + '\n' + posBody;
} }
// Update or insert Plan line // Update Current Plan to 1 (starting from the first plan)
const newPlan = `Plan: 1 of ${planCount || '?'}`; result = stateReplaceField(content, 'Current Plan', '1');
if (/^Plan:/m.test(posBody)) { if (result) { content = result; updated.push('Current Plan'); }
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
} else { // Update Total Plans in Phase
posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`); if (planCount) {
result = stateReplaceField(content, 'Total Plans in Phase', String(planCount));
if (result) { content = result; updated.push('Total Plans in Phase'); }
} }
// Update Status line if present // Update **Current focus:** body text line (#1104)
const newStatus = `Status: Executing Phase ${phaseNumber}`; const focusLabel = phaseName ? `Phase ${phaseNumber}${phaseName}` : `Phase ${phaseNumber}`;
if (/^Status:/m.test(posBody)) { const focusPattern = /(\*\*Current focus:\*\*\s*).*/i;
posBody = posBody.replace(/^Status:.*$/m, newStatus); if (focusPattern.test(content)) {
content = content.replace(focusPattern, (_match, prefix) => `${prefix}${focusLabel}`);
updated.push('Current focus');
} }
// Update Last activity line if present // Update ## Current Position section (#1104, #1365)
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`; // Update individual fields within Current Position instead of replacing the
if (/^Last activity:/im.test(posBody)) { // entire section, so that Status, Last activity, and Progress are preserved.
posBody = posBody.replace(/^Last activity:.*$/im, newActivity); const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = content.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// Update or insert Phase line
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
posBody = newPhase + '\n' + posBody;
}
// Update or insert Plan line
const newPlan = `Plan: 1 of ${planCount || '?'}`;
if (/^Plan:/m.test(posBody)) {
posBody = posBody.replace(/^Plan:.*$/m, newPlan);
} else {
posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`);
}
// Update Status line if present
const newStatus = `Status: Executing Phase ${phaseNumber}`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
}
content = content.replace(positionPattern, `${header}${posBody}`);
updated.push('Current Position');
} }
content = content.replace(positionPattern, `${header}${posBody}`); return content;
updated.push('Current Position'); }, cwd);
}
if (updated.length > 0) {
writeStateMd(statePath, content, cwd);
}
output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false'); output({ updated, phase: phaseNumber, phase_name: phaseName || null, plan_count: planCount || null }, raw, updated.length > 0 ? 'true' : 'false');
} }

View File

@@ -0,0 +1,377 @@
/**
* Regression tests for locking bugs #1909, #1916, #1925, #1927.
*
* These tests are written FIRST (TDD) — they must fail before the fixes are applied
* and pass after.
*
* #1909 — CPU-burning busy-wait in acquireStateLock
* #1916 — Lock files persist after process.exit()
* #1925 — TOCTOU races in 8 state commands (read outside lock)
* #1927 — config.json has no locking in setConfigValue
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync, execSync } = require('child_process');
const { promisify } = require('util');
const { exec } = require('child_process');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const execAsync = promisify(exec);
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function writeStateMd(tmpDir, content) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
content,
'utf-8'
);
}
function readStateMd(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
function writeConfig(tmpDir, obj) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
function readConfig(tmpDir) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
// ─────────────────────────────────────────────────────────────────────────────
// #1909 — CPU-burning busy-wait in acquireStateLock
// Verify the implementation uses Atomics.wait (not a while-loop spin).
// ─────────────────────────────────────────────────────────────────────────────
describe('#1909 acquireStateLock: no CPU-burning busy-wait', () => {
test('acquireStateLock source code uses Atomics.wait, not a spin-loop', () => {
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
// The bug: spin-loop pattern in acquireStateLock
// The fix: use Atomics.wait() for cross-platform sleep, matching withPlanningLock in core.cjs
const spinLoopPattern = /while\s*\(Date\.now\(\)\s*-\s*start\s*<\s*\w+\)\s*\{\s*(?:\/\*[^*]*\*\/)?\s*\}/;
// Find the acquireStateLock function text
const fnStart = stateSrc.indexOf('function acquireStateLock(');
assert.ok(fnStart !== -1, 'acquireStateLock function must exist');
// Extract ~50 lines after the function start to cover the retry logic
const fnSnippet = stateSrc.slice(fnStart, fnStart + 2000);
assert.ok(
!spinLoopPattern.test(fnSnippet),
'acquireStateLock must not use a CPU-burning spin-loop (while Date.now()-start < delay)'
);
assert.ok(
fnSnippet.includes('Atomics.wait'),
'acquireStateLock must use Atomics.wait() for sleeping, matching withPlanningLock in core.cjs'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1916 — Lock files persist after process.exit()
// Verify that the STATE.md.lock file is removed even when process.exit() is called
// while the lock is held (e.g., via error() inside a locked region).
// ─────────────────────────────────────────────────────────────────────────────
describe('#1916 lock cleanup on process.exit()', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md.lock is removed after a command exits with an error', () => {
// Intentionally trigger an error path: state update with missing STATE.md leaves
// no lock behind (the read-before-lock path returns gracefully, but let's verify
// a command that holds the lock can't accidentally leave the file).
writeStateMd(tmpDir, [
'# Project State',
'',
'**Status:** Planning',
'**Current Phase:** 01',
].join('\n') + '\n');
// Run a state update — even if it fails, the lock must not remain
runGsdTools('state update Status "In progress"', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(
!fs.existsSync(lockPath),
'STATE.md.lock must not persist after any state command terminates'
);
});
test('STATE.md.lock module-level cleanup set is present in source', () => {
// Verify the fix: module-level Set tracks held locks and process.on('exit') cleans them up.
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
assert.ok(
stateSrc.includes("process.on('exit'"),
"state.cjs must register process.on('exit', ...) to clean up held lock files"
);
});
test('core.cjs .planning/.lock is removed after a command exits with an error', () => {
// The withPlanningLock in core.cjs also needs exit cleanup.
const coreSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'),
'utf-8'
);
assert.ok(
coreSrc.includes("process.on('exit'"),
"core.cjs must register process.on('exit', ...) to clean up held planning lock files"
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1925 — TOCTOU races in 8 state commands
// Each of the 8 commands reads STATE.md outside the lock, then calls writeStateMd
// (which only locks the write). Two concurrent callers reading the same content
// means the second write clobbers the first.
//
// Fix: migrate all 8 to use readModifyWriteStateMd().
// Test: call the same command twice concurrently on SEPARATE fields and verify
// both updates survive.
// ─────────────────────────────────────────────────────────────────────────────
describe('#1925 TOCTOU: state commands use readModifyWriteStateMd', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state update: both concurrent updates to different fields survive', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Status:** Planning',
'**Current Phase:** 01',
'**Current Plan:** 01-01',
'**Last Activity:** 2024-01-01',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state update Status Executing --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state update "Current Phase" 02 --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Executing') && content.includes('02'),
'Both concurrent state update commands must survive (TOCTOU bug: second write clobbers first).\n' +
'Content:\n' + content
);
});
test('state add-decision: both concurrent calls append different decisions', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'',
'### Decisions',
'None yet.',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state add-decision --phase 01 --summary "Use TypeScript" --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state add-decision --phase 01 --summary "Use PostgreSQL" --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Use TypeScript') && content.includes('Use PostgreSQL'),
'Both concurrent add-decision calls must survive.\n' +
'Content:\n' + content
);
});
test('state add-blocker: both concurrent calls append different blockers', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Current Phase:** 01',
'',
'### Blockers',
'None.',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state add-blocker --text "Need API credentials" --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state add-blocker --text "Waiting for design review" --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Need API credentials') && content.includes('Waiting for design review'),
'Both concurrent add-blocker calls must survive.\n' +
'Content:\n' + content
);
});
test('state commands use readModifyWriteStateMd (source audit)', () => {
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
// Each of these functions should NOT contain a bare `fs.readFileSync(...STATE.md...)`
// followed by a `writeStateMd` — they should use readModifyWriteStateMd instead.
//
// We verify this by checking that within the function body we do NOT see the
// TOCTOU pattern: `let content = fs.readFileSync(statePath` (old pattern)
// while also calling `writeStateMd` — except wrapped in readModifyWrite.
const affectedFunctions = [
'cmdStateUpdate',
'cmdStateAdvancePlan',
'cmdStateRecordMetric',
'cmdStateUpdateProgress',
'cmdStateAddDecision',
'cmdStateAddBlocker',
'cmdStateResolveBlocker',
'cmdStateRecordSession',
'cmdStateBeginPhase',
];
for (const fnName of affectedFunctions) {
// Find the function in source
const fnIdx = stateSrc.indexOf(`function ${fnName}(`);
assert.ok(fnIdx !== -1, `${fnName} must exist in state.cjs`);
// Grab the function body (rough heuristic: up to the next top-level function)
const bodyStart = stateSrc.indexOf('{', fnIdx);
// Find end by tracking braces
let depth = 0;
let bodyEnd = bodyStart;
for (let i = bodyStart; i < stateSrc.length; i++) {
if (stateSrc[i] === '{') depth++;
else if (stateSrc[i] === '}') {
depth--;
if (depth === 0) { bodyEnd = i; break; }
}
}
const fnBody = stateSrc.slice(fnIdx, bodyEnd + 1);
// The function must call readModifyWriteStateMd
assert.ok(
fnBody.includes('readModifyWriteStateMd'),
`${fnName} must use readModifyWriteStateMd() to prevent TOCTOU races`
);
// The function must NOT have bare readFileSync for statePath outside the lambda
// (the readFileSync inside readModifyWrite's lambda is fine — that's inside the lock)
// We check for the pre-fix pattern: `let content = fs.readFileSync(statePath`
assert.ok(
!fnBody.match(/let content\s*=\s*fs\.readFileSync\s*\(\s*statePath/),
`${fnName} must not read STATE.md with fs.readFileSync outside readModifyWriteStateMd (TOCTOU)`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1927 — config.json has no locking in setConfigValue
// setConfigValue does read-modify-write on config.json without holding any lock.
// Fix: wrap in withPlanningLock.
// ─────────────────────────────────────────────────────────────────────────────
describe('#1927 config.json: setConfigValue must hold planning lock', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('both concurrent config-set calls persist their values', async () => {
writeConfig(tmpDir, {
model_profile: 'balanced',
workflow: {
research: true,
plan_check: true,
},
});
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" config-set model_profile quality --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" config-set workflow.research false --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const config = readConfig(tmpDir);
assert.strictEqual(
config.model_profile,
'quality',
'config-set model_profile must survive concurrent write'
);
assert.strictEqual(
config.workflow?.research,
false,
'config-set workflow.research must survive concurrent write'
);
});
test('config.cjs setConfigValue uses withPlanningLock (source audit)', () => {
const configSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'config.cjs'),
'utf-8'
);
// setConfigValue must import/use withPlanningLock
assert.ok(
configSrc.includes('withPlanningLock'),
'config.cjs must use withPlanningLock in setConfigValue to prevent concurrent write data loss'
);
});
});