wogiflow 2.22.0 → 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.
@@ -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' });
@@ -61,57 +61,84 @@ runHook('Stop', async ({ parsedInput }) => {
61
61
  };
62
62
  }
63
63
 
64
- // Workspace worker: send results to manager via HTTP when stopping.
65
- // Uses execFileSync with array args to avoid shell injection (finding-001).
66
- if (process.env.WOGI_MANAGER_PORT && process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
64
+ // Workspace worker: write a structured `worker-stopped` message to the
65
+ // workspace message bus when stopping. This is the graceful-stop half of
66
+ // silent-halt detection (wf-d3e67abe) the manager's overdue check uses
67
+ // this (vs. task-complete vs. nothing) to tell "finished" from "gave up
68
+ // gracefully" from "died silently".
69
+ //
70
+ // Replaces the previous plain-text curl POST to the manager channel — that
71
+ // was fire-and-forget with no structure, so manager-side reconciliation
72
+ // couldn't distinguish graceful stops from silent deaths.
73
+ if (process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
67
74
  try {
68
- const { execFileSync, execSync } = require('node:child_process');
69
- const path = require('node:path');
75
+ const nodePath = require('node:path');
76
+ const childProcess = require('node:child_process');
70
77
  const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
71
78
  const repoName = process.env.WOGI_REPO_NAME;
72
- const managerPort = parseInt(process.env.WOGI_MANAGER_PORT, 10);
73
79
 
74
- // Validate inputs before using them (finding-001, finding-002)
75
- if (!VALID_NAME.test(repoName) || !Number.isInteger(managerPort) || managerPort < 1024 || managerPort > 65535) {
76
- throw new Error(`Invalid WOGI_REPO_NAME or WOGI_MANAGER_PORT`);
80
+ if (!VALID_NAME.test(repoName)) {
81
+ throw new Error(`Invalid WOGI_REPO_NAME`);
77
82
  }
78
83
 
79
- // Build summary from available state
80
- const summaryParts = [];
81
- const { PATHS, safeJsonParse } = require('../../flow-utils');
84
+ const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
85
+ if (workspaceRoot) {
86
+ const { PATHS, safeJsonParse } = require('../../flow-utils');
87
+ const ready = safeJsonParse(nodePath.join(PATHS.state, 'ready.json'), {});
88
+ const recentTask = (ready.recentlyCompleted || [])[0];
89
+ const inProgressTask = (ready.inProgress || [])[0];
90
+ const mostRecent = recentTask || inProgressTask;
82
91
 
83
- const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), {});
84
- const recentTask = (ready.recentlyCompleted || [])[0];
85
- const inProgressTask = (ready.inProgress || [])[0];
86
- const task = recentTask || inProgressTask;
92
+ // Determine worker state at stop-time
93
+ const hasInProgress = Boolean(inProgressTask);
94
+ const state = hasInProgress ? 'mid-work' : 'idle';
95
+ const taskInProgress = hasInProgress ? inProgressTask.id : null;
87
96
 
88
- if (task) {
89
- summaryParts.push(`**Task**: ${task.title || task.id}`);
90
- if (task.type) summaryParts.push(`**Type**: ${task.type}`);
91
- }
97
+ // Best-effort lastSha
98
+ let lastSha = null;
99
+ try {
100
+ lastSha = childProcess.execSync('git rev-parse --short HEAD 2>/dev/null || true', {
101
+ cwd: PATHS.root,
102
+ encoding: 'utf-8',
103
+ timeout: 2000
104
+ }).trim() || null;
105
+ } catch (_err) { /* non-critical */ }
92
106
 
93
- try {
94
- const diff = execSync('git diff --name-only HEAD 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
95
- const staged = execSync('git diff --name-only --staged 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
96
- const allChanged = [...new Set([...diff.split('\n'), ...staged.split('\n')].filter(Boolean))];
97
- if (allChanged.length > 0) {
98
- summaryParts.push(`**Files changed**: ${allChanged.join(', ')}`);
107
+ // Build structured message and persist via the workspace message bus.
108
+ // The worker-stopped type was added to MESSAGE_TYPES in
109
+ // workspace-messages.js (wf-d3e67abe).
110
+ try {
111
+ const libMessages = nodePath.resolve(__dirname, '..', '..', '..', '..', 'lib', 'workspace-messages');
112
+ const { createMessage, saveMessage } = require(libMessages);
113
+ const msg = createMessage({
114
+ from: repoName,
115
+ to: 'manager',
116
+ type: 'worker-stopped',
117
+ subject: hasInProgress
118
+ ? `Worker stopped mid-work on ${taskInProgress}`
119
+ : `Worker stopped (idle)`,
120
+ body: [
121
+ `Worker "${repoName}" is stopping.`,
122
+ `State: ${state}`,
123
+ taskInProgress ? `Task in progress: ${taskInProgress}` : null,
124
+ mostRecent?.title ? `Most recent task: ${mostRecent.title}` : null,
125
+ lastSha ? `Last commit: ${lastSha}` : null
126
+ ].filter(Boolean).join('\n'),
127
+ priority: hasInProgress ? 'high' : 'medium',
128
+ actionRequired: hasInProgress
129
+ });
130
+ // Attach structured fields the manager-side reconciler consumes.
131
+ msg.taskId = taskInProgress;
132
+ msg.reason = 'graceful';
133
+ msg.state = state;
134
+ msg.taskInProgress = taskInProgress;
135
+ msg.lastSha = lastSha;
136
+ saveMessage(workspaceRoot, msg);
137
+ } catch (err) {
138
+ if (process.env.DEBUG) {
139
+ console.error(`[Stop] Workspace message write failed: ${err.message}`);
140
+ }
99
141
  }
100
- } catch (_err) { /* non-critical */ }
101
-
102
- const body = summaryParts.join('\n') || `Work completed by ${repoName}.`;
103
-
104
- // execFileSync with array args — no shell interpretation (finding-001 fix)
105
- try {
106
- execFileSync('curl', [
107
- '-s', '-X', 'POST',
108
- `http://127.0.0.1:${managerPort}`,
109
- '-H', 'Content-Type: text/plain',
110
- '-H', `X-Wogi-From: ${repoName}`,
111
- '--data-binary', '@-'
112
- ], { input: body, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
113
- } catch (_err) {
114
- // Manager might be offline — that's OK
115
142
  }
116
143
  } catch (err) {
117
144
  if (process.env.DEBUG) {
@@ -168,14 +195,32 @@ runHook('Stop', async ({ parsedInput }) => {
168
195
  }
169
196
 
170
197
  const restartResult = consumeAndTriggerRestart();
171
- if (restartResult.triggered && process.env.DEBUG) {
172
- console.error(`[Stop] Task-boundary restart triggered — claude will exit, wrapper will relaunch`);
173
- } else if (!restartResult.triggered && restartResult.reason !== 'no-pending-marker' && process.env.DEBUG) {
198
+ if (restartResult.triggered) {
199
+ if (process.env.DEBUG) {
200
+ console.error(`[Stop] Task-boundary restart triggered claude will exit, wrapper will relaunch`);
201
+ }
202
+ // CRITICAL: return NOW, short-circuiting subsequent stop-blocking gates.
203
+ //
204
+ // Before this fix (observed 2026-04-17): Phase 2 would SIGTERM claude and
205
+ // write the restart flag, then fall through to the workspace autopickup
206
+ // gate (lines below). For a worker with queued dispatches (the common
207
+ // case), that gate returns `{ continue: true, stopReason: ... }` which
208
+ // Claude Code honours as "don't stop, pick up next dispatch." Result: the
209
+ // SIGTERM + restart flag became a no-op because claude was told to keep
210
+ // running in the SAME session. Symptom: single claude PID survives across
211
+ // N tasks, context accumulates, tokens burn — exactly the complaint this
212
+ // feature was supposed to solve.
213
+ //
214
+ // The restart is our stop path. The next session's SessionStart hook will
215
+ // inject queued-dispatch context, so the worker picks up the next task
216
+ // on RESTART rather than via the autopickup gate's continue-override.
217
+ // __raw skips the adapter transform — we want the literal {continue:false}
218
+ // wire format to reach claude unchanged.
219
+ return { __raw: true, continue: false };
220
+ }
221
+ if (restartResult.reason !== 'no-pending-marker' && process.env.DEBUG) {
174
222
  console.error(`[Stop] Task-boundary restart check: ${restartResult.reason}`);
175
223
  }
176
- // If we SIGTERM'd our parent, the process will begin shutting down. Still
177
- // return the normal Stop-hook result so any in-flight return value flows
178
- // back to claude before the signal is handled.
179
224
  } catch (err) {
180
225
  if (process.env.DEBUG) {
181
226
  console.error(`[Stop] Task-boundary restart module error (fail-open): ${err.message}`);
@@ -12,6 +12,7 @@ const { checkImplementationGate } = require('../../core/implementation-gate');
12
12
  const { checkResearchRequirement } = require('../../core/research-gate');
13
13
  const { setRoutingPending, clearRoutingPending, ROUTING_CLEARED_PATH } = require('../../core/routing-gate');
14
14
  const { getPhaseContextPrompt } = require('../../core/phase-gate');
15
+ const { buildOverdueContext } = require('../../core/overdue-dispatches');
15
16
  const { markSkillPending, loadDurableSession } = require('../../../flow-durable-session');
16
17
  const { captureCurrentPrompt } = require('../../../flow-prompt-capture');
17
18
  const { spawnBackgroundDetection } = require('../../../flow-correction-detector');
@@ -172,6 +173,23 @@ runHook('UserPromptSubmit', async ({ input, parsedInput }) => {
172
173
  };
173
174
  }
174
175
 
176
+ // wf-d3e67abe — surface overdue workspace dispatches (silent worker deaths)
177
+ // to the manager model before it processes the next prompt. Manager-only;
178
+ // fail-open (buildOverdueContext returns null on any error or wrong scope).
179
+ try {
180
+ const overduePrompt = buildOverdueContext();
181
+ if (overduePrompt) {
182
+ coreResult = {
183
+ ...coreResult,
184
+ overduePrompt
185
+ };
186
+ }
187
+ } catch (err) {
188
+ if (process.env.DEBUG) {
189
+ console.error(`[Hook] Overdue dispatches check failed: ${err.message}`);
190
+ }
191
+ }
192
+
175
193
  return coreResult;
176
194
  }, {
177
195
  failMode: 'block',