palmier 0.9.27 → 0.9.29

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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Daemon-owned supervisors for command-triggered tasks. A command task's shell
3
+ * command is a long-running trigger source — the daemon spawns it while the task
4
+ * is enabled, reads its stdout, and feeds each line into the shared per-task
5
+ * event queue (the same one the NATS notification/SMS subscriptions populate).
6
+ * The idle→active edge launches a short-lived `palmier run` that drains the
7
+ * queue, so command tasks share the exact lifecycle as on_new_* event tasks:
8
+ * one run per burst, "running" only while the agent is actually invoked.
9
+ *
10
+ * Lifecycle parity:
11
+ * - Enabled command task → command process running (= being monitored).
12
+ * - Disable / delete → command process killed; no further triggers.
13
+ * - Abort → kills only the in-flight run; the command process is untouched.
14
+ */
15
+ import type { HostConfig, ParsedTask } from "./types.js";
16
+ /** Start, stop, or restart a task's command process to match its current state. */
17
+ export declare function reconcileCommandRunner(config: HostConfig, task: ParsedTask): void;
18
+ export declare function stopCommandRunner(taskId: string): void;
19
+ /** Recover command runners for all enabled command tasks (daemon startup). */
20
+ export declare function startEnabledCommandRunners(config: HostConfig): void;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Daemon-owned supervisors for command-triggered tasks. A command task's shell
3
+ * command is a long-running trigger source — the daemon spawns it while the task
4
+ * is enabled, reads its stdout, and feeds each line into the shared per-task
5
+ * event queue (the same one the NATS notification/SMS subscriptions populate).
6
+ * The idle→active edge launches a short-lived `palmier run` that drains the
7
+ * queue, so command tasks share the exact lifecycle as on_new_* event tasks:
8
+ * one run per burst, "running" only while the agent is actually invoked.
9
+ *
10
+ * Lifecycle parity:
11
+ * - Enabled command task → command process running (= being monitored).
12
+ * - Disable / delete → command process killed; no further triggers.
13
+ * - Abort → kills only the in-flight run; the command process is untouched.
14
+ */
15
+ import * as readline from "readline";
16
+ import { execFileSync } from "child_process";
17
+ import { spawnStreamingCommand } from "./spawn-command.js";
18
+ import { getTaskDir, listTasks, parseTaskFile } from "./task.js";
19
+ import { dispatchTrigger } from "./trigger-dispatch.js";
20
+ import { getPlatform } from "./platform/index.js";
21
+ const runners = new Map();
22
+ const stopping = new Set();
23
+ function shouldRun(task) {
24
+ return !!task.frontmatter.command
25
+ && !!task.frontmatter.schedule_enabled
26
+ && !task.frontmatter.one_off;
27
+ }
28
+ function killChild(child) {
29
+ if (process.platform === "win32" && child.pid) {
30
+ try {
31
+ execFileSync("taskkill", ["/pid", String(child.pid), "/f", "/t"], { windowsHide: true, stdio: "pipe" });
32
+ return;
33
+ }
34
+ catch { /* may have already exited */ }
35
+ }
36
+ child.kill();
37
+ }
38
+ function spawnRunner(config, taskId, command) {
39
+ stopping.delete(taskId);
40
+ const taskDir = getTaskDir(config.projectRoot, taskId);
41
+ const platform = getPlatform();
42
+ const child = spawnStreamingCommand(command, {
43
+ cwd: taskDir,
44
+ env: { ...platform.getGuiEnv(), PALMIER_HTTP_PORT: String(config.httpPort ?? 7256) },
45
+ });
46
+ runners.set(taskId, { child, command });
47
+ console.log(`[command-runner] ${taskId} spawned: ${command}`);
48
+ const rl = readline.createInterface({ input: child.stdout });
49
+ rl.on("line", (line) => {
50
+ if (!line.trim())
51
+ return;
52
+ dispatchTrigger(taskId, line);
53
+ });
54
+ child.stderr?.on("data", (d) => process.stderr.write(d));
55
+ const handleExit = () => {
56
+ rl.close();
57
+ if (runners.get(taskId)?.child === child)
58
+ runners.delete(taskId);
59
+ if (stopping.has(taskId)) {
60
+ stopping.delete(taskId);
61
+ return;
62
+ }
63
+ // Exited on its own while still enabled — relaunch so monitoring stays live.
64
+ console.log(`[command-runner] ${taskId} command exited; relaunching in 1s`);
65
+ setTimeout(() => {
66
+ if (stopping.has(taskId) || runners.has(taskId))
67
+ return;
68
+ let task;
69
+ try {
70
+ task = parseTaskFile(taskDir);
71
+ }
72
+ catch {
73
+ return;
74
+ }
75
+ if (shouldRun(task))
76
+ spawnRunner(config, taskId, task.frontmatter.command);
77
+ }, 1000);
78
+ };
79
+ child.on("close", handleExit);
80
+ child.on("error", (err) => {
81
+ console.error(`[command-runner] ${taskId} error:`, err);
82
+ handleExit();
83
+ });
84
+ }
85
+ /** Start, stop, or restart a task's command process to match its current state. */
86
+ export function reconcileCommandRunner(config, task) {
87
+ const taskId = task.frontmatter.id;
88
+ if (!shouldRun(task)) {
89
+ stopCommandRunner(taskId);
90
+ return;
91
+ }
92
+ const existing = runners.get(taskId);
93
+ if (existing) {
94
+ if (existing.command === task.frontmatter.command)
95
+ return;
96
+ stopCommandRunner(taskId);
97
+ }
98
+ spawnRunner(config, taskId, task.frontmatter.command);
99
+ }
100
+ export function stopCommandRunner(taskId) {
101
+ const existing = runners.get(taskId);
102
+ if (!existing)
103
+ return;
104
+ stopping.add(taskId);
105
+ runners.delete(taskId);
106
+ killChild(existing.child);
107
+ }
108
+ /** Recover command runners for all enabled command tasks (daemon startup). */
109
+ export function startEnabledCommandRunners(config) {
110
+ for (const task of listTasks(config.projectRoot)) {
111
+ if (shouldRun(task))
112
+ reconcileCommandRunner(config, task);
113
+ }
114
+ }
@@ -1,8 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import * as readline from "readline";
4
- import { StringCodec } from "nats";
5
- import { spawnCommand, spawnStreamingCommand } from "../spawn-command.js";
3
+ import { spawnCommand } from "../spawn-command.js";
6
4
  import { loadConfig } from "../config.js";
