@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 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.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
- 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,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
- void handleWebhookTrigger(
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
- void handleSupervisorTrigger(
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
- void handleTaskTrigger(
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.0";
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
- async function handleWebhookTrigger(data, client, serverUrl, authToken) {
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
- client.emitWebhookStatus({
2048
- webhookEventId: data.webhookEventId,
2049
- status: "dispatched"
2050
- });
2051
- try {
2052
- const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
2053
- const result = await spawnSession({
2054
- directory: data.repoPath,
2055
- approvedNewDirectoryCreation: false,
2056
- automationContext: {
2057
- kind: "webhook",
2058
- trigger: `issue#${data.issueNumber}`
2059
- },
2060
- environmentVariables: {
2061
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
2062
- HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
2063
- HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
2064
- HAPPY_SERVER_URL: serverUrl,
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
- } catch (error) {
2079
- const msg = error instanceof Error ? error.message : String(error);
2080
- logger.debug(`[TRIGGER] Webhook error: ${msg}`);
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
- async function handleSupervisorTrigger(data, client, serverUrl, authToken) {
2106
+ function handleSupervisorTrigger(data, client, serverUrl, authToken, scheduler) {
2089
2107
  logger.debug(`[TRIGGER] Supervisor: run ${data.runId} in ${data.repoPath}`);
2090
- client.emitSupervisorRunStatus({
2091
- runId: data.runId,
2092
- projectId: data.projectId,
2093
- status: "running"
2094
- });
2095
- try {
2096
- const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
2097
- const result = await spawnSession({
2098
- directory: data.repoPath,
2099
- approvedNewDirectoryCreation: false,
2100
- automationContext: {
2101
- kind: "supervisor",
2102
- trigger: data.trigger,
2103
- projectId: data.projectId,
2104
- runId: data.runId
2105
- },
2106
- environmentVariables: {
2107
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
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
- } catch (error) {
2123
- const msg = error instanceof Error ? error.message : String(error);
2124
- logger.debug(`[TRIGGER] Supervisor error: ${msg}`);
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
- async function handleTaskTrigger(data, serverUrl, _authToken) {
2148
+ function handleTaskTrigger(data, serverUrl, _authToken, scheduler) {
2134
2149
  logger.debug(`[TRIGGER] Task: ${data.taskId} in ${data.directory}`);
2135
- try {
2136
- const promptFile = await writePromptFile("task", data.prompt);
2137
- const skillEnv = {};
2138
- if (data.skillContents?.length) {
2139
- skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
2140
- for (let i = 0; i < data.skillContents.length; i++) {
2141
- skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
2142
- skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
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
- const result = await spawnSession({
2146
- directory: data.directory,
2147
- approvedNewDirectoryCreation: true,
2148
- // Tasks always auto-approve directory
2149
- automationContext: {
2150
- kind: "task",
2151
- trigger: "task-dispatch",
2152
- projectId: data.projectId
2153
- },
2154
- environmentVariables: {
2155
- HAPPY_INITIAL_PROMPT_FILE: promptFile,
2156
- HAPPY_TASK_ID: data.taskId,
2157
- HAPPY_TASK_PRIORITY: data.priority,
2158
- HAPPY_TASK_SERVER_URL: serverUrl,
2159
- HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
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
- if (result.type !== "success") {
2165
- logger.debug(`[TRIGGER] Task spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
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
- } catch (error) {
2168
- logger.debug(`[TRIGGER] Task error: ${error instanceof Error ? error.message : String(error)}`);
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
- void handleWebhookTrigger(
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
- void handleSupervisorTrigger(
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
- void handleTaskTrigger(
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kmmao/happy-agent",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI client for controlling Happy Coder agents remotely",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "license": "MIT",