wogiflow 2.32.0 → 2.34.1
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/.claude/docs/claude-code-compatibility.md +51 -0
- package/.claude/docs/scheduled-mode.md +213 -0
- package/.claude/docs/skill-portability.md +190 -0
- package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
- package/.claude/settings.json +2 -1
- package/.claude/skills/_template/skill.md +1 -0
- package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
- package/.claude/skills/conventional-commit/skill.md +76 -0
- package/bin/flow +16 -0
- package/lib/scheduled-mode.js +374 -0
- package/lib/skill-export-agentskills.js +211 -0
- package/lib/skill-export-claude-plugin.js +183 -0
- package/lib/skill-portability.js +342 -0
- package/lib/skill-registry.js +32 -2
- package/lib/workspace-channel-server.js +106 -3
- package/lib/workspace-channel-tracking.js +102 -1
- package/lib/workspace-dispatch-tracking.js +28 -0
- package/lib/workspace-messages.js +32 -4
- package/lib/workspace-subtask-state.js +215 -0
- package/lib/workspace.js +81 -0
- package/package.json +2 -2
- package/scripts/flow +25 -0
- package/scripts/flow-config-defaults.js +20 -0
- package/scripts/flow-constants.js +3 -1
- package/scripts/flow-schedule.js +486 -0
- package/scripts/flow-scheduled-runner.js +659 -0
- package/scripts/flow-skill-export.js +334 -0
- package/scripts/flow-standards-checker.js +37 -0
- package/scripts/hooks/adapters/claude-code.js +18 -3
- package/scripts/hooks/core/git-safety-gate.js +118 -27
- package/scripts/hooks/core/long-input-enforcement.js +139 -4
- package/scripts/hooks/core/overdue-dispatches.js +28 -6
- package/scripts/hooks/core/session-start-worker.js +52 -0
- package/scripts/hooks/core/stop-orchestrator.js +17 -2
- package/scripts/hooks/core/validation.js +8 -0
- package/scripts/hooks/core/worker-continuation-gate.js +326 -0
- package/scripts/hooks/core/workspace-stop-gates.js +21 -0
- package/scripts/hooks/core/workspace-stop-notify.js +174 -59
- package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
|
@@ -22,6 +22,21 @@ const http = require('node:http');
|
|
|
22
22
|
const readline = require('node:readline');
|
|
23
23
|
const { safeJsonParseContent } = require('./utils');
|
|
24
24
|
|
|
25
|
+
// S5 (wf-ee87a24e): the version this long-lived server process loaded at boot.
|
|
26
|
+
// Compared against the on-disk package.json to detect a mid-session
|
|
27
|
+
// `npm i wogiflow@latest` that left this process running stale code.
|
|
28
|
+
const SERVER_VERSION = (() => {
|
|
29
|
+
try { return require('../package.json').version || null; } catch (_err) { return null; }
|
|
30
|
+
})();
|
|
31
|
+
function readDiskVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const pkgPath = require('node:path').join(__dirname, '..', 'package.json');
|
|
35
|
+
const raw = fs.readFileSync(pkgPath, 'utf-8'); // fresh read, bypasses require cache
|
|
36
|
+
return JSON.parse(raw).version || null;
|
|
37
|
+
} catch (_err) { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
// ============================================================
|
|
26
41
|
// Constants
|
|
27
42
|
// ============================================================
|
|
@@ -129,8 +144,11 @@ When you receive a message:
|
|
|
129
144
|
2. If it's a question or investigation request → do the work, then ALWAYS send results back
|
|
130
145
|
3. If it's a status check → respond with your current task status
|
|
131
146
|
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
SUSTAINED EXECUTION — a task dispatch runs to COMPLETION across turns:
|
|
148
|
+
A "/wogi-" dispatch (especially one you decompose into sub-tasks) is NOT a one-turn request. Work through ALL sub-tasks in the same session; the Stop hook's continuation gate will keep you going while the task is in-progress with work remaining. Do NOT stop to "report progress" mid-task — only reply when the task is COMPLETE or you are ESCALATING a blocker.
|
|
149
|
+
|
|
150
|
+
CRITICAL — REPLY TO THE MANAGER WHEN THE TASK IS DONE OR BLOCKED:
|
|
151
|
+
When the dispatched task is complete (or you must escalate), you MUST send results back using the workspace_send_message tool with to: "manager". The user only sees the manager terminal — if you don't reply, they never see your results.
|
|
134
152
|
|
|
135
153
|
Example: workspace_send_message(to: "manager", message: "## Investigation Results\\n\\n1. Found the bug in X\\n2. Root cause: Y\\n3. Fix: Z")
|
|
136
154
|
|
|
@@ -466,18 +484,60 @@ function broadcastSSE(event) {
|
|
|
466
484
|
|
|
467
485
|
const channelTracking = require('./workspace-channel-tracking');
|
|
468
486
|
|
|
487
|
+
// S4 (wf-87611c5e): the channel server is the only process that sees every
|
|
488
|
+
// inbound dispatch, so it owns the "ack-received" timestamp used by GET /status.
|
|
489
|
+
let _lastInboundAt = 0;
|
|
490
|
+
const STATUS_STALENESS_MS = (() => {
|
|
491
|
+
const raw = parseInt(process.env.WOGI_STATUS_STALENESS_MS || '', 10);
|
|
492
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 300000;
|
|
493
|
+
})();
|
|
494
|
+
|
|
469
495
|
// ============================================================
|
|
470
496
|
// HTTP Server
|
|
471
497
|
// ============================================================
|
|
472
498
|
|
|
473
499
|
const server = http.createServer(async (req, res) => {
|
|
474
|
-
// Health check — minimal info, no topology exposure
|
|
500
|
+
// Health check — minimal info, no topology exposure. PURE liveness: "the
|
|
501
|
+
// server process is up." Says nothing about whether the agent is working —
|
|
502
|
+
// use /status for that (S4).
|
|
475
503
|
if (req.method === 'GET' && req.url === '/health') {
|
|
476
504
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
477
505
|
res.end(JSON.stringify({ status: 'ok', repo: REPO_NAME, port: PORT }));
|
|
478
506
|
return;
|
|
479
507
|
}
|
|
480
508
|
|
|
509
|
+
// Activity status (S4 / wf-87611c5e) — the real execution state, so a manager
|
|
510
|
+
// can never mistake a channel POST `ok` for "work happening". Derived from the
|
|
511
|
+
// worker's own state files + the last inbound dispatch this server saw.
|
|
512
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
513
|
+
let body;
|
|
514
|
+
try {
|
|
515
|
+
const path = require('node:path');
|
|
516
|
+
const stateDir = path.join(process.cwd(), '.workflow', 'state');
|
|
517
|
+
body = channelTracking.computeWorkerStatus({
|
|
518
|
+
stateDir,
|
|
519
|
+
repoName: REPO_NAME,
|
|
520
|
+
lastInboundAt: _lastInboundAt || undefined,
|
|
521
|
+
stalenessMs: STATUS_STALENESS_MS
|
|
522
|
+
});
|
|
523
|
+
body.port = PORT;
|
|
524
|
+
// S5: version-drift signal — if the on-disk wogiflow differs from what this
|
|
525
|
+
// long-lived server loaded, a `flow workspace restart` is required to load it.
|
|
526
|
+
const diskVersion = readDiskVersion();
|
|
527
|
+
body.serverVersion = SERVER_VERSION;
|
|
528
|
+
body.diskVersion = diskVersion;
|
|
529
|
+
body.versionDrift = Boolean(SERVER_VERSION && diskVersion && SERVER_VERSION !== diskVersion);
|
|
530
|
+
if (body.versionDrift) {
|
|
531
|
+
body.restartRequired = `Server is running ${SERVER_VERSION} but ${diskVersion} is on disk — run 'flow workspace restart ${REPO_NAME}'`;
|
|
532
|
+
}
|
|
533
|
+
} catch (_err) {
|
|
534
|
+
body = { repo: REPO_NAME, port: PORT, state: 'unknown' };
|
|
535
|
+
}
|
|
536
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
537
|
+
res.end(JSON.stringify(body));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
481
541
|
// SSE endpoint for event subscriptions
|
|
482
542
|
if (req.method === 'GET' && req.url?.startsWith('/events')) {
|
|
483
543
|
const lastEventId = req.headers['last-event-id'] || '';
|
|
@@ -485,6 +545,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
485
545
|
return;
|
|
486
546
|
}
|
|
487
547
|
|
|
548
|
+
// Manager-triggered restart (S5 / wf-ee87a24e). Writes the wogi-claude
|
|
549
|
+
// wrapper's restart flag and SIGTERMs this server's parent (the claude
|
|
550
|
+
// process). The wrapper relaunches claude with a FRESH require cache —
|
|
551
|
+
// reloading any upgraded wogiflow code, and claude respawns this MCP server.
|
|
552
|
+
// No PID tracking needed; reuses the proven task-boundary restart loop.
|
|
553
|
+
if (req.method === 'POST' && (req.url === '/restart' || req.url === '/control/restart')) {
|
|
554
|
+
const rawFrom = req.headers['x-wogi-from'] || '';
|
|
555
|
+
// localhost-bound already; additionally require the manager as sender.
|
|
556
|
+
if (rawFrom && rawFrom !== 'manager' && rawFrom !== 'workspace-manager') {
|
|
557
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
558
|
+
res.end(JSON.stringify({ ok: false, error: 'restart may only be triggered by the manager' }));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
let scheduled = false;
|
|
562
|
+
try {
|
|
563
|
+
const fs = require('node:fs');
|
|
564
|
+
const nodePath = require('node:path');
|
|
565
|
+
const flagPath = process.env.WOGI_RESTART_FLAG ||
|
|
566
|
+
nodePath.join(process.cwd(), '.workflow', 'state', 'restart-requested');
|
|
567
|
+
fs.mkdirSync(nodePath.dirname(flagPath), { recursive: true });
|
|
568
|
+
fs.writeFileSync(flagPath, JSON.stringify({
|
|
569
|
+
version: 1, reason: 'manager-restart', repo: REPO_NAME,
|
|
570
|
+
triggeredAt: new Date().toISOString()
|
|
571
|
+
}, null, 2));
|
|
572
|
+
// Defer the SIGTERM briefly so the HTTP response flushes first.
|
|
573
|
+
const ppid = process.ppid;
|
|
574
|
+
setTimeout(() => { try { process.kill(ppid, 'SIGTERM'); } catch (_err) { /* parent gone */ } }, 150);
|
|
575
|
+
scheduled = true;
|
|
576
|
+
} catch (err) {
|
|
577
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
578
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(202, { 'Content-Type': 'application/json' });
|
|
582
|
+
res.end(JSON.stringify({ ok: true, scheduled, repo: REPO_NAME, note: 'worker restarting; channel server will respawn with fresh code' }));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
488
586
|
// Receive webhook (POST)
|
|
489
587
|
if (req.method === 'POST') {
|
|
490
588
|
const { body, truncated } = await collectBody(req, MAX_BODY_BYTES);
|
|
@@ -508,6 +606,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
508
606
|
cleanBody = body.substring(effortMatch[0].length);
|
|
509
607
|
}
|
|
510
608
|
|
|
609
|
+
// S4: record when a dispatch arrived (ack-received signal for /status).
|
|
610
|
+
if (channelTracking.DISPATCH_BODY_PATTERN.test(cleanBody)) {
|
|
611
|
+
_lastInboundAt = Date.now();
|
|
612
|
+
}
|
|
613
|
+
|
|
511
614
|
// Forward as channel notification to Claude Code
|
|
512
615
|
const meta = {
|
|
513
616
|
from,
|
|
@@ -115,11 +115,112 @@ function tryReconcileInboundCompletion(ctx, tracking) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ============================================================
|
|
119
|
+
// Worker activity status (epic-workspace-sustained-exec / S4, wf-87611c5e)
|
|
120
|
+
// ============================================================
|
|
121
|
+
|
|
122
|
+
const fsNode = require('node:fs');
|
|
123
|
+
const pathNode = require('node:path');
|
|
124
|
+
|
|
125
|
+
const ACTIVE_PHASES = new Set(['coding', 'validating']);
|
|
126
|
+
const DEFAULT_STALENESS_MS = 300000; // 5 min
|
|
127
|
+
|
|
128
|
+
function _safeRead(p) {
|
|
129
|
+
try { return JSON.parse(fsNode.readFileSync(p, 'utf-8')); } catch (_err) { return null; }
|
|
130
|
+
}
|
|
131
|
+
function _mtimeMs(p) {
|
|
132
|
+
try { return fsNode.statSync(p).mtimeMs; } catch (_err) { return 0; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Derive the worker's real execution state for GET /status. Distinguishes
|
|
137
|
+
* ack-received / work-started / in-progress / complete / blocked / idle so the
|
|
138
|
+
* manager can never mistake a channel POST `ok` (or `/health` ok) for progress.
|
|
139
|
+
*
|
|
140
|
+
* Pure-ish (reads files from stateDir); injectable for tests.
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} opts
|
|
143
|
+
* @param {string} opts.stateDir worker .workflow/state dir
|
|
144
|
+
* @param {string} [opts.repoName]
|
|
145
|
+
* @param {number} [opts.lastInboundAt] ms epoch of the last dispatch POST the server saw
|
|
146
|
+
* @param {number} [opts.stalenessMs] heartbeat freshness window
|
|
147
|
+
* @param {number} [opts.now]
|
|
148
|
+
* @returns {{repo, state, taskId, subtasks:{total,remaining}, lastHeartbeatAt, lastSha, phase}}
|
|
149
|
+
*/
|
|
150
|
+
function computeWorkerStatus(opts = {}) {
|
|
151
|
+
const stateDir = opts.stateDir;
|
|
152
|
+
const now = opts.now || Date.now();
|
|
153
|
+
const stalenessMs = Number.isFinite(opts.stalenessMs) ? opts.stalenessMs : DEFAULT_STALENESS_MS;
|
|
154
|
+
const out = {
|
|
155
|
+
repo: opts.repoName || null,
|
|
156
|
+
state: 'idle',
|
|
157
|
+
taskId: null,
|
|
158
|
+
subtasks: { total: 0, remaining: 0 },
|
|
159
|
+
lastHeartbeatAt: null,
|
|
160
|
+
lastSha: null,
|
|
161
|
+
phase: null
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
if (!stateDir) return out;
|
|
165
|
+
const ready = _safeRead(pathNode.join(stateDir, 'ready.json')) || {};
|
|
166
|
+
const phaseData = _safeRead(pathNode.join(stateDir, 'workflow-phase.json')) || {};
|
|
167
|
+
const ledger = _safeRead(pathNode.join(stateDir, 'subtask-state.json'));
|
|
168
|
+
const counter = _safeRead(pathNode.join(stateDir, 'worker-continuation.json'));
|
|
169
|
+
const phase = typeof phaseData.phase === 'string' ? phaseData.phase : null;
|
|
170
|
+
out.phase = phase;
|
|
171
|
+
|
|
172
|
+
const inProgress = (ready.inProgress || [])[0] || null;
|
|
173
|
+
|
|
174
|
+
// Activity freshness: newest mtime of the files a working worker touches.
|
|
175
|
+
const lastActivityMs = Math.max(
|
|
176
|
+
_mtimeMs(pathNode.join(stateDir, 'workflow-phase.json')),
|
|
177
|
+
_mtimeMs(pathNode.join(stateDir, 'subtask-state.json')),
|
|
178
|
+
_mtimeMs(pathNode.join(stateDir, 'worker-continuation.json'))
|
|
179
|
+
);
|
|
180
|
+
if (lastActivityMs > 0) out.lastHeartbeatAt = new Date(lastActivityMs).toISOString();
|
|
181
|
+
const activityFresh = lastActivityMs > 0 && (now - lastActivityMs) < stalenessMs;
|
|
182
|
+
|
|
183
|
+
if (!inProgress) {
|
|
184
|
+
const recent = (ready.recentlyCompleted || [])[0] || null;
|
|
185
|
+
const completedTs = recent && recent.completedAt ? Date.parse(recent.completedAt) : NaN;
|
|
186
|
+
if (Number.isFinite(completedTs) && (now - completedTs) < stalenessMs) {
|
|
187
|
+
out.state = 'complete';
|
|
188
|
+
out.taskId = recent.id || null;
|
|
189
|
+
} else {
|
|
190
|
+
out.state = 'idle';
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
out.taskId = inProgress.id || null;
|
|
196
|
+
if (ledger && (!ledger.taskId || ledger.taskId === out.taskId) && Array.isArray(ledger.subtasks)) {
|
|
197
|
+
const open = ledger.subtasks.filter(s => s && (s.status === 'pending' || s.status === 'in_progress')).length;
|
|
198
|
+
out.subtasks = { total: ledger.subtasks.length, remaining: open };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const escalated = counter && counter.taskId === out.taskId && counter.escalated === true;
|
|
202
|
+
if (escalated) {
|
|
203
|
+
out.state = 'blocked';
|
|
204
|
+
} else if (ACTIVE_PHASES.has(phase)) {
|
|
205
|
+
out.state = activityFresh ? 'in-progress' : 'work-started';
|
|
206
|
+
} else {
|
|
207
|
+
// Picked up (in inProgress) but not yet in active-work phase.
|
|
208
|
+
out.state = 'ack-received';
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
} catch (_err) {
|
|
212
|
+
return out; // fail-open: never 500
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
118
216
|
module.exports = {
|
|
119
217
|
TASK_ID_PATTERN,
|
|
120
218
|
DISPATCH_BODY_PATTERN,
|
|
121
219
|
QUESTION_BODY_PATTERN,
|
|
122
220
|
COMPLETION_BODY_PATTERN,
|
|
123
221
|
tryRecordInboundDispatch,
|
|
124
|
-
tryReconcileInboundCompletion
|
|
222
|
+
tryReconcileInboundCompletion,
|
|
223
|
+
computeWorkerStatus,
|
|
224
|
+
ACTIVE_PHASES,
|
|
225
|
+
DEFAULT_STALENESS_MS
|
|
125
226
|
};
|
|
@@ -136,6 +136,33 @@ function reconcileDispatch(workspaceRoot, taskId, status, reason) {
|
|
|
136
136
|
return null;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Refresh a pending dispatch's deadline on a worker-progress heartbeat
|
|
141
|
+
* (epic-workspace-sustained-exec / S3). A worker grinding through a decomposed
|
|
142
|
+
* task across many turns would otherwise blow past expectedDeadline and be
|
|
143
|
+
* misclassified as a silent-halt. Each heartbeat pushes the deadline out and
|
|
144
|
+
* records lastHeartbeatAt. Keeps status 'pending'. Returns the record or null.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} workspaceRoot
|
|
147
|
+
* @param {string} taskId
|
|
148
|
+
* @param {number} [extendMs=DEFAULT_DURATION_MS]
|
|
149
|
+
*/
|
|
150
|
+
function refreshDispatchDeadline(workspaceRoot, taskId, extendMs) {
|
|
151
|
+
const state = loadState(workspaceRoot);
|
|
152
|
+
const ms = Number.isFinite(extendMs) && extendMs > 0 ? extendMs : DEFAULT_DURATION_MS;
|
|
153
|
+
for (let i = state.dispatches.length - 1; i >= 0; i--) {
|
|
154
|
+
const r = state.dispatches[i];
|
|
155
|
+
if (r && r.taskId === taskId && r.status === 'pending') {
|
|
156
|
+
r.lastHeartbeatAt = new Date().toISOString();
|
|
157
|
+
r.expectedDeadline = new Date(Date.now() + ms).toISOString();
|
|
158
|
+
r.heartbeatCount = (r.heartbeatCount || 0) + 1;
|
|
159
|
+
saveState(workspaceRoot, state);
|
|
160
|
+
return r;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
/**
|
|
140
167
|
* Read all currently-active dispatch records (not archived).
|
|
141
168
|
*
|
|
@@ -306,6 +333,7 @@ module.exports = {
|
|
|
306
333
|
MAX_ACTIVE,
|
|
307
334
|
recordDispatch,
|
|
308
335
|
reconcileDispatch,
|
|
336
|
+
refreshDispatchDeadline,
|
|
309
337
|
readDispatches,
|
|
310
338
|
getOverdueDispatches,
|
|
311
339
|
attachCompletionSummary,
|
|
@@ -25,6 +25,10 @@ const MESSAGE_TYPES = [
|
|
|
25
25
|
'task-complete', // "I finished my side of feature Z"
|
|
26
26
|
'worker-stopped', // Graceful Stop hook — worker session ending, not necessarily at task completion
|
|
27
27
|
'worker-ready', // Fresh worker session with empty queue — "got anything for me?" (wf-restart-handoff)
|
|
28
|
+
'worker-progress', // Heartbeat on a forced continuation — work ongoing, NOT a stop (epic-workspace-sustained-exec S3)
|
|
29
|
+
'worker-blocked', // Escalation: gate hit a cap / no-progress / validation failure — needs manager (S2/S3)
|
|
30
|
+
'worker-idle', // Real terminal stop: nothing in progress and nothing queued (S3)
|
|
31
|
+
'worker-awaiting-approval', // Spec written, in spec_review — waiting on manager GO, NOT done (S3)
|
|
28
32
|
'needs-help', // "I'm stuck, can you check X on your side?"
|
|
29
33
|
'heads-up', // "I'm about to change Y, just FYI"
|
|
30
34
|
'impact-query', // Pre-dev: "I'm about to change X, will this break you?"
|
|
@@ -96,6 +100,32 @@ function createMessage({ from, to, type, subject, body, priority, diff, suggeste
|
|
|
96
100
|
// Message Persistence (Criterion 2 — lifecycle)
|
|
97
101
|
// ============================================================
|
|
98
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Atomically write a JSON file: tmp + fsync(file) + rename (+ best-effort dir
|
|
105
|
+
* fsync). Guarantees a concurrent reader sees old-or-new, never torn JSON, and
|
|
106
|
+
* survives the SIGTERM/relaunch boundary. (epic-workspace-sustained-exec / S3 —
|
|
107
|
+
* the manager was reading partial worker→manager messages off the bus.)
|
|
108
|
+
* @param {string} filePath
|
|
109
|
+
* @param {string} data
|
|
110
|
+
*/
|
|
111
|
+
function atomicWriteFile(filePath, data) {
|
|
112
|
+
const dir = path.dirname(filePath);
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
114
|
+
const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
|
|
115
|
+
const fd = fs.openSync(tmp, 'w');
|
|
116
|
+
try {
|
|
117
|
+
fs.writeSync(fd, data);
|
|
118
|
+
fs.fsyncSync(fd);
|
|
119
|
+
} finally {
|
|
120
|
+
fs.closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
fs.renameSync(tmp, filePath);
|
|
123
|
+
try {
|
|
124
|
+
const dfd = fs.openSync(dir, 'r');
|
|
125
|
+
try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
|
|
126
|
+
} catch (_err) { /* directory fsync best-effort (not supported on all FS) */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
/**
|
|
100
130
|
* Save a message to the workspace message bus
|
|
101
131
|
* @param {string} workspaceRoot
|
|
@@ -104,10 +134,8 @@ function createMessage({ from, to, type, subject, body, priority, diff, suggeste
|
|
|
104
134
|
*/
|
|
105
135
|
function saveMessage(workspaceRoot, message) {
|
|
106
136
|
const messagesDir = path.join(workspaceRoot, '.workspace', 'messages');
|
|
107
|
-
fs.mkdirSync(messagesDir, { recursive: true });
|
|
108
|
-
|
|
109
137
|
const filePath = path.join(messagesDir, `${message.id}.json`);
|
|
110
|
-
|
|
138
|
+
atomicWriteFile(filePath, JSON.stringify(message, null, 2));
|
|
111
139
|
return filePath;
|
|
112
140
|
}
|
|
113
141
|
|
|
@@ -178,7 +206,7 @@ function updateMessageStatus(workspaceRoot, messageId, newStatus, extra = {}) {
|
|
|
178
206
|
}
|
|
179
207
|
}
|
|
180
208
|
}
|
|
181
|
-
|
|
209
|
+
atomicWriteFile(filePath, JSON.stringify(message, null, 2));
|
|
182
210
|
return message;
|
|
183
211
|
} catch (_err) {
|
|
184
212
|
return null;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Durable Sub-task State (epic-workspace-sustained-exec / S1, wf-e72350bf)
|
|
5
|
+
*
|
|
6
|
+
* Sub-tasks of a decomposed task are normally tracked ONLY in Claude Code's
|
|
7
|
+
* in-context TodoWrite list, which is ephemeral: a Stop hook running in a fresh
|
|
8
|
+
* `node` process can't read it, and a session restart loses it entirely. That's
|
|
9
|
+
* the root cause behind workers re-executing completed sub-tasks after a restart
|
|
10
|
+
* and behind the continuation gate (S2) having no reliable "work remaining"
|
|
11
|
+
* signal.
|
|
12
|
+
*
|
|
13
|
+
* This module mirrors the decomposition to a durable, atomically-written ledger
|
|
14
|
+
* at `.workflow/state/subtask-state.json` so that:
|
|
15
|
+
* - a fresh-process Stop hook can read "N of M sub-tasks remain" (S2),
|
|
16
|
+
* - a restarted session can resume without redoing completed sub-tasks (S5).
|
|
17
|
+
*
|
|
18
|
+
* The ledger holds the CURRENT in-progress task's decomposition. There is one
|
|
19
|
+
* active task per worker at a time, so a single-task shape is sufficient and
|
|
20
|
+
* avoids stale cross-task contamination — read* helpers take an optional taskId
|
|
21
|
+
* and return empty when it doesn't match the ledger's task.
|
|
22
|
+
*
|
|
23
|
+
* Atomic write: tmp + fsync(file) + rename + fsync(dir), mirroring
|
|
24
|
+
* task-boundary-reset.js:writeCleanCompletionMarker so the ledger survives the
|
|
25
|
+
* SIGTERM/relaunch boundary and concurrent readers never see torn JSON.
|
|
26
|
+
*
|
|
27
|
+
* Fail-open throughout: any error degrades to "no durable state" (callers fall
|
|
28
|
+
* back to their prior behavior). Never throws.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('node:fs');
|
|
32
|
+
const path = require('node:path');
|
|
33
|
+
|
|
34
|
+
const { PATHS } = require('../scripts/flow-utils');
|
|
35
|
+
const { safeJsonParse } = require('../scripts/flow-io');
|
|
36
|
+
|
|
37
|
+
const LEDGER_FILE = 'subtask-state.json';
|
|
38
|
+
const LEDGER_VERSION = 1;
|
|
39
|
+
// Statuses that count as "still needs work" for remaining().
|
|
40
|
+
const OPEN_STATUSES = new Set(['pending', 'in_progress']);
|
|
41
|
+
const TERMINAL_STATUSES = new Set(['completed', 'blocked']);
|
|
42
|
+
|
|
43
|
+
function getLedgerPath() {
|
|
44
|
+
return path.join(PATHS.state, LEDGER_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a free-form sub-task entry (TodoWrite item or plain object/string)
|
|
49
|
+
* into the ledger shape { id, title, status }.
|
|
50
|
+
*/
|
|
51
|
+
function normalizeSubtask(entry, index) {
|
|
52
|
+
if (entry == null) return null;
|
|
53
|
+
if (typeof entry === 'string') {
|
|
54
|
+
return { id: String(index + 1).padStart(2, '0'), title: entry.slice(0, 500), status: 'pending' };
|
|
55
|
+
}
|
|
56
|
+
if (typeof entry !== 'object') return null;
|
|
57
|
+
const rawStatus = typeof entry.status === 'string' ? entry.status.toLowerCase() : 'pending';
|
|
58
|
+
const status = OPEN_STATUSES.has(rawStatus) || TERMINAL_STATUSES.has(rawStatus) ? rawStatus : 'pending';
|
|
59
|
+
const title = entry.title || entry.content || entry.text || entry.description || entry.activeForm || `Sub-task ${index + 1}`;
|
|
60
|
+
const id = entry.id != null ? String(entry.id) : String(index + 1).padStart(2, '0');
|
|
61
|
+
return { id, title: String(title).slice(0, 500), status };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map a Claude Code TodoWrite toolInput ({ todos: [{ content, status }] })
|
|
66
|
+
* into normalized sub-tasks. Returns [] for anything unparseable.
|
|
67
|
+
*/
|
|
68
|
+
function subtasksFromTodos(toolInput) {
|
|
69
|
+
try {
|
|
70
|
+
const todos = toolInput && Array.isArray(toolInput.todos) ? toolInput.todos : null;
|
|
71
|
+
if (!todos) return [];
|
|
72
|
+
return todos.map(normalizeSubtask).filter(Boolean);
|
|
73
|
+
} catch (_err) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the full ledger object, or null if absent/unreadable.
|
|
80
|
+
* @returns {{version:number, taskId:string, updatedAt:string, subtasks:Array}|null}
|
|
81
|
+
*/
|
|
82
|
+
function readLedger() {
|
|
83
|
+
try {
|
|
84
|
+
const data = safeJsonParse(getLedgerPath(), null);
|
|
85
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.subtasks)) return null;
|
|
86
|
+
return data;
|
|
87
|
+
} catch (_err) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read the sub-tasks for a given task. When taskId is provided and doesn't match
|
|
94
|
+
* the ledger's task, returns [] (the ledger belongs to a different task).
|
|
95
|
+
* @param {string} [taskId]
|
|
96
|
+
* @returns {Array<{id:string,title:string,status:string}>}
|
|
97
|
+
*/
|
|
98
|
+
function read(taskId) {
|
|
99
|
+
const led = readLedger();
|
|
100
|
+
if (!led) return [];
|
|
101
|
+
if (taskId && led.taskId && led.taskId !== taskId) return [];
|
|
102
|
+
return led.subtasks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Write/replace the ledger for taskId atomically.
|
|
107
|
+
* @param {string} taskId
|
|
108
|
+
* @param {Array} subtasks raw or normalized entries
|
|
109
|
+
* @returns {{written:boolean, reason?:string, path?:string}}
|
|
110
|
+
*/
|
|
111
|
+
function write(taskId, subtasks) {
|
|
112
|
+
if (!taskId) return { written: false, reason: 'no-task-id' };
|
|
113
|
+
try {
|
|
114
|
+
const normalized = (Array.isArray(subtasks) ? subtasks : [])
|
|
115
|
+
.map(normalizeSubtask)
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
const payload = {
|
|
118
|
+
version: LEDGER_VERSION,
|
|
119
|
+
taskId,
|
|
120
|
+
updatedAt: new Date().toISOString(),
|
|
121
|
+
subtasks: normalized
|
|
122
|
+
};
|
|
123
|
+
const p = getLedgerPath();
|
|
124
|
+
const dir = path.dirname(p);
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
|
|
127
|
+
const fd = fs.openSync(tmp, 'w');
|
|
128
|
+
try {
|
|
129
|
+
fs.writeSync(fd, JSON.stringify(payload, null, 2));
|
|
130
|
+
fs.fsyncSync(fd);
|
|
131
|
+
} finally {
|
|
132
|
+
fs.closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
fs.renameSync(tmp, p);
|
|
135
|
+
try {
|
|
136
|
+
const dfd = fs.openSync(dir, 'r');
|
|
137
|
+
try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
|
|
138
|
+
} catch (_err) { /* directory fsync best-effort (not supported on all FS) */ }
|
|
139
|
+
return { written: true, path: p };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return { written: false, reason: `write-failed: ${err.message}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Count sub-tasks still needing work (pending + in_progress) for taskId.
|
|
147
|
+
* blocked and completed do NOT count. Returns 0 when no matching ledger exists
|
|
148
|
+
* (no durable state ⇒ "nothing known to remain"; callers decide what that means).
|
|
149
|
+
* @param {string} [taskId]
|
|
150
|
+
* @returns {number}
|
|
151
|
+
*/
|
|
152
|
+
function remaining(taskId) {
|
|
153
|
+
const subs = read(taskId);
|
|
154
|
+
return subs.filter(s => OPEN_STATUSES.has(s.status)).length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Summary counts for heartbeats / status (S3, S4).
|
|
159
|
+
* @param {string} [taskId]
|
|
160
|
+
* @returns {{total:number, remaining:number, completed:number, blocked:number}}
|
|
161
|
+
*/
|
|
162
|
+
function summary(taskId) {
|
|
163
|
+
const subs = read(taskId);
|
|
164
|
+
return {
|
|
165
|
+
total: subs.length,
|
|
166
|
+
remaining: subs.filter(s => OPEN_STATUSES.has(s.status)).length,
|
|
167
|
+
completed: subs.filter(s => s.status === 'completed').length,
|
|
168
|
+
blocked: subs.filter(s => s.status === 'blocked').length
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Mark one sub-task's status, preserving the rest. No-op if the ledger task
|
|
174
|
+
* doesn't match. Returns the updated count.
|
|
175
|
+
* @param {string} taskId
|
|
176
|
+
* @param {string} subId
|
|
177
|
+
* @param {string} [status='completed']
|
|
178
|
+
*/
|
|
179
|
+
function markStatus(taskId, subId, status = 'completed') {
|
|
180
|
+
const led = readLedger();
|
|
181
|
+
if (!led || (taskId && led.taskId !== taskId)) return { written: false, reason: 'no-matching-ledger' };
|
|
182
|
+
const next = led.subtasks.map(s => (s.id === String(subId) ? { ...s, status } : s));
|
|
183
|
+
return write(led.taskId, next);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clear the ledger (e.g. on task completion). Best-effort.
|
|
188
|
+
*/
|
|
189
|
+
function clear() {
|
|
190
|
+
try {
|
|
191
|
+
fs.unlinkSync(getLedgerPath());
|
|
192
|
+
return { cleared: true };
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err && err.code !== 'ENOENT' && process.env.DEBUG) {
|
|
195
|
+
console.error(`[workspace-subtask-state] clear: ${err.code} ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
return { cleared: false };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
getLedgerPath,
|
|
203
|
+
normalizeSubtask,
|
|
204
|
+
subtasksFromTodos,
|
|
205
|
+
readLedger,
|
|
206
|
+
read,
|
|
207
|
+
write,
|
|
208
|
+
remaining,
|
|
209
|
+
summary,
|
|
210
|
+
markStatus,
|
|
211
|
+
clear,
|
|
212
|
+
LEDGER_FILE,
|
|
213
|
+
OPEN_STATUSES,
|
|
214
|
+
TERMINAL_STATUSES
|
|
215
|
+
};
|