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