@kmmao/happy-agent 0.4.0 → 0.4.1
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 +324 -120
- package/dist/index.d.cts +86 -1
- package/dist/index.d.mts +86 -1
- package/dist/index.mjs +325 -121
- package/package.json +1 -1
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.4.
|
|
21
|
+
var version = "0.4.1";
|
|
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([
|
|
@@ -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,7 @@ class MachineClient {
|
|
|
2188
2212
|
automationEnabled = false;
|
|
2189
2213
|
automationServerUrl = "";
|
|
2190
2214
|
automationAuthToken = "";
|
|
2215
|
+
scheduler = null;
|
|
2191
2216
|
constructor(opts) {
|
|
2192
2217
|
this.token = opts.token;
|
|
2193
2218
|
this.machine = opts.machine;
|
|
@@ -2410,37 +2435,41 @@ class MachineClient {
|
|
|
2410
2435
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
2411
2436
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2412
2437
|
*/
|
|
2413
|
-
enableAutomation(serverUrl, authToken) {
|
|
2438
|
+
enableAutomation(serverUrl, authToken, scheduler) {
|
|
2414
2439
|
this.automationEnabled = true;
|
|
2415
2440
|
this.automationServerUrl = serverUrl;
|
|
2416
2441
|
this.automationAuthToken = authToken;
|
|
2442
|
+
this.scheduler = scheduler;
|
|
2417
2443
|
logger.debug("[MACHINE] Automation enabled");
|
|
2418
2444
|
}
|
|
2419
2445
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
2420
2446
|
handleAutomationEvent(event) {
|
|
2421
|
-
if (!this.automationEnabled) return;
|
|
2447
|
+
if (!this.automationEnabled || !this.scheduler) return;
|
|
2422
2448
|
switch (event.type) {
|
|
2423
2449
|
case "webhook-trigger":
|
|
2424
|
-
|
|
2450
|
+
handleWebhookTrigger(
|
|
2425
2451
|
event,
|
|
2426
2452
|
this,
|
|
2427
2453
|
this.automationServerUrl,
|
|
2428
|
-
this.automationAuthToken
|
|
2454
|
+
this.automationAuthToken,
|
|
2455
|
+
this.scheduler
|
|
2429
2456
|
);
|
|
2430
2457
|
break;
|
|
2431
2458
|
case "supervisor-trigger":
|
|
2432
|
-
|
|
2459
|
+
handleSupervisorTrigger(
|
|
2433
2460
|
event,
|
|
2434
2461
|
this,
|
|
2435
2462
|
this.automationServerUrl,
|
|
2436
|
-
this.automationAuthToken
|
|
2463
|
+
this.automationAuthToken,
|
|
2464
|
+
this.scheduler
|
|
2437
2465
|
);
|
|
2438
2466
|
break;
|
|
2439
2467
|
case "task-trigger":
|
|
2440
|
-
|
|
2468
|
+
handleTaskTrigger(
|
|
2441
2469
|
event,
|
|
2442
2470
|
this.automationServerUrl,
|
|
2443
|
-
this.automationAuthToken
|
|
2471
|
+
this.automationAuthToken,
|
|
2472
|
+
this.scheduler
|
|
2444
2473
|
);
|
|
2445
2474
|
break;
|
|
2446
2475
|
}
|
|
@@ -2520,6 +2549,179 @@ function tailscaleChanged(prev, next) {
|
|
|
2520
2549
|
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
2550
|
}
|
|
2522
2551
|
|
|
2552
|
+
const PRIORITY_ORDER = {
|
|
2553
|
+
urgent: 0,
|
|
2554
|
+
user: 1,
|
|
2555
|
+
background: 2
|
|
2556
|
+
};
|
|
2557
|
+
const ACTIVE_STATUSES = /* @__PURE__ */ new Set(["queued", "dispatching", "running"]);
|
|
2558
|
+
class AutomationScheduler {
|
|
2559
|
+
maxConcurrentJobs;
|
|
2560
|
+
retryDelayMs;
|
|
2561
|
+
defaultMaxAttempts;
|
|
2562
|
+
maxRecentCompletions;
|
|
2563
|
+
/** Active jobs indexed by id. */
|
|
2564
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2565
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
2566
|
+
dedupeIndex = /* @__PURE__ */ new Map();
|
|
2567
|
+
/** Ring buffer for completed/failed jobs. */
|
|
2568
|
+
recentCompletions = [];
|
|
2569
|
+
pumpTimer = null;
|
|
2570
|
+
pumping = false;
|
|
2571
|
+
constructor(options) {
|
|
2572
|
+
this.maxConcurrentJobs = options?.maxConcurrentJobs ?? 2;
|
|
2573
|
+
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2574
|
+
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2575
|
+
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2576
|
+
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2577
|
+
}
|
|
2578
|
+
// -----------------------------------------------------------------------
|
|
2579
|
+
// Public API
|
|
2580
|
+
// -----------------------------------------------------------------------
|
|
2581
|
+
enqueue(opts) {
|
|
2582
|
+
const existingId = this.dedupeIndex.get(opts.dedupeKey);
|
|
2583
|
+
if (existingId) {
|
|
2584
|
+
const existing = this.jobs.get(existingId);
|
|
2585
|
+
if (existing && ACTIVE_STATUSES.has(existing.status)) {
|
|
2586
|
+
logger.debug(`[SCHEDULER] Deduped: ${opts.dedupeKey} (job ${existingId} is ${existing.status})`);
|
|
2587
|
+
return { job: existing, deduped: true };
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
const job = {
|
|
2591
|
+
id: crypto.randomUUID(),
|
|
2592
|
+
kind: opts.kind,
|
|
2593
|
+
dedupeKey: opts.dedupeKey,
|
|
2594
|
+
priority: opts.priority,
|
|
2595
|
+
status: "queued",
|
|
2596
|
+
attempt: 0,
|
|
2597
|
+
maxAttempts: this.defaultMaxAttempts,
|
|
2598
|
+
createdAt: Date.now(),
|
|
2599
|
+
updatedAt: Date.now(),
|
|
2600
|
+
nextRunAt: Date.now(),
|
|
2601
|
+
run: opts.run
|
|
2602
|
+
};
|
|
2603
|
+
this.jobs.set(job.id, job);
|
|
2604
|
+
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2605
|
+
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2606
|
+
this.pump();
|
|
2607
|
+
return { job, deduped: false };
|
|
2608
|
+
}
|
|
2609
|
+
markCompleted(jobId) {
|
|
2610
|
+
const job = this.jobs.get(jobId);
|
|
2611
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2612
|
+
job.status = "completed";
|
|
2613
|
+
job.updatedAt = Date.now();
|
|
2614
|
+
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2615
|
+
this.finalize(job);
|
|
2616
|
+
}
|
|
2617
|
+
markFailed(jobId, error) {
|
|
2618
|
+
const job = this.jobs.get(jobId);
|
|
2619
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2620
|
+
job.errorMessage = error;
|
|
2621
|
+
job.updatedAt = Date.now();
|
|
2622
|
+
if (job.attempt < job.maxAttempts) {
|
|
2623
|
+
job.status = "queued";
|
|
2624
|
+
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2625
|
+
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2626
|
+
this.pump();
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
job.status = "failed";
|
|
2630
|
+
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2631
|
+
this.finalize(job);
|
|
2632
|
+
}
|
|
2633
|
+
getStatus() {
|
|
2634
|
+
let queueLength = 0;
|
|
2635
|
+
let runningCount = 0;
|
|
2636
|
+
for (const job of this.jobs.values()) {
|
|
2637
|
+
if (job.status === "queued") queueLength++;
|
|
2638
|
+
else if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
queueLength,
|
|
2642
|
+
runningCount,
|
|
2643
|
+
recentCompletions: [...this.recentCompletions]
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
shutdown() {
|
|
2647
|
+
if (this.pumpTimer) {
|
|
2648
|
+
clearInterval(this.pumpTimer);
|
|
2649
|
+
this.pumpTimer = null;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
// -----------------------------------------------------------------------
|
|
2653
|
+
// Internal
|
|
2654
|
+
// -----------------------------------------------------------------------
|
|
2655
|
+
pump() {
|
|
2656
|
+
if (this.pumping) return;
|
|
2657
|
+
this.pumping = true;
|
|
2658
|
+
try {
|
|
2659
|
+
const now = Date.now();
|
|
2660
|
+
let runningCount = 0;
|
|
2661
|
+
for (const job of this.jobs.values()) {
|
|
2662
|
+
if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2663
|
+
}
|
|
2664
|
+
if (runningCount >= this.maxConcurrentJobs) return;
|
|
2665
|
+
const ready = [];
|
|
2666
|
+
for (const job of this.jobs.values()) {
|
|
2667
|
+
if (job.status === "queued" && job.nextRunAt <= now) {
|
|
2668
|
+
ready.push(job);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
ready.sort((a, b) => {
|
|
2672
|
+
const pDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
2673
|
+
if (pDiff !== 0) return pDiff;
|
|
2674
|
+
return a.createdAt - b.createdAt;
|
|
2675
|
+
});
|
|
2676
|
+
const slotsAvailable = this.maxConcurrentJobs - runningCount;
|
|
2677
|
+
const toDispatch = ready.slice(0, slotsAvailable);
|
|
2678
|
+
for (const job of toDispatch) {
|
|
2679
|
+
this.dispatch(job);
|
|
2680
|
+
}
|
|
2681
|
+
} finally {
|
|
2682
|
+
this.pumping = false;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
dispatch(job) {
|
|
2686
|
+
job.status = "dispatching";
|
|
2687
|
+
job.attempt++;
|
|
2688
|
+
job.updatedAt = Date.now();
|
|
2689
|
+
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2690
|
+
job.run(job.id).then(({ pid }) => {
|
|
2691
|
+
if (job.status === "dispatching") {
|
|
2692
|
+
job.status = "running";
|
|
2693
|
+
job.pid = pid;
|
|
2694
|
+
job.updatedAt = Date.now();
|
|
2695
|
+
logger.debug(`[SCHEDULER] Running: ${job.dedupeKey} pid=${pid}`);
|
|
2696
|
+
}
|
|
2697
|
+
}).catch((error) => {
|
|
2698
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2699
|
+
logger.debug(`[SCHEDULER] Dispatch failed: ${job.dedupeKey}: ${msg}`);
|
|
2700
|
+
if (job.status === "dispatching") {
|
|
2701
|
+
this.markFailed(job.id, msg);
|
|
2702
|
+
}
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
finalize(job) {
|
|
2706
|
+
this.jobs.delete(job.id);
|
|
2707
|
+
if (this.dedupeIndex.get(job.dedupeKey) === job.id) {
|
|
2708
|
+
this.dedupeIndex.delete(job.dedupeKey);
|
|
2709
|
+
}
|
|
2710
|
+
this.recentCompletions.push({
|
|
2711
|
+
id: job.id,
|
|
2712
|
+
kind: job.kind,
|
|
2713
|
+
dedupeKey: job.dedupeKey,
|
|
2714
|
+
status: job.status,
|
|
2715
|
+
completedAt: job.updatedAt,
|
|
2716
|
+
errorMessage: job.errorMessage
|
|
2717
|
+
});
|
|
2718
|
+
while (this.recentCompletions.length > this.maxRecentCompletions) {
|
|
2719
|
+
this.recentCompletions.shift();
|
|
2720
|
+
}
|
|
2721
|
+
this.pump();
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2523
2725
|
function pidFilePath(homeDir) {
|
|
2524
2726
|
return node_path.join(homeDir, "agent-daemon.pid");
|
|
2525
2727
|
}
|
|
@@ -2587,8 +2789,9 @@ async function startDaemon(options) {
|
|
|
2587
2789
|
agentVersion: version,
|
|
2588
2790
|
workingDirectory: workDir
|
|
2589
2791
|
});
|
|
2792
|
+
const scheduler = new AutomationScheduler({ maxConcurrentJobs: 2 });
|
|
2590
2793
|
client.setTailscaleInfo(fullTailscale);
|
|
2591
|
-
client.enableAutomation(config.serverUrl, creds.token);
|
|
2794
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler);
|
|
2592
2795
|
client.connect();
|
|
2593
2796
|
writePidFile(config.homeDir, process.pid);
|
|
2594
2797
|
console.log(`Daemon started (PID ${process.pid})`);
|
|
@@ -2599,6 +2802,7 @@ async function startDaemon(options) {
|
|
|
2599
2802
|
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2600
2803
|
console.log(`
|
|
2601
2804
|
Received ${signal}, shutting down...`);
|
|
2805
|
+
scheduler.shutdown();
|
|
2602
2806
|
client.shutdown();
|
|
2603
2807
|
removePidFile(config.homeDir);
|
|
2604
2808
|
process.exit(0);
|
package/dist/index.d.cts
CHANGED
|
@@ -345,6 +345,90 @@ declare class TunnelManager {
|
|
|
345
345
|
stopRefresh(): void;
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* AutomationScheduler — lightweight in-memory job queue for agent triggers.
|
|
350
|
+
*
|
|
351
|
+
* Features:
|
|
352
|
+
* - Priority queue (urgent > user > background)
|
|
353
|
+
* - dedupeKey deduplication (queued/dispatching/running)
|
|
354
|
+
* - Concurrency limit (maxConcurrentJobs)
|
|
355
|
+
* - Retry with incremental backoff (attempt * retryDelayMs)
|
|
356
|
+
* - Ring buffer for recent completions (observability)
|
|
357
|
+
*/
|
|
358
|
+
type JobPriority = "urgent" | "user" | "background";
|
|
359
|
+
type JobStatus = "queued" | "dispatching" | "running" | "completed" | "failed";
|
|
360
|
+
interface SchedulerJob {
|
|
361
|
+
readonly id: string;
|
|
362
|
+
readonly kind: "webhook" | "supervisor" | "task";
|
|
363
|
+
readonly dedupeKey: string;
|
|
364
|
+
readonly priority: JobPriority;
|
|
365
|
+
status: JobStatus;
|
|
366
|
+
attempt: number;
|
|
367
|
+
readonly maxAttempts: number;
|
|
368
|
+
readonly createdAt: number;
|
|
369
|
+
updatedAt: number;
|
|
370
|
+
nextRunAt: number;
|
|
371
|
+
errorMessage?: string;
|
|
372
|
+
pid?: number;
|
|
373
|
+
/** The thunk that performs the actual work (spawn session etc.) */
|
|
374
|
+
readonly run: (jobId: string) => Promise<{
|
|
375
|
+
pid: number;
|
|
376
|
+
}>;
|
|
377
|
+
}
|
|
378
|
+
interface EnqueueOptions {
|
|
379
|
+
kind: SchedulerJob["kind"];
|
|
380
|
+
dedupeKey: string;
|
|
381
|
+
priority: JobPriority;
|
|
382
|
+
run: (jobId: string) => Promise<{
|
|
383
|
+
pid: number;
|
|
384
|
+
}>;
|
|
385
|
+
}
|
|
386
|
+
interface EnqueueResult {
|
|
387
|
+
job: SchedulerJob;
|
|
388
|
+
deduped: boolean;
|
|
389
|
+
}
|
|
390
|
+
interface SchedulerStatus {
|
|
391
|
+
queueLength: number;
|
|
392
|
+
runningCount: number;
|
|
393
|
+
recentCompletions: Array<{
|
|
394
|
+
id: string;
|
|
395
|
+
kind: string;
|
|
396
|
+
dedupeKey: string;
|
|
397
|
+
status: "completed" | "failed";
|
|
398
|
+
completedAt: number;
|
|
399
|
+
errorMessage?: string;
|
|
400
|
+
}>;
|
|
401
|
+
}
|
|
402
|
+
interface SchedulerOptions {
|
|
403
|
+
maxConcurrentJobs?: number;
|
|
404
|
+
retryDelayMs?: number;
|
|
405
|
+
maxAttempts?: number;
|
|
406
|
+
maxRecentCompletions?: number;
|
|
407
|
+
}
|
|
408
|
+
declare class AutomationScheduler {
|
|
409
|
+
private readonly maxConcurrentJobs;
|
|
410
|
+
private readonly retryDelayMs;
|
|
411
|
+
private readonly defaultMaxAttempts;
|
|
412
|
+
private readonly maxRecentCompletions;
|
|
413
|
+
/** Active jobs indexed by id. */
|
|
414
|
+
private readonly jobs;
|
|
415
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
416
|
+
private readonly dedupeIndex;
|
|
417
|
+
/** Ring buffer for completed/failed jobs. */
|
|
418
|
+
private readonly recentCompletions;
|
|
419
|
+
private pumpTimer;
|
|
420
|
+
private pumping;
|
|
421
|
+
constructor(options?: SchedulerOptions);
|
|
422
|
+
enqueue(opts: EnqueueOptions): EnqueueResult;
|
|
423
|
+
markCompleted(jobId: string): void;
|
|
424
|
+
markFailed(jobId: string, error: string): void;
|
|
425
|
+
getStatus(): SchedulerStatus;
|
|
426
|
+
shutdown(): void;
|
|
427
|
+
private pump;
|
|
428
|
+
private dispatch;
|
|
429
|
+
private finalize;
|
|
430
|
+
}
|
|
431
|
+
|
|
348
432
|
/**
|
|
349
433
|
* Machine WebSocket client — trimmed from CLI's ApiMachineClient.
|
|
350
434
|
*
|
|
@@ -431,6 +515,7 @@ declare class MachineClient {
|
|
|
431
515
|
private automationEnabled;
|
|
432
516
|
private automationServerUrl;
|
|
433
517
|
private automationAuthToken;
|
|
518
|
+
private scheduler;
|
|
434
519
|
constructor(opts: MachineClientOptions);
|
|
435
520
|
private registerMachineHandlers;
|
|
436
521
|
connect(): void;
|
|
@@ -494,7 +579,7 @@ declare class MachineClient {
|
|
|
494
579
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
495
580
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
496
581
|
*/
|
|
497
|
-
enableAutomation(serverUrl: string, authToken: string): void;
|
|
582
|
+
enableAutomation(serverUrl: string, authToken: string, scheduler: AutomationScheduler): void;
|
|
498
583
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
499
584
|
private handleAutomationEvent;
|
|
500
585
|
/** Seed initial Tailscale info detected before connect. */
|
package/dist/index.d.mts
CHANGED
|
@@ -345,6 +345,90 @@ declare class TunnelManager {
|
|
|
345
345
|
stopRefresh(): void;
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* AutomationScheduler — lightweight in-memory job queue for agent triggers.
|
|
350
|
+
*
|
|
351
|
+
* Features:
|
|
352
|
+
* - Priority queue (urgent > user > background)
|
|
353
|
+
* - dedupeKey deduplication (queued/dispatching/running)
|
|
354
|
+
* - Concurrency limit (maxConcurrentJobs)
|
|
355
|
+
* - Retry with incremental backoff (attempt * retryDelayMs)
|
|
356
|
+
* - Ring buffer for recent completions (observability)
|
|
357
|
+
*/
|
|
358
|
+
type JobPriority = "urgent" | "user" | "background";
|
|
359
|
+
type JobStatus = "queued" | "dispatching" | "running" | "completed" | "failed";
|
|
360
|
+
interface SchedulerJob {
|
|
361
|
+
readonly id: string;
|
|
362
|
+
readonly kind: "webhook" | "supervisor" | "task";
|
|
363
|
+
readonly dedupeKey: string;
|
|
364
|
+
readonly priority: JobPriority;
|
|
365
|
+
status: JobStatus;
|
|
366
|
+
attempt: number;
|
|
367
|
+
readonly maxAttempts: number;
|
|
368
|
+
readonly createdAt: number;
|
|
369
|
+
updatedAt: number;
|
|
370
|
+
nextRunAt: number;
|
|
371
|
+
errorMessage?: string;
|
|
372
|
+
pid?: number;
|
|
373
|
+
/** The thunk that performs the actual work (spawn session etc.) */
|
|
374
|
+
readonly run: (jobId: string) => Promise<{
|
|
375
|
+
pid: number;
|
|
376
|
+
}>;
|
|
377
|
+
}
|
|
378
|
+
interface EnqueueOptions {
|
|
379
|
+
kind: SchedulerJob["kind"];
|
|
380
|
+
dedupeKey: string;
|
|
381
|
+
priority: JobPriority;
|
|
382
|
+
run: (jobId: string) => Promise<{
|
|
383
|
+
pid: number;
|
|
384
|
+
}>;
|
|
385
|
+
}
|
|
386
|
+
interface EnqueueResult {
|
|
387
|
+
job: SchedulerJob;
|
|
388
|
+
deduped: boolean;
|
|
389
|
+
}
|
|
390
|
+
interface SchedulerStatus {
|
|
391
|
+
queueLength: number;
|
|
392
|
+
runningCount: number;
|
|
393
|
+
recentCompletions: Array<{
|
|
394
|
+
id: string;
|
|
395
|
+
kind: string;
|
|
396
|
+
dedupeKey: string;
|
|
397
|
+
status: "completed" | "failed";
|
|
398
|
+
completedAt: number;
|
|
399
|
+
errorMessage?: string;
|
|
400
|
+
}>;
|
|
401
|
+
}
|
|
402
|
+
interface SchedulerOptions {
|
|
403
|
+
maxConcurrentJobs?: number;
|
|
404
|
+
retryDelayMs?: number;
|
|
405
|
+
maxAttempts?: number;
|
|
406
|
+
maxRecentCompletions?: number;
|
|
407
|
+
}
|
|
408
|
+
declare class AutomationScheduler {
|
|
409
|
+
private readonly maxConcurrentJobs;
|
|
410
|
+
private readonly retryDelayMs;
|
|
411
|
+
private readonly defaultMaxAttempts;
|
|
412
|
+
private readonly maxRecentCompletions;
|
|
413
|
+
/** Active jobs indexed by id. */
|
|
414
|
+
private readonly jobs;
|
|
415
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
416
|
+
private readonly dedupeIndex;
|
|
417
|
+
/** Ring buffer for completed/failed jobs. */
|
|
418
|
+
private readonly recentCompletions;
|
|
419
|
+
private pumpTimer;
|
|
420
|
+
private pumping;
|
|
421
|
+
constructor(options?: SchedulerOptions);
|
|
422
|
+
enqueue(opts: EnqueueOptions): EnqueueResult;
|
|
423
|
+
markCompleted(jobId: string): void;
|
|
424
|
+
markFailed(jobId: string, error: string): void;
|
|
425
|
+
getStatus(): SchedulerStatus;
|
|
426
|
+
shutdown(): void;
|
|
427
|
+
private pump;
|
|
428
|
+
private dispatch;
|
|
429
|
+
private finalize;
|
|
430
|
+
}
|
|
431
|
+
|
|
348
432
|
/**
|
|
349
433
|
* Machine WebSocket client — trimmed from CLI's ApiMachineClient.
|
|
350
434
|
*
|
|
@@ -431,6 +515,7 @@ declare class MachineClient {
|
|
|
431
515
|
private automationEnabled;
|
|
432
516
|
private automationServerUrl;
|
|
433
517
|
private automationAuthToken;
|
|
518
|
+
private scheduler;
|
|
434
519
|
constructor(opts: MachineClientOptions);
|
|
435
520
|
private registerMachineHandlers;
|
|
436
521
|
connect(): void;
|
|
@@ -494,7 +579,7 @@ declare class MachineClient {
|
|
|
494
579
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
495
580
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
496
581
|
*/
|
|
497
|
-
enableAutomation(serverUrl: string, authToken: string): void;
|
|
582
|
+
enableAutomation(serverUrl: string, authToken: string, scheduler: AutomationScheduler): void;
|
|
498
583
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
499
584
|
private handleAutomationEvent;
|
|
500
585
|
/** Seed initial Tailscale info detected before connect. */
|
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.4.
|
|
19
|
+
var version = "0.4.1";
|
|
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([
|
|
@@ -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,7 @@ class MachineClient {
|
|
|
2186
2210
|
automationEnabled = false;
|
|
2187
2211
|
automationServerUrl = "";
|
|
2188
2212
|
automationAuthToken = "";
|
|
2213
|
+
scheduler = null;
|
|
2189
2214
|
constructor(opts) {
|
|
2190
2215
|
this.token = opts.token;
|
|
2191
2216
|
this.machine = opts.machine;
|
|
@@ -2408,37 +2433,41 @@ class MachineClient {
|
|
|
2408
2433
|
* Enable automation handling — agent will process webhook, supervisor,
|
|
2409
2434
|
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2410
2435
|
*/
|
|
2411
|
-
enableAutomation(serverUrl, authToken) {
|
|
2436
|
+
enableAutomation(serverUrl, authToken, scheduler) {
|
|
2412
2437
|
this.automationEnabled = true;
|
|
2413
2438
|
this.automationServerUrl = serverUrl;
|
|
2414
2439
|
this.automationAuthToken = authToken;
|
|
2440
|
+
this.scheduler = scheduler;
|
|
2415
2441
|
logger.debug("[MACHINE] Automation enabled");
|
|
2416
2442
|
}
|
|
2417
2443
|
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
2418
2444
|
handleAutomationEvent(event) {
|
|
2419
|
-
if (!this.automationEnabled) return;
|
|
2445
|
+
if (!this.automationEnabled || !this.scheduler) return;
|
|
2420
2446
|
switch (event.type) {
|
|
2421
2447
|
case "webhook-trigger":
|
|
2422
|
-
|
|
2448
|
+
handleWebhookTrigger(
|
|
2423
2449
|
event,
|
|
2424
2450
|
this,
|
|
2425
2451
|
this.automationServerUrl,
|
|
2426
|
-
this.automationAuthToken
|
|
2452
|
+
this.automationAuthToken,
|
|
2453
|
+
this.scheduler
|
|
2427
2454
|
);
|
|
2428
2455
|
break;
|
|
2429
2456
|
case "supervisor-trigger":
|
|
2430
|
-
|
|
2457
|
+
handleSupervisorTrigger(
|
|
2431
2458
|
event,
|
|
2432
2459
|
this,
|
|
2433
2460
|
this.automationServerUrl,
|
|
2434
|
-
this.automationAuthToken
|
|
2461
|
+
this.automationAuthToken,
|
|
2462
|
+
this.scheduler
|
|
2435
2463
|
);
|
|
2436
2464
|
break;
|
|
2437
2465
|
case "task-trigger":
|
|
2438
|
-
|
|
2466
|
+
handleTaskTrigger(
|
|
2439
2467
|
event,
|
|
2440
2468
|
this.automationServerUrl,
|
|
2441
|
-
this.automationAuthToken
|
|
2469
|
+
this.automationAuthToken,
|
|
2470
|
+
this.scheduler
|
|
2442
2471
|
);
|
|
2443
2472
|
break;
|
|
2444
2473
|
}
|
|
@@ -2518,6 +2547,179 @@ function tailscaleChanged(prev, next) {
|
|
|
2518
2547
|
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
2548
|
}
|
|
2520
2549
|
|
|
2550
|
+
const PRIORITY_ORDER = {
|
|
2551
|
+
urgent: 0,
|
|
2552
|
+
user: 1,
|
|
2553
|
+
background: 2
|
|
2554
|
+
};
|
|
2555
|
+
const ACTIVE_STATUSES = /* @__PURE__ */ new Set(["queued", "dispatching", "running"]);
|
|
2556
|
+
class AutomationScheduler {
|
|
2557
|
+
maxConcurrentJobs;
|
|
2558
|
+
retryDelayMs;
|
|
2559
|
+
defaultMaxAttempts;
|
|
2560
|
+
maxRecentCompletions;
|
|
2561
|
+
/** Active jobs indexed by id. */
|
|
2562
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2563
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
2564
|
+
dedupeIndex = /* @__PURE__ */ new Map();
|
|
2565
|
+
/** Ring buffer for completed/failed jobs. */
|
|
2566
|
+
recentCompletions = [];
|
|
2567
|
+
pumpTimer = null;
|
|
2568
|
+
pumping = false;
|
|
2569
|
+
constructor(options) {
|
|
2570
|
+
this.maxConcurrentJobs = options?.maxConcurrentJobs ?? 2;
|
|
2571
|
+
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2572
|
+
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2573
|
+
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2574
|
+
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2575
|
+
}
|
|
2576
|
+
// -----------------------------------------------------------------------
|
|
2577
|
+
// Public API
|
|
2578
|
+
// -----------------------------------------------------------------------
|
|
2579
|
+
enqueue(opts) {
|
|
2580
|
+
const existingId = this.dedupeIndex.get(opts.dedupeKey);
|
|
2581
|
+
if (existingId) {
|
|
2582
|
+
const existing = this.jobs.get(existingId);
|
|
2583
|
+
if (existing && ACTIVE_STATUSES.has(existing.status)) {
|
|
2584
|
+
logger.debug(`[SCHEDULER] Deduped: ${opts.dedupeKey} (job ${existingId} is ${existing.status})`);
|
|
2585
|
+
return { job: existing, deduped: true };
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
const job = {
|
|
2589
|
+
id: randomUUID(),
|
|
2590
|
+
kind: opts.kind,
|
|
2591
|
+
dedupeKey: opts.dedupeKey,
|
|
2592
|
+
priority: opts.priority,
|
|
2593
|
+
status: "queued",
|
|
2594
|
+
attempt: 0,
|
|
2595
|
+
maxAttempts: this.defaultMaxAttempts,
|
|
2596
|
+
createdAt: Date.now(),
|
|
2597
|
+
updatedAt: Date.now(),
|
|
2598
|
+
nextRunAt: Date.now(),
|
|
2599
|
+
run: opts.run
|
|
2600
|
+
};
|
|
2601
|
+
this.jobs.set(job.id, job);
|
|
2602
|
+
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2603
|
+
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2604
|
+
this.pump();
|
|
2605
|
+
return { job, deduped: false };
|
|
2606
|
+
}
|
|
2607
|
+
markCompleted(jobId) {
|
|
2608
|
+
const job = this.jobs.get(jobId);
|
|
2609
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2610
|
+
job.status = "completed";
|
|
2611
|
+
job.updatedAt = Date.now();
|
|
2612
|
+
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2613
|
+
this.finalize(job);
|
|
2614
|
+
}
|
|
2615
|
+
markFailed(jobId, error) {
|
|
2616
|
+
const job = this.jobs.get(jobId);
|
|
2617
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2618
|
+
job.errorMessage = error;
|
|
2619
|
+
job.updatedAt = Date.now();
|
|
2620
|
+
if (job.attempt < job.maxAttempts) {
|
|
2621
|
+
job.status = "queued";
|
|
2622
|
+
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2623
|
+
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2624
|
+
this.pump();
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
job.status = "failed";
|
|
2628
|
+
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2629
|
+
this.finalize(job);
|
|
2630
|
+
}
|
|
2631
|
+
getStatus() {
|
|
2632
|
+
let queueLength = 0;
|
|
2633
|
+
let runningCount = 0;
|
|
2634
|
+
for (const job of this.jobs.values()) {
|
|
2635
|
+
if (job.status === "queued") queueLength++;
|
|
2636
|
+
else if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2637
|
+
}
|
|
2638
|
+
return {
|
|
2639
|
+
queueLength,
|
|
2640
|
+
runningCount,
|
|
2641
|
+
recentCompletions: [...this.recentCompletions]
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
shutdown() {
|
|
2645
|
+
if (this.pumpTimer) {
|
|
2646
|
+
clearInterval(this.pumpTimer);
|
|
2647
|
+
this.pumpTimer = null;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
// -----------------------------------------------------------------------
|
|
2651
|
+
// Internal
|
|
2652
|
+
// -----------------------------------------------------------------------
|
|
2653
|
+
pump() {
|
|
2654
|
+
if (this.pumping) return;
|
|
2655
|
+
this.pumping = true;
|
|
2656
|
+
try {
|
|
2657
|
+
const now = Date.now();
|
|
2658
|
+
let runningCount = 0;
|
|
2659
|
+
for (const job of this.jobs.values()) {
|
|
2660
|
+
if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2661
|
+
}
|
|
2662
|
+
if (runningCount >= this.maxConcurrentJobs) return;
|
|
2663
|
+
const ready = [];
|
|
2664
|
+
for (const job of this.jobs.values()) {
|
|
2665
|
+
if (job.status === "queued" && job.nextRunAt <= now) {
|
|
2666
|
+
ready.push(job);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
ready.sort((a, b) => {
|
|
2670
|
+
const pDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
2671
|
+
if (pDiff !== 0) return pDiff;
|
|
2672
|
+
return a.createdAt - b.createdAt;
|
|
2673
|
+
});
|
|
2674
|
+
const slotsAvailable = this.maxConcurrentJobs - runningCount;
|
|
2675
|
+
const toDispatch = ready.slice(0, slotsAvailable);
|
|
2676
|
+
for (const job of toDispatch) {
|
|
2677
|
+
this.dispatch(job);
|
|
2678
|
+
}
|
|
2679
|
+
} finally {
|
|
2680
|
+
this.pumping = false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
dispatch(job) {
|
|
2684
|
+
job.status = "dispatching";
|
|
2685
|
+
job.attempt++;
|
|
2686
|
+
job.updatedAt = Date.now();
|
|
2687
|
+
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2688
|
+
job.run(job.id).then(({ pid }) => {
|
|
2689
|
+
if (job.status === "dispatching") {
|
|
2690
|
+
job.status = "running";
|
|
2691
|
+
job.pid = pid;
|
|
2692
|
+
job.updatedAt = Date.now();
|
|
2693
|
+
logger.debug(`[SCHEDULER] Running: ${job.dedupeKey} pid=${pid}`);
|
|
2694
|
+
}
|
|
2695
|
+
}).catch((error) => {
|
|
2696
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2697
|
+
logger.debug(`[SCHEDULER] Dispatch failed: ${job.dedupeKey}: ${msg}`);
|
|
2698
|
+
if (job.status === "dispatching") {
|
|
2699
|
+
this.markFailed(job.id, msg);
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
finalize(job) {
|
|
2704
|
+
this.jobs.delete(job.id);
|
|
2705
|
+
if (this.dedupeIndex.get(job.dedupeKey) === job.id) {
|
|
2706
|
+
this.dedupeIndex.delete(job.dedupeKey);
|
|
2707
|
+
}
|
|
2708
|
+
this.recentCompletions.push({
|
|
2709
|
+
id: job.id,
|
|
2710
|
+
kind: job.kind,
|
|
2711
|
+
dedupeKey: job.dedupeKey,
|
|
2712
|
+
status: job.status,
|
|
2713
|
+
completedAt: job.updatedAt,
|
|
2714
|
+
errorMessage: job.errorMessage
|
|
2715
|
+
});
|
|
2716
|
+
while (this.recentCompletions.length > this.maxRecentCompletions) {
|
|
2717
|
+
this.recentCompletions.shift();
|
|
2718
|
+
}
|
|
2719
|
+
this.pump();
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2521
2723
|
function pidFilePath(homeDir) {
|
|
2522
2724
|
return join(homeDir, "agent-daemon.pid");
|
|
2523
2725
|
}
|
|
@@ -2585,8 +2787,9 @@ async function startDaemon(options) {
|
|
|
2585
2787
|
agentVersion: version,
|
|
2586
2788
|
workingDirectory: workDir
|
|
2587
2789
|
});
|
|
2790
|
+
const scheduler = new AutomationScheduler({ maxConcurrentJobs: 2 });
|
|
2588
2791
|
client.setTailscaleInfo(fullTailscale);
|
|
2589
|
-
client.enableAutomation(config.serverUrl, creds.token);
|
|
2792
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler);
|
|
2590
2793
|
client.connect();
|
|
2591
2794
|
writePidFile(config.homeDir, process.pid);
|
|
2592
2795
|
console.log(`Daemon started (PID ${process.pid})`);
|
|
@@ -2597,6 +2800,7 @@ async function startDaemon(options) {
|
|
|
2597
2800
|
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2598
2801
|
console.log(`
|
|
2599
2802
|
Received ${signal}, shutting down...`);
|
|
2803
|
+
scheduler.shutdown();
|
|
2600
2804
|
client.shutdown();
|
|
2601
2805
|
removePidFile(config.homeDir);
|
|
2602
2806
|
process.exit(0);
|