wogiflow 2.33.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.
@@ -108,14 +108,19 @@ function getAffectedFileCount(targetRef) {
108
108
 
109
109
  /**
110
110
  * Create a backup branch at current HEAD.
111
+ *
112
+ * Uses `execFileSync` (no shell) per `security-patterns.md §8` — the branch
113
+ * name is timestamp-derived and currently safe, but going through the shell
114
+ * with template-string interpolation is the wrong-by-default pattern.
115
+ *
111
116
  * @returns {string|null} Branch name or null on failure.
112
117
  */
113
118
  function createBackupBranch() {
114
- const { execSync } = require('node:child_process');
119
+ const { execFileSync } = require('node:child_process');
115
120
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
116
121
  const branchName = `backup/pre-reset-${timestamp}`;
117
122
  try {
118
- execSync(`git branch "${branchName}"`, {
123
+ execFileSync('git', ['branch', branchName], {
119
124
  encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
120
125
  });
121
126
  return branchName;
@@ -126,20 +131,27 @@ function createBackupBranch() {
126
131
 
127
132
  /**
128
133
  * Clean up old backup branches, keeping only the most recent N.
134
+ *
135
+ * Uses `execFileSync` (no shell) per `security-patterns.md §8`. Branch names
136
+ * pulled from `git branch --list` output and passed back to `git branch -D`
137
+ * never touch a shell.
138
+ *
129
139
  * @param {number} keep
130
140
  */
131
141
  function rotateBackupBranches(keep) {
132
- const { execSync } = require('node:child_process');
142
+ const { execFileSync } = require('node:child_process');
133
143
  try {
134
- const branches = execSync('git branch --list "backup/pre-reset-*" --sort=-creatordate', {
135
- encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
136
- }).trim().split('\n').map(b => b.trim()).filter(Boolean);
144
+ const branches = execFileSync(
145
+ 'git',
146
+ ['branch', '--list', 'backup/pre-reset-*', '--sort=-creatordate'],
147
+ { encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] }
148
+ ).trim().split('\n').map(b => b.trim()).filter(Boolean);
137
149
 
138
150
  if (branches.length > keep) {
139
151
  const toDelete = branches.slice(keep);
140
152
  for (const branch of toDelete) {
141
153
  try {
142
- execSync(`git branch -D "${branch}"`, {
154
+ execFileSync('git', ['branch', '-D', branch], {
143
155
  cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe']
144
156
  });
145
157
  } catch (_err) {
@@ -170,27 +182,34 @@ function rotateBackupBranches(keep) {
170
182
  * `git checkout .` / `git restore .` through either way. This was BUG-1.
171
183
  *
172
184
  * @param {Object} [opts]
173
- * @param {Function} [opts.exec] - execSync replacement for testing (string cmd → string stdout, throws on error).
185
+ * @param {Function} [opts.exec] - execFileSync-shaped replacement for testing.
186
+ * Signature: `(file, args, opts) → stdout`. Throws on non-zero exit. Default
187
+ * uses `child_process.execFileSync` so no shell layer is involved — closes
188
+ * the security-patterns.md §8 violation that earlier versions had.
174
189
  * @returns {{ status: 'no-changes' | 'stashed' | 'failed', error?: string, stashRef?: string }}
175
190
  */
176
191
  function autoStash(opts = {}) {
177
- const exec = opts.exec || require('node:child_process').execSync;
192
+ const exec = opts.exec || require('node:child_process').execFileSync;
178
193
  const runOpts = { encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] };
179
194
 
180
195
  // 1. Anything to stash?
181
196
  let status;
182
197
  try {
183
- status = exec('git status --porcelain', runOpts).trim();
198
+ status = exec('git', ['status', '--porcelain'], runOpts).trim();
184
199
  } catch (err) {
185
200
  return { status: 'failed', error: `git status failed: ${err.message || err}` };
186
201
  }
187
202
  if (!status) return { status: 'no-changes' };
188
203
 
189
- // 2. Attempt the stash.
204
+ // 2. Attempt the stash. TOCTOU-resistant message (F18): include a random
205
+ // UUID-style suffix so two parallel runs can't collide on the stash@{0}
206
+ // verification step and so substring matches can't be forged by an
207
+ // attacker-controlled stash with the same timestamp prefix.
190
208
  const timestamp = new Date().toISOString().slice(0, 19);
191
- const stashMessage = `auto-backup-${timestamp}`;
209
+ const nonce = require('node:crypto').randomBytes(6).toString('hex');
210
+ const stashMessage = `auto-backup-${timestamp}-${nonce}`;
192
211
  try {
193
- exec(`git stash push -m "${stashMessage}"`, runOpts);
212
+ exec('git', ['stash', 'push', '-m', stashMessage], runOpts);
194
213
  } catch (err) {
195
214
  return { status: 'failed', error: `git stash push failed: ${err.message || err}` };
196
215
  }
@@ -198,11 +217,11 @@ function autoStash(opts = {}) {
198
217
  // 3. VERIFY the stash actually saved (BUG-1 / wf-2d3d09b8). A zero exit code
199
218
  // from `git stash push` is NOT proof: lock contention, broken hooks, or
200
219
  // edge cases can leave the working tree unchanged with status 0. Confirm
201
- // by reading `git stash list` and matching our timestamped message at
220
+ // by reading `git stash list` and matching our nonce-suffixed message at
202
221
  // stash@{0}.
203
222
  let stashList;
204
223
  try {
205
- stashList = exec('git stash list', runOpts);
224
+ stashList = exec('git', ['stash', 'list'], runOpts);
206
225
  } catch (err) {
207
226
  return { status: 'failed', error: `stash verification (git stash list) failed: ${err.message || err}` };
208
227
  }
@@ -53,7 +53,7 @@ function formatLine(record, now) {
53
53
  */
54
54
  function sweepAndReconcile(workspaceRoot) {
55
55
  let reconciled = 0;
56
- let readMessages, reconcileDispatch, readDispatches;
56
+ let readMessages, reconcileDispatch, readDispatches, refreshDispatchDeadline;
57
57
  try {
58
58
  const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages.js');
59
59
  const libTracking = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
@@ -61,6 +61,7 @@ function sweepAndReconcile(workspaceRoot) {
61
61
  const tracking = require(libTracking);
62
62
  reconcileDispatch = tracking.reconcileDispatch;
63
63
  readDispatches = tracking.readDispatches;
64
+ refreshDispatchDeadline = tracking.refreshDispatchDeadline;
64
65
  } catch (_err) {
65
66
  return 0; // Fail-open
66
67
  }
@@ -78,13 +79,32 @@ function sweepAndReconcile(workspaceRoot) {
78
79
  if (r.taskId && !byTaskId.has(r.taskId)) byTaskId.set(r.taskId, r);
79
80
  }
80
81
 
81
- // Pull both message types. readMessages throws on missing dir internally
82
- // but guards with existsSync, so it's safe.
82
+ // S3 (wf-d3ae1717): heartbeats refresh the deadline (work ongoing, NOT a
83
+ // silent halt); terminal types resolve the dispatch. worker-progress is
84
+ // applied FIRST so a heartbeat that arrived before a terminal doesn't keep a
85
+ // since-resolved dispatch alive.
86
+ try {
87
+ const heartbeats = readMessages(workspaceRoot, { type: 'worker-progress' });
88
+ if (refreshDispatchDeadline) {
89
+ for (const hb of heartbeats) {
90
+ const taskId = hb.taskId;
91
+ if (!taskId || !byTaskId.has(taskId)) continue;
92
+ try { refreshDispatchDeadline(workspaceRoot, taskId); } catch (_err) { /* per-record */ }
93
+ }
94
+ }
95
+ } catch (_err) { /* heartbeats are best-effort */ }
96
+
97
+ // Pull terminal message types. readMessages throws on missing dir internally
98
+ // but guards with existsSync, so it's safe. worker-blocked / worker-idle /
99
+ // worker-awaiting-approval are terminal stops alongside the legacy pair.
83
100
  let messages = [];
84
101
  try {
85
102
  const completes = readMessages(workspaceRoot, { type: 'task-complete' });
86
103
  const stops = readMessages(workspaceRoot, { type: 'worker-stopped' });
87
- messages = completes.concat(stops);
104
+ const blocked = readMessages(workspaceRoot, { type: 'worker-blocked' });
105
+ const idle = readMessages(workspaceRoot, { type: 'worker-idle' });
106
+ const awaiting = readMessages(workspaceRoot, { type: 'worker-awaiting-approval' });
107
+ messages = completes.concat(stops, blocked, idle, awaiting);
88
108
  } catch (_err) {
89
109
  return 0;
90
110
  }
@@ -93,8 +113,10 @@ function sweepAndReconcile(workspaceRoot) {
93
113
  const taskId = msg.taskId || (msg.type === 'task-complete' ? msg.subject : null);
94
114
  if (!taskId || !byTaskId.has(taskId)) continue;
95
115
  try {
96
- const status = msg.type === 'worker-stopped' ? 'graceful-stop' : 'completed';
97
- const reason = msg.type === 'worker-stopped' ? (msg.reason || 'graceful') : null;
116
+ // task-complete completed; everything else is a non-overdue graceful
117
+ // stop (the reason field distinguishes blocked / awaiting / idle / graceful).
118
+ const status = msg.type === 'task-complete' ? 'completed' : 'graceful-stop';
119
+ const reason = msg.type === 'task-complete' ? null : (msg.reason || msg.type);
98
120
  const result = reconcileDispatch(workspaceRoot, taskId, status, reason);
99
121
  if (result) {
100
122
  reconciled++;
@@ -40,6 +40,58 @@ function handleWorkerSessionStart() {
40
40
  const { isWorker, shouldAnnounceReady, announceWorkerReady } = require(WORKER_READY_LIB);
41
41
  if (!isWorker()) return { branch: 'skip', reason: 'not-worker' };
42
42
 
43
+ // S5 (wf-ee87a24e): RESUME-IN-PROGRESS. If this restarted session has a task
44
+ // still in `inProgress` with sub-tasks remaining (durable S1 ledger), resume
45
+ // THAT task — do NOT fall through to "announce idle" (which would orphan it)
46
+ // or pick a different next task. The durable ledger means completed sub-tasks
47
+ // are NOT redone. Also post a worker-ready ack so the manager actively
48
+ // re-triggers if the resume wake-up was missed.
49
+ try {
50
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
51
+ const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), { inProgress: [] });
52
+ const inProgress = (ready.inProgress || [])[0] || null;
53
+ if (inProgress && inProgress.id) {
54
+ let remaining = null, total = null;
55
+ try {
56
+ const subtaskState = require(path.join(__dirname, '..', '..', '..', 'lib', 'workspace-subtask-state.js'));
57
+ const summary = subtaskState.summary(inProgress.id);
58
+ remaining = summary.remaining; total = summary.total;
59
+ } catch (_err) { /* ledger optional */ }
60
+ // Only treat as resumable if there is remaining decomposed work, OR no
61
+ // ledger exists at all (single-step task interrupted mid-flight).
62
+ if (remaining === null || remaining > 0) {
63
+ // Best-effort ack so the manager knows the worker is back on this task.
64
+ // Bypass shouldAnnounceReady's empty-queue gating (it returns
65
+ // 'in-progress-not-empty' here by design) — for a resume we WANT the
66
+ // manager pinged. announceWorkerReady dedups via hasPendingAnnounce.
67
+ try {
68
+ const wr = require(WORKER_READY_LIB);
69
+ const wsRoot = process.env.WOGI_WORKSPACE_ROOT;
70
+ const repoName = process.env.WOGI_REPO_NAME;
71
+ if (wsRoot && repoName && repoName !== 'manager') {
72
+ wr.announceWorkerReady(wsRoot, repoName);
73
+ }
74
+ } catch (_err) { /* ack is best-effort */ }
75
+ const ctx = [
76
+ `⚡ WORKSPACE SESSION START — RESUMING IN-PROGRESS TASK`,
77
+ '',
78
+ `This worker restarted with task ${inProgress.id} still in progress${total != null ? ` (${remaining} of ${total} sub-task(s) remaining)` : ''}.`,
79
+ `Durable sub-task state is on disk — completed sub-tasks are recorded and must NOT be redone.`,
80
+ '',
81
+ 'AUTONOMOUS MODE CONTRACT (workspace worker):',
82
+ ' • Resume the SAME task — do not pick a different one, do not go idle.',
83
+ ' • Read .workflow/state/subtask-state.json to see which sub-tasks remain.',
84
+ ' • Grind to completion; only stop when done (flow done) or genuinely blocked.',
85
+ '',
86
+ `ACT NOW: Invoke Skill(skill="wogi-start", args="${inProgress.id}")`
87
+ ].join('\n');
88
+ return { branch: 'resume-in-progress', context: ctx, taskId: inProgress.id, remaining, total };
89
+ }
90
+ }
91
+ } catch (err) {
92
+ if (process.env.DEBUG) console.error(`[session-start-worker] resume-in-progress check failed (fail-open): ${err.message}`);
93
+ }
94
+
43
95
  // Check for queued work first — if any, tell the model to pick it up
44
96
  // instead of announcing idle readiness.
45
97
  let pickup;
@@ -87,8 +87,12 @@ async function orchestrateStop({ parsedInput }) {
87
87
  };
88
88
  }
89
89
 
90
+ // S3 (wf-d3ae1717): the worker-stopped emission used to fire HERE,
91
+ // unconditionally, before any gate decided to continue — so the manager saw
92
+ // "stopped mid-work" on every turn boundary. It now fires only at a genuine
93
+ // stop (end of this function) with a precise terminal type, and a
94
+ // worker-progress heartbeat fires from the continuation gate instead.
90
95
  const workspaceNotify = require('./workspace-stop-notify');
91
- await workspaceNotify.notifyWorkerStopped();
92
96
 
93
97
  const restartCoordinator = require('./task-boundary-restart-coordinator');
94
98
  const restartResult = await restartCoordinator.handleTaskBoundaryRestart({ parsedInput });
@@ -120,7 +124,18 @@ async function orchestrateStop({ parsedInput }) {
120
124
  const wsResult = await workspaceGates.checkWorkspaceStopGates({ parsedInput });
121
125
  if (wsResult?.shouldReturn) return wsResult.result;
122
126
 
123
- return await checkLoopExit();
127
+ // Genuine stop path: no gate forced continuation. Emit a precise terminal
128
+ // worker signal ONLY when we're actually allowing the turn to end (canExit).
129
+ // continueToNext / blocked-continue are not terminal stops.
130
+ const loopResult = await checkLoopExit();
131
+ try {
132
+ if (loopResult?.canExit === true) {
133
+ await workspaceNotify.notifyWorkerTerminal();
134
+ }
135
+ } catch (err) {
136
+ if (process.env.DEBUG) console.error(`[Stop] terminal notify error (fail-open): ${err.message}`);
137
+ }
138
+ return loopResult;
124
139
  }
125
140
 
126
141
  module.exports = { orchestrateStop };
@@ -222,6 +222,14 @@ async function runValidation(options = {}) {
222
222
 
223
223
  return {
224
224
  passed: allPassed,
225
+ // F6 (R-379): signal `blocked` so the adapter's `decision: 'block'` path
226
+ // actually fires when validation fails. Without this, the `continueOnBlock`
227
+ // wiring in transformPostToolUse is inert (decision is always undefined).
228
+ // With it, lint/typecheck failure after Edit/Write feeds back to Claude
229
+ // and (per the continueOnBlock setting) the turn continues so Claude can
230
+ // fix the error in-loop — which is what CLAUDE.md's "validate after every
231
+ // file edit" rule needs.
232
+ blocked: !allPassed,
225
233
  skipped: false,
226
234
  results,
227
235
  summary: generateValidationSummary(results, filePath)
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Worker In-Progress Continuation Gate
5
+ * (epic-workspace-sustained-exec / S2, wf-aee4a4fa)
6
+ *
7
+ * THE core fix for "channel-dispatched workers stall after one turn." The
8
+ * existing "Gap B" gate (workspace-stop-gates.js) only forces continuation when
9
+ * a dispatch is queued but NOT started (inProgressCount===0). When a decomposed
10
+ * task is already in-progress and the worker stops mid-way, nothing keeps it
11
+ * going. This gate fills that hole.
12
+ *
13
+ * On a worker Stop, if a task is in-progress in an active-work phase with
14
+ * sub-tasks remaining (durable ledger from S1) and no escalation is pending and
15
+ * the per-task cap isn't exhausted, it returns {continue:true} so Claude Code
16
+ * does NOT stop — keeping the SAME session alive (preserving in-context
17
+ * decomposition, sidestepping the flaky channel wake-up). The proven mechanism
18
+ * is the same one the routing-gate / research-required-gate / tool-first-gate
19
+ * already use.
20
+ *
21
+ * Termination (no infinite loop):
22
+ * - Task leaves inProgress (done) ⇒ remaining()=0 / no inProgress ⇒ gate stops.
23
+ * - Per-task iteration cap = total*perSubtaskTurns + capBuffer (≤ maxContinuations).
24
+ * - No-progress detector: a "progress fingerprint" = hash(git status --porcelain)
25
+ * + remaining-count. If it doesn't change across noProgressK consecutive
26
+ * continuations, the worker isn't doing anything ⇒ escalate ## BLOCKED + stop.
27
+ * Any file edit OR completed sub-task changes the fingerprint, so legitimately
28
+ * long multi-turn refactors (no commits yet) are NOT false-killed, and a
29
+ * failing `flow done` that the worker keeps editing around still counts as
30
+ * progress.
31
+ *
32
+ * Phase-gating: fires only in active-work phases (coding/validating). In
33
+ * spec_review / exploring the worker is legitimately waiting on the manager's
34
+ * approval, so the gate stays out of the way.
35
+ *
36
+ * Worker-mode only. Fail-open: any error returns null (normal Stop flow).
37
+ */
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const childProcess = require('node:child_process');
42
+ const crypto = require('node:crypto');
43
+
44
+ const COUNTER_FILE = 'worker-continuation.json';
45
+ const PHASE_FILE = 'workflow-phase.json';
46
+
47
+ const DEFAULTS = {
48
+ enabled: true,
49
+ activePhases: ['coding', 'validating'],
50
+ perSubtaskTurns: 6,
51
+ capBuffer: 4,
52
+ maxContinuations: 60,
53
+ noProgressK: 4
54
+ };
55
+
56
+ function getCfg(config) {
57
+ const ws = (config && config.workspace && config.workspace.continuationGate) || {};
58
+ return {
59
+ enabled: ws.enabled !== false,
60
+ activePhases: Array.isArray(ws.activePhases) && ws.activePhases.length ? ws.activePhases : DEFAULTS.activePhases,
61
+ perSubtaskTurns: Number.isFinite(ws.perSubtaskTurns) ? ws.perSubtaskTurns : DEFAULTS.perSubtaskTurns,
62
+ capBuffer: Number.isFinite(ws.capBuffer) ? ws.capBuffer : DEFAULTS.capBuffer,
63
+ maxContinuations: Number.isFinite(ws.maxContinuations) ? ws.maxContinuations : DEFAULTS.maxContinuations,
64
+ noProgressK: Number.isFinite(ws.noProgressK) ? ws.noProgressK : DEFAULTS.noProgressK
65
+ };
66
+ }
67
+
68
+ function isWorkerMode(env = process.env) {
69
+ return Boolean(env.WOGI_WORKSPACE_ROOT && env.WOGI_REPO_NAME && env.WOGI_REPO_NAME !== 'manager');
70
+ }
71
+
72
+ function getCounterPath(stateDir) {
73
+ return path.join(stateDir, COUNTER_FILE);
74
+ }
75
+
76
+ function readCounter(stateDir) {
77
+ try {
78
+ const raw = fs.readFileSync(getCounterPath(stateDir), 'utf-8');
79
+ const data = JSON.parse(raw);
80
+ if (data && typeof data === 'object') return data;
81
+ } catch (_err) { /* absent or unreadable */ }
82
+ return null;
83
+ }
84
+
85
+ function writeCounter(stateDir, state) {
86
+ try {
87
+ const p = getCounterPath(stateDir);
88
+ fs.mkdirSync(path.dirname(p), { recursive: true });
89
+ const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
90
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
91
+ fs.renameSync(tmp, p);
92
+ } catch (_err) { /* best effort */ }
93
+ }
94
+
95
+ function clearCounter(stateDir) {
96
+ try { fs.unlinkSync(getCounterPath(stateDir)); } catch (_err) { /* fine */ }
97
+ }
98
+
99
+ function readPhase(stateDir) {
100
+ try {
101
+ const raw = fs.readFileSync(path.join(stateDir, PHASE_FILE), 'utf-8');
102
+ const data = JSON.parse(raw);
103
+ return data && typeof data.phase === 'string' ? data.phase : null;
104
+ } catch (_err) {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Progress fingerprint: working-tree state (uncommitted edits count too) plus the
111
+ * remaining sub-task count. Changes whenever a file is touched OR a sub-task
112
+ * completes. Used to detect "doing nothing" without false-killing long refactors.
113
+ */
114
+ function defaultProgressFingerprint(root, remainingCount) {
115
+ let porcelain = '';
116
+ try {
117
+ porcelain = childProcess.execSync('git status --porcelain 2>/dev/null || true', {
118
+ cwd: root, encoding: 'utf-8', timeout: 3000
119
+ });
120
+ } catch (_err) { /* non-fatal */ }
121
+ let sha = '';
122
+ try {
123
+ sha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
124
+ cwd: root, encoding: 'utf-8', timeout: 2000
125
+ }).trim();
126
+ } catch (_err) { /* non-fatal */ }
127
+ return crypto.createHash('sha1').update(`${sha}\n${remainingCount}\n${porcelain}`).digest('hex');
128
+ }
129
+
130
+ function derivedCap(total, cfg) {
131
+ const base = (total > 0 ? total : 1) * cfg.perSubtaskTurns + cfg.capBuffer;
132
+ return Math.min(base, cfg.maxContinuations);
133
+ }
134
+
135
+ function buildContinueDirective({ taskId, remaining, total, attempt, cap }) {
136
+ return [
137
+ `SUSTAINED EXECUTION — task ${taskId} is in progress with ${remaining} of ${total} sub-task(s) remaining.`,
138
+ `You are a workspace worker. A dispatched task runs to COMPLETION across turns — do NOT stop to "report progress" mid-task.`,
139
+ '',
140
+ `Do the next sub-task NOW (one tool call to start). Keep going until ALL sub-tasks are done.`,
141
+ '',
142
+ 'Exit conditions (only these):',
143
+ ` • DONE → run \`flow done ${taskId}\` (quality gates run). When the task leaves inProgress, this gate stops automatically.`,
144
+ ` • BLOCKED on something only the manager/user can resolve, OR the next step is DESTRUCTIVE / IRREVERSIBLE /`,
145
+ ` touches PRODUCTION / needs external credentials → do NOT proceed. Escalate instead:`,
146
+ ` curl -s -X POST http://127.0.0.1:${process.env.WOGI_MANAGER_PORT || '8800'} \\`,
147
+ ` -H "X-Wogi-From: ${process.env.WOGI_REPO_NAME || 'worker'}" \\`,
148
+ ` --data-binary "## QUESTION: <blocker>" (then end the turn)`,
149
+ '',
150
+ `(continuation ${attempt}/${cap} — make real progress this turn or escalate; idle turns are detected and will be stopped.)`
151
+ ].join('\n');
152
+ }
153
+
154
+ /**
155
+ * Best-effort escalation to the manager when the gate gives up (cap / no-progress).
156
+ * Writes a worker-blocked message to the bus AND POSTs to the manager port.
157
+ */
158
+ function escalateBlocked({ workspaceRoot, repoName, taskId, reason, managerPort }) {
159
+ const summary = `## BLOCKED: ${reason} (task ${taskId})`;
160
+ try {
161
+ if (workspaceRoot) {
162
+ const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages');
163
+ const { createMessage, saveMessage } = require(libMessages);
164
+ const msg = createMessage({
165
+ from: repoName, to: 'manager', type: 'worker-blocked',
166
+ subject: `Worker ${repoName} blocked on ${taskId}`,
167
+ body: summary, priority: 'high', actionRequired: true
168
+ });
169
+ msg.taskId = taskId;
170
+ msg.reason = reason;
171
+ saveMessage(workspaceRoot, msg);
172
+ }
173
+ } catch (_err) { /* best effort */ }
174
+ try {
175
+ if (managerPort) {
176
+ const http = require('node:http');
177
+ const buf = Buffer.from(summary, 'utf-8');
178
+ const req = http.request({
179
+ hostname: '127.0.0.1', port: parseInt(managerPort, 10), path: '/', method: 'POST',
180
+ headers: { 'Content-Type': 'text/plain', 'Content-Length': buf.byteLength, 'X-Wogi-From': repoName }
181
+ });
182
+ req.on('error', () => {});
183
+ req.write(buf); req.end();
184
+ }
185
+ } catch (_err) { /* best effort */ }
186
+ }
187
+
188
+ /**
189
+ * Main gate. Returns one of:
190
+ * { continue: true, stopReason } — force continuation
191
+ * null — allow normal stop (no fire)
192
+ * plus a `decision` field for diagnostics/tests: 'continue' | 'allow' with reason.
193
+ *
194
+ * @param {Object} opts
195
+ * @param {Object} opts.config
196
+ * @param {string} [opts.stateDir] default PATHS.state
197
+ * @param {string} [opts.root] repo root for git probe (default PATHS.root)
198
+ * @param {Object} [opts.env] default process.env
199
+ * @param {Function} [opts.fingerprintFn] (root, remaining) => string (injectable for tests)
200
+ * @param {Object} [opts.subtaskState] injectable S1 module (tests)
201
+ */
202
+ function checkWorkerContinuation(opts = {}) {
203
+ try {
204
+ const env = opts.env || process.env;
205
+ if (!isWorkerMode(env)) return { fired: false, decision: 'allow', reason: 'not-worker' };
206
+
207
+ const cfg = getCfg(opts.config);
208
+ if (!cfg.enabled) return { fired: false, decision: 'allow', reason: 'disabled' };
209
+
210
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
211
+ const stateDir = opts.stateDir || PATHS.state;
212
+ const root = opts.root || PATHS.root;
213
+
214
+ // Active task?
215
+ const ready = safeJsonParse(path.join(stateDir, 'ready.json'), { inProgress: [] });
216
+ const task = (ready.inProgress || [])[0];
217
+ const taskId = task && task.id;
218
+ if (!taskId) {
219
+ clearCounter(stateDir); // nothing in progress — reset for next task
220
+ return { fired: false, decision: 'allow', reason: 'no-in-progress' };
221
+ }
222
+
223
+ // Active-work phase only (respect spec-approval / exploration waits).
224
+ const phase = readPhase(stateDir);
225
+ if (!cfg.activePhases.includes(phase)) {
226
+ return { fired: false, decision: 'allow', reason: `phase-not-active:${phase || 'none'}` };
227
+ }
228
+
229
+ // Remaining decomposed work?
230
+ const subtaskState = opts.subtaskState || require('../../../lib/workspace-subtask-state');
231
+ const summary = subtaskState.summary(taskId);
232
+ const remaining = summary.remaining;
233
+ if (remaining <= 0) {
234
+ return { fired: false, decision: 'allow', reason: 'no-remaining-subtasks' };
235
+ }
236
+
237
+ // Per-task counter (reset when the task changes).
238
+ let counter = readCounter(stateDir);
239
+ if (!counter || counter.taskId !== taskId) {
240
+ counter = { taskId, count: 0, noProgressStreak: 0, fingerprint: null, escalated: false };
241
+ }
242
+
243
+ // Already escalated for this task ⇒ allow stop (don't re-fire). Manager
244
+ // re-dispatch / restart resets the counter (taskId match but escalated flag);
245
+ // we clear the escalation only when progress resumes (fingerprint changes).
246
+ const fingerprintFn = opts.fingerprintFn || defaultProgressFingerprint;
247
+ const fingerprint = fingerprintFn(root, remaining);
248
+
249
+ if (counter.escalated) {
250
+ if (counter.fingerprint && fingerprint !== counter.fingerprint) {
251
+ // Work resumed since we escalated — clear and continue.
252
+ counter.escalated = false;
253
+ counter.noProgressStreak = 0;
254
+ } else {
255
+ return { fired: false, decision: 'allow', reason: 'already-escalated' };
256
+ }
257
+ }
258
+
259
+ const cap = derivedCap(summary.total, cfg);
260
+
261
+ // No-progress detection.
262
+ if (counter.fingerprint !== null && fingerprint === counter.fingerprint) {
263
+ counter.noProgressStreak = (counter.noProgressStreak || 0) + 1;
264
+ } else {
265
+ counter.noProgressStreak = 0;
266
+ }
267
+ counter.fingerprint = fingerprint;
268
+
269
+ if (counter.noProgressStreak >= cfg.noProgressK) {
270
+ counter.escalated = true;
271
+ writeCounter(stateDir, counter);
272
+ escalateBlocked({
273
+ workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
274
+ taskId, reason: `no progress across ${counter.noProgressStreak} continuations`,
275
+ managerPort: env.WOGI_MANAGER_PORT
276
+ });
277
+ return { fired: false, decision: 'allow', reason: 'no-progress-escalated', escalated: true };
278
+ }
279
+
280
+ if (counter.count >= cap) {
281
+ counter.escalated = true;
282
+ writeCounter(stateDir, counter);
283
+ escalateBlocked({
284
+ workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
285
+ taskId, reason: `iteration cap (${cap}) reached`,
286
+ managerPort: env.WOGI_MANAGER_PORT
287
+ });
288
+ return { fired: false, decision: 'allow', reason: 'cap-escalated', escalated: true };
289
+ }
290
+
291
+ // Fire continuation.
292
+ counter.count += 1;
293
+ writeCounter(stateDir, counter);
294
+ const directive = buildContinueDirective({
295
+ taskId, remaining, total: summary.total, attempt: counter.count, cap
296
+ });
297
+ return {
298
+ fired: true,
299
+ decision: 'continue',
300
+ reason: 'remaining-subtasks',
301
+ taskId,
302
+ remaining,
303
+ total: summary.total,
304
+ attempt: counter.count,
305
+ cap,
306
+ stopReason: directive
307
+ };
308
+ } catch (err) {
309
+ if (process.env.DEBUG) console.error(`[worker-continuation-gate] fail-open: ${err.message}`);
310
+ return { fired: false, decision: 'allow', reason: `error:${err.message}` };
311
+ }
312
+ }
313
+
314
+ module.exports = {
315
+ checkWorkerContinuation,
316
+ isWorkerMode,
317
+ getCfg,
318
+ derivedCap,
319
+ buildContinueDirective,
320
+ readCounter,
321
+ writeCounter,
322
+ clearCounter,
323
+ getCounterPath,
324
+ readPhase,
325
+ DEFAULTS
326
+ };
@@ -60,6 +60,27 @@ async function checkWorkspaceStopGates({ parsedInput }) {
60
60
  if (process.env.DEBUG) console.error(`[Stop] Workspace autopickup gate error (fail-open): ${err.message}`);
61
61
  }
62
62
 
63
+ // In-Progress Continuation Gate (S2 / wf-aee4a4fa) — the core sustained-
64
+ // execution fix. Gap B (above) handles NOT-STARTED dispatches; this handles
65
+ // an IN-PROGRESS decomposed task with sub-tasks remaining. Keeps the SAME
66
+ // session grinding via {continue:true} instead of going idle after one turn.
67
+ try {
68
+ const { checkWorkerContinuation } = require('./worker-continuation-gate');
69
+ const { getConfig } = require('../../flow-utils');
70
+ const result = checkWorkerContinuation({ config: getConfig() });
71
+ if (result?.fired && result.stopReason) {
72
+ // S3: emit a worker-progress heartbeat (NOT a terminal stop) so the
73
+ // manager sees ongoing work and refreshes the dispatch deadline.
74
+ try {
75
+ const { notifyWorkerProgress } = require('./workspace-stop-notify');
76
+ await notifyWorkerProgress({ taskId: result.taskId, remaining: result.remaining, total: result.total, attempt: result.attempt });
77
+ } catch (_err) { /* best effort */ }
78
+ return { shouldReturn: true, result: { __raw: true, continue: true, stopReason: result.stopReason } };
79
+ }
80
+ } catch (err) {
81
+ if (process.env.DEBUG) console.error(`[Stop] Worker continuation gate error (fail-open): ${err.message}`);
82
+ }
83
+
63
84
  // Worker Tool-First Turn Gate
64
85
  try {
65
86
  const { isWorkerMode, checkWorkerToolFirstTurn, renderBlockMessage } = require('./worker-tool-first-gate');