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