@kmmao/happy-agent 0.4.0 → 0.5.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/dist/index.cjs +738 -123
- package/dist/index.d.cts +272 -1
- package/dist/index.d.mts +272 -1
- package/dist/index.mjs +739 -124
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -18,7 +18,7 @@ var path = require('path');
|
|
|
18
18
|
var fs = require('fs');
|
|
19
19
|
var os = require('os');
|
|
20
20
|
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.5.0";
|
|
22
22
|
|
|
23
23
|
function loadConfig() {
|
|
24
24
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -1846,9 +1846,24 @@ function untrackSession(pid) {
|
|
|
1846
1846
|
pidToSession.delete(pid);
|
|
1847
1847
|
return session;
|
|
1848
1848
|
}
|
|
1849
|
+
function getTrackedSession(pid) {
|
|
1850
|
+
return pidToSession.get(pid);
|
|
1851
|
+
}
|
|
1849
1852
|
function getAllTrackedSessions() {
|
|
1850
1853
|
return [...pidToSession.values()];
|
|
1851
1854
|
}
|
|
1855
|
+
function getTrackedSessionCount() {
|
|
1856
|
+
return pidToSession.size;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
var trackedSessions = /*#__PURE__*/Object.freeze({
|
|
1860
|
+
__proto__: null,
|
|
1861
|
+
getAllTrackedSessions: getAllTrackedSessions,
|
|
1862
|
+
getTrackedSession: getTrackedSession,
|
|
1863
|
+
getTrackedSessionCount: getTrackedSessionCount,
|
|
1864
|
+
trackSession: trackSession,
|
|
1865
|
+
untrackSession: untrackSession
|
|
1866
|
+
});
|
|
1852
1867
|
|
|
1853
1868
|
const execFileAsync = util.promisify(child_process.execFile);
|
|
1854
1869
|
const SERVER_INTERNAL_SECRETS = /* @__PURE__ */ new Set([
|
|
@@ -2010,11 +2025,11 @@ function stopSession(pid) {
|
|
|
2010
2025
|
}
|
|
2011
2026
|
}
|
|
2012
2027
|
|
|
2013
|
-
const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-prompts");
|
|
2028
|
+
const PROMPT_DIR$1 = path.join(os.tmpdir(), "happy", "agent-prompts");
|
|
2014
2029
|
async function writePromptFile(prefix, content) {
|
|
2015
|
-
await promises.mkdir(PROMPT_DIR, { recursive: true });
|
|
2030
|
+
await promises.mkdir(PROMPT_DIR$1, { recursive: true });
|
|
2016
2031
|
const filename = `${prefix}-${Date.now()}.md`;
|
|
2017
|
-
const filepath = path.join(PROMPT_DIR, filename);
|
|
2032
|
+
const filepath = path.join(PROMPT_DIR$1, filename);
|
|
2018
2033
|
await promises.writeFile(filepath, content, "utf-8");
|
|
2019
2034
|
return filepath;
|
|
2020
2035
|
}
|
|
@@ -2044,130 +2059,139 @@ function buildSupervisorPrompt(data) {
|
|
|
2044
2059
|
if (data.customRules) parts.push("", "## Custom Rules", data.customRules);
|
|
2045
2060
|
return parts.join("\n");
|
|
2046
2061
|
}
|
|
2047
|
-
|
|
2062
|
+
function mapTaskPriority(priority) {
|
|
2063
|
+
if (priority === "urgent" || priority === "high") return "urgent";
|
|
2064
|
+
if (priority === "background" || priority === "low") return "background";
|
|
2065
|
+
return "user";
|
|
2066
|
+
}
|
|
2067
|
+
function handleWebhookTrigger(data, client, serverUrl, authToken, scheduler) {
|
|
2048
2068
|
logger.debug(`[TRIGGER] Webhook: issue #${data.issueNumber} in ${data.repoPath}`);
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
kind: "webhook",
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
HAPPY_AUTH_TOKEN: authToken
|
|
2068
|
-
}
|
|
2069
|
-
});
|
|
2070
|
-
if (result.type === "success") {
|
|
2071
|
-
logger.debug(`[TRIGGER] Webhook session spawned: PID ${result.pid}`);
|
|
2072
|
-
} else {
|
|
2073
|
-
logger.debug(`[TRIGGER] Webhook spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
|
|
2074
|
-
client.emitWebhookStatus({
|
|
2075
|
-
webhookEventId: data.webhookEventId,
|
|
2076
|
-
status: "failed",
|
|
2077
|
-
errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
|
|
2069
|
+
const { deduped } = scheduler.enqueue({
|
|
2070
|
+
kind: "webhook",
|
|
2071
|
+
dedupeKey: `webhook:${data.webhookEventId}`,
|
|
2072
|
+
priority: "background",
|
|
2073
|
+
run: async (jobId) => {
|
|
2074
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "dispatched" });
|
|
2075
|
+
const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
|
|
2076
|
+
const result = await spawnSession({
|
|
2077
|
+
directory: data.repoPath,
|
|
2078
|
+
approvedNewDirectoryCreation: false,
|
|
2079
|
+
automationContext: { kind: "webhook", trigger: `issue#${data.issueNumber}` },
|
|
2080
|
+
environmentVariables: {
|
|
2081
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2082
|
+
HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
|
|
2083
|
+
HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
|
|
2084
|
+
HAPPY_SERVER_URL: serverUrl,
|
|
2085
|
+
HAPPY_AUTH_TOKEN: authToken
|
|
2086
|
+
}
|
|
2078
2087
|
});
|
|
2088
|
+
if (result.type !== "success") {
|
|
2089
|
+
const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
|
|
2090
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "failed", errorMessage: msg });
|
|
2091
|
+
throw new Error(msg);
|
|
2092
|
+
}
|
|
2093
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2094
|
+
if (tracked?.childProcess) {
|
|
2095
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2096
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2097
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2098
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: code === 0 ? "completed" : "failed" });
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
return { pid: result.pid };
|
|
2079
2102
|
}
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
logger.debug(`[TRIGGER] Webhook
|
|
2083
|
-
client.emitWebhookStatus({
|
|
2084
|
-
webhookEventId: data.webhookEventId,
|
|
2085
|
-
status: "failed",
|
|
2086
|
-
errorMessage: msg
|
|
2087
|
-
});
|
|
2103
|
+
});
|
|
2104
|
+
if (deduped) {
|
|
2105
|
+
logger.debug(`[TRIGGER] Webhook deduped: ${data.webhookEventId}`);
|
|
2088
2106
|
}
|
|
2089
2107
|
}
|
|
2090
|
-
|
|
2108
|
+
function handleSupervisorTrigger(data, client, serverUrl, authToken, scheduler) {
|
|
2091
2109
|
logger.debug(`[TRIGGER] Supervisor: run ${data.runId} in ${data.repoPath}`);
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
HAPPY_SUPERVISOR_RUN_ID: data.runId,
|
|
2111
|
-
HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
|
|
2112
|
-
HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
|
|
2113
|
-
HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
|
|
2114
|
-
}
|
|
2115
|
-
});
|
|
2116
|
-
if (result.type !== "success") {
|
|
2117
|
-
client.emitSupervisorRunStatus({
|
|
2118
|
-
runId: data.runId,
|
|
2119
|
-
projectId: data.projectId,
|
|
2120
|
-
status: "failed",
|
|
2121
|
-
errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
|
|
2110
|
+
const { deduped } = scheduler.enqueue({
|
|
2111
|
+
kind: "supervisor",
|
|
2112
|
+
dedupeKey: `supervisor:${data.runId}`,
|
|
2113
|
+
priority: "background",
|
|
2114
|
+
run: async (jobId) => {
|
|
2115
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "running" });
|
|
2116
|
+
const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
|
|
2117
|
+
const result = await spawnSession({
|
|
2118
|
+
directory: data.repoPath,
|
|
2119
|
+
approvedNewDirectoryCreation: false,
|
|
2120
|
+
automationContext: { kind: "supervisor", trigger: data.trigger, projectId: data.projectId, runId: data.runId },
|
|
2121
|
+
environmentVariables: {
|
|
2122
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2123
|
+
HAPPY_SUPERVISOR_RUN_ID: data.runId,
|
|
2124
|
+
HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
|
|
2125
|
+
HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
|
|
2126
|
+
HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
|
|
2127
|
+
}
|
|
2122
2128
|
});
|
|
2129
|
+
if (result.type !== "success") {
|
|
2130
|
+
const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
|
|
2131
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "failed", errorMessage: msg });
|
|
2132
|
+
throw new Error(msg);
|
|
2133
|
+
}
|
|
2134
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2135
|
+
if (tracked?.childProcess) {
|
|
2136
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2137
|
+
const status = code === 0 ? "completed" : "failed";
|
|
2138
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2139
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2140
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status });
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
return { pid: result.pid };
|
|
2123
2144
|
}
|
|
2124
|
-
}
|
|
2125
|
-
|
|
2126
|
-
logger.debug(`[TRIGGER] Supervisor
|
|
2127
|
-
client.emitSupervisorRunStatus({
|
|
2128
|
-
runId: data.runId,
|
|
2129
|
-
projectId: data.projectId,
|
|
2130
|
-
status: "failed",
|
|
2131
|
-
errorMessage: msg
|
|
2132
|
-
});
|
|
2145
|
+
});
|
|
2146
|
+
if (deduped) {
|
|
2147
|
+
logger.debug(`[TRIGGER] Supervisor deduped: ${data.runId}`);
|
|
2133
2148
|
}
|
|
2134
2149
|
}
|
|
2135
|
-
|
|
2150
|
+
function handleTaskTrigger(data, serverUrl, _authToken, scheduler) {
|
|
2136
2151
|
logger.debug(`[TRIGGER] Task: ${data.taskId} in ${data.directory}`);
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2152
|
+
const { deduped } = scheduler.enqueue({
|
|
2153
|
+
kind: "task",
|
|
2154
|
+
dedupeKey: `task:${data.taskId}`,
|
|
2155
|
+
priority: mapTaskPriority(data.priority),
|
|
2156
|
+
run: async (jobId) => {
|
|
2157
|
+
const promptFile = await writePromptFile("task", data.prompt);
|
|
2158
|
+
const skillEnv = {};
|
|
2159
|
+
if (data.skillContents?.length) {
|
|
2160
|
+
skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
|
|
2161
|
+
for (let i = 0; i < data.skillContents.length; i++) {
|
|
2162
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
|
|
2163
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
|
|
2164
|
+
}
|
|
2145
2165
|
}
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
|
|
2163
|
-
...skillEnv
|
|
2166
|
+
const result = await spawnSession({
|
|
2167
|
+
directory: data.directory,
|
|
2168
|
+
approvedNewDirectoryCreation: true,
|
|
2169
|
+
automationContext: { kind: "task", trigger: "task-dispatch", projectId: data.projectId },
|
|
2170
|
+
environmentVariables: {
|
|
2171
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2172
|
+
HAPPY_TASK_ID: data.taskId,
|
|
2173
|
+
HAPPY_TASK_PRIORITY: data.priority,
|
|
2174
|
+
HAPPY_TASK_SERVER_URL: serverUrl,
|
|
2175
|
+
HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
|
|
2176
|
+
HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
|
|
2177
|
+
...skillEnv
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
if (result.type !== "success") {
|
|
2181
|
+
throw new Error(result.type === "error" ? result.errorMessage : "Directory creation not approved");
|
|
2164
2182
|
}
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2183
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2184
|
+
if (tracked?.childProcess) {
|
|
2185
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2186
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2187
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
return { pid: result.pid };
|
|
2168
2191
|
}
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2192
|
+
});
|
|
2193
|
+
if (deduped) {
|
|
2194
|
+
logger.debug(`[TRIGGER] Task deduped: ${data.taskId}`);
|
|
2171
2195
|
}
|
|
2172
2196
|
}
|
|
2173
2197
|
|
|
@@ -2188,6 +2212,9 @@ class MachineClient {
|
|
|
2188
2212
|
automationEnabled = false;
|
|
2189
2213
|
automationServerUrl = "";
|
|
2190
2214
|
automationAuthToken = "";
|
|
2215
|
+
scheduler = null;
|
|
2216
|
+
loopCoordinator = null;
|
|
2217
|
+
auditStore = null;
|
|
2191
2218
|
constructor(opts) {
|
|
2192
2219
|
this.token = opts.token;
|
|
2193
2220
|
this.machine = opts.machine;
|
|
@@ -2229,6 +2256,34 @@ class MachineClient {
|
|
|
2229
2256
|
return { sessions };
|
|
2230
2257
|
});
|
|
2231
2258
|
}
|
|
2259
|
+
registerLoopHandlers() {
|
|
2260
|
+
const coord = this.loopCoordinator;
|
|
2261
|
+
this.rpcHandlerManager.registerHandler("create-loop", async (data) => {
|
|
2262
|
+
const loop = coord.createLoop(data);
|
|
2263
|
+
return { loop: { id: loop.id, name: loop.name, state: loop.state } };
|
|
2264
|
+
});
|
|
2265
|
+
this.rpcHandlerManager.registerHandler("list-loops", async () => {
|
|
2266
|
+
return { loops: coord.listLoops() };
|
|
2267
|
+
});
|
|
2268
|
+
this.rpcHandlerManager.registerHandler("pause-loop", async (data) => {
|
|
2269
|
+
return { success: coord.pauseLoop(data.loopId) };
|
|
2270
|
+
});
|
|
2271
|
+
this.rpcHandlerManager.registerHandler("resume-loop", async (data) => {
|
|
2272
|
+
return { success: coord.resumeLoop(data.loopId) };
|
|
2273
|
+
});
|
|
2274
|
+
this.rpcHandlerManager.registerHandler("delete-loop", async (data) => {
|
|
2275
|
+
return { success: coord.deleteLoop(data.loopId) };
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
registerAuditHandlers() {
|
|
2279
|
+
const audit = this.auditStore;
|
|
2280
|
+
this.rpcHandlerManager.registerHandler("query-audit-log", async (data) => {
|
|
2281
|
+
return { events: audit.query(data) };
|
|
2282
|
+
});
|
|
2283
|
+
this.rpcHandlerManager.registerHandler("audit-summary", async () => {
|
|
2284
|
+
return { summary: audit.summarize() };
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2232
2287
|
// -----------------------------------------------------------------------
|
|
2233
2288
|
// Connection
|
|
2234
2289
|
// -----------------------------------------------------------------------
|
|
@@ -2410,37 +2465,49 @@ class MachineClient {
|
|
|
2410
2465
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
2411
2466
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2412
2467
|
*/
|
|
2413
|
-
enableAutomation(serverUrl, authToken) {
|
|
2468
|
+
enableAutomation(serverUrl, authToken, scheduler, loopCoordinator, auditStore) {
|
|
2414
2469
|
this.automationEnabled = true;
|
|
2415
2470
|
this.automationServerUrl = serverUrl;
|
|
2416
2471
|
this.automationAuthToken = authToken;
|
|
2472
|
+
this.scheduler = scheduler;
|
|
2473
|
+
this.loopCoordinator = loopCoordinator ?? null;
|
|
2474
|
+
this.auditStore = auditStore ?? null;
|
|
2475
|
+
if (this.loopCoordinator) {
|
|
2476
|
+
this.registerLoopHandlers();
|
|
2477
|
+
}
|
|
2478
|
+
if (this.auditStore) {
|
|
2479
|
+
this.registerAuditHandlers();
|
|
2480
|
+
}
|
|
2417
2481
|
logger.debug("[MACHINE] Automation enabled");
|
|
2418
2482
|
}
|
|
2419
2483
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
2420
2484
|
handleAutomationEvent(event) {
|
|
2421
|
-
if (!this.automationEnabled) return;
|
|
2485
|
+
if (!this.automationEnabled || !this.scheduler) return;
|
|
2422
2486
|
switch (event.type) {
|
|
2423
2487
|
case "webhook-trigger":
|
|
2424
|
-
|
|
2488
|
+
handleWebhookTrigger(
|
|
2425
2489
|
event,
|
|
2426
2490
|
this,
|
|
2427
2491
|
this.automationServerUrl,
|
|
2428
|
-
this.automationAuthToken
|
|
2492
|
+
this.automationAuthToken,
|
|
2493
|
+
this.scheduler
|
|
2429
2494
|
);
|
|
2430
2495
|
break;
|
|
2431
2496
|
case "supervisor-trigger":
|
|
2432
|
-
|
|
2497
|
+
handleSupervisorTrigger(
|
|
2433
2498
|
event,
|
|
2434
2499
|
this,
|
|
2435
2500
|
this.automationServerUrl,
|
|
2436
|
-
this.automationAuthToken
|
|
2501
|
+
this.automationAuthToken,
|
|
2502
|
+
this.scheduler
|
|
2437
2503
|
);
|
|
2438
2504
|
break;
|
|
2439
2505
|
case "task-trigger":
|
|
2440
|
-
|
|
2506
|
+
handleTaskTrigger(
|
|
2441
2507
|
event,
|
|
2442
2508
|
this.automationServerUrl,
|
|
2443
|
-
this.automationAuthToken
|
|
2509
|
+
this.automationAuthToken,
|
|
2510
|
+
this.scheduler
|
|
2444
2511
|
);
|
|
2445
2512
|
break;
|
|
2446
2513
|
}
|
|
@@ -2520,6 +2587,544 @@ function tailscaleChanged(prev, next) {
|
|
|
2520
2587
|
return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
|
|
2521
2588
|
}
|
|
2522
2589
|
|
|
2590
|
+
const PRIORITY_ORDER = {
|
|
2591
|
+
urgent: 0,
|
|
2592
|
+
user: 1,
|
|
2593
|
+
background: 2
|
|
2594
|
+
};
|
|
2595
|
+
const ACTIVE_STATUSES = /* @__PURE__ */ new Set(["queued", "dispatching", "running"]);
|
|
2596
|
+
class AutomationScheduler {
|
|
2597
|
+
maxConcurrentJobs;
|
|
2598
|
+
retryDelayMs;
|
|
2599
|
+
defaultMaxAttempts;
|
|
2600
|
+
maxRecentCompletions;
|
|
2601
|
+
onAudit;
|
|
2602
|
+
/** Active jobs indexed by id. */
|
|
2603
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2604
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
2605
|
+
dedupeIndex = /* @__PURE__ */ new Map();
|
|
2606
|
+
/** Ring buffer for completed/failed jobs. */
|
|
2607
|
+
recentCompletions = [];
|
|
2608
|
+
pumpTimer = null;
|
|
2609
|
+
pumping = false;
|
|
2610
|
+
constructor(options) {
|
|
2611
|
+
this.maxConcurrentJobs = options?.maxConcurrentJobs ?? 2;
|
|
2612
|
+
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2613
|
+
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2614
|
+
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2615
|
+
this.onAudit = options?.onAudit ?? null;
|
|
2616
|
+
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2617
|
+
}
|
|
2618
|
+
// -----------------------------------------------------------------------
|
|
2619
|
+
// Public API
|
|
2620
|
+
// -----------------------------------------------------------------------
|
|
2621
|
+
enqueue(opts) {
|
|
2622
|
+
const existingId = this.dedupeIndex.get(opts.dedupeKey);
|
|
2623
|
+
if (existingId) {
|
|
2624
|
+
const existing = this.jobs.get(existingId);
|
|
2625
|
+
if (existing && ACTIVE_STATUSES.has(existing.status)) {
|
|
2626
|
+
logger.debug(`[SCHEDULER] Deduped: ${opts.dedupeKey} (job ${existingId} is ${existing.status})`);
|
|
2627
|
+
return { job: existing, deduped: true };
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
const job = {
|
|
2631
|
+
id: crypto.randomUUID(),
|
|
2632
|
+
kind: opts.kind,
|
|
2633
|
+
dedupeKey: opts.dedupeKey,
|
|
2634
|
+
priority: opts.priority,
|
|
2635
|
+
status: "queued",
|
|
2636
|
+
attempt: 0,
|
|
2637
|
+
maxAttempts: this.defaultMaxAttempts,
|
|
2638
|
+
createdAt: Date.now(),
|
|
2639
|
+
updatedAt: Date.now(),
|
|
2640
|
+
nextRunAt: Date.now(),
|
|
2641
|
+
run: opts.run
|
|
2642
|
+
};
|
|
2643
|
+
this.jobs.set(job.id, job);
|
|
2644
|
+
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2645
|
+
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2646
|
+
this.onAudit?.({ kind: "job_enqueued", jobId: job.id, dedupeKey: job.dedupeKey, message: `${job.kind}:${job.priority}` });
|
|
2647
|
+
this.pump();
|
|
2648
|
+
return { job, deduped: false };
|
|
2649
|
+
}
|
|
2650
|
+
markCompleted(jobId) {
|
|
2651
|
+
const job = this.jobs.get(jobId);
|
|
2652
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2653
|
+
job.status = "completed";
|
|
2654
|
+
job.updatedAt = Date.now();
|
|
2655
|
+
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2656
|
+
this.onAudit?.({ kind: "job_completed", jobId, dedupeKey: job.dedupeKey });
|
|
2657
|
+
this.finalize(job);
|
|
2658
|
+
}
|
|
2659
|
+
markFailed(jobId, error) {
|
|
2660
|
+
const job = this.jobs.get(jobId);
|
|
2661
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2662
|
+
job.errorMessage = error;
|
|
2663
|
+
job.updatedAt = Date.now();
|
|
2664
|
+
if (job.attempt < job.maxAttempts) {
|
|
2665
|
+
job.status = "queued";
|
|
2666
|
+
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2667
|
+
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2668
|
+
this.onAudit?.({ kind: "job_retried", jobId, dedupeKey: job.dedupeKey, errorMessage: error, message: `attempt ${job.attempt}/${job.maxAttempts}` });
|
|
2669
|
+
this.pump();
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
job.status = "failed";
|
|
2673
|
+
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2674
|
+
this.onAudit?.({ kind: "job_failed", jobId, dedupeKey: job.dedupeKey, errorMessage: error });
|
|
2675
|
+
this.finalize(job);
|
|
2676
|
+
}
|
|
2677
|
+
getStatus() {
|
|
2678
|
+
let queueLength = 0;
|
|
2679
|
+
let runningCount = 0;
|
|
2680
|
+
for (const job of this.jobs.values()) {
|
|
2681
|
+
if (job.status === "queued") queueLength++;
|
|
2682
|
+
else if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2683
|
+
}
|
|
2684
|
+
return {
|
|
2685
|
+
queueLength,
|
|
2686
|
+
runningCount,
|
|
2687
|
+
recentCompletions: [...this.recentCompletions]
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
shutdown() {
|
|
2691
|
+
if (this.pumpTimer) {
|
|
2692
|
+
clearInterval(this.pumpTimer);
|
|
2693
|
+
this.pumpTimer = null;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
// -----------------------------------------------------------------------
|
|
2697
|
+
// Internal
|
|
2698
|
+
// -----------------------------------------------------------------------
|
|
2699
|
+
pump() {
|
|
2700
|
+
if (this.pumping) return;
|
|
2701
|
+
this.pumping = true;
|
|
2702
|
+
try {
|
|
2703
|
+
const now = Date.now();
|
|
2704
|
+
let runningCount = 0;
|
|
2705
|
+
for (const job of this.jobs.values()) {
|
|
2706
|
+
if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2707
|
+
}
|
|
2708
|
+
if (runningCount >= this.maxConcurrentJobs) return;
|
|
2709
|
+
const ready = [];
|
|
2710
|
+
for (const job of this.jobs.values()) {
|
|
2711
|
+
if (job.status === "queued" && job.nextRunAt <= now) {
|
|
2712
|
+
ready.push(job);
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
ready.sort((a, b) => {
|
|
2716
|
+
const pDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
2717
|
+
if (pDiff !== 0) return pDiff;
|
|
2718
|
+
return a.createdAt - b.createdAt;
|
|
2719
|
+
});
|
|
2720
|
+
const slotsAvailable = this.maxConcurrentJobs - runningCount;
|
|
2721
|
+
const toDispatch = ready.slice(0, slotsAvailable);
|
|
2722
|
+
for (const job of toDispatch) {
|
|
2723
|
+
this.dispatch(job);
|
|
2724
|
+
}
|
|
2725
|
+
} finally {
|
|
2726
|
+
this.pumping = false;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
dispatch(job) {
|
|
2730
|
+
job.status = "dispatching";
|
|
2731
|
+
job.attempt++;
|
|
2732
|
+
job.updatedAt = Date.now();
|
|
2733
|
+
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2734
|
+
this.onAudit?.({ kind: "job_dispatched", jobId: job.id, dedupeKey: job.dedupeKey, message: `attempt ${job.attempt}` });
|
|
2735
|
+
job.run(job.id).then(({ pid }) => {
|
|
2736
|
+
if (job.status === "dispatching") {
|
|
2737
|
+
job.status = "running";
|
|
2738
|
+
job.pid = pid;
|
|
2739
|
+
job.updatedAt = Date.now();
|
|
2740
|
+
logger.debug(`[SCHEDULER] Running: ${job.dedupeKey} pid=${pid}`);
|
|
2741
|
+
}
|
|
2742
|
+
}).catch((error) => {
|
|
2743
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2744
|
+
logger.debug(`[SCHEDULER] Dispatch failed: ${job.dedupeKey}: ${msg}`);
|
|
2745
|
+
if (job.status === "dispatching") {
|
|
2746
|
+
this.markFailed(job.id, msg);
|
|
2747
|
+
}
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
finalize(job) {
|
|
2751
|
+
this.jobs.delete(job.id);
|
|
2752
|
+
if (this.dedupeIndex.get(job.dedupeKey) === job.id) {
|
|
2753
|
+
this.dedupeIndex.delete(job.dedupeKey);
|
|
2754
|
+
}
|
|
2755
|
+
this.recentCompletions.push({
|
|
2756
|
+
id: job.id,
|
|
2757
|
+
kind: job.kind,
|
|
2758
|
+
dedupeKey: job.dedupeKey,
|
|
2759
|
+
status: job.status,
|
|
2760
|
+
completedAt: job.updatedAt,
|
|
2761
|
+
errorMessage: job.errorMessage
|
|
2762
|
+
});
|
|
2763
|
+
while (this.recentCompletions.length > this.maxRecentCompletions) {
|
|
2764
|
+
this.recentCompletions.shift();
|
|
2765
|
+
}
|
|
2766
|
+
this.pump();
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-loop-prompts");
|
|
2771
|
+
const DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
2772
|
+
const DEFAULT_MAX_ITERATIONS = 0;
|
|
2773
|
+
class AgentLoopCoordinator {
|
|
2774
|
+
loops = /* @__PURE__ */ new Map();
|
|
2775
|
+
scheduler;
|
|
2776
|
+
serverUrl;
|
|
2777
|
+
authToken;
|
|
2778
|
+
guardian;
|
|
2779
|
+
tickTimer = null;
|
|
2780
|
+
constructor(scheduler, serverUrl, authToken, guardian) {
|
|
2781
|
+
this.scheduler = scheduler;
|
|
2782
|
+
this.serverUrl = serverUrl;
|
|
2783
|
+
this.authToken = authToken;
|
|
2784
|
+
this.guardian = guardian ?? null;
|
|
2785
|
+
}
|
|
2786
|
+
// -----------------------------------------------------------------------
|
|
2787
|
+
// Lifecycle
|
|
2788
|
+
// -----------------------------------------------------------------------
|
|
2789
|
+
start() {
|
|
2790
|
+
if (this.tickTimer) return;
|
|
2791
|
+
this.tickTimer = setInterval(() => this.tick(), 1e3);
|
|
2792
|
+
logger.debug("[LOOP] Coordinator started");
|
|
2793
|
+
}
|
|
2794
|
+
shutdown() {
|
|
2795
|
+
if (this.tickTimer) {
|
|
2796
|
+
clearInterval(this.tickTimer);
|
|
2797
|
+
this.tickTimer = null;
|
|
2798
|
+
}
|
|
2799
|
+
logger.debug("[LOOP] Coordinator shutdown");
|
|
2800
|
+
}
|
|
2801
|
+
// -----------------------------------------------------------------------
|
|
2802
|
+
// CRUD
|
|
2803
|
+
// -----------------------------------------------------------------------
|
|
2804
|
+
createLoop(input) {
|
|
2805
|
+
const loop = {
|
|
2806
|
+
id: crypto.randomUUID(),
|
|
2807
|
+
name: input.name,
|
|
2808
|
+
prompt: input.prompt,
|
|
2809
|
+
directory: input.directory,
|
|
2810
|
+
intervalMs: Math.max(input.intervalMs, 1e4),
|
|
2811
|
+
// min 10s
|
|
2812
|
+
createdAt: Date.now(),
|
|
2813
|
+
state: "idle",
|
|
2814
|
+
iteration: 0,
|
|
2815
|
+
nextRunAt: Date.now() + Math.max(input.intervalMs, 1e4),
|
|
2816
|
+
consecutiveFailures: 0,
|
|
2817
|
+
maxConsecutiveFailures: input.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
|
|
2818
|
+
maxIterations: input.maxIterations ?? DEFAULT_MAX_ITERATIONS
|
|
2819
|
+
};
|
|
2820
|
+
this.loops.set(loop.id, loop);
|
|
2821
|
+
logger.debug(`[LOOP] Created: ${loop.name} (${loop.id}) interval=${loop.intervalMs}ms`);
|
|
2822
|
+
return loop;
|
|
2823
|
+
}
|
|
2824
|
+
getLoop(id) {
|
|
2825
|
+
return this.loops.get(id);
|
|
2826
|
+
}
|
|
2827
|
+
listLoops() {
|
|
2828
|
+
return [...this.loops.values()].map((l) => ({
|
|
2829
|
+
id: l.id,
|
|
2830
|
+
name: l.name,
|
|
2831
|
+
state: l.state,
|
|
2832
|
+
iteration: l.iteration,
|
|
2833
|
+
intervalMs: l.intervalMs,
|
|
2834
|
+
nextRunAt: l.nextRunAt,
|
|
2835
|
+
lastCompletedAt: l.lastCompletedAt
|
|
2836
|
+
}));
|
|
2837
|
+
}
|
|
2838
|
+
pauseLoop(id) {
|
|
2839
|
+
const loop = this.loops.get(id);
|
|
2840
|
+
if (!loop || loop.state === "paused") return false;
|
|
2841
|
+
loop.state = "paused";
|
|
2842
|
+
logger.debug(`[LOOP] Paused: ${loop.name} (${id})`);
|
|
2843
|
+
return true;
|
|
2844
|
+
}
|
|
2845
|
+
resumeLoop(id) {
|
|
2846
|
+
const loop = this.loops.get(id);
|
|
2847
|
+
if (!loop || loop.state !== "paused") return false;
|
|
2848
|
+
loop.state = "idle";
|
|
2849
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2850
|
+
loop.consecutiveFailures = 0;
|
|
2851
|
+
loop.errorMessage = void 0;
|
|
2852
|
+
logger.debug(`[LOOP] Resumed: ${loop.name} (${id})`);
|
|
2853
|
+
return true;
|
|
2854
|
+
}
|
|
2855
|
+
deleteLoop(id) {
|
|
2856
|
+
const deleted = this.loops.delete(id);
|
|
2857
|
+
if (deleted) logger.debug(`[LOOP] Deleted: ${id}`);
|
|
2858
|
+
return deleted;
|
|
2859
|
+
}
|
|
2860
|
+
// -----------------------------------------------------------------------
|
|
2861
|
+
// Scheduler callback
|
|
2862
|
+
// -----------------------------------------------------------------------
|
|
2863
|
+
onJobTerminal(loopId, status, errorMessage) {
|
|
2864
|
+
const loop = this.loops.get(loopId);
|
|
2865
|
+
if (!loop) return;
|
|
2866
|
+
loop.activeJobId = void 0;
|
|
2867
|
+
loop.lastCompletedAt = Date.now();
|
|
2868
|
+
if (status === "completed") {
|
|
2869
|
+
loop.consecutiveFailures = 0;
|
|
2870
|
+
loop.state = "idle";
|
|
2871
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2872
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} completed: ${loop.name}`);
|
|
2873
|
+
} else {
|
|
2874
|
+
loop.consecutiveFailures++;
|
|
2875
|
+
loop.errorMessage = errorMessage;
|
|
2876
|
+
if (loop.consecutiveFailures >= loop.maxConsecutiveFailures) {
|
|
2877
|
+
loop.state = "blocked";
|
|
2878
|
+
logger.debug(`[LOOP] Blocked after ${loop.consecutiveFailures} failures: ${loop.name}`);
|
|
2879
|
+
} else {
|
|
2880
|
+
loop.state = "idle";
|
|
2881
|
+
loop.nextRunAt = Date.now() + loop.intervalMs;
|
|
2882
|
+
logger.debug(`[LOOP] Iteration ${loop.iteration} failed (${loop.consecutiveFailures}/${loop.maxConsecutiveFailures}): ${loop.name}`);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
// -----------------------------------------------------------------------
|
|
2887
|
+
// Tick
|
|
2888
|
+
// -----------------------------------------------------------------------
|
|
2889
|
+
tick() {
|
|
2890
|
+
const now = Date.now();
|
|
2891
|
+
for (const loop of this.loops.values()) {
|
|
2892
|
+
if (loop.state !== "idle") continue;
|
|
2893
|
+
if (loop.nextRunAt > now) continue;
|
|
2894
|
+
if (loop.maxIterations > 0 && loop.iteration >= loop.maxIterations) {
|
|
2895
|
+
loop.state = "paused";
|
|
2896
|
+
logger.debug(`[LOOP] Max iterations reached (${loop.maxIterations}): ${loop.name}`);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
this.enqueueLoop(loop);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
enqueueLoop(loop) {
|
|
2903
|
+
loop.iteration++;
|
|
2904
|
+
loop.state = "active";
|
|
2905
|
+
loop.lastStartedAt = Date.now();
|
|
2906
|
+
const iterationNum = loop.iteration;
|
|
2907
|
+
const loopId = loop.id;
|
|
2908
|
+
const coordinator = this;
|
|
2909
|
+
const guardianSessionId = this.guardian?.resolve({ loopId: loop.id }) ?? void 0;
|
|
2910
|
+
const { job, deduped } = this.scheduler.enqueue({
|
|
2911
|
+
kind: "task",
|
|
2912
|
+
dedupeKey: `agent-loop:${loop.id}:${loop.iteration}`,
|
|
2913
|
+
priority: "background",
|
|
2914
|
+
run: async (jobId) => {
|
|
2915
|
+
const promptFile = await writeLoopPromptFile(loop.name, loop.prompt, iterationNum);
|
|
2916
|
+
const result = await spawnSession({
|
|
2917
|
+
directory: loop.directory,
|
|
2918
|
+
approvedNewDirectoryCreation: false,
|
|
2919
|
+
happySessionId: guardianSessionId,
|
|
2920
|
+
automationContext: {
|
|
2921
|
+
kind: "agent_loop",
|
|
2922
|
+
trigger: `loop:${loop.name}:iteration-${iterationNum}`
|
|
2923
|
+
},
|
|
2924
|
+
environmentVariables: {
|
|
2925
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2926
|
+
HAPPY_LOOP_ID: loopId,
|
|
2927
|
+
HAPPY_LOOP_NAME: loop.name,
|
|
2928
|
+
HAPPY_LOOP_ITERATION: String(iterationNum),
|
|
2929
|
+
HAPPY_SERVER_URL: coordinator.serverUrl,
|
|
2930
|
+
HAPPY_AUTH_TOKEN: coordinator.authToken
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
if (result.type !== "success") {
|
|
2934
|
+
throw new Error(result.type === "error" ? result.errorMessage : "Directory not approved");
|
|
2935
|
+
}
|
|
2936
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2937
|
+
if (tracked?.childProcess) {
|
|
2938
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2939
|
+
const status = code === 0 ? "completed" : "failed";
|
|
2940
|
+
coordinator.onJobTerminal(loopId, status, code !== 0 ? `exit code ${code}` : void 0);
|
|
2941
|
+
if (code === 0) coordinator.scheduler.markCompleted(jobId);
|
|
2942
|
+
else coordinator.scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
return { pid: result.pid };
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
if (deduped) {
|
|
2949
|
+
loop.iteration--;
|
|
2950
|
+
loop.state = "idle";
|
|
2951
|
+
logger.debug(`[LOOP] Enqueue deduped: ${loop.name} iteration ${iterationNum}`);
|
|
2952
|
+
} else {
|
|
2953
|
+
loop.activeJobId = job.id;
|
|
2954
|
+
logger.debug(`[LOOP] Enqueued: ${loop.name} iteration ${iterationNum} job=${job.id}`);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
async function writeLoopPromptFile(name, prompt, iteration) {
|
|
2959
|
+
await promises.mkdir(PROMPT_DIR, { recursive: true });
|
|
2960
|
+
const filename = `loop-${name.replace(/[^a-zA-Z0-9-]/g, "_")}-${iteration}-${Date.now()}.md`;
|
|
2961
|
+
const filepath = path.join(PROMPT_DIR, filename);
|
|
2962
|
+
const content = [
|
|
2963
|
+
`# Agent Loop: ${name}`,
|
|
2964
|
+
`Iteration: ${iteration}`,
|
|
2965
|
+
"",
|
|
2966
|
+
prompt
|
|
2967
|
+
].join("\n");
|
|
2968
|
+
await promises.writeFile(filepath, content, "utf-8");
|
|
2969
|
+
return filepath;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
class GuardianSessionRegistry {
|
|
2973
|
+
entries = /* @__PURE__ */ new Map();
|
|
2974
|
+
/**
|
|
2975
|
+
* Find an existing session to reuse.
|
|
2976
|
+
* Tries loop key first, then project key.
|
|
2977
|
+
*/
|
|
2978
|
+
resolve(input) {
|
|
2979
|
+
if (input.loopId) {
|
|
2980
|
+
const entry = this.entries.get(`loop:${input.loopId}`);
|
|
2981
|
+
if (entry) {
|
|
2982
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for loop:${input.loopId}`);
|
|
2983
|
+
return entry.sessionId;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
if (input.projectId) {
|
|
2987
|
+
const entry = this.entries.get(`project:${input.projectId}`);
|
|
2988
|
+
if (entry) {
|
|
2989
|
+
logger.debug(`[GUARDIAN] Resolved session ${entry.sessionId} for project:${input.projectId}`);
|
|
2990
|
+
return entry.sessionId;
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Remember a session for future reuse.
|
|
2997
|
+
* Stores under both loop and project keys if available.
|
|
2998
|
+
*/
|
|
2999
|
+
remember(sessionId, input) {
|
|
3000
|
+
const now = Date.now();
|
|
3001
|
+
if (input.loopId) {
|
|
3002
|
+
const key = `loop:${input.loopId}`;
|
|
3003
|
+
this.entries.set(key, {
|
|
3004
|
+
key,
|
|
3005
|
+
sessionId,
|
|
3006
|
+
loopId: input.loopId,
|
|
3007
|
+
projectId: input.projectId,
|
|
3008
|
+
updatedAt: now
|
|
3009
|
+
});
|
|
3010
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3011
|
+
}
|
|
3012
|
+
if (input.projectId) {
|
|
3013
|
+
const key = `project:${input.projectId}`;
|
|
3014
|
+
if (!this.entries.has(key)) {
|
|
3015
|
+
this.entries.set(key, {
|
|
3016
|
+
key,
|
|
3017
|
+
sessionId,
|
|
3018
|
+
projectId: input.projectId,
|
|
3019
|
+
updatedAt: now
|
|
3020
|
+
});
|
|
3021
|
+
logger.debug(`[GUARDIAN] Remembered ${sessionId} for ${key}`);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
/**
|
|
3026
|
+
* Forget a specific session (e.g., after it exits).
|
|
3027
|
+
*/
|
|
3028
|
+
forgetSession(sessionId) {
|
|
3029
|
+
let removed = 0;
|
|
3030
|
+
for (const [key, entry] of this.entries) {
|
|
3031
|
+
if (entry.sessionId === sessionId) {
|
|
3032
|
+
this.entries.delete(key);
|
|
3033
|
+
removed++;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
if (removed > 0) {
|
|
3037
|
+
logger.debug(`[GUARDIAN] Forgot session ${sessionId} (${removed} entries)`);
|
|
3038
|
+
}
|
|
3039
|
+
return removed;
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Forget all entries for a loop.
|
|
3043
|
+
*/
|
|
3044
|
+
forgetLoop(loopId) {
|
|
3045
|
+
return this.entries.delete(`loop:${loopId}`);
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Get all entries (for observability).
|
|
3049
|
+
*/
|
|
3050
|
+
getSnapshot() {
|
|
3051
|
+
return [...this.entries.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Number of tracked guardian entries.
|
|
3055
|
+
*/
|
|
3056
|
+
get size() {
|
|
3057
|
+
return this.entries.size;
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
class AutomationAuditStore {
|
|
3062
|
+
maxEntries;
|
|
3063
|
+
events = [];
|
|
3064
|
+
nextId = 1;
|
|
3065
|
+
constructor(options) {
|
|
3066
|
+
this.maxEntries = options?.maxEntries ?? 500;
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Record an audit event.
|
|
3070
|
+
*/
|
|
3071
|
+
record(event) {
|
|
3072
|
+
const entry = {
|
|
3073
|
+
...event,
|
|
3074
|
+
id: this.nextId++,
|
|
3075
|
+
timestamp: Date.now()
|
|
3076
|
+
};
|
|
3077
|
+
this.events.push(entry);
|
|
3078
|
+
while (this.events.length > this.maxEntries) {
|
|
3079
|
+
this.events.shift();
|
|
3080
|
+
}
|
|
3081
|
+
logger.debug(`[AUDIT] ${entry.kind}: ${entry.message ?? entry.dedupeKey ?? entry.jobId ?? ""}`);
|
|
3082
|
+
return entry;
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Query events with optional filters.
|
|
3086
|
+
*/
|
|
3087
|
+
query(filter) {
|
|
3088
|
+
const limit = filter?.limit ?? 50;
|
|
3089
|
+
let results = this.events;
|
|
3090
|
+
if (filter?.kind) {
|
|
3091
|
+
results = results.filter((e) => e.kind === filter.kind);
|
|
3092
|
+
}
|
|
3093
|
+
if (filter?.loopId) {
|
|
3094
|
+
results = results.filter((e) => e.loopId === filter.loopId);
|
|
3095
|
+
}
|
|
3096
|
+
if (filter?.since) {
|
|
3097
|
+
results = results.filter((e) => e.timestamp >= filter.since);
|
|
3098
|
+
}
|
|
3099
|
+
return results.slice(-limit).reverse();
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Summary counts by kind.
|
|
3103
|
+
*/
|
|
3104
|
+
summarize() {
|
|
3105
|
+
const counts = {
|
|
3106
|
+
job_enqueued: 0,
|
|
3107
|
+
job_dispatched: 0,
|
|
3108
|
+
job_completed: 0,
|
|
3109
|
+
job_failed: 0,
|
|
3110
|
+
job_retried: 0,
|
|
3111
|
+
loop_started: 0,
|
|
3112
|
+
loop_blocked: 0,
|
|
3113
|
+
loop_paused: 0
|
|
3114
|
+
};
|
|
3115
|
+
for (const event of this.events) {
|
|
3116
|
+
counts[event.kind]++;
|
|
3117
|
+
}
|
|
3118
|
+
return counts;
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* Total number of stored events.
|
|
3122
|
+
*/
|
|
3123
|
+
get size() {
|
|
3124
|
+
return this.events.length;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
|
|
2523
3128
|
function pidFilePath(homeDir) {
|
|
2524
3129
|
return node_path.join(homeDir, "agent-daemon.pid");
|
|
2525
3130
|
}
|
|
@@ -2587,8 +3192,16 @@ async function startDaemon(options) {
|
|
|
2587
3192
|
agentVersion: version,
|
|
2588
3193
|
workingDirectory: workDir
|
|
2589
3194
|
});
|
|
3195
|
+
const auditStore = new AutomationAuditStore();
|
|
3196
|
+
const scheduler = new AutomationScheduler({
|
|
3197
|
+
maxConcurrentJobs: 2,
|
|
3198
|
+
onAudit: (event) => auditStore.record(event)
|
|
3199
|
+
});
|
|
3200
|
+
const guardian = new GuardianSessionRegistry();
|
|
3201
|
+
const loopCoordinator = new AgentLoopCoordinator(scheduler, config.serverUrl, creds.token, guardian);
|
|
2590
3202
|
client.setTailscaleInfo(fullTailscale);
|
|
2591
|
-
client.enableAutomation(config.serverUrl, creds.token);
|
|
3203
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler, loopCoordinator, auditStore);
|
|
3204
|
+
loopCoordinator.start();
|
|
2592
3205
|
client.connect();
|
|
2593
3206
|
writePidFile(config.homeDir, process.pid);
|
|
2594
3207
|
console.log(`Daemon started (PID ${process.pid})`);
|
|
@@ -2599,6 +3212,8 @@ async function startDaemon(options) {
|
|
|
2599
3212
|
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2600
3213
|
console.log(`
|
|
2601
3214
|
Received ${signal}, shutting down...`);
|
|
3215
|
+
loopCoordinator.shutdown();
|
|
3216
|
+
scheduler.shutdown();
|
|
2602
3217
|
client.shutdown();
|
|
2603
3218
|
removePidFile(config.homeDir);
|
|
2604
3219
|
process.exit(0);
|