wogiflow 2.29.3 → 2.29.4
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.
|
@@ -450,6 +450,22 @@ function broadcastSSE(event) {
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// ============================================================
|
|
454
|
+
// Dispatch tracking integration (silent-halt RCA fix, v2.29.4)
|
|
455
|
+
// ============================================================
|
|
456
|
+
// The channel server is the only place that sees EVERY inbound message,
|
|
457
|
+
// regardless of whether the manager dispatched via the programmatic
|
|
458
|
+
// `dispatchToChannel()` helper or a raw `curl POST`. Recording at this
|
|
459
|
+
// layer guarantees `dispatched-tasks.json` exists for every dispatch,
|
|
460
|
+
// closing the wogi-hub 2026-04-27 silent-halt failure shape.
|
|
461
|
+
//
|
|
462
|
+
// Helpers live in `workspace-channel-tracking.js` so they can be unit-
|
|
463
|
+
// tested without spawning the channel-server process. Both fail-open;
|
|
464
|
+
// idempotency lives at the call site (Fix A skips on existing record;
|
|
465
|
+
// Fix B delegates to reconcileDispatch which is idempotent).
|
|
466
|
+
|
|
467
|
+
const channelTracking = require('./workspace-channel-tracking');
|
|
468
|
+
|
|
453
469
|
// ============================================================
|
|
454
470
|
// HTTP Server
|
|
455
471
|
// ============================================================
|
|
@@ -506,6 +522,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
506
522
|
: cleanBody;
|
|
507
523
|
sendChannelNotification(notificationBody, meta);
|
|
508
524
|
|
|
525
|
+
// v2.29.4 silent-halt RCA fixes — both fail-open
|
|
526
|
+
const trackingCtx = { workspaceRoot: WORKSPACE_ROOT, repoName: REPO_NAME, from, body: cleanBody };
|
|
527
|
+
channelTracking.tryRecordInboundDispatch(trackingCtx);
|
|
528
|
+
channelTracking.tryReconcileInboundCompletion(trackingCtx);
|
|
529
|
+
|
|
509
530
|
// Also broadcast to SSE subscribers
|
|
510
531
|
if (sseClients.size > 0) {
|
|
511
532
|
const crypto = require('node:crypto');
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Workspace — Channel Server Dispatch Tracking Helpers (v2.29.4)
|
|
5
|
+
*
|
|
6
|
+
* Pure-function helpers that the channel server's HTTP POST handler calls
|
|
7
|
+
* to record inbound dispatches and reconcile inbound completions. Extracted
|
|
8
|
+
* from `workspace-channel-server.js` so they can be unit-tested without
|
|
9
|
+
* spawning the channel-server process.
|
|
10
|
+
*
|
|
11
|
+
* Why these exist (silent-halt RCA, 2026-04-27):
|
|
12
|
+
* `recordDispatch` was only called from the programmatic dispatch helper
|
|
13
|
+
* (`workspace-routing.js → dispatchToChannel`). Manager AI sessions that
|
|
14
|
+
* used raw `curl POST http://localhost:8801` bypassed it entirely. With
|
|
15
|
+
* no record, the overdue detector had nothing to detect — workers could
|
|
16
|
+
* die silently with zero manager-side signal.
|
|
17
|
+
*
|
|
18
|
+
* Both helpers are best-effort and fail-open. Idempotency lives at the
|
|
19
|
+
* call site (Fix A skips when a pending record already exists; Fix B
|
|
20
|
+
* delegates idempotency to `reconcileDispatch` which returns `null` when
|
|
21
|
+
* no pending record matches).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TASK_ID_PATTERN = /\bwf-[0-9a-f]{8}\b/i;
|
|
25
|
+
const DISPATCH_BODY_PATTERN = /^\s*\/wogi-start\s+(wf-[0-9a-f]{8})\b/i;
|
|
26
|
+
const QUESTION_BODY_PATTERN = /^\s*##\s*QUESTION/im;
|
|
27
|
+
const COMPLETION_BODY_PATTERN = /##\s*Results\b|task-complete\b/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Record an inbound dispatch when the channel server (running in worker
|
|
31
|
+
* mode) receives a `/wogi-start <id>` POST from the manager.
|
|
32
|
+
*
|
|
33
|
+
* No-ops when:
|
|
34
|
+
* - workspaceRoot is missing
|
|
35
|
+
* - body is not a non-empty string
|
|
36
|
+
* - this server is in manager mode (REPO_NAME === 'manager')
|
|
37
|
+
* - the `from` header is not the manager
|
|
38
|
+
* - the body does not match the dispatch pattern
|
|
39
|
+
* - a pending record for (taskId, repoName) already exists (idempotency)
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} ctx
|
|
42
|
+
* @param {string} ctx.workspaceRoot
|
|
43
|
+
* @param {string} ctx.repoName
|
|
44
|
+
* @param {string} ctx.from
|
|
45
|
+
* @param {string} ctx.body
|
|
46
|
+
* @param {Object} [tracking] — injectable for tests; defaults to the lib module
|
|
47
|
+
* @returns {{action: 'recorded'|'skip-existing'|'skip-not-worker'|'skip-bad-from'|'skip-no-match'|'skip-no-root'|'skip-empty-body'|'error', reason?: string, taskId?: string}}
|
|
48
|
+
*/
|
|
49
|
+
function tryRecordInboundDispatch(ctx, tracking) {
|
|
50
|
+
const { workspaceRoot, repoName, from, body } = ctx || {};
|
|
51
|
+
if (!workspaceRoot) return { action: 'skip-no-root' };
|
|
52
|
+
if (typeof body !== 'string' || !body) return { action: 'skip-empty-body' };
|
|
53
|
+
if (repoName === 'manager') return { action: 'skip-not-worker' };
|
|
54
|
+
if (from !== 'manager' && from !== 'workspace-manager') return { action: 'skip-bad-from' };
|
|
55
|
+
const m = body.match(DISPATCH_BODY_PATTERN);
|
|
56
|
+
if (!m) return { action: 'skip-no-match' };
|
|
57
|
+
const taskId = m[1].toLowerCase();
|
|
58
|
+
try {
|
|
59
|
+
const tr = tracking || require('./workspace-dispatch-tracking');
|
|
60
|
+
const existing = tr.readDispatches(workspaceRoot).find(r =>
|
|
61
|
+
r && r.taskId === taskId && r.repoName === repoName && r.status === 'pending'
|
|
62
|
+
);
|
|
63
|
+
if (existing) return { action: 'skip-existing', taskId };
|
|
64
|
+
tr.recordDispatch(workspaceRoot, {
|
|
65
|
+
taskId,
|
|
66
|
+
repoName,
|
|
67
|
+
dispatchedBy: from
|
|
68
|
+
});
|
|
69
|
+
return { action: 'recorded', taskId };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { action: 'error', reason: err.message, taskId };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reconcile an inbound completion when the channel server (running in
|
|
77
|
+
* manager mode) receives a worker-side POST that looks like a completion.
|
|
78
|
+
*
|
|
79
|
+
* No-ops when:
|
|
80
|
+
* - workspaceRoot is missing
|
|
81
|
+
* - body is not a non-empty string
|
|
82
|
+
* - this server is in worker mode (REPO_NAME !== 'manager')
|
|
83
|
+
* - the `from` header IS the manager (no self-completion)
|
|
84
|
+
* - the body looks like a `## QUESTION:` (escalation, not completion)
|
|
85
|
+
* - the body does not contain `## Results` or `task-complete`
|
|
86
|
+
* - the body does not contain a `wf-XXXXXXXX` reference
|
|
87
|
+
* - reconcileDispatch finds no pending record (idempotent)
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} ctx
|
|
90
|
+
* @param {string} ctx.workspaceRoot
|
|
91
|
+
* @param {string} ctx.repoName
|
|
92
|
+
* @param {string} ctx.from
|
|
93
|
+
* @param {string} ctx.body
|
|
94
|
+
* @param {Object} [tracking] — injectable for tests; defaults to the lib module
|
|
95
|
+
* @returns {{action: 'reconciled'|'skip-not-manager'|'skip-self'|'skip-question'|'skip-not-completion'|'skip-no-id'|'skip-no-pending'|'skip-no-root'|'skip-empty-body'|'error', reason?: string, taskId?: string}}
|
|
96
|
+
*/
|
|
97
|
+
function tryReconcileInboundCompletion(ctx, tracking) {
|
|
98
|
+
const { workspaceRoot, repoName, from, body } = ctx || {};
|
|
99
|
+
if (!workspaceRoot) return { action: 'skip-no-root' };
|
|
100
|
+
if (typeof body !== 'string' || !body) return { action: 'skip-empty-body' };
|
|
101
|
+
if (repoName !== 'manager') return { action: 'skip-not-manager' };
|
|
102
|
+
if (from === 'manager' || from === 'workspace-manager') return { action: 'skip-self' };
|
|
103
|
+
if (QUESTION_BODY_PATTERN.test(body)) return { action: 'skip-question' };
|
|
104
|
+
if (!COMPLETION_BODY_PATTERN.test(body)) return { action: 'skip-not-completion' };
|
|
105
|
+
const m = body.match(TASK_ID_PATTERN);
|
|
106
|
+
if (!m) return { action: 'skip-no-id' };
|
|
107
|
+
const taskId = m[0].toLowerCase();
|
|
108
|
+
try {
|
|
109
|
+
const tr = tracking || require('./workspace-dispatch-tracking');
|
|
110
|
+
const result = tr.reconcileDispatch(workspaceRoot, taskId, 'completed', 'channel-server-completion');
|
|
111
|
+
if (!result) return { action: 'skip-no-pending', taskId };
|
|
112
|
+
return { action: 'reconciled', taskId };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { action: 'error', reason: err.message, taskId };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
TASK_ID_PATTERN,
|
|
120
|
+
DISPATCH_BODY_PATTERN,
|
|
121
|
+
QUESTION_BODY_PATTERN,
|
|
122
|
+
COMPLETION_BODY_PATTERN,
|
|
123
|
+
tryRecordInboundDispatch,
|
|
124
|
+
tryReconcileInboundCompletion
|
|
125
|
+
};
|