wogiflow 2.33.0 → 2.34.2
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/.workflow/templates/partials/methodology-rules.hbs +3 -1
- 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 +116 -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-io.js +17 -0
- package/scripts/flow-paths.js +81 -0
- 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/flow-utils.js +2 -0
- package/scripts/hooks/adapters/claude-code.js +6 -2
- package/scripts/hooks/core/git-safety-gate.js +34 -15
- package/scripts/hooks/core/long-input-enforcement.js +49 -39
- package/scripts/hooks/core/overdue-dispatches.js +28 -6
- package/scripts/hooks/core/phase-gate.js +34 -5
- package/scripts/hooks/core/phase-read-gate.js +62 -10
- 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 +487 -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
- package/.claude/rules/README.md +0 -36
- package/.claude/rules/_internal/README.md +0 -64
- package/.claude/rules/_internal/document-structure.md +0 -77
- package/.claude/rules/_internal/dual-repo-management.md +0 -174
- package/.claude/rules/_internal/feature-refactoring-cleanup.md +0 -87
- package/.claude/rules/_internal/github-releases.md +0 -71
- package/.claude/rules/_internal/model-management.md +0 -35
- package/.claude/rules/_internal/self-maintenance.md +0 -87
- package/.claude/rules/_internal/worker-tool-first-turn.md +0 -82
- package/.claude/rules/alternative-execpolicy-toml-command-policy.md +0 -11
- package/.claude/rules/alternative-hand-edit-ready-json-to-register-orpha.md +0 -11
- package/.claude/rules/alternative-hook-args-exec-form.md +0 -6
- package/.claude/rules/alternative-permission-ruleset-per-phase.md +0 -11
- package/.claude/rules/alternative-short-name.md +0 -12
- package/.claude/rules/alternative-wogi-flow-as-mcp-client-oauth-manager.md +0 -11
- package/.claude/rules/architecture/component-reuse.md +0 -38
- package/.claude/rules/architecture/hook-three-layer.md +0 -68
- package/.claude/rules/code-style/naming-conventions.md +0 -107
- package/.claude/rules/dual-repo-architecture-2026-02-28.md +0 -18
- package/.claude/rules/github-release-workflow-2026-01-30.md +0 -16
- package/.claude/rules/operations/git-workflows.md +0 -92
- package/.claude/rules/operations/scratch-directory.md +0 -54
- package/.claude/skills/figma-analyzer/knowledge/learnings.md +0 -11
- package/.workflow/specs/architecture.md.template +0 -24
- package/.workflow/specs/stack.md.template +0 -33
- package/.workflow/specs/testing.md.template +0 -36
|
@@ -0,0 +1,487 @@
|
|
|
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
|
+
* Is autonomous walk-away mode active for this worker? Read from the canonical
|
|
190
|
+
* session-state.json. Tailors the stall directive (pre-approved → proceed) but
|
|
191
|
+
* does NOT change the never-idle guarantee — that holds in all worker mode.
|
|
192
|
+
*/
|
|
193
|
+
function isAutonomousActive(stateDir) {
|
|
194
|
+
try {
|
|
195
|
+
const { safeJsonParse } = require('../../flow-utils');
|
|
196
|
+
const ss = safeJsonParse(path.join(stateDir, 'session-state.json'), null);
|
|
197
|
+
return Boolean(ss && ss.autonomousMode && ss.autonomousMode.active);
|
|
198
|
+
} catch (_err) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Directive injected when an in-progress worker is parked at a gate. Tells it to
|
|
205
|
+
* make real progress by SATISFYING the gate legitimately (read the phase doc,
|
|
206
|
+
* decompose, provide evidence) — or to channel-escalate — and EXPLICITLY forbids
|
|
207
|
+
* gate circumvention. This is the integrity half of RC2: never give the worker a
|
|
208
|
+
* reason to reach for a worktree / marker-write.
|
|
209
|
+
*/
|
|
210
|
+
function buildStallDirective({ taskId, phase, remaining, total, attempt, k, autonomous, env }) {
|
|
211
|
+
const port = (env && env.WOGI_MANAGER_PORT) || '8800';
|
|
212
|
+
const repo = (env && env.WOGI_REPO_NAME) || 'worker';
|
|
213
|
+
const isParked = phase === 'exploring' || phase === 'spec_review';
|
|
214
|
+
const lines = [
|
|
215
|
+
`SUSTAINED EXECUTION — task ${taskId} is in progress but appears PARKED (phase=${phase}, ${remaining}/${total || 0} sub-tasks).`,
|
|
216
|
+
`You are a workspace worker. A dispatched task runs to COMPLETION across turns. Idling silently while a task is in-progress is NOT a valid end-of-turn state.`,
|
|
217
|
+
'',
|
|
218
|
+
'Make REAL progress THIS turn by SATISFYING the gate legitimately:'
|
|
219
|
+
];
|
|
220
|
+
if (isParked) {
|
|
221
|
+
lines.push(
|
|
222
|
+
` • You are in the ${phase} phase. Read the required phase instruction file (.claude/docs/phases/), finish the ${phase === 'spec_review' ? 'spec' : 'exploration'}, and advance the pipeline.`,
|
|
223
|
+
autonomous
|
|
224
|
+
? ` • Autonomous mode is ACTIVE → you are PRE-APPROVED. Do NOT wait for spec/architect approval — proceed.`
|
|
225
|
+
: ` • If this needs manager/user approval, channel-escalate (below) instead of waiting silently.`
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
lines.push(
|
|
229
|
+
` • The task is in an active phase but has no decomposed sub-task ledger yet. Decompose it (TodoWrite) and START the first sub-task, OR if a gate is blocking you, satisfy it (read the required phase doc / provide the required evidence).`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
lines.push(
|
|
233
|
+
'',
|
|
234
|
+
'PROHIBITED — gate circumvention is forbidden and pointless (gates resolve phase from the canonical main-repo state, not your cwd):',
|
|
235
|
+
' ✗ Do NOT create a git worktree to reach an "ungated" context.',
|
|
236
|
+
' ✗ Do NOT hand-write gate-satisfying markers or edit .workflow/state files to fake gate satisfaction.',
|
|
237
|
+
' ✗ Do NOT change working directory to dodge a gate.',
|
|
238
|
+
'',
|
|
239
|
+
'If you genuinely cannot proceed (blocked on the manager/user, or the next step is destructive / needs credentials), ESCALATE then end the turn:',
|
|
240
|
+
` curl -s -X POST http://127.0.0.1:${port} \\`,
|
|
241
|
+
` -H "X-Wogi-From: ${repo}" \\`,
|
|
242
|
+
` --data-binary "## QUESTION: <your blocker>"`,
|
|
243
|
+
'',
|
|
244
|
+
`(stall continuation ${attempt}/${k} — make real progress or escalate; ${k} idle turns in a row will auto-escalate to the manager and stop.)`
|
|
245
|
+
);
|
|
246
|
+
return lines.join('\n');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Stall handler (RC1). Drives a proceed-or-escalate continuation for an
|
|
251
|
+
* in-progress worker task the happy path won't cover, then escalates to the
|
|
252
|
+
* manager after `noProgressK` consecutive no-progress turns. Shares the per-task
|
|
253
|
+
* counter file but tracks stall progress in dedicated fields so it never
|
|
254
|
+
* conflates with the happy-path continuation count. Never returns a silent stop
|
|
255
|
+
* without having escalated.
|
|
256
|
+
*/
|
|
257
|
+
function handleStall({ stateDir, root, env, taskId, phase, remaining, total, fingerprintFn, cfg }) {
|
|
258
|
+
let counter = readCounter(stateDir);
|
|
259
|
+
if (!counter || counter.taskId !== taskId) {
|
|
260
|
+
counter = { taskId, count: 0, noProgressStreak: 0, fingerprint: null, escalated: false };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const fp = fingerprintFn(root, remaining);
|
|
264
|
+
|
|
265
|
+
// Use the SAME progress fields as the happy path (fingerprint /
|
|
266
|
+
// noProgressStreak / escalated). The stall and happy paths are mutually
|
|
267
|
+
// exclusive per turn but share one task counter; keeping separate fingerprint
|
|
268
|
+
// fields would break the escalated-resume check when a task transitions
|
|
269
|
+
// between modes after an escalation (the other mode's fingerprint is null →
|
|
270
|
+
// resume never fires → worker stuck "already-escalated"). Only `stallCount`
|
|
271
|
+
// is stall-specific (the attempt display).
|
|
272
|
+
|
|
273
|
+
// Respect an existing escalation — only resume if work moved since.
|
|
274
|
+
if (counter.escalated) {
|
|
275
|
+
if (counter.fingerprint && fp !== counter.fingerprint) {
|
|
276
|
+
counter.escalated = false;
|
|
277
|
+
counter.noProgressStreak = 0;
|
|
278
|
+
} else {
|
|
279
|
+
counter.fingerprint = fp;
|
|
280
|
+
writeCounter(stateDir, counter);
|
|
281
|
+
return { fired: false, decision: 'allow', reason: 'stall-already-escalated', escalated: true };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// No-progress streak (shared with happy path — no-progress is no-progress
|
|
286
|
+
// regardless of which mode produced the turn).
|
|
287
|
+
if (counter.fingerprint != null && fp === counter.fingerprint) {
|
|
288
|
+
counter.noProgressStreak = (counter.noProgressStreak || 0) + 1;
|
|
289
|
+
} else {
|
|
290
|
+
counter.noProgressStreak = 0;
|
|
291
|
+
}
|
|
292
|
+
counter.fingerprint = fp;
|
|
293
|
+
|
|
294
|
+
// Escalate after K consecutive no-progress turns, then stop.
|
|
295
|
+
if (counter.noProgressStreak >= cfg.noProgressK) {
|
|
296
|
+
counter.escalated = true;
|
|
297
|
+
writeCounter(stateDir, counter);
|
|
298
|
+
escalateBlocked({
|
|
299
|
+
workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
|
|
300
|
+
taskId, reason: `parked at "${phase}" gate with no progress across ${counter.noProgressStreak} turns`,
|
|
301
|
+
managerPort: env.WOGI_MANAGER_PORT
|
|
302
|
+
});
|
|
303
|
+
return { fired: false, decision: 'allow', reason: 'stall-escalated', escalated: true, phase };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Fire a proceed-or-escalate continuation.
|
|
307
|
+
counter.stallCount = (counter.stallCount || 0) + 1;
|
|
308
|
+
writeCounter(stateDir, counter);
|
|
309
|
+
const directive = buildStallDirective({
|
|
310
|
+
taskId, phase, remaining, total,
|
|
311
|
+
attempt: counter.noProgressStreak + 1, k: cfg.noProgressK,
|
|
312
|
+
autonomous: isAutonomousActive(stateDir), env
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
fired: true, decision: 'continue', reason: 'in-progress-stall',
|
|
316
|
+
taskId, phase, remaining, total, attempt: counter.stallCount, stopReason: directive
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Main gate. Returns one of:
|
|
322
|
+
* { continue: true, stopReason } — force continuation
|
|
323
|
+
* null — allow normal stop (no fire)
|
|
324
|
+
* plus a `decision` field for diagnostics/tests: 'continue' | 'allow' with reason.
|
|
325
|
+
*
|
|
326
|
+
* @param {Object} opts
|
|
327
|
+
* @param {Object} opts.config
|
|
328
|
+
* @param {string} [opts.stateDir] default PATHS.state
|
|
329
|
+
* @param {string} [opts.root] repo root for git probe (default PATHS.root)
|
|
330
|
+
* @param {Object} [opts.env] default process.env
|
|
331
|
+
* @param {Function} [opts.fingerprintFn] (root, remaining) => string (injectable for tests)
|
|
332
|
+
* @param {Object} [opts.subtaskState] injectable S1 module (tests)
|
|
333
|
+
*/
|
|
334
|
+
function checkWorkerContinuation(opts = {}) {
|
|
335
|
+
try {
|
|
336
|
+
const env = opts.env || process.env;
|
|
337
|
+
if (!isWorkerMode(env)) return { fired: false, decision: 'allow', reason: 'not-worker' };
|
|
338
|
+
|
|
339
|
+
const cfg = getCfg(opts.config);
|
|
340
|
+
if (!cfg.enabled) return { fired: false, decision: 'allow', reason: 'disabled' };
|
|
341
|
+
|
|
342
|
+
const { PATHS, safeJsonParse } = require('../../flow-utils');
|
|
343
|
+
const stateDir = opts.stateDir || PATHS.state;
|
|
344
|
+
const root = opts.root || PATHS.root;
|
|
345
|
+
|
|
346
|
+
// Active task?
|
|
347
|
+
const ready = safeJsonParse(path.join(stateDir, 'ready.json'), { inProgress: [] });
|
|
348
|
+
const task = (ready.inProgress || [])[0];
|
|
349
|
+
const taskId = task && task.id;
|
|
350
|
+
if (!taskId) {
|
|
351
|
+
clearCounter(stateDir); // nothing in progress — reset for next task
|
|
352
|
+
return { fired: false, decision: 'allow', reason: 'no-in-progress' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const phase = readPhase(stateDir);
|
|
356
|
+
const fingerprintFn = opts.fingerprintFn || defaultProgressFingerprint;
|
|
357
|
+
|
|
358
|
+
// Remaining decomposed work (needed by both happy-path and stall fallback).
|
|
359
|
+
const subtaskState = opts.subtaskState || require('../../../lib/workspace-subtask-state');
|
|
360
|
+
const summary = subtaskState.summary(taskId);
|
|
361
|
+
const remaining = summary.remaining;
|
|
362
|
+
const total = summary.total;
|
|
363
|
+
|
|
364
|
+
const PARKED_PHASES = ['exploring', 'spec_review'];
|
|
365
|
+
const inActivePhase = cfg.activePhases.includes(phase);
|
|
366
|
+
|
|
367
|
+
// Decomposed ledger exists and ALL sub-tasks are complete (total>0,
|
|
368
|
+
// remaining<=0): the task is wrapping up. Allow a clean stop so the worker
|
|
369
|
+
// can run `flow done` and task-complete can fire (preserves S2/S6
|
|
370
|
+
// termination — this is the completion boundary, not a parked stall).
|
|
371
|
+
if (inActivePhase && remaining <= 0 && total > 0) {
|
|
372
|
+
return { fired: false, decision: 'allow', reason: 'subtasks-complete' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Stall fallback (RC1, wf-e5e57361): an in-progress worker task that the
|
|
376
|
+
// happy path won't cover MUST NOT idle silently. Two shapes:
|
|
377
|
+
// (a) active phase but NO decomposed ledger ever (total<=0) — e.g. parked
|
|
378
|
+
// at an architect / phase-read gate before TodoWrite decomposition.
|
|
379
|
+
// (b) parked in an approval / explore phase (exploring / spec_review).
|
|
380
|
+
// Drive a proceed-or-escalate continuation; escalate to the manager after
|
|
381
|
+
// noProgressK no-progress turns. Never a silent allow-stop.
|
|
382
|
+
if ((inActivePhase && total <= 0) || PARKED_PHASES.includes(phase)) {
|
|
383
|
+
return handleStall({
|
|
384
|
+
stateDir, root, env, taskId, phase,
|
|
385
|
+
remaining, total, fingerprintFn, cfg
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Not actionable (idle / routing / completing / unknown) — genuinely allow a
|
|
390
|
+
// normal stop; there is no in-progress work to sustain here.
|
|
391
|
+
if (!inActivePhase) {
|
|
392
|
+
return { fired: false, decision: 'allow', reason: `phase-not-actionable:${phase || 'none'}` };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Happy path: active phase + remaining > 0 (unchanged S2 logic) ──
|
|
396
|
+
// Per-task counter (reset when the task changes).
|
|
397
|
+
let counter = readCounter(stateDir);
|
|
398
|
+
if (!counter || counter.taskId !== taskId) {
|
|
399
|
+
counter = { taskId, count: 0, noProgressStreak: 0, fingerprint: null, escalated: false };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Already escalated for this task ⇒ allow stop (don't re-fire). Manager
|
|
403
|
+
// re-dispatch / restart resets the counter (taskId match but escalated flag);
|
|
404
|
+
// we clear the escalation only when progress resumes (fingerprint changes).
|
|
405
|
+
const fingerprint = fingerprintFn(root, remaining);
|
|
406
|
+
|
|
407
|
+
if (counter.escalated) {
|
|
408
|
+
if (counter.fingerprint && fingerprint !== counter.fingerprint) {
|
|
409
|
+
// Work resumed since we escalated — clear and continue.
|
|
410
|
+
counter.escalated = false;
|
|
411
|
+
counter.noProgressStreak = 0;
|
|
412
|
+
} else {
|
|
413
|
+
return { fired: false, decision: 'allow', reason: 'already-escalated' };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const cap = derivedCap(summary.total, cfg);
|
|
418
|
+
|
|
419
|
+
// No-progress detection.
|
|
420
|
+
if (counter.fingerprint !== null && fingerprint === counter.fingerprint) {
|
|
421
|
+
counter.noProgressStreak = (counter.noProgressStreak || 0) + 1;
|
|
422
|
+
} else {
|
|
423
|
+
counter.noProgressStreak = 0;
|
|
424
|
+
}
|
|
425
|
+
counter.fingerprint = fingerprint;
|
|
426
|
+
|
|
427
|
+
if (counter.noProgressStreak >= cfg.noProgressK) {
|
|
428
|
+
counter.escalated = true;
|
|
429
|
+
writeCounter(stateDir, counter);
|
|
430
|
+
escalateBlocked({
|
|
431
|
+
workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
|
|
432
|
+
taskId, reason: `no progress across ${counter.noProgressStreak} continuations`,
|
|
433
|
+
managerPort: env.WOGI_MANAGER_PORT
|
|
434
|
+
});
|
|
435
|
+
return { fired: false, decision: 'allow', reason: 'no-progress-escalated', escalated: true };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (counter.count >= cap) {
|
|
439
|
+
counter.escalated = true;
|
|
440
|
+
writeCounter(stateDir, counter);
|
|
441
|
+
escalateBlocked({
|
|
442
|
+
workspaceRoot: env.WOGI_WORKSPACE_ROOT, repoName: env.WOGI_REPO_NAME,
|
|
443
|
+
taskId, reason: `iteration cap (${cap}) reached`,
|
|
444
|
+
managerPort: env.WOGI_MANAGER_PORT
|
|
445
|
+
});
|
|
446
|
+
return { fired: false, decision: 'allow', reason: 'cap-escalated', escalated: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Fire continuation.
|
|
450
|
+
counter.count += 1;
|
|
451
|
+
writeCounter(stateDir, counter);
|
|
452
|
+
const directive = buildContinueDirective({
|
|
453
|
+
taskId, remaining, total: summary.total, attempt: counter.count, cap
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
fired: true,
|
|
457
|
+
decision: 'continue',
|
|
458
|
+
reason: 'remaining-subtasks',
|
|
459
|
+
taskId,
|
|
460
|
+
remaining,
|
|
461
|
+
total: summary.total,
|
|
462
|
+
attempt: counter.count,
|
|
463
|
+
cap,
|
|
464
|
+
stopReason: directive
|
|
465
|
+
};
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (process.env.DEBUG) console.error(`[worker-continuation-gate] fail-open: ${err.message}`);
|
|
468
|
+
return { fired: false, decision: 'allow', reason: `error:${err.message}` };
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
module.exports = {
|
|
473
|
+
checkWorkerContinuation,
|
|
474
|
+
handleStall,
|
|
475
|
+
buildStallDirective,
|
|
476
|
+
isAutonomousActive,
|
|
477
|
+
isWorkerMode,
|
|
478
|
+
getCfg,
|
|
479
|
+
derivedCap,
|
|
480
|
+
buildContinueDirective,
|
|
481
|
+
readCounter,
|
|
482
|
+
writeCounter,
|
|
483
|
+
clearCounter,
|
|
484
|
+
getCounterPath,
|
|
485
|
+
readPhase,
|
|
486
|
+
DEFAULTS
|
|
487
|
+
};
|
|
@@ -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');
|