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.
@@ -1,76 +1,191 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Workspace worker-stopped notification (wf-6e31850e A-3 / extracted from stop.js).
4
+ * Workspace worker signal emission (epic-workspace-sustained-exec / S3, wf-d3ae1717;
5
+ * originally wf-6e31850e A-3 / wf-d3e67abe).
5
6
  *
6
- * Writes a structured `worker-stopped` message to the workspace message bus
7
- * so the manager's overdue-check can distinguish "graceful stop" from
8
- * "silent death" vs "task-complete". Original: wf-d3e67abe.
7
+ * Two distinct signals so the manager never mistakes a pause for a stop:
8
+ *
9
+ * notifyWorkerProgress() a HEARTBEAT, emitted when the continuation gate (S2)
10
+ * forces the worker to keep going. "Work ongoing," NOT a stop. Carries the
11
+ * current sub-task progress + git HEAD so the manager can refresh the
12
+ * dispatch deadline instead of polling git.
13
+ *
14
+ * notifyWorkerTerminal() — emitted ONLY on a genuine stop (after all gates
15
+ * decline to continue). Picks a precise terminal type:
16
+ * worker-awaiting-approval — in-progress task sitting in spec_review/exploring
17
+ * (waiting on the manager's GO — NOT done)
18
+ * worker-stopped — in-progress, active phase, but stopping anyway
19
+ * (legacy mid-work stop)
20
+ * worker-idle — nothing in progress and nothing queued
21
+ *
22
+ * The old code emitted `worker-stopped` UNCONDITIONALLY at the top of the Stop
23
+ * sequence — before the gates ran — so the manager saw "stopped mid-work" on
24
+ * every turn boundary, even when the worker was about to be forced to continue.
25
+ * That ordering bug is fixed: emission now happens at the decision point.
26
+ *
27
+ * All emission is best-effort and fail-open; never throws into the Stop path.
9
28
  */
10
29
 
11
- async function notifyWorkerStopped() {
12
- if (!process.env.WOGI_REPO_NAME || process.env.WOGI_REPO_NAME === 'manager') return;
30
+ const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
31
+ const AWAITING_PHASES = new Set(['spec_review', 'exploring', 'routing', 'idle']);
32
+
33
+ function isWorker() {
34
+ return Boolean(process.env.WOGI_REPO_NAME &&
35
+ process.env.WOGI_REPO_NAME !== 'manager' &&
36
+ VALID_NAME.test(process.env.WOGI_REPO_NAME) &&
37
+ process.env.WOGI_WORKSPACE_ROOT);
38
+ }
39
+
40
+ function readState() {
41
+ const nodePath = require('node:path');
42
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
43
+ const ready = safeJsonParse(nodePath.join(PATHS.state, 'ready.json'), {});
44
+ const phaseData = safeJsonParse(nodePath.join(PATHS.state, 'workflow-phase.json'), {});
45
+ const inProgressTask = (ready.inProgress || [])[0] || null;
46
+ const queued = (ready.ready || []).length;
47
+ let lastSha = null;
13
48
  try {
14
- const nodePath = require('node:path');
15
- const childProcess = require('node:child_process');
16
- const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
17
- const repoName = process.env.WOGI_REPO_NAME;
18
- if (!VALID_NAME.test(repoName)) throw new Error('Invalid WOGI_REPO_NAME');
49
+ lastSha = require('node:child_process').execSync('git rev-parse --short HEAD 2>/dev/null || true', {
50
+ cwd: PATHS.root, encoding: 'utf-8', timeout: 2000
51
+ }).trim() || null;
52
+ } catch (_err) { /* non-critical */ }
53
+ return { inProgressTask, queued, phase: phaseData?.phase || null, lastSha };
54
+ }
19
55
 
20
- const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
21
- if (!workspaceRoot) return;
56
+ function emit(type, fields) {
57
+ const nodePath = require('node:path');
58
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
59
+ const repoName = process.env.WOGI_REPO_NAME;
60
+ const libMessages = nodePath.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages');
61
+ const { createMessage, saveMessage } = require(libMessages);
62
+ const msg = createMessage({
63
+ from: repoName,
64
+ to: 'manager',
65
+ type,
66
+ subject: fields.subject,
67
+ body: fields.body,
68
+ priority: fields.priority || 'medium',
69
+ actionRequired: Boolean(fields.actionRequired)
70
+ });
71
+ Object.assign(msg, fields.extra || {});
72
+ saveMessage(workspaceRoot, msg);
22
73
 
23
- const { PATHS, safeJsonParse } = require('../../flow-utils');
24
- const ready = safeJsonParse(nodePath.join(PATHS.state, 'ready.json'), {});
25
- const recentTask = (ready.recentlyCompleted || [])[0];
26
- const inProgressTask = (ready.inProgress || [])[0];
27
- const mostRecent = recentTask || inProgressTask;
74
+ // Real-time nudge to the manager port (best-effort) so it doesn't have to wait
75
+ // for its own next prompt to read the bus.
76
+ try {
77
+ const managerPort = process.env.WOGI_MANAGER_PORT;
78
+ if (managerPort) {
79
+ const http = require('node:http');
80
+ const payload = `[${type}] ${fields.subject}`;
81
+ const buf = Buffer.from(payload, 'utf-8');
82
+ const req = http.request({
83
+ hostname: '127.0.0.1', port: parseInt(managerPort, 10), path: '/', method: 'POST',
84
+ headers: { 'Content-Type': 'text/plain', 'Content-Length': buf.byteLength, 'X-Wogi-From': repoName }
85
+ });
86
+ req.on('error', () => {});
87
+ req.write(buf); req.end();
88
+ }
89
+ } catch (_err) { /* best effort */ }
90
+ }
28
91
 
29
- const hasInProgress = Boolean(inProgressTask);
30
- const state = hasInProgress ? 'mid-work' : 'idle';
31
- const taskInProgress = hasInProgress ? inProgressTask.id : null;
92
+ /**
93
+ * Heartbeat the continuation gate forced another turn. NOT a stop.
94
+ * @param {Object} info { taskId, remaining, total, attempt }
95
+ */
96
+ async function notifyWorkerProgress(info = {}) {
97
+ if (!isWorker()) return;
98
+ try {
99
+ const { lastSha } = readState();
100
+ emit('worker-progress', {
101
+ subject: `Worker ${process.env.WOGI_REPO_NAME} working on ${info.taskId || 'task'} (${info.remaining ?? '?'} sub-task(s) left)`,
102
+ body: [
103
+ `Worker "${process.env.WOGI_REPO_NAME}" is continuing (heartbeat).`,
104
+ info.taskId ? `Task: ${info.taskId}` : null,
105
+ (info.total != null) ? `Sub-tasks: ${(info.total - (info.remaining ?? 0))}/${info.total} done, ${info.remaining ?? '?'} remaining` : null,
106
+ info.attempt != null ? `Continuation: ${info.attempt}` : null,
107
+ lastSha ? `HEAD: ${lastSha}` : null
108
+ ].filter(Boolean).join('\n'),
109
+ priority: 'low',
110
+ actionRequired: false,
111
+ extra: {
112
+ reason: 'continuation',
113
+ state: 'in-progress',
114
+ taskId: info.taskId || null,
115
+ taskInProgress: info.taskId || null,
116
+ remaining: info.remaining ?? null,
117
+ total: info.total ?? null,
118
+ continuation: info.attempt ?? null,
119
+ lastSha,
120
+ heartbeatAt: new Date().toISOString()
121
+ }
122
+ });
123
+ } catch (err) {
124
+ if (process.env.DEBUG) console.error(`[Stop] worker-progress emit failed: ${err.message}`);
125
+ }
126
+ }
32
127
 
33
- let lastSha = null;
34
- try {
35
- lastSha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
36
- cwd: PATHS.root,
37
- encoding: 'utf-8',
38
- timeout: 2000
39
- }).trim() || null;
40
- } catch (_err) { /* non-critical */ }
128
+ /**
129
+ * Terminal — emitted at a genuine stop. Picks the precise terminal type.
130
+ */
131
+ async function notifyWorkerTerminal() {
132
+ if (!isWorker()) return;
133
+ try {
134
+ const { inProgressTask, queued, phase, lastSha } = readState();
135
+ const taskId = inProgressTask?.id || null;
41
136
 
42
- try {
43
- const libMessages = nodePath.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages');
44
- const { createMessage, saveMessage } = require(libMessages);
45
- const msg = createMessage({
46
- from: repoName,
47
- to: 'manager',
48
- type: 'worker-stopped',
49
- subject: hasInProgress
50
- ? `Worker stopped mid-work on ${taskInProgress}`
51
- : `Worker stopped (idle)`,
52
- body: [
53
- `Worker "${repoName}" is stopping.`,
54
- `State: ${state}`,
55
- taskInProgress ? `Task in progress: ${taskInProgress}` : null,
56
- mostRecent?.title ? `Most recent task: ${mostRecent.title}` : null,
57
- lastSha ? `Last commit: ${lastSha}` : null
58
- ].filter(Boolean).join('\n'),
59
- priority: hasInProgress ? 'high' : 'medium',
60
- actionRequired: hasInProgress
61
- });
62
- msg.taskId = taskInProgress;
63
- msg.reason = 'graceful';
64
- msg.state = state;
65
- msg.taskInProgress = taskInProgress;
66
- msg.lastSha = lastSha;
67
- saveMessage(workspaceRoot, msg);
68
- } catch (err) {
69
- if (process.env.DEBUG) console.error(`[Stop] Workspace message write failed: ${err.message}`);
137
+ let type, subject, priority, actionRequired, state;
138
+ if (!inProgressTask) {
139
+ type = 'worker-idle';
140
+ state = 'idle';
141
+ subject = `Worker ${process.env.WOGI_REPO_NAME} idle (${queued} queued)`;
142
+ priority = 'medium';
143
+ actionRequired = queued > 0; // queued-but-not-started is worth the manager's attention
144
+ } else if (AWAITING_PHASES.has(phase)) {
145
+ type = 'worker-awaiting-approval';
146
+ state = 'awaiting-approval';
147
+ subject = `Worker ${process.env.WOGI_REPO_NAME} awaiting approval on ${taskId} (phase: ${phase})`;
148
+ priority = 'high';
149
+ actionRequired = true;
150
+ } else {
151
+ type = 'worker-stopped';
152
+ state = 'mid-work';
153
+ subject = `Worker stopped mid-work on ${taskId}`;
154
+ priority = 'high';
155
+ actionRequired = true;
70
156
  }
157
+
158
+ emit(type, {
159
+ subject,
160
+ body: [
161
+ `Worker "${process.env.WOGI_REPO_NAME}" stopped.`,
162
+ `State: ${state}`,
163
+ taskId ? `Task in progress: ${taskId}` : `Queued: ${queued}`,
164
+ phase ? `Phase: ${phase}` : null,
165
+ lastSha ? `Last commit: ${lastSha}` : null
166
+ ].filter(Boolean).join('\n'),
167
+ priority,
168
+ actionRequired,
169
+ extra: {
170
+ reason: 'graceful',
171
+ state,
172
+ taskId,
173
+ taskInProgress: taskId,
174
+ phase,
175
+ lastSha
176
+ }
177
+ });
71
178
  } catch (err) {
72
- if (process.env.DEBUG) console.error(`[Stop] Workspace notification failed: ${err.message}`);
179
+ if (process.env.DEBUG) console.error(`[Stop] worker terminal emit failed: ${err.message}`);
73
180
  }
74
181
  }
75
182
 
76
- module.exports = { notifyWorkerStopped };
183
+ module.exports = {
184
+ notifyWorkerProgress,
185
+ notifyWorkerTerminal,
186
+ isWorker,
187
+ AWAITING_PHASES,
188
+ // Back-compat alias: the old single unconditional emitter. Now routes to the
189
+ // typed terminal notifier (callers that want a terminal stop signal).
190
+ notifyWorkerStopped: notifyWorkerTerminal
191
+ };
@@ -57,6 +57,32 @@ runHook('PostToolUse', async ({ parsedInput }) => {
57
57
  }
58
58
  }
59
59
 
60
+ // S1 (wf-e72350bf): mirror the worker's TodoWrite decomposition to the durable
61
+ // sub-task ledger so the continuation gate (S2) and restart-resume (S5) have a
62
+ // disk-readable "work remaining" signal. Worker-mode only by default (solo
63
+ // sessions opt in via workspace.subtaskLedger.alsoInSolo). Fail-open.
64
+ if (toolName === 'TodoWrite' && !toolFailed) {
65
+ try {
66
+ const { getConfig, PATHS, safeJsonParse } = require('../../../flow-utils');
67
+ const cfg = (() => { try { return getConfig()?.workspace?.subtaskLedger || {}; } catch (_err) { return {}; } })();
68
+ const ledgerEnabled = cfg.enabled !== false;
69
+ const isWorker = process.env.WOGI_WORKSPACE_ROOT &&
70
+ process.env.WOGI_REPO_NAME &&
71
+ process.env.WOGI_REPO_NAME !== 'manager';
72
+ if (ledgerEnabled && (isWorker || cfg.alsoInSolo === true)) {
73
+ const ready = safeJsonParse(require('node:path').join(PATHS.state, 'ready.json'), { inProgress: [] });
74
+ const taskId = ready.inProgress?.[0]?.id || null;
75
+ if (taskId) {
76
+ const subtaskState = require('../../../../lib/workspace-subtask-state');
77
+ const subs = subtaskState.subtasksFromTodos(toolInput);
78
+ if (subs.length > 0) subtaskState.write(taskId, subs);
79
+ }
80
+ }
81
+ } catch (err) {
82
+ if (process.env.DEBUG) console.error(`[post-tool-use] subtask-ledger mirror: ${err.message}`);
83
+ }
84
+ }
85
+
60
86
  // Track B B3 fix (2026-04-13): mark when templates are edited, so
61
87
  // session-end and the next /wogi-start can remind to run flow bridge sync.
62
88
  // Mechanical enforcement of self-maintenance.md §1.