7
5
  import { connectNats } from "../nats-client.js";
8
6
  import { parseTaskFile, getTaskDir, writeTaskFile, writeTaskStatus, readTaskStatus, appendHistory, createRunDir, appendRunMessage, readRunMessages, getRunDir, beginStreamingMessage } from "../task.js";
@@ -10,22 +8,10 @@ import { getAgent } from "../agents/agent.js";
10
8
  import { getPlatform } from "../platform/index.js";
11
9
  import { TASK_SUCCESS_MARKER, TASK_FAILURE_MARKER, TASK_REPORT_PREFIX, TASK_PERMISSION_PREFIX } from "../agents/shared-prompt.js";
12
10
  import { publishHostEvent } from "../events.js";
13
- async function sendPushNotification(nc, hostId, title, body) {
14
- if (!nc)
15
- return;
16
- try {
17
- const sc = StringCodec();
18
- const subject = `host.${hostId}.push.send`;
19
- await nc.request(subject, sc.encode(JSON.stringify({ hostId, title, body })), { timeout: 15_000 });
20
- }
21
- catch (err) {
22
- console.warn(`[push] failed to send notification:`, err);
23
- }
24
- }
25
11
  /**
26
12
  * Invoke the agent CLI in a continuation loop to handle permission requests.
27
- * `invokeTask` is the ParsedTask whose prompt is passed to the agent (in
28
- * command-triggered mode this is the per-line augmented task).
13
+ * `invokeTask` is the ParsedTask whose prompt is passed to the agent (for
14
+ * triggered tasks this is the per-trigger augmented task).
29
15
  */
