wogiflow 2.22.1 → 2.22.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/lib/workspace-messages.js +1 -0
- package/lib/workspace-worker-ready.js +190 -0
- package/package.json +2 -2
- package/scripts/hooks/core/overdue-dispatches.js +146 -14
- package/scripts/hooks/core/session-context.js +17 -0
- package/scripts/hooks/core/session-start-worker.js +114 -0
- package/scripts/hooks/entry/claude-code/session-start.js +22 -0
|
@@ -23,6 +23,7 @@ const MESSAGE_TYPES = [
|
|
|
23
23
|
'bug-report', // "Your endpoint returns 500 when I send Y"
|
|
24
24
|
'task-complete', // "I finished my side of feature Z"
|
|
25
25
|
'worker-stopped', // Graceful Stop hook — worker session ending, not necessarily at task completion
|
|
26
|
+
'worker-ready', // Fresh worker session with empty queue — "got anything for me?" (wf-restart-handoff)
|
|
26
27
|
'needs-help', // "I'm stuck, can you check X on your side?"
|
|
27
28
|
'heads-up', // "I'm about to change Y, just FYI"
|
|
28
29
|
'impact-query', // Pre-dev: "I'm about to change X, will this break you?"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Workspace — Worker Readiness Announce (restart-handoff protocol)
|
|
5
|
+
*
|
|
6
|
+
* Problem this solves:
|
|
7
|
+
* When a worker session restarts (wogi-claude wrapper relaunches claude
|
|
8
|
+
* after a task-boundary), channel dispatches sent by the manager during
|
|
9
|
+
* the restart window can be lost — they arrive either while the old
|
|
10
|
+
* claude is shutting down or before the new claude has wired up its
|
|
11
|
+
* MCP channel, and the notification never reaches a live session.
|
|
12
|
+
*
|
|
13
|
+
* Previous sessions observed: manager dispatches session N, worker
|
|
14
|
+
* completes N and restarts, manager tries to dispatch N+1 during the
|
|
15
|
+
* restart gap, N+1 is lost, worker comes up fresh with empty queue
|
|
16
|
+
* and sits idle until the user notices and the manager re-dispatches.
|
|
17
|
+
*
|
|
18
|
+
* Design:
|
|
19
|
+
* File-based announce via the workspace-messages bus. When a worker
|
|
20
|
+
* SessionStart fires and the worker has zero in-progress tasks and
|
|
21
|
+
* zero queued channel dispatches, write a structured `worker-ready`
|
|
22
|
+
* message to `.workspace/messages/`. The manager's next turn sweeps
|
|
23
|
+
* the bus, cross-references the dispatched-tasks.json (wf-d3e67abe)
|
|
24
|
+
* to see if anything is owed to this worker, and surfaces lost
|
|
25
|
+
* dispatches for re-dispatch.
|
|
26
|
+
*
|
|
27
|
+
* File-based delivery is durable: no timing games, no buffer TTL,
|
|
28
|
+
* no dependency on the MCP channel server being up during the
|
|
29
|
+
* restart gap. Worker writes → manager reads → reconciles.
|
|
30
|
+
*
|
|
31
|
+
* Dedup:
|
|
32
|
+
* If a pending `worker-ready` message already exists for this repo,
|
|
33
|
+
* we skip — no need to stack announcements while the manager hasn't
|
|
34
|
+
* picked up the first one yet.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
const { safeReadJson } = require('./utils');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect if the current process is a workspace worker (not a manager and not a
|
|
43
|
+
* single-repo session). Mirrors the isWorkspaceWorker detection used in
|
|
44
|
+
* scripts/hooks/core/task-completed.js.
|
|
45
|
+
*
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
function isWorker() {
|
|
49
|
+
if (!process.env.WOGI_WORKSPACE_ROOT) return false;
|
|
50
|
+
const repo = process.env.WOGI_REPO_NAME;
|
|
51
|
+
if (!repo || repo === 'manager') return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the workspace root from env (worker mode).
|
|
57
|
+
*
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function getWorkspaceRoot() {
|
|
61
|
+
const root = process.env.WOGI_WORKSPACE_ROOT;
|
|
62
|
+
if (!root || !path.isAbsolute(root)) return null;
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check whether a pending worker-ready message already exists for this repo.
|
|
68
|
+
* Used to dedup — we don't need to stack announcements.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} workspaceRoot
|
|
71
|
+
* @param {string} repoName
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function hasPendingAnnounce(workspaceRoot, repoName) {
|
|
75
|
+
try {
|
|
76
|
+
const messagesDir = path.join(workspaceRoot, '.workspace', 'messages');
|
|
77
|
+
if (!fs.existsSync(messagesDir)) return false;
|
|
78
|
+
const files = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
try {
|
|
81
|
+
const msg = safeReadJson(path.join(messagesDir, file));
|
|
82
|
+
if (!msg) continue;
|
|
83
|
+
if (msg.type === 'worker-ready' &&
|
|
84
|
+
msg.from === repoName &&
|
|
85
|
+
msg.status === 'pending') {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
} catch (_err) { /* skip malformed */ }
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
} catch (_err) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Decide whether this worker should announce ready.
|
|
98
|
+
* Preconditions:
|
|
99
|
+
* - Worker mode (WOGI_WORKSPACE_ROOT + WOGI_REPO_NAME !== 'manager')
|
|
100
|
+
* - ready.json has zero in-progress tasks
|
|
101
|
+
* - ready.json has zero queued channel-dispatched tasks
|
|
102
|
+
* (if it has queued work, the SessionStart hook auto-invokes
|
|
103
|
+
* /wogi-start instead of announcing — different branch)
|
|
104
|
+
* - No pending worker-ready message already exists for this repo
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} [opts] - override knobs for testing
|
|
107
|
+
* @param {string} [opts.workspaceRoot]
|
|
108
|
+
* @param {string} [opts.repoName]
|
|
109
|
+
* @param {Object} [opts.readyData]
|
|
110
|
+
* @returns {{announce: boolean, reason: string, repoName?: string, workspaceRoot?: string}}
|
|
111
|
+
*/
|
|
112
|
+
function shouldAnnounceReady(opts = {}) {
|
|
113
|
+
const workspaceRoot = opts.workspaceRoot || getWorkspaceRoot();
|
|
114
|
+
const repoName = opts.repoName || process.env.WOGI_REPO_NAME;
|
|
115
|
+
|
|
116
|
+
if (!workspaceRoot) return { announce: false, reason: 'no-workspace-root' };
|
|
117
|
+
if (!opts.repoName && !isWorker()) return { announce: false, reason: 'not-worker' };
|
|
118
|
+
if (!repoName || repoName === 'manager') return { announce: false, reason: 'not-worker' };
|
|
119
|
+
|
|
120
|
+
let readyData = opts.readyData;
|
|
121
|
+
if (!readyData) {
|
|
122
|
+
try {
|
|
123
|
+
const { PATHS } = require('../scripts/flow-utils');
|
|
124
|
+
const readyPath = path.join(PATHS.state, 'ready.json');
|
|
125
|
+
readyData = safeReadJson(readyPath, { ready: [], inProgress: [] });
|
|
126
|
+
} catch (_err) {
|
|
127
|
+
readyData = { ready: [], inProgress: [] };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const inProgress = Array.isArray(readyData.inProgress) ? readyData.inProgress : [];
|
|
132
|
+
if (inProgress.length > 0) {
|
|
133
|
+
return { announce: false, reason: 'in-progress-not-empty' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const queuedChannel = (Array.isArray(readyData.ready) ? readyData.ready : [])
|
|
137
|
+
.filter(t => t && (
|
|
138
|
+
t.channelSource === 'wogi-workspace-channel' ||
|
|
139
|
+
t.dispatchedBy === 'workspace-manager' ||
|
|
140
|
+
(typeof t.source === 'string' && t.source.startsWith('workspace:'))
|
|
141
|
+
));
|
|
142
|
+
if (queuedChannel.length > 0) {
|
|
143
|
+
return { announce: false, reason: 'queued-channel-work-present' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (hasPendingAnnounce(workspaceRoot, repoName)) {
|
|
147
|
+
return { announce: false, reason: 'already-announced' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { announce: true, reason: 'ok', workspaceRoot, repoName };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write a worker-ready message to the workspace-messages bus.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} workspaceRoot
|
|
157
|
+
* @param {string} repoName
|
|
158
|
+
* @returns {{written: boolean, messageId?: string, path?: string, reason?: string}}
|
|
159
|
+
*/
|
|
160
|
+
function announceWorkerReady(workspaceRoot, repoName) {
|
|
161
|
+
try {
|
|
162
|
+
const { createMessage, saveMessage } = require('./workspace-messages');
|
|
163
|
+
const msg = createMessage({
|
|
164
|
+
from: repoName,
|
|
165
|
+
to: 'manager',
|
|
166
|
+
type: 'worker-ready',
|
|
167
|
+
subject: `Worker ${repoName} ready — queue empty, awaiting dispatch`,
|
|
168
|
+
body: [
|
|
169
|
+
`Worker "${repoName}" has started a fresh session with an empty task queue.`,
|
|
170
|
+
`If you dispatched any tasks to this worker that were lost during the`,
|
|
171
|
+
`restart window, they can be re-dispatched now. No pending work detected`,
|
|
172
|
+
`in ready.json (zero inProgress, zero queued channel dispatches).`
|
|
173
|
+
].join('\n'),
|
|
174
|
+
priority: 'medium',
|
|
175
|
+
actionRequired: false
|
|
176
|
+
});
|
|
177
|
+
const filePath = saveMessage(workspaceRoot, msg);
|
|
178
|
+
return { written: true, messageId: msg.id, path: filePath };
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { written: false, reason: `write-failed: ${err.message}` };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
isWorker,
|
|
186
|
+
getWorkspaceRoot,
|
|
187
|
+
hasPendingAnnounce,
|
|
188
|
+
shouldAnnounceReady,
|
|
189
|
+
announceWorkerReady
|
|
190
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wogiflow",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.2",
|
|
4
4
|
"description": "AI-powered development workflow management system with multi-model support",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"flow": "./scripts/flow",
|
|
13
|
-
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/flow-story-gates.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
13
|
+
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
14
14
|
"test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
|
|
15
15
|
"lint": "eslint scripts/ lib/ tests/",
|
|
16
16
|
"lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
|
|
@@ -108,10 +108,122 @@ function sweepAndReconcile(workspaceRoot) {
|
|
|
108
108
|
return reconciled;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Reconcile pending `worker-ready` messages (2.22.2 restart-handoff).
|
|
113
|
+
*
|
|
114
|
+
* A worker-ready message signals that a worker session started with an
|
|
115
|
+
* empty queue — possibly because a prior dispatch was lost during the
|
|
116
|
+
* wrapper's restart window. For each pending worker-ready:
|
|
117
|
+
* - Find pending dispatches to that repo in dispatched-tasks.json
|
|
118
|
+
* - If any found: they're likely the lost dispatches. Collect as
|
|
119
|
+
* `lostDispatches` for surface to the manager.
|
|
120
|
+
* - Mark the worker-ready message as acknowledged regardless — once
|
|
121
|
+
* the manager has seen it, there's nothing more to do with the
|
|
122
|
+
* same message. If another restart happens, a fresh worker-ready
|
|
123
|
+
* will be written.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} workspaceRoot
|
|
126
|
+
* @param {Object} [opts]
|
|
127
|
+
* @param {number} [opts.staleGraceMs=30000] — ignore dispatches newer than this
|
|
128
|
+
* (to avoid flagging just-sent dispatches still in flight).
|
|
129
|
+
* @returns {{acknowledged: number, lostDispatches: Array}}
|
|
130
|
+
*/
|
|
131
|
+
function reconcileWorkerReady(workspaceRoot, opts = {}) {
|
|
132
|
+
const staleGraceMs = Number.isFinite(opts.staleGraceMs) ? opts.staleGraceMs : 30000;
|
|
133
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
134
|
+
|
|
135
|
+
let readMessages, updateMessageStatus, readDispatches;
|
|
136
|
+
try {
|
|
137
|
+
const libMessages = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-messages.js');
|
|
138
|
+
const libTracking = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
|
|
139
|
+
const bus = require(libMessages);
|
|
140
|
+
readMessages = bus.readMessages;
|
|
141
|
+
updateMessageStatus = bus.updateMessageStatus;
|
|
142
|
+
const tracking = require(libTracking);
|
|
143
|
+
readDispatches = tracking.readDispatches;
|
|
144
|
+
} catch (_err) {
|
|
145
|
+
return { acknowledged: 0, lostDispatches: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let pendingReady = [];
|
|
149
|
+
try {
|
|
150
|
+
pendingReady = readMessages(workspaceRoot, { type: 'worker-ready', status: 'pending' });
|
|
151
|
+
} catch (_err) {
|
|
152
|
+
return { acknowledged: 0, lostDispatches: [] };
|
|
153
|
+
}
|
|
154
|
+
if (pendingReady.length === 0) return { acknowledged: 0, lostDispatches: [] };
|
|
155
|
+
|
|
156
|
+
let dispatches = [];
|
|
157
|
+
try {
|
|
158
|
+
dispatches = readDispatches(workspaceRoot).filter(r => r && r.status === 'pending');
|
|
159
|
+
} catch (_err) {
|
|
160
|
+
dispatches = [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lostDispatches = [];
|
|
164
|
+
let acknowledged = 0;
|
|
165
|
+
|
|
166
|
+
for (const msg of pendingReady) {
|
|
167
|
+
const repoName = msg.from;
|
|
168
|
+
if (!repoName) continue;
|
|
169
|
+
|
|
170
|
+
// Find pending dispatches to this repo that are older than the grace
|
|
171
|
+
// period (avoid race conditions with just-sent dispatches).
|
|
172
|
+
const candidates = dispatches.filter(r => {
|
|
173
|
+
if (r.repoName !== repoName) return false;
|
|
174
|
+
const dispatched = Date.parse(r.dispatchedAt || '');
|
|
175
|
+
if (!Number.isFinite(dispatched)) return false;
|
|
176
|
+
return (now - dispatched) > staleGraceMs;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const c of candidates) {
|
|
180
|
+
lostDispatches.push({ ...c, workerReadyMsgId: msg.id });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Acknowledge the worker-ready message — we've processed it once.
|
|
184
|
+
// If the restart-loss recurs, a fresh worker-ready will be written.
|
|
185
|
+
try {
|
|
186
|
+
if (updateMessageStatus) {
|
|
187
|
+
updateMessageStatus(workspaceRoot, msg.id, 'acknowledged');
|
|
188
|
+
acknowledged++;
|
|
189
|
+
}
|
|
190
|
+
} catch (_err) { /* non-fatal */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { acknowledged, lostDispatches };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Format the lost-dispatches block for manager additionalContext.
|
|
198
|
+
*
|
|
199
|
+
* @param {Array} lost
|
|
200
|
+
* @returns {string|null}
|
|
201
|
+
*/
|
|
202
|
+
function formatLostDispatchesContext(lost) {
|
|
203
|
+
if (!Array.isArray(lost) || lost.length === 0) return null;
|
|
204
|
+
const lines = lost.map(r => {
|
|
205
|
+
const dispatchedAt = r.dispatchedAt || '?';
|
|
206
|
+
return `• ${r.taskId} → ${r.repoName} | dispatched ${dispatchedAt} | still pending after worker restart`;
|
|
207
|
+
});
|
|
208
|
+
return [
|
|
209
|
+
`━━━ LOST DISPATCHES — WORKER RESTARTED WITH EMPTY QUEUE (${lost.length}) ━━━`,
|
|
210
|
+
...lines,
|
|
211
|
+
'',
|
|
212
|
+
'A worker announced fresh readiness but these dispatches are still',
|
|
213
|
+
'pending. Likely lost during the wrapper\'s restart window. Re-dispatch',
|
|
214
|
+
'them now via dispatchToChannel(workspaceRoot, repoName, taskId).',
|
|
215
|
+
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
|
|
111
219
|
/**
|
|
112
220
|
* Build the overdue-dispatches additionalContext block, or return null
|
|
113
221
|
* when nothing to surface (non-manager, no workspace root, no overdue).
|
|
114
222
|
*
|
|
223
|
+
* Also handles worker-ready reconciliation (2.22.2) — if workers announced
|
|
224
|
+
* readiness and there are matching pending dispatches, include a lost-dispatch
|
|
225
|
+
* section so the manager can re-dispatch.
|
|
226
|
+
*
|
|
115
227
|
* @param {Object} [opts]
|
|
116
228
|
* @param {string} [opts.workspaceRoot] — override (primarily for tests)
|
|
117
229
|
* @param {number} [opts.now=Date.now()]
|
|
@@ -128,32 +240,52 @@ function buildOverdueContext(opts = {}) {
|
|
|
128
240
|
try { sweepAndReconcile(workspaceRoot); }
|
|
129
241
|
catch (_err) { /* fail-open */ }
|
|
130
242
|
|
|
243
|
+
// Reconcile worker-ready announcements. Surface any lost dispatches
|
|
244
|
+
// the manager should re-send.
|
|
245
|
+
let lostBlock = null;
|
|
246
|
+
try {
|
|
247
|
+
const { lostDispatches } = reconcileWorkerReady(workspaceRoot, { now: opts.now });
|
|
248
|
+
lostBlock = formatLostDispatchesContext(lostDispatches);
|
|
249
|
+
} catch (_err) { /* fail-open */ }
|
|
250
|
+
|
|
131
251
|
let overdue;
|
|
132
252
|
try {
|
|
133
253
|
const libPath = path.resolve(__dirname, '..', '..', '..', 'lib', 'workspace-dispatch-tracking.js');
|
|
134
254
|
const { getOverdueDispatches } = require(libPath);
|
|
135
255
|
overdue = getOverdueDispatches(workspaceRoot, opts.now);
|
|
136
256
|
} catch (_err) {
|
|
137
|
-
|
|
257
|
+
// If dispatch-tracking is missing but we have lost-dispatches from
|
|
258
|
+
// worker-ready, still surface those.
|
|
259
|
+
return lostBlock;
|
|
138
260
|
}
|
|
139
261
|
|
|
140
|
-
if (!Array.isArray(overdue) || overdue.length === 0) return null;
|
|
262
|
+
if ((!Array.isArray(overdue) || overdue.length === 0) && !lostBlock) return null;
|
|
141
263
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
264
|
+
const sections = [];
|
|
265
|
+
|
|
266
|
+
if (lostBlock) sections.push(lostBlock);
|
|
267
|
+
|
|
268
|
+
if (Array.isArray(overdue) && overdue.length > 0) {
|
|
269
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
270
|
+
const lines = overdue.map(r => formatLine(r, now));
|
|
271
|
+
sections.push([
|
|
272
|
+
`━━━ OVERDUE WORKSPACE DISPATCHES (${overdue.length}) ━━━`,
|
|
273
|
+
...lines,
|
|
274
|
+
'',
|
|
275
|
+
'These workers may have died silently. Check worker terminals;',
|
|
276
|
+
'if dead, re-dispatch or mark failed. Records:',
|
|
277
|
+
' .workspace/state/dispatched-tasks.json',
|
|
278
|
+
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
|
279
|
+
].join('\n'));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return sections.length > 0 ? sections.join('\n\n') : null;
|
|
153
283
|
}
|
|
154
284
|
|
|
155
285
|
module.exports = {
|
|
156
286
|
isManagerSession,
|
|
157
287
|
buildOverdueContext,
|
|
158
|
-
sweepAndReconcile
|
|
288
|
+
sweepAndReconcile,
|
|
289
|
+
reconcileWorkerReady,
|
|
290
|
+
formatLostDispatchesContext
|
|
159
291
|
};
|
|
@@ -871,6 +871,23 @@ function formatContextForInjection(context) {
|
|
|
871
871
|
// Non-critical — history file may not exist; continue with normal context
|
|
872
872
|
}
|
|
873
873
|
|
|
874
|
+
// Workspace worker auto-resume (wf-restart-handoff / 2.22.2).
|
|
875
|
+
// CRITICAL priority — shown at the top so the model acts on it before
|
|
876
|
+
// anything else. Fires when a worker session starts with queued channel
|
|
877
|
+
// dispatches that were inherited from the prior (restarted) session.
|
|
878
|
+
if (ctx.workerAutoResume) {
|
|
879
|
+
output += `### Workspace Worker Auto-Resume\n`;
|
|
880
|
+
output += ctx.workerAutoResume + '\n\n';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Workspace worker readiness announcement (wf-restart-handoff / 2.22.2).
|
|
884
|
+
// Informational — worker started idle, announced readiness to manager.
|
|
885
|
+
// Manager will reconcile async; no immediate action required from the worker.
|
|
886
|
+
if (ctx.workerReadyAnnounce) {
|
|
887
|
+
output += `### Workspace Worker Ready\n`;
|
|
888
|
+
output += ctx.workerReadyAnnounce + '\n\n';
|
|
889
|
+
}
|
|
890
|
+
|
|
874
891
|
// CRITICAL: CLAUDE_CODE_SIMPLE mode warning (highest priority)
|
|
875
892
|
if (ctx.simpleModeWarning && ctx.simpleModeWarning.active) {
|
|
876
893
|
output += `### CLAUDE_CODE_SIMPLE Mode Detected\n`;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wogi Flow — Worker SessionStart handler (wf-restart-handoff)
|
|
3
|
+
*
|
|
4
|
+
* Handles the "worker just started" branch of SessionStart:
|
|
5
|
+
*
|
|
6
|
+
* - If worker has queued channel dispatches in ready.json:
|
|
7
|
+
* inject additionalContext telling the model to invoke
|
|
8
|
+
* /wogi-start <nextId> now. Mirrors the existing Stop-hook
|
|
9
|
+
* autopickup flow (task-completed.js::buildAutoPickupContext)
|
|
10
|
+
* but fires at session boundary instead of turn boundary —
|
|
11
|
+
* necessary because restart (wf-d3e67abe/2.22.1) kills the
|
|
12
|
+
* previous claude and the Stop-hook autopickup no longer bridges
|
|
13
|
+
* between tasks.
|
|
14
|
+
*
|
|
15
|
+
* - Else if worker has zero in-progress + zero queued channel
|
|
16
|
+
* dispatches: write a `worker-ready` message to the workspace
|
|
17
|
+
* message bus so the manager can reconcile against its durable
|
|
18
|
+
* dispatched-tasks.json and re-dispatch any work lost during
|
|
19
|
+
* the restart window.
|
|
20
|
+
*
|
|
21
|
+
* Returns a context fragment (or null) that the SessionStart entry
|
|
22
|
+
* merges into the overall hook output.
|
|
23
|
+
*
|
|
24
|
+
* Fail-open: any error returns null and logs in DEBUG mode. Never
|
|
25
|
+
* blocks session startup.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const WORKER_READY_LIB = path.join(__dirname, '..', '..', '..', 'lib', 'workspace-worker-ready.js');
|
|
31
|
+
const TASK_COMPLETED_CORE = path.join(__dirname, 'task-completed.js');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Handle worker SessionStart.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{branch: 'auto-resume'|'announce-ready'|'skip', context?: string, announced?: Object, pickup?: Object}}
|
|
37
|
+
*/
|
|
38
|
+
function handleWorkerSessionStart() {
|
|
39
|
+
try {
|
|
40
|
+
const { isWorker, shouldAnnounceReady, announceWorkerReady } = require(WORKER_READY_LIB);
|
|
41
|
+
if (!isWorker()) return { branch: 'skip', reason: 'not-worker' };
|
|
42
|
+
|
|
43
|
+
// Check for queued work first — if any, tell the model to pick it up
|
|
44
|
+
// instead of announcing idle readiness.
|
|
45
|
+
let pickup;
|
|
46
|
+
try {
|
|
47
|
+
const { findQueuedChannelDispatches, buildAutoPickupContext } = require(TASK_COMPLETED_CORE);
|
|
48
|
+
pickup = findQueuedChannelDispatches();
|
|
49
|
+
if (pickup && pickup.count > 0 && pickup.nextTaskId) {
|
|
50
|
+
const base = buildAutoPickupContext(pickup);
|
|
51
|
+
// Adjust the leading line for session-start context — the canonical
|
|
52
|
+
// pickup message starts with "You just completed a task." which isn't
|
|
53
|
+
// true on session start. Re-frame it here.
|
|
54
|
+
const context = [
|
|
55
|
+
`⚡ WORKSPACE SESSION START — ${pickup.count} CHANNEL DISPATCH${pickup.count === 1 ? '' : 'ES'} QUEUED`,
|
|
56
|
+
'',
|
|
57
|
+
`This fresh worker session has ${pickup.count} channel-dispatched task${pickup.count === 1 ? '' : 's'} queued in ready.json.`,
|
|
58
|
+
`The previous session restarted cleanly (wogi-claude wrapper). Pick up the next task now.`,
|
|
59
|
+
'',
|
|
60
|
+
`Next: ${pickup.nextTaskId} — ${pickup.nextTaskTitle || '(no title)'}`,
|
|
61
|
+
'',
|
|
62
|
+
'AUTONOMOUS MODE CONTRACT (workspace worker):',
|
|
63
|
+
' • These dispatches are pre-approved by the manager.',
|
|
64
|
+
' • You MUST start the next one IMMEDIATELY in this same turn.',
|
|
65
|
+
' • Do NOT hedge ("awaiting signal", "let me know"). Forbidden.',
|
|
66
|
+
'',
|
|
67
|
+
`ACT NOW: Invoke Skill(skill="wogi-start", args="${pickup.nextTaskId}")`
|
|
68
|
+
].join('\n');
|
|
69
|
+
// base included for logging/telemetry parity if we ever want to diff them
|
|
70
|
+
void base;
|
|
71
|
+
return { branch: 'auto-resume', context, pickup };
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (process.env.DEBUG) {
|
|
75
|
+
console.error(`[session-start-worker] pickup-check failed (fail-open): ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No queued work — announce readiness so the manager can reconcile.
|
|
80
|
+
const decision = shouldAnnounceReady();
|
|
81
|
+
if (!decision.announce) {
|
|
82
|
+
return { branch: 'skip', reason: decision.reason };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const announced = announceWorkerReady(decision.workspaceRoot, decision.repoName);
|
|
86
|
+
if (!announced.written) {
|
|
87
|
+
if (process.env.DEBUG) {
|
|
88
|
+
console.error(`[session-start-worker] announce failed: ${announced.reason}`);
|
|
89
|
+
}
|
|
90
|
+
return { branch: 'skip', reason: announced.reason };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Optional context surface — not strictly needed since the manager
|
|
94
|
+
// handles reconciliation asynchronously, but a one-line note helps
|
|
95
|
+
// humans reading worker transcripts understand why the worker is idle.
|
|
96
|
+
const context = [
|
|
97
|
+
`Worker session started with empty queue.`,
|
|
98
|
+
`Announced readiness to manager (msg ${announced.messageId}) —`,
|
|
99
|
+
`manager will reconcile against its dispatch log and re-dispatch`,
|
|
100
|
+
`any work lost during the restart window.`
|
|
101
|
+
].join(' ');
|
|
102
|
+
|
|
103
|
+
return { branch: 'announce-ready', context, announced };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (process.env.DEBUG) {
|
|
106
|
+
console.error(`[session-start-worker] unexpected error (fail-open): ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
return { branch: 'skip', reason: `error: ${err.message}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
handleWorkerSessionStart
|
|
114
|
+
};
|
|
@@ -303,5 +303,27 @@ runHook('SessionStart', async ({ parsedInput }) => {
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// Workspace worker restart-handoff (wf-restart-handoff / 2.22.2).
|
|
307
|
+
// When the wogi-claude wrapper restarts a worker (via task-boundary-reset),
|
|
308
|
+
// queued dispatches from the PRIOR session are picked up by auto-resume;
|
|
309
|
+
// if the queue is truly empty, announce worker-ready so the manager can
|
|
310
|
+
// reconcile against its dispatch log and re-dispatch anything lost during
|
|
311
|
+
// the restart window. See scripts/hooks/core/session-start-worker.js.
|
|
312
|
+
try {
|
|
313
|
+
const { handleWorkerSessionStart } = require('../../core/session-start-worker');
|
|
314
|
+
const workerResult = handleWorkerSessionStart();
|
|
315
|
+
if (workerResult.context && coreResult && coreResult.context) {
|
|
316
|
+
if (workerResult.branch === 'auto-resume') {
|
|
317
|
+
coreResult.context.workerAutoResume = workerResult.context;
|
|
318
|
+
} else if (workerResult.branch === 'announce-ready') {
|
|
319
|
+
coreResult.context.workerReadyAnnounce = workerResult.context;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (process.env.DEBUG) {
|
|
324
|
+
console.error(`[session-start] Worker session-start handler failed: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
306
328
|
return coreResult;
|
|
307
329
|
}, { failMode: 'warn' });
|