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.
- package/lib/scheduled-mode.js +12 -15
- package/lib/skill-export-claude-plugin.js +41 -1
- package/lib/skill-portability.js +21 -3
- 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 +17 -0
- package/scripts/flow-constants.js +3 -1
- package/scripts/flow-schedule.js +23 -6
- package/scripts/flow-scheduled-runner.js +53 -8
- package/scripts/flow-standards-checker.js +37 -0
- package/scripts/hooks/adapters/claude-code.js +6 -2
- package/scripts/hooks/core/git-safety-gate.js +34 -15
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
142
|
+
const { execFileSync } = require('node:child_process');
|
|
133
143
|
try {
|
|
134
|
-
const branches =
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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] -
|
|
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').
|
|
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
|
|
209
|
+
const nonce = require('node:crypto').randomBytes(6).toString('hex');
|
|
210
|
+
const stashMessage = `auto-backup-${timestamp}-${nonce}`;
|
|
192
211
|
try {
|
|
193
|
-
exec(
|
|
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
|
|
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
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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');
|