triflux 10.42.0 → 10.44.0
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/README.md +2 -0
- package/adapters/codex/skills/tfx-harness/SKILL.md +30 -0
- package/adapters/codex/skills/tfx-harness/skill.json +5 -0
- package/bin/tfx-live.mjs +3 -1
- package/bin/triflux.mjs +14 -0
- package/config/routing-policy.json +1 -1
- package/hooks/keyword-rules.json +139 -19
- package/hooks/pipeline-stop.mjs +1 -1
- package/hub/bridge.mjs +80 -1
- package/hub/cli-adapter-base.mjs +111 -21
- package/hub/codex-adapter.mjs +8 -1
- package/hub/dynamic-routing-engine.mjs +1 -1
- package/hub/gemini-adapter.mjs +3 -1
- package/hub/intent.mjs +24 -28
- package/hub/lib/cto-env.mjs +46 -0
- package/hub/lib/memory-store.mjs +25 -13
- package/hub/lib/timeout-defaults.mjs +45 -0
- package/hub/lib/worker-lifecycle.mjs +101 -0
- package/hub/middleware/request-logger.mjs +4 -1
- package/hub/pipe.mjs +9 -0
- package/hub/role-contract.mjs +314 -0
- package/hub/role-control-events.mjs +113 -0
- package/hub/router.mjs +383 -29
- package/hub/schema.sql +62 -2
- package/hub/server.mjs +130 -43
- package/hub/store.mjs +609 -19
- package/hub/team/claude-daemon-control.mjs +28 -15
- package/hub/team/cli/commands/start/parse-args.mjs +2 -2
- package/hub/team/conductor.mjs +23 -2
- package/hub/team/execution-mode.mjs +12 -2
- package/hub/team/headless.mjs +311 -24
- package/hub/team/intervention.mjs +530 -0
- package/hub/team/retry-state-machine.mjs +12 -3
- package/hub/team/swarm-hypervisor.mjs +21 -2
- package/hub/team/swarm-locks.mjs +17 -1
- package/hub/team/swarm-preflight.mjs +47 -0
- package/hub/team/synapse-http.mjs +149 -4
- package/hub/team/uds-orchestrator.mjs +62 -9
- package/hub/team/worktree-lifecycle.mjs +56 -59
- package/hub/tools.mjs +19 -3
- package/hub/workers/codex-app-server-worker.mjs +54 -5
- package/hub/workers/codex-mcp.mjs +21 -5
- package/hub/workers/delegator-mcp.mjs +3 -49
- package/hud/constants.mjs +21 -0
- package/hud/context-monitor.mjs +18 -21
- package/hud/hud-qos-status.mjs +1 -1
- package/hud/providers/codex-probe.mjs +216 -0
- package/hud/providers/codex.mjs +206 -29
- package/hud/renderers.mjs +6 -9
- package/package.json +2 -1
- package/scripts/__tests__/lint-skills.test.mjs +68 -0
- package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
- package/scripts/__tests__/tfx-route-node-entry.test.mjs +42 -6
- package/scripts/headless-guard.mjs +70 -23
- package/scripts/keyword-detector.mjs +55 -8
- package/scripts/lib/agent-route-policy.mjs +360 -0
- package/scripts/lib/cli-agy.mjs +1 -0
- package/scripts/lib/cli-claude.mjs +1 -0
- package/scripts/lib/cli-codex.mjs +23 -160
- package/scripts/lib/codex-profile-config.mjs +31 -5
- package/scripts/lib/keyword-rules.mjs +12 -0
- package/scripts/lint-skills.mjs +65 -0
- package/scripts/pack.mjs +13 -0
- package/scripts/release/check-packages-mirror.mjs +5 -0
- package/scripts/setup.mjs +108 -12
- package/scripts/tfx-gate-activate.mjs +1 -1
- package/scripts/tfx-route-worker.mjs +5 -0
- package/scripts/tfx-route.mjs +14 -1
- package/scripts/tfx-route.sh +333 -118
- package/skills/tfx-analysis/SKILL.md +3 -1
- package/skills/tfx-harness/SKILL.md +19 -76
- package/skills/tfx-hub/SKILL.md +2 -2
- package/skills/tfx-interview/SKILL.md +3 -1
- package/skills/tfx-interview/skill.json +1 -13
- package/skills/tfx-plan/SKILL.md +3 -1
- package/skills/tfx-profile/SKILL.md +32 -22
- package/skills/tfx-prune/SKILL.md +7 -3
- package/skills/tfx-prune/skill.json +1 -8
- package/skills/tfx-qa/SKILL.md +6 -2
- package/skills/tfx-research/SKILL.md +3 -1
- package/skills/tfx-review/SKILL.md +5 -3
- package/skills/tfx-setup/SKILL.md +1 -1
- package/tui/codex-profile.mjs +14 -3
- package/tui/setup.mjs +6 -4
package/hub/router.mjs
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// hub/router.mjs — 실시간 라우팅/수신함 상태 관리자
|
|
2
2
|
// SQLite는 감사 로그만 담당하고, 실제 배달 상태는 메모리에서 관리한다.
|
|
3
3
|
import { EventEmitter, once } from "node:events";
|
|
4
|
+
import {
|
|
5
|
+
clampDurationMs,
|
|
6
|
+
DEFAULT_ASSIGN_TIMEOUT_MS,
|
|
7
|
+
DEFAULT_ASSIGN_TTL_MS,
|
|
8
|
+
DEFAULT_HANDOFF_TTL_MS,
|
|
9
|
+
resolveHardCeilingMs,
|
|
10
|
+
} from "./lib/timeout-defaults.mjs";
|
|
4
11
|
import { uuidv7 } from "./lib/uuidv7.mjs";
|
|
12
|
+
import { normalizeRoleKey } from "./role-contract.mjs";
|
|
5
13
|
|
|
6
14
|
const ASSIGN_PENDING_STATUSES = new Set(["queued", "running"]);
|
|
7
15
|
const ROLE_TOPICS = new Set(["cto"]);
|
|
@@ -9,6 +17,258 @@ const ROLE_SOURCE_RANK = Object.freeze({
|
|
|
9
17
|
explicit: 2,
|
|
10
18
|
"topic-fallback": 1,
|
|
11
19
|
});
|
|
20
|
+
const TRANSPORT_LOCATOR_SCHEMA_VERSION = "tfx.transport-locator.v1";
|
|
21
|
+
const TRANSPORT_KINDS = new Set(["claude-uds", "tmux"]);
|
|
22
|
+
const TRANSPORT_CAPABILITIES = new Set([
|
|
23
|
+
"probe",
|
|
24
|
+
"wake",
|
|
25
|
+
"activate",
|
|
26
|
+
"interrupt",
|
|
27
|
+
]);
|
|
28
|
+
const HOST_ID_RE = /^hst_[A-Za-z0-9_-]+$/u;
|
|
29
|
+
|
|
30
|
+
function registrationError(code, message = code) {
|
|
31
|
+
const error = new Error(message);
|
|
32
|
+
error.code = code;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function registrationString(value, code, { maxLength = 256 } = {}) {
|
|
37
|
+
if (typeof value !== "string" || !value.trim() || value.length > maxLength) {
|
|
38
|
+
throw registrationError(code);
|
|
39
|
+
}
|
|
40
|
+
return value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function registrationLogicalRef(value, code, { maxLength = 128 } = {}) {
|
|
44
|
+
const normalized = registrationString(value, code, { maxLength });
|
|
45
|
+
if (!/^[A-Za-z0-9._:-]+$/u.test(normalized)) {
|
|
46
|
+
throw registrationError(code);
|
|
47
|
+
}
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertExactFields(value, allowed, code) {
|
|
52
|
+
if (
|
|
53
|
+
value === null ||
|
|
54
|
+
typeof value !== "object" ||
|
|
55
|
+
Array.isArray(value) ||
|
|
56
|
+
Object.keys(value).some((field) => !allowed.has(field))
|
|
57
|
+
) {
|
|
58
|
+
throw registrationError(code);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeTransportLocator(
|
|
63
|
+
value,
|
|
64
|
+
{ cli, hostId, now, leaseExpiresMs },
|
|
65
|
+
) {
|
|
66
|
+
assertExactFields(
|
|
67
|
+
value,
|
|
68
|
+
new Set([
|
|
69
|
+
"schema_version",
|
|
70
|
+
"transport_id",
|
|
71
|
+
"kind",
|
|
72
|
+
"host_id",
|
|
73
|
+
"capabilities",
|
|
74
|
+
"priority",
|
|
75
|
+
"expires_at_ms",
|
|
76
|
+
"locator",
|
|
77
|
+
]),
|
|
78
|
+
"TRANSPORT_LOCATOR_INVALID",
|
|
79
|
+
);
|
|
80
|
+
if (value.schema_version !== TRANSPORT_LOCATOR_SCHEMA_VERSION) {
|
|
81
|
+
throw registrationError("TRANSPORT_LOCATOR_SCHEMA_UNSUPPORTED");
|
|
82
|
+
}
|
|
83
|
+
const transportId = registrationLogicalRef(
|
|
84
|
+
value.transport_id,
|
|
85
|
+
"TRANSPORT_ID_INVALID",
|
|
86
|
+
{ maxLength: 128 },
|
|
87
|
+
);
|
|
88
|
+
if (!TRANSPORT_KINDS.has(value.kind)) {
|
|
89
|
+
throw registrationError("TRANSPORT_KIND_UNSUPPORTED");
|
|
90
|
+
}
|
|
91
|
+
if (cli === "codex" && value.kind !== "tmux") {
|
|
92
|
+
throw registrationError("CODEX_TRANSPORT_UNSUPPORTED");
|
|
93
|
+
}
|
|
94
|
+
const locatorHostId = registrationString(value.host_id, "HOST_ID_INVALID", {
|
|
95
|
+
maxLength: 128,
|
|
96
|
+
});
|
|
97
|
+
if (!HOST_ID_RE.test(locatorHostId) || (hostId && locatorHostId !== hostId)) {
|
|
98
|
+
throw registrationError("HOST_ID_INVALID");
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(value.capabilities)) {
|
|
101
|
+
throw registrationError("TRANSPORT_CAPABILITIES_INVALID");
|
|
102
|
+
}
|
|
103
|
+
const capabilities = uniqueStrings(value.capabilities);
|
|
104
|
+
if (
|
|
105
|
+
capabilities.length !== value.capabilities.length ||
|
|
106
|
+
capabilities.some((capability) => !TRANSPORT_CAPABILITIES.has(capability))
|
|
107
|
+
) {
|
|
108
|
+
throw registrationError("TRANSPORT_CAPABILITIES_INVALID");
|
|
109
|
+
}
|
|
110
|
+
if (!Number.isSafeInteger(value.priority)) {
|
|
111
|
+
throw registrationError("TRANSPORT_PRIORITY_INVALID");
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
!Number.isSafeInteger(value.expires_at_ms) ||
|
|
115
|
+
value.expires_at_ms <= now ||
|
|
116
|
+
value.expires_at_ms > leaseExpiresMs
|
|
117
|
+
) {
|
|
118
|
+
throw registrationError("TRANSPORT_EXPIRY_INVALID");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let locator;
|
|
122
|
+
if (value.kind === "claude-uds") {
|
|
123
|
+
assertExactFields(
|
|
124
|
+
value.locator,
|
|
125
|
+
new Set(["session_id", "short", "bridge_id"]),
|
|
126
|
+
"TRANSPORT_LOCATOR_INVALID",
|
|
127
|
+
);
|
|
128
|
+
const sessionId = value.locator.session_id;
|
|
129
|
+
const short = value.locator.short;
|
|
130
|
+
if (!sessionId && !short) {
|
|
131
|
+
throw registrationError("CLAUDE_UDS_SESSION_REQUIRED");
|
|
132
|
+
}
|
|
133
|
+
locator = {
|
|
134
|
+
...(sessionId
|
|
135
|
+
? {
|
|
136
|
+
session_id: registrationLogicalRef(
|
|
137
|
+
sessionId,
|
|
138
|
+
"CLAUDE_UDS_SESSION_INVALID",
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
...(short
|
|
143
|
+
? {
|
|
144
|
+
short: registrationLogicalRef(short, "CLAUDE_UDS_SHORT_INVALID", {
|
|
145
|
+
maxLength: 128,
|
|
146
|
+
}),
|
|
147
|
+
}
|
|
148
|
+
: {}),
|
|
149
|
+
bridge_id: registrationLogicalRef(
|
|
150
|
+
value.locator.bridge_id,
|
|
151
|
+
"CLAUDE_UDS_BRIDGE_INVALID",
|
|
152
|
+
{ maxLength: 128 },
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
} else {
|
|
156
|
+
assertExactFields(
|
|
157
|
+
value.locator,
|
|
158
|
+
new Set(["session_name", "mux_server_id"]),
|
|
159
|
+
"TRANSPORT_LOCATOR_INVALID",
|
|
160
|
+
);
|
|
161
|
+
locator = {
|
|
162
|
+
session_name: registrationLogicalRef(
|
|
163
|
+
value.locator.session_name,
|
|
164
|
+
"TMUX_SESSION_INVALID",
|
|
165
|
+
{ maxLength: 128 },
|
|
166
|
+
),
|
|
167
|
+
mux_server_id: registrationLogicalRef(
|
|
168
|
+
value.locator.mux_server_id,
|
|
169
|
+
"TMUX_SERVER_INVALID",
|
|
170
|
+
{ maxLength: 128 },
|
|
171
|
+
),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
schema_version: TRANSPORT_LOCATOR_SCHEMA_VERSION,
|
|
177
|
+
transport_id: transportId,
|
|
178
|
+
kind: value.kind,
|
|
179
|
+
host_id: locatorHostId,
|
|
180
|
+
capabilities,
|
|
181
|
+
priority: value.priority,
|
|
182
|
+
expires_at_ms: value.expires_at_ms,
|
|
183
|
+
locator,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function normalizeRegistrationIdentity(args = {}, now = Date.now()) {
|
|
188
|
+
const hasProjectId = Object.hasOwn(args, "project_id");
|
|
189
|
+
const hasSessionId = Object.hasOwn(args, "session_id");
|
|
190
|
+
const hasHostId = Object.hasOwn(args, "host_id");
|
|
191
|
+
const hasLocatorJson = Object.hasOwn(args, "transport_locators_json");
|
|
192
|
+
const hasLocators = Object.hasOwn(args, "transport_locators");
|
|
193
|
+
if (
|
|
194
|
+
!hasProjectId &&
|
|
195
|
+
!hasSessionId &&
|
|
196
|
+
!hasHostId &&
|
|
197
|
+
!hasLocatorJson &&
|
|
198
|
+
!hasLocators
|
|
199
|
+
) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const projectId = hasProjectId
|
|
204
|
+
? normalizeRoleKey({ project_id: args.project_id, role_kind: "cto" })
|
|
205
|
+
.project_id
|
|
206
|
+
: null;
|
|
207
|
+
const sessionId = hasSessionId
|
|
208
|
+
? registrationString(args.session_id, "SESSION_ID_INVALID")
|
|
209
|
+
: null;
|
|
210
|
+
const hostId = hasHostId
|
|
211
|
+
? registrationString(args.host_id, "HOST_ID_INVALID", { maxLength: 128 })
|
|
212
|
+
: null;
|
|
213
|
+
if (hostId && !HOST_ID_RE.test(hostId)) {
|
|
214
|
+
throw registrationError("HOST_ID_INVALID");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let rawLocators = [];
|
|
218
|
+
if (hasLocatorJson) {
|
|
219
|
+
if (Array.isArray(args.transport_locators_json)) {
|
|
220
|
+
rawLocators = args.transport_locators_json;
|
|
221
|
+
} else if (typeof args.transport_locators_json === "string") {
|
|
222
|
+
try {
|
|
223
|
+
rawLocators = JSON.parse(args.transport_locators_json);
|
|
224
|
+
} catch {
|
|
225
|
+
throw registrationError("TRANSPORT_LOCATORS_JSON_INVALID");
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
throw registrationError("TRANSPORT_LOCATORS_JSON_INVALID");
|
|
229
|
+
}
|
|
230
|
+
} else if (hasLocators) {
|
|
231
|
+
rawLocators = args.transport_locators;
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(rawLocators)) {
|
|
234
|
+
throw registrationError("TRANSPORT_LOCATORS_INVALID");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ttlMs = Number(args.heartbeat_ttl_ms ?? 30000);
|
|
238
|
+
if (rawLocators.length > 0 && (!Number.isFinite(ttlMs) || ttlMs <= 0)) {
|
|
239
|
+
throw registrationError("HEARTBEAT_TTL_INVALID");
|
|
240
|
+
}
|
|
241
|
+
const leaseExpiresMs = now + ttlMs;
|
|
242
|
+
const normalizedLocators = rawLocators
|
|
243
|
+
.map((locator) =>
|
|
244
|
+
normalizeTransportLocator(locator, {
|
|
245
|
+
cli: args.cli,
|
|
246
|
+
hostId,
|
|
247
|
+
now,
|
|
248
|
+
leaseExpiresMs,
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
.sort(
|
|
252
|
+
(left, right) =>
|
|
253
|
+
right.priority - left.priority ||
|
|
254
|
+
left.transport_id.localeCompare(right.transport_id),
|
|
255
|
+
);
|
|
256
|
+
const resolvedHostId = hostId || normalizedLocators[0]?.host_id || null;
|
|
257
|
+
if (
|
|
258
|
+
resolvedHostId &&
|
|
259
|
+
normalizedLocators.some((locator) => locator.host_id !== resolvedHostId)
|
|
260
|
+
) {
|
|
261
|
+
throw registrationError("HOST_ID_INVALID");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
project_id: projectId,
|
|
266
|
+
session_id: sessionId,
|
|
267
|
+
host_id: resolvedHostId,
|
|
268
|
+
transport_locators_json:
|
|
269
|
+
hasLocatorJson || hasLocators ? JSON.stringify(normalizedLocators) : null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
12
272
|
|
|
13
273
|
function uniqueStrings(values = []) {
|
|
14
274
|
return Array.from(
|
|
@@ -49,17 +309,6 @@ function rolePriority(metadata = {}, roleName = "") {
|
|
|
49
309
|
return 0;
|
|
50
310
|
}
|
|
51
311
|
|
|
52
|
-
function clampAssignDuration(
|
|
53
|
-
value,
|
|
54
|
-
fallback = 600000,
|
|
55
|
-
min = 1000,
|
|
56
|
-
max = 86400000,
|
|
57
|
-
) {
|
|
58
|
-
const num = Number(value);
|
|
59
|
-
if (!Number.isFinite(num)) return fallback;
|
|
60
|
-
return Math.max(min, Math.min(Math.trunc(num), max));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
312
|
function normalizeAssignTerminalStatus(input, metadata = {}) {
|
|
64
313
|
const status = String(input || "")
|
|
65
314
|
.trim()
|
|
@@ -127,6 +376,18 @@ export function createRouter(store) {
|
|
|
127
376
|
const queuesByAgent = new Map();
|
|
128
377
|
const liveMessages = new Map();
|
|
129
378
|
const roleStates = new Map();
|
|
379
|
+
const updateRegistrationIdentity = store.db?.prepare
|
|
380
|
+
? store.db.prepare(`
|
|
381
|
+
UPDATE agents SET
|
|
382
|
+
project_id=COALESCE(@project_id, project_id),
|
|
383
|
+
session_id=COALESCE(@session_id, session_id),
|
|
384
|
+
host_id=COALESCE(@host_id, host_id),
|
|
385
|
+
transport_locators_json=COALESCE(
|
|
386
|
+
@transport_locators_json,
|
|
387
|
+
transport_locators_json
|
|
388
|
+
)
|
|
389
|
+
WHERE agent_id=@agent_id`)
|
|
390
|
+
: null;
|
|
130
391
|
const MAX_LATENCY_SAMPLES = 100;
|
|
131
392
|
let latencyIdx = 0;
|
|
132
393
|
const deliveryLatencies = new Array(MAX_LATENCY_SAMPLES).fill(0);
|
|
@@ -606,11 +867,31 @@ export function createRouter(store) {
|
|
|
606
867
|
record.message.status = "delivered";
|
|
607
868
|
store.updateMessageStatus(messageId, "delivered");
|
|
608
869
|
recordLatency(delivery.delivered_at_ms - record.message.created_at_ms);
|
|
870
|
+
rearmAssignDeadlineOnDispatch(record.message, delivery.delivered_at_ms);
|
|
609
871
|
return true;
|
|
610
872
|
}
|
|
611
873
|
return false;
|
|
612
874
|
}
|
|
613
875
|
|
|
876
|
+
function rearmAssignDeadlineOnDispatch(message, deliveredAtMs) {
|
|
877
|
+
if (message?.payload?.kind !== "assign.job") return;
|
|
878
|
+
const jobId = message.payload.assign_job_id;
|
|
879
|
+
if (!jobId) return;
|
|
880
|
+
const job = store.getAssign(jobId);
|
|
881
|
+
if (
|
|
882
|
+
!job ||
|
|
883
|
+
!ASSIGN_PENDING_STATUSES.has(job.status) ||
|
|
884
|
+
job.dispatched_at_ms
|
|
885
|
+
)
|
|
886
|
+
return;
|
|
887
|
+
if (Number(message.payload.attempt) !== job.attempt) return;
|
|
888
|
+
store.updateAssignStatus(job.job_id, job.status, {
|
|
889
|
+
dispatched_at_ms: deliveredAtMs,
|
|
890
|
+
deadline_ms:
|
|
891
|
+
deliveredAtMs + clampDurationMs(job.timeout_ms, job.timeout_ms),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
614
895
|
function ackMessages(ids, agentId) {
|
|
615
896
|
const now = Date.now();
|
|
616
897
|
let count = 0;
|
|
@@ -706,7 +987,7 @@ export function createRouter(store) {
|
|
|
706
987
|
to: job.supervisor_agent,
|
|
707
988
|
topic: "assign.result",
|
|
708
989
|
priority: Math.max(5, job.priority || 5),
|
|
709
|
-
ttl_ms: job.ttl_ms || job.timeout_ms ||
|
|
990
|
+
ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
|
|
710
991
|
payload: {
|
|
711
992
|
event,
|
|
712
993
|
...buildAssignSnapshot(job),
|
|
@@ -725,7 +1006,7 @@ export function createRouter(store) {
|
|
|
725
1006
|
to: job.worker_agent,
|
|
726
1007
|
topic: job.topic || "assign.job",
|
|
727
1008
|
priority: job.priority || 5,
|
|
728
|
-
ttl_ms: job.ttl_ms || job.timeout_ms ||
|
|
1009
|
+
ttl_ms: job.ttl_ms || job.timeout_ms || DEFAULT_ASSIGN_TTL_MS,
|
|
729
1010
|
payload: {
|
|
730
1011
|
kind: "assign.job",
|
|
731
1012
|
reason,
|
|
@@ -797,6 +1078,24 @@ export function createRouter(store) {
|
|
|
797
1078
|
const timedOut = store.updateAssignStatus(job.job_id, "timed_out", {
|
|
798
1079
|
error: job.error ?? { message: "assign job timed out" },
|
|
799
1080
|
});
|
|
1081
|
+
if (timedOut?.worker_agent) {
|
|
1082
|
+
dispatchMessage({
|
|
1083
|
+
type: "event",
|
|
1084
|
+
from: "assign-router",
|
|
1085
|
+
to: timedOut.worker_agent,
|
|
1086
|
+
topic: "assign.cancel",
|
|
1087
|
+
priority: 8,
|
|
1088
|
+
ttl_ms: clampDurationMs(timedOut.ttl_ms, DEFAULT_ASSIGN_TTL_MS),
|
|
1089
|
+
payload: {
|
|
1090
|
+
kind: "assign.cancel",
|
|
1091
|
+
assign_job_id: timedOut.job_id,
|
|
1092
|
+
attempt: timedOut.attempt,
|
|
1093
|
+
reason: "timed_out",
|
|
1094
|
+
},
|
|
1095
|
+
trace_id: timedOut.trace_id,
|
|
1096
|
+
correlation_id: timedOut.correlation_id,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
800
1099
|
|
|
801
1100
|
if (timedOut.retry_count < timedOut.max_retries) {
|
|
802
1101
|
return scheduleAssignRetry(
|
|
@@ -821,10 +1120,17 @@ export function createRouter(store) {
|
|
|
821
1120
|
deliveryEmitter,
|
|
822
1121
|
|
|
823
1122
|
registerAgent(args) {
|
|
1123
|
+
const identity = normalizeRegistrationIdentity(args);
|
|
824
1124
|
const result = store.registerAgent(args);
|
|
825
1125
|
if (!registerPresenceIsEffective(result)) {
|
|
826
1126
|
return registerPresenceFailure(args.agent_id, result);
|
|
827
1127
|
}
|
|
1128
|
+
if (identity && updateRegistrationIdentity) {
|
|
1129
|
+
updateRegistrationIdentity.run({
|
|
1130
|
+
agent_id: args.agent_id,
|
|
1131
|
+
...identity,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
828
1134
|
upsertRuntimeTopics(args.agent_id, args.topics || [], { replace: true });
|
|
829
1135
|
refreshRoleCandidateForAgent(args.agent_id);
|
|
830
1136
|
for (const roleName of ROLE_TOPICS) {
|
|
@@ -1146,7 +1452,7 @@ export function createRouter(store) {
|
|
|
1146
1452
|
acceptance_criteria,
|
|
1147
1453
|
context_refs,
|
|
1148
1454
|
priority = 5,
|
|
1149
|
-
ttl_ms =
|
|
1455
|
+
ttl_ms = DEFAULT_HANDOFF_TTL_MS,
|
|
1150
1456
|
trace_id,
|
|
1151
1457
|
correlation_id,
|
|
1152
1458
|
}) {
|
|
@@ -1174,8 +1480,8 @@ export function createRouter(store) {
|
|
|
1174
1480
|
task = "",
|
|
1175
1481
|
payload = {},
|
|
1176
1482
|
priority = 5,
|
|
1177
|
-
ttl_ms =
|
|
1178
|
-
timeout_ms =
|
|
1483
|
+
ttl_ms = DEFAULT_ASSIGN_TTL_MS,
|
|
1484
|
+
timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
|
|
1179
1485
|
max_retries = 0,
|
|
1180
1486
|
trace_id,
|
|
1181
1487
|
correlation_id,
|
|
@@ -1232,16 +1538,6 @@ export function createRouter(store) {
|
|
|
1232
1538
|
},
|
|
1233
1539
|
};
|
|
1234
1540
|
}
|
|
1235
|
-
if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
|
|
1236
|
-
return {
|
|
1237
|
-
ok: false,
|
|
1238
|
-
error: {
|
|
1239
|
-
code: "ASSIGN_ATTEMPT_MISMATCH",
|
|
1240
|
-
message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
|
|
1241
|
-
},
|
|
1242
|
-
};
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
1541
|
const mergedMetadata = {
|
|
1246
1542
|
...(payload?.metadata || {}),
|
|
1247
1543
|
...(metadata || {}),
|
|
@@ -1255,11 +1551,37 @@ export function createRouter(store) {
|
|
|
1255
1551
|
(Object.hasOwn(payload || {}, "result") ? payload.result : payload);
|
|
1256
1552
|
const nextError = error ?? payload?.error ?? null;
|
|
1257
1553
|
|
|
1554
|
+
if (Number.isFinite(Number(attempt)) && Number(attempt) !== job.attempt) {
|
|
1555
|
+
const preserved = notifyAssignSupervisor(job, "late_result", {
|
|
1556
|
+
stale_attempt: Number(attempt),
|
|
1557
|
+
late_status: normalizedStatus,
|
|
1558
|
+
late_result: nextResult,
|
|
1559
|
+
late_error: nextError,
|
|
1560
|
+
reported_by: worker_agent || job.worker_agent,
|
|
1561
|
+
});
|
|
1562
|
+
return {
|
|
1563
|
+
ok: false,
|
|
1564
|
+
error: {
|
|
1565
|
+
code: "ASSIGN_ATTEMPT_MISMATCH",
|
|
1566
|
+
message: `stale assign result for attempt ${attempt} (current ${job.attempt})`,
|
|
1567
|
+
details: {
|
|
1568
|
+
preserved: Boolean(preserved),
|
|
1569
|
+
preserved_message_id: preserved?.id ?? null,
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1258
1575
|
if (normalizedStatus === "running") {
|
|
1576
|
+
const nowMs = Date.now();
|
|
1577
|
+
const ceilingAtMs =
|
|
1578
|
+
(job.dispatched_at_ms || job.created_at_ms) + resolveHardCeilingMs();
|
|
1259
1579
|
const running = store.updateAssignStatus(job.job_id, "running", {
|
|
1260
|
-
started_at_ms: job.started_at_ms ||
|
|
1261
|
-
deadline_ms:
|
|
1262
|
-
|
|
1580
|
+
started_at_ms: job.started_at_ms || nowMs,
|
|
1581
|
+
deadline_ms: Math.min(
|
|
1582
|
+
nowMs + clampDurationMs(job.timeout_ms, job.timeout_ms),
|
|
1583
|
+
ceilingAtMs,
|
|
1584
|
+
),
|
|
1263
1585
|
result: nextResult,
|
|
1264
1586
|
error: nextError,
|
|
1265
1587
|
});
|
|
@@ -1328,12 +1650,44 @@ export function createRouter(store) {
|
|
|
1328
1650
|
sweepExpired() {
|
|
1329
1651
|
const now = Date.now();
|
|
1330
1652
|
let expired = 0;
|
|
1653
|
+
const deadLettered = [];
|
|
1331
1654
|
for (const [messageId, record] of Array.from(liveMessages.entries())) {
|
|
1332
1655
|
if (record.message.expires_at_ms > now) continue;
|
|
1333
1656
|
store.moveToDeadLetter(messageId, "ttl_expired", null);
|
|
1657
|
+
deadLettered.push(record.message);
|
|
1334
1658
|
removeMessage(messageId);
|
|
1335
1659
|
expired += 1;
|
|
1336
1660
|
}
|
|
1661
|
+
for (const message of deadLettered) {
|
|
1662
|
+
if (
|
|
1663
|
+
!["handoff", "request"].includes(message.type) ||
|
|
1664
|
+
message.payload?.kind === "assign.job"
|
|
1665
|
+
)
|
|
1666
|
+
continue;
|
|
1667
|
+
if (
|
|
1668
|
+
!message.from_agent ||
|
|
1669
|
+
String(message.from_agent).startsWith("topic:")
|
|
1670
|
+
)
|
|
1671
|
+
continue;
|
|
1672
|
+
dispatchMessage({
|
|
1673
|
+
type: "event",
|
|
1674
|
+
from: "hub-sweeper",
|
|
1675
|
+
to: message.from_agent,
|
|
1676
|
+
topic: "handoff.dead_letter",
|
|
1677
|
+
priority: 7,
|
|
1678
|
+
ttl_ms: 300000,
|
|
1679
|
+
payload: {
|
|
1680
|
+
kind: "handoff.dead_letter",
|
|
1681
|
+
message_id: message.id,
|
|
1682
|
+
original_topic: message.topic,
|
|
1683
|
+
original_to: message.to_agent,
|
|
1684
|
+
reason: "ttl_expired",
|
|
1685
|
+
task: message.payload?.task ?? null,
|
|
1686
|
+
},
|
|
1687
|
+
trace_id: message.trace_id,
|
|
1688
|
+
correlation_id: message.correlation_id,
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1337
1691
|
return { messages: expired };
|
|
1338
1692
|
},
|
|
1339
1693
|
|
package/hub/schema.sql
CHANGED
|
@@ -11,7 +11,11 @@ CREATE TABLE IF NOT EXISTS agents (
|
|
|
11
11
|
last_seen_ms INTEGER NOT NULL,
|
|
12
12
|
lease_expires_ms INTEGER NOT NULL,
|
|
13
13
|
status TEXT NOT NULL CHECK (status IN ('online','stale','offline')),
|
|
14
|
-
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
14
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
15
|
+
project_id TEXT,
|
|
16
|
+
session_id TEXT,
|
|
17
|
+
host_id TEXT,
|
|
18
|
+
transport_locators_json TEXT NOT NULL DEFAULT '[]'
|
|
15
19
|
);
|
|
16
20
|
|
|
17
21
|
-- 메시지 테이블
|
|
@@ -28,7 +32,61 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
28
32
|
correlation_id TEXT NOT NULL,
|
|
29
33
|
trace_id TEXT NOT NULL,
|
|
30
34
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
31
|
-
status TEXT NOT NULL CHECK (status IN ('queued','delivered','acked','expired','dead_letter'))
|
|
35
|
+
status TEXT NOT NULL CHECK (status IN ('queued','delivered','acked','expired','dead_letter')),
|
|
36
|
+
role_key_wire TEXT
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- 동적 역할 레지스트리 (schema v6)
|
|
40
|
+
CREATE TABLE IF NOT EXISTS role_registry (
|
|
41
|
+
role_key_wire TEXT PRIMARY KEY,
|
|
42
|
+
project_id TEXT NOT NULL,
|
|
43
|
+
role_kind TEXT NOT NULL CHECK (role_kind IN ('cto','lead')),
|
|
44
|
+
scope_id TEXT NOT NULL DEFAULT '',
|
|
45
|
+
state TEXT NOT NULL CHECK (
|
|
46
|
+
state IN (
|
|
47
|
+
'electable',
|
|
48
|
+
'elected-probing',
|
|
49
|
+
'active',
|
|
50
|
+
'unreachable',
|
|
51
|
+
'quarantined'
|
|
52
|
+
)
|
|
53
|
+
),
|
|
54
|
+
holder_agent_id TEXT,
|
|
55
|
+
previous_holder_agent_id TEXT,
|
|
56
|
+
epoch INTEGER NOT NULL DEFAULT 0 CHECK (epoch >= 0),
|
|
57
|
+
activation_seq INTEGER NOT NULL DEFAULT 0 CHECK (activation_seq >= 0),
|
|
58
|
+
activation_id TEXT,
|
|
59
|
+
activation_deadline_ms INTEGER,
|
|
60
|
+
holder_lease_expires_ms INTEGER,
|
|
61
|
+
charter_version TEXT,
|
|
62
|
+
charter_acked_epoch INTEGER,
|
|
63
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
next_probe_ms INTEGER,
|
|
65
|
+
blocked_reason TEXT,
|
|
66
|
+
last_transition_ms INTEGER NOT NULL,
|
|
67
|
+
last_reason TEXT NOT NULL,
|
|
68
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
CHECK (
|
|
70
|
+
(role_kind = 'cto' AND scope_id = '') OR
|
|
71
|
+
(role_kind = 'lead' AND scope_id <> '')
|
|
72
|
+
),
|
|
73
|
+
UNIQUE(project_id, role_kind, scope_id)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS role_candidates (
|
|
77
|
+
role_key_wire TEXT NOT NULL,
|
|
78
|
+
agent_id TEXT NOT NULL,
|
|
79
|
+
source TEXT NOT NULL CHECK (source IN ('grant','standup','operator')),
|
|
80
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
transport_locators_json TEXT NOT NULL DEFAULT '[]',
|
|
82
|
+
consecutive_probe_failures INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
excluded_until_ms INTEGER,
|
|
84
|
+
last_probe_ms INTEGER,
|
|
85
|
+
last_probe_code TEXT,
|
|
86
|
+
updated_at_ms INTEGER NOT NULL,
|
|
87
|
+
PRIMARY KEY(role_key_wire, agent_id),
|
|
88
|
+
FOREIGN KEY(role_key_wire)
|
|
89
|
+
REFERENCES role_registry(role_key_wire) ON DELETE CASCADE
|
|
32
90
|
);
|
|
33
91
|
|
|
34
92
|
-- 메시지 수신함 (배달 추적)
|
|
@@ -73,6 +131,7 @@ CREATE INDEX IF NOT EXISTS idx_messages_correlation ON messages(correlation_id);
|
|
|
73
131
|
CREATE INDEX IF NOT EXISTS idx_messages_trace ON messages(trace_id);
|
|
74
132
|
CREATE INDEX IF NOT EXISTS idx_messages_expires ON messages(expires_at_ms);
|
|
75
133
|
CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority DESC, created_at_ms ASC);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_messages_role_key ON messages(role_key_wire, status, expires_at_ms);
|
|
76
135
|
CREATE INDEX IF NOT EXISTS idx_inbox_agent ON message_inbox(agent_id, delivered_at_ms);
|
|
77
136
|
CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
|
|
78
137
|
CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
|
|
@@ -103,6 +162,7 @@ CREATE TABLE IF NOT EXISTS assign_jobs (
|
|
|
103
162
|
created_at_ms INTEGER NOT NULL,
|
|
104
163
|
updated_at_ms INTEGER NOT NULL,
|
|
105
164
|
started_at_ms INTEGER,
|
|
165
|
+
dispatched_at_ms INTEGER,
|
|
106
166
|
completed_at_ms INTEGER,
|
|
107
167
|
last_retry_at_ms INTEGER
|
|
108
168
|
);
|