30
16
  async function invokeAgentWithRetries(ctx, invokeTask) {
31
17
  // eslint-disable-next-line no-constant-condition
@@ -226,33 +212,18 @@ export async function runCommand(taskId) {
226
212
  agent, task, taskDir, runId, guiEnv, nc, config, taskId,
227
213
  transientPermissions: [],
228
214
  };
229
- if (task.frontmatter.command) {
230
- let outcome;
231
- // Command-triggered tasks auto-restart when the underlying command exits
232
- // on its own only a user abort breaks the loop.
233
- while (true) {
234
- const result = await runCommandTriggeredMode(ctx);
235
- outcome = resolveOutcome(taskDir, result.outcome);
236
- if (outcome === "aborted")
237
- break;
238
- console.log(`Task ${taskId} command exited (${outcome}); auto-restarting.`);
239
- await new Promise((r) => setTimeout(r, 1000));
240
- if (resolveOutcome(taskDir, "finished") === "aborted") {
241
- outcome = "aborted";
242
- break;
243
- }
244
- }
245
- appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
246
- await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
247
- console.log(`Task ${taskId} completed (command-triggered).`);
248
- }
249
- else if (task.frontmatter.schedule_type === "on_new_notification"
215
+ // Command-triggered and on_new_* tasks share the same trigger machinery: the
216
+ // daemon owns the trigger source (the shell command's stdout / a NATS
217
+ // subscription) and feeds the shared per-task queue, while this run drains
218
+ // that queue one invocation at a time.
219
+ if (task.frontmatter.command
220
+ || task.frontmatter.schedule_type === "on_new_notification"
250
221
  || task.frontmatter.schedule_type === "on_new_sms") {
251
222
  const result = await runEventTriggeredMode(ctx);
252
223
  const outcome = resolveOutcome(taskDir, result.outcome);
253
224
  appendRunMessage(taskDir, runId, { role: "status", time: Date.now(), content: "", type: outcome });
254
225
  await publishTaskEvent(nc, config, taskDir, taskId, outcome, taskName, runId);
255
- console.log(`Task ${taskId} completed (event-triggered).`);
226
+ console.log(`Task ${taskId} completed (triggered).`);
256
227
  }
257
228
  else {
258
229
  await appendAndNotify(ctx, {
@@ -284,155 +255,21 @@ export async function runCommand(taskId) {
284
255
  await cleanup();
285
256
  }
286
257
  }
287
- const MAX_QUEUE_SIZE = 100;
288
- const MAX_LOG_ENTRIES = 1000;
289
- /** Max input line length (chars). Long emails can take up to 200k chars. */
290
- const MAX_LINE_LENGTH = 200_000;
291
- /**
292
- * Spawn a long-running shell command and invoke the agent CLI once per stdout
293
- * line, with the user's prompt augmented by that line. Sequential with a
294
- * bounded queue.
295
- */
296
- async function runCommandTriggeredMode(ctx) {
297
- const commandStr = ctx.task.frontmatter.command;
298
- console.log(`[command-triggered] Spawning: ${commandStr}`);
299
- appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
300
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
301
- const child = spawnStreamingCommand(commandStr, {
302
- cwd: getRunDir(ctx.taskDir, ctx.runId),
303
- env: { ...ctx.guiEnv, PALMIER_RUN_DIR: getRunDir(ctx.taskDir, ctx.runId), PALMIER_HTTP_PORT: String(ctx.config.httpPort ?? 7256) },
304
- });
305
- let linesProcessed = 0;
306
- let invocationsSucceeded = 0;
307
- let invocationsFailed = 0;
308
- const lineQueue = [];
309
- let processing = false;
310
- let commandExited = false;
311
- let resolveWhenDone;
312
- const logPath = path.join(getRunDir(ctx.taskDir, ctx.runId), "command-output.log");
313
- function appendLog(line, agentOutput, outcome) {
314
- const entry = `[${new Date().toISOString()}] (${outcome}) input: ${line}\n${agentOutput}\n---\n`;
315
- fs.appendFileSync(logPath, entry, "utf-8");
316
- try {
317
- const content = fs.readFileSync(logPath, "utf-8");
318
- const entries = content.split("\n---\n").filter(Boolean);
319
- if (entries.length > MAX_LOG_ENTRIES) {
320
- const trimmed = entries.slice(-MAX_LOG_ENTRIES).join("\n---\n") + "\n---\n";
321
- fs.writeFileSync(logPath, trimmed, "utf-8");
322
- }
323
- }
324
- catch { /* ignore trim errors */ }
325
- }
326
- async function processLine(line) {
327
- linesProcessed++;
328
- if (line.length > MAX_LINE_LENGTH) {
329
- console.warn(`[command-triggered] Skipping line #${linesProcessed}: ${line.length} chars exceeds limit`);
330
- invocationsFailed++;
331
- appendLog(line.slice(0, 200) + "...(truncated)", "", "skipped");
332
- return;
333
- }
334
- console.log(`[command-triggered] Processing line #${linesProcessed}: ${line}`);
335
- const perLinePrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this input:\n${line}`;
336
- const perLineTask = {
337
- frontmatter: { ...ctx.task.frontmatter, user_prompt: perLinePrompt },
338
- };
339
- const result = await invokeAgentWithRetries(ctx, perLineTask);
340
- if (result.outcome === "finished") {
341
- invocationsSucceeded++;
342
- }
343
- else {
344
- invocationsFailed++;
345
- const taskLabel = ctx.task.frontmatter.name || ctx.task.frontmatter.user_prompt;
346
- await sendPushNotification(ctx.nc, ctx.config.hostId, `Task "${taskLabel}" invocation failed`, line.length > 200 ? line.slice(0, 200) + "…" : line);
347
- }
348
- appendLog(line, "", result.outcome);
349
- // Signal "waiting for more input" in the UI.
350
- appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
351
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
352
- }
353
- async function drainQueue() {
354
- if (processing)
355
- return;
356
- processing = true;
357
- try {
358
- while (lineQueue.length > 0) {
359
- const line = lineQueue.shift();
360
- await processLine(line);
361
- }
362
- }
363
- finally {
364
- processing = false;
365
- if (commandExited && lineQueue.length === 0 && resolveWhenDone) {
366
- resolveWhenDone();
367
- }
368
- }
369
- }
370
- const rl = readline.createInterface({ input: child.stdout });
371
- rl.on("line", (line) => {
372
- if (!line.trim())
373
- return;
374
- if (lineQueue.length >= MAX_QUEUE_SIZE) {
375
- console.warn(`[command-triggered] Queue full, dropping oldest line.`);
376
- lineQueue.shift();
377
- }
378
- lineQueue.push(line);
379
- drainQueue().catch((err) => {
380
- console.error(`[command-triggered] Error processing line:`, err);
381
- invocationsFailed++;
382
- });
383
- });
384
- let stderrBuf = "";
385
- child.stderr?.on("data", (d) => {
386
- const chunk = d.toString();
387
- stderrBuf += chunk;
388
- process.stderr.write(d);
389
- });
390
- const exitCode = await new Promise((resolve) => {
391
- child.on("close", (code) => {
392
- commandExited = true;
393
- rl.close();
394
- resolve(code);
395
- });
396
- child.on("error", (err) => {
397
- console.error(`[command-triggered] Command error:`, err);
398
- stderrBuf += err.message;
399
- commandExited = true;
400
- rl.close();
401
- resolve(1);
402
- });
403
- });
404
- if (lineQueue.length > 0 || processing) {
405
- await new Promise((resolve) => {
406
- resolveWhenDone = resolve;
407
- drainQueue();
408
- });
409
- }
410
- const endTime = Date.now();
411
- if (exitCode !== 0) {
412
- const errorDetail = stderrBuf.trim() || `Command exited with code ${exitCode}`;
413
- appendRunMessage(ctx.taskDir, ctx.runId, {
414
- role: "status",
415
- time: endTime,
416
- content: errorDetail,
417
- type: "error",
418
- });
419
- await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
420
- return { outcome: "failed", endTime };
421
- }
422
- return { outcome: "finished", endTime };
423
- }
424
258
  /**
425
- * Drain the daemon-owned per-task event queue via /task-event/pop, invoking
426
- * the agent once per event. The run process holds no NATS subscription the
427
- * daemon owns that and atomically clears the active flag on empty pop so it
428
- * can fire a fresh run on the next incoming event.
259
+ * Drain the daemon-owned per-task event queue via /task-event/pop, invoking the
260
+ * agent once per queued trigger. The run process holds no subscription of its
261
+ * own the daemon owns the trigger source (a NATS subscription for on_new_*
262
+ * tasks, the command's stdout for command tasks) and atomically clears the
263
+ * active flag on empty pop so it can fire a fresh run on the next trigger.
429
264
  */
430
265
  async function runEventTriggeredMode(ctx) {
431
- const scheduleType = ctx.task.frontmatter.schedule_type;
432
- const label = scheduleType === "on_new_notification" ? "notification" : "SMS";
266
+ const isCommand = !!ctx.task.frontmatter.command;
267
+ const label = isCommand
268
+ ? "input"
269
+ : ctx.task.frontmatter.schedule_type === "on_new_notification" ? "notification" : "SMS";
433
270
  const port = ctx.config.httpPort ?? 7256;
434
271
  const popUrl = `http://localhost:${port}/task-event/pop?taskId=${encodeURIComponent(ctx.taskId)}`;
435
- console.log(`[event-triggered] Draining ${label} queue`);
272
+ console.log(`[triggered] Draining ${label} queue`);
436
273
  appendRunMessage(ctx.taskDir, ctx.runId, { role: "status", time: Date.now(), content: "", type: "monitoring" });
437
274
  await publishHostEvent(ctx.nc, ctx.config.hostId, ctx.taskId, { event_type: "result-updated", run_id: ctx.runId });
438
275
  let eventsProcessed = 0;
@@ -447,8 +284,10 @@ async function runEventTriggeredMode(ctx) {
447
284
  if (body.empty || !body.event)
448
285
  break;
449
286
  eventsProcessed++;
450
- console.log(`[event-triggered] Processing ${label} #${eventsProcessed}`);
451
- const perEventPrompt = `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
287
+ console.log(`[triggered] Processing ${label} #${eventsProcessed}`);
288
+ const perEventPrompt = isCommand
289
+ ? `${ctx.task.frontmatter.user_prompt}\n\nProcess this input:\n${body.event}`
290
+ : `${ctx.task.frontmatter.user_prompt}\n\nProcess this new ${label}:\n${body.event}`;
452
291
  const perEventTask = {
453
292
  frontmatter: { ...ctx.task.frontmatter, user_prompt: perEventPrompt },
454
293
  };
@@ -14,7 +14,8 @@ import { CONFIG_DIR } from "../config.js";
14
14
  import { StringCodec } from "nats";
15
15
  import { addNotification } from "../notification-store.js";
16
16
  import { addSmsMessage } from "../sms-store.js";
17
- import { enqueueEvent } from "../event-queues.js";
17
+ import { dispatchTrigger } from "../trigger-dispatch.js";
18
+ import { startEnabledCommandRunners } from "../command-runners.js";
18
19
  const POLL_INTERVAL_MS = 30_000;
19
20
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
20
21
  /**
@@ -122,6 +123,9 @@ export async function serveCommand() {
122
123
  console.error(`Warning: failed to install timer for task ${task.frontmatter.id}: ${err}`);
123
124
  }
124
125
  }
126
+ // Spawn the long-running command process for every enabled command task —
127
+ // the daemon owns these the same way it owns the device-event subscriptions.
128
+ startEnabledCommandRunners(config);
125
129
  setInterval(() => {
126
130
  checkStaleTasks(config, nc).catch((err) => {
127
131
  console.error("[monitor] Error checking stale tasks:", err);
@@ -153,12 +157,7 @@ export async function serveCommand() {
153
157
  if (!normalizedSender || !task.frontmatter.schedule_values.some((s) => normalizeSender(s) === normalizedSender))
154
158
  continue;
155
159
  }
156
- const { shouldStart } = enqueueEvent(task.frontmatter.id, payload);
157
- if (shouldStart) {
158
- platform.startTask(task.frontmatter.id).catch((err) => {
159
- console.error(`[event-trigger] Failed to start ${task.frontmatter.id}:`, err);
160
- });
161
- }
160
+ dispatchTrigger(task.frontmatter.id, payload);
162
161
  }
163
162
  }
164
163
  const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
@@ -4,8 +4,11 @@
4
4
  * `palmier run` process drains via /task-event/pop.
5
5
  *
6
6
  * Invariants:
7
- * - popEvent clears activeRuns atomically when the queue empties, so a
8
- * fresh startTask cannot race the tearing-down run.
7
+ * - popEvent clears activeRuns when the queue empties. This races the run's
8
+ * own teardown (the process/unit is still alive briefly after the empty
9
+ * pop), so a trigger arriving in that window can set activeRuns yet fail to
10
+ * launch a run (a oneshot `systemctl start` no-ops on an active unit). The
11
+ * dispatch watchdog in trigger-dispatch.ts reconciles that stranded state.
9
12
  * - enqueueEvent returns shouldStart=true only on the idle→active edge.
10
13
  */
11
14
  export declare function enqueueEvent(taskId: string, payload: string): {
@@ -16,5 +19,10 @@ export declare function popEvent(taskId: string): {
16
19
  } | {
17
20
  empty: true;
18
21
  };
22
+ export declare function hasPendingEvents(taskId: string): boolean;
23
+ /** Drop a stranded active flag so a fresh run can be launched (watchdog only). */
24
+ export declare function resetActiveRun(taskId: string): void;
25
+ /** Re-acquire the active flag without enqueuing (watchdog relaunch only). */
26
+ export declare function markActiveRun(taskId: string): void;
19
27
  /** Remove any state for a task (called from task.delete). */
20
28
  export declare function clearTaskQueue(taskId: string): void;
@@ -4,8 +4,11 @@
4
4
  * `palmier run` process drains via /task-event/pop.
5
5
  *
6
6
  * Invariants:
7
- * - popEvent clears activeRuns atomically when the queue empties, so a
8
- * fresh startTask cannot race the tearing-down run.
7
+ * - popEvent clears activeRuns when the queue empties. This races the run's
8
+ * own teardown (the process/unit is still alive briefly after the empty
9
+ * pop), so a trigger arriving in that window can set activeRuns yet fail to
10
+ * launch a run (a oneshot `systemctl start` no-ops on an active unit). The
11
+ * dispatch watchdog in trigger-dispatch.ts reconciles that stranded state.
9
12
  * - enqueueEvent returns shouldStart=true only on the idle→active edge.
10
13
  */
11
14
  const MAX_QUEUE_SIZE = 100;
@@ -30,6 +33,18 @@ export function popEvent(taskId) {
30
33
  activeRuns.delete(taskId);
31
34
  return { empty: true };
32
35
  }
36
+ export function hasPendingEvents(taskId) {
37
+ const queue = queues.get(taskId);
38
+ return !!queue && queue.length > 0;
39
+ }
40
+ /** Drop a stranded active flag so a fresh run can be launched (watchdog only). */
41
+ export function resetActiveRun(taskId) {
42
+ activeRuns.delete(taskId);
43
+ }
44
+ /** Re-acquire the active flag without enqueuing (watchdog relaunch only). */
45
+ export function markActiveRun(taskId) {
46
+ activeRuns.add(taskId);
47
+ }
33
48
  /** Remove any state for a task (called from task.delete). */
34
49
  export function clearTaskQueue(taskId) {
35
50
  queues.delete(taskId);