@cydm/happy-elves 0.1.0-beta.244 → 0.1.0-beta.247

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.
@@ -149,7 +149,8 @@ Session prompts can run memory off, readonly, or readwrite; readwrite writes dai
149
149
 
150
150
  Schedule v1 is daemon-local repeat automation. Runs use normal Relay admission
151
151
  with deterministic requestId/turnId per occurrence. Offline due times are marked
152
- missed and are never auto-caught-up.
152
+ missed and are never auto-caught-up. Commands that take a scheduleId auto-locate
153
+ the owning online daemon when --machine is omitted.
153
154
  `,
154
155
  loop: `Usage:
155
156
  happy-elves loop create <sessionId> [--every 15m] (--prompt <text> | --prompt-file <path>) [--stop-on blocked] --json
@@ -60,40 +60,41 @@ export async function handleSchedule({ domain, action, positional, flags }) {
60
60
  if (action === "show") {
61
61
  const config = await readConfig(flags);
62
62
  const client = new ControllerClient(config);
63
- const machineId = await resolveScheduleMachineId(client, flags);
63
+ const scheduleId = requirePositional(positional[0], "scheduleId");
64
+ const machineId = await resolveScheduleOwnerMachineId(client, flags, scheduleId);
64
65
  const result = await client.schedule({
65
66
  machineId,
66
67
  action: "show",
67
- scheduleId: requirePositional(positional[0], "scheduleId"),
68
+ scheduleId,
68
69
  });
69
- ok("schedule.show", { schedule: result.schedule });
70
+ ok("schedule.show", { machineId, schedule: result.schedule });
70
71
  return true;
71
72
  }
72
73
  if (action === "pause" || action === "resume" || action === "delete") {
73
74
  const config = await readConfig(flags);
74
75
  const client = new ControllerClient(config);
75
- const machineId = await resolveScheduleMachineId(client, flags);
76
76
  const scheduleId = requirePositional(positional[0], "scheduleId");
77
+ const machineId = await resolveScheduleOwnerMachineId(client, flags, scheduleId);
77
78
  const result = await client.schedule({ machineId, action, scheduleId });
78
- ok(`schedule.${action}`, { schedule: compactSchedule(result.schedule), scheduleId: result.scheduleId ?? scheduleId });
79
+ ok(`schedule.${action}`, { machineId, schedule: compactSchedule(result.schedule), scheduleId: result.scheduleId ?? scheduleId });
79
80
  return true;
80
81
  }
81
82
  if (action === "run" || action === "run-now") {
82
83
  const config = await readConfig(flags);
83
84
  const client = new ControllerClient(config);
84
- const machineId = await resolveScheduleMachineId(client, flags);
85
85
  const scheduleId = requirePositional(positional[0], "scheduleId");
86
+ const machineId = await resolveScheduleOwnerMachineId(client, flags, scheduleId);
86
87
  const result = await client.schedule({ machineId, action: "run", scheduleId });
87
- ok("schedule.run", { scheduleId: result.scheduleId ?? scheduleId, run: result.run });
88
+ ok("schedule.run", { machineId, scheduleId: result.scheduleId ?? scheduleId, run: result.run });
88
89
  return true;
89
90
  }
90
91
  if (action === "history") {
91
92
  const config = await readConfig(flags);
92
93
  const client = new ControllerClient(config);
93
- const machineId = await resolveScheduleMachineId(client, flags);
94
94
  const scheduleId = requirePositional(positional[0], "scheduleId");
95
+ const machineId = await resolveScheduleOwnerMachineId(client, flags, scheduleId);
95
96
  const result = await client.schedule({ machineId, action: "history", scheduleId });
96
- ok("schedule.history", { scheduleId: result.scheduleId ?? scheduleId, runs: result.history ?? [] });
97
+ ok("schedule.history", { machineId, scheduleId: result.scheduleId ?? scheduleId, runs: result.history ?? [] });
97
98
  return true;
98
99
  }
99
100
  if (action === "run-due") {
@@ -107,11 +108,11 @@ export async function handleSchedule({ domain, action, positional, flags }) {
107
108
  if (action === "run-missed") {
108
109
  const config = await readConfig(flags);
109
110
  const client = new ControllerClient(config);
110
- const machineId = await resolveScheduleMachineId(client, flags);
111
111
  const scheduleId = requirePositional(positional[0], "scheduleId");
112
+ const machineId = await resolveScheduleOwnerMachineId(client, flags, scheduleId);
112
113
  const occurrenceId = requirePositional(positional[1], "occurrenceId");
113
114
  const result = await client.schedule({ machineId, action: "runMissed", scheduleId, occurrenceId });
114
- ok("schedule.run-missed", { scheduleId: result.scheduleId ?? scheduleId, run: result.run });
115
+ ok("schedule.run-missed", { machineId, scheduleId: result.scheduleId ?? scheduleId, run: result.run });
115
116
  return true;
116
117
  }
117
118
  return false;
@@ -130,6 +131,42 @@ async function resolveScheduleMachineId(client, flags, sessionId) {
130
131
  throw new CliError("No daemon machine is available; pass --machine <machineId>", "MACHINE_NOT_FOUND");
131
132
  return machine.id;
132
133
  }
134
+ async function resolveScheduleOwnerMachineId(client, flags, scheduleId) {
135
+ if (typeof flags.machine === "string" && flags.machine.length > 0)
136
+ return flags.machine;
137
+ const snapshot = await client.snapshot();
138
+ const machines = snapshot.machines.filter((machine) => machine.online);
139
+ if (machines.length === 0) {
140
+ throw new CliError(`Schedule ${scheduleId} cannot be located because no daemon machine is online; pass --machine <machineId> after starting the target daemon`, "MACHINE_NOT_FOUND");
141
+ }
142
+ const checks = await Promise.all(machines.map(async (machine) => {
143
+ try {
144
+ const result = await client.schedule({ machineId: machine.id, action: "list" });
145
+ return {
146
+ machineId: machine.id,
147
+ found: (result.schedules ?? []).some((schedule) => schedule.id === scheduleId),
148
+ };
149
+ }
150
+ catch (error) {
151
+ return {
152
+ machineId: machine.id,
153
+ found: false,
154
+ error,
155
+ };
156
+ }
157
+ }));
158
+ const matches = checks.filter((check) => check.found);
159
+ if (matches.length === 1)
160
+ return matches[0].machineId;
161
+ if (matches.length > 1) {
162
+ throw new CliError(`Schedule ${scheduleId} exists on multiple online daemons (${matches.map((match) => match.machineId).join(", ")}); pass --machine <machineId>`, "SCHEDULE_OWNER_AMBIGUOUS");
163
+ }
164
+ const failedChecks = checks.filter((check) => check.error);
165
+ const unavailable = failedChecks.length > 0
166
+ ? ` ${failedChecks.length}/${machines.length} online daemon(s) did not respond to schedule list.`
167
+ : "";
168
+ throw new CliError(`Schedule not found on any online daemon: ${scheduleId}.${unavailable} Pass --machine <machineId> if the owning daemon is offline or not visible.`, "SCHEDULE_NOT_FOUND");
169
+ }
133
170
  function parseScheduleDueAt(value) {
134
171
  const date = new Date(value);
135
172
  if (!Number.isFinite(date.getTime()))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.244",
3
+ "version": "0.1.0-beta.247",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -8,6 +8,7 @@ export declare function runDueSchedulesNow(config: DaemonConfig): Promise<Array<
8
8
  run: ScheduleRunRecord;
9
9
  }>>;
10
10
  export declare function occurrenceIdFor(schedule: Pick<ScheduleRecord, "id" | "dueAt" | "nextRunAt">, dueAt?: string): string;
11
+ export declare function __testWithActiveScheduleOccurrence<T>(scheduleId: string, occurrenceId: string, action: () => Promise<T> | T): Promise<T>;
11
12
  export declare function nextRunAtForSchedule(schedule: Pick<ScheduleRecord, "schedule" | "dueAt" | "runs">, afterMs?: number): string | undefined;
12
13
  export declare function nextRunPreviewForSchedule(schedule: Pick<ScheduleRecord, "id" | "schedule" | "dueAt" | "runs">, count?: number, afterMs?: number): string[];
13
14
  export declare function settleScheduleTurnTerminal(input: {
@@ -20,6 +20,8 @@ export async function tickSchedules(config, onlineSinceMs) {
20
20
  const dueMs = Date.parse(dueAt);
21
21
  if (!Number.isFinite(dueMs) || dueMs > nowMs)
22
22
  continue;
23
+ if (isScheduleOccurrenceActive(schedule.id, occurrenceId))
24
+ continue;
23
25
  if (dueMs < onlineSinceMs) {
24
26
  const run = scheduleRun(schedule, occurrenceId, dueAt, "missed", "daemon was offline at due time");
25
27
  await recordScheduleRun(schedule.id, run);
@@ -64,6 +66,8 @@ export async function runDueSchedulesNow(config) {
64
66
  const dueMs = Date.parse(dueAt);
65
67
  if (!Number.isFinite(dueMs) || dueMs > nowMs)
66
68
  continue;
69
+ if (isScheduleOccurrenceActive(schedule.id, occurrenceId))
70
+ continue;
67
71
  const run = await runScheduleOccurrence(config, schedule, occurrenceId, dueAt, "manual");
68
72
  results.push({ scheduleId: schedule.id, run });
69
73
  }
@@ -79,6 +83,22 @@ function stableScheduleId(prefix, ...parts) {
79
83
  const hash = createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 32);
80
84
  return `${prefix}_${hash}`;
81
85
  }
86
+ function activeScheduleRunKey(scheduleId, occurrenceId) {
87
+ return `${scheduleId}:${occurrenceId}`;
88
+ }
89
+ function isScheduleOccurrenceActive(scheduleId, occurrenceId) {
90
+ return activeScheduleRuns.has(activeScheduleRunKey(scheduleId, occurrenceId));
91
+ }
92
+ export async function __testWithActiveScheduleOccurrence(scheduleId, occurrenceId, action) {
93
+ const key = activeScheduleRunKey(scheduleId, occurrenceId);
94
+ activeScheduleRuns.add(key);
95
+ try {
96
+ return await action();
97
+ }
98
+ finally {
99
+ activeScheduleRuns.delete(key);
100
+ }
101
+ }
82
102
  export function nextRunAtForSchedule(schedule, afterMs = Date.now()) {
83
103
  const spec = normalizedSpec(schedule.schedule, schedule.dueAt);
84
104
  if (spec.type === "manual")
@@ -162,7 +182,7 @@ export async function settleScheduleTurnTerminal(input) {
162
182
  return settledRun;
163
183
  }
164
184
  async function runScheduleOccurrence(config, schedule, occurrenceId, dueAt, reason) {
165
- const key = `${schedule.id}:${occurrenceId}`;
185
+ const key = activeScheduleRunKey(schedule.id, occurrenceId);
166
186
  if (activeScheduleRuns.has(key)) {
167
187
  throw new Error("Schedule occurrence is already running");
168
188
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.244",
3
+ "version": "0.1.0-beta.247",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.244",
3
+ "version": "0.1.0-beta.247",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,11 +101,14 @@ export async function sendCommand(context, command, waitForDone = false, timeout
101
101
  const targetTurnId = "turnId" in command ? command.turnId : undefined;
102
102
  return await new Promise((resolve, reject) => {
103
103
  let settled = false;
104
+ let retainedPoller;
104
105
  const finish = (result) => {
105
106
  if (settled)
106
107
  return;
107
108
  settled = true;
108
109
  globalThis.clearTimeout(timer);
110
+ if (retainedPoller)
111
+ globalThis.clearInterval(retainedPoller);
109
112
  ws.close();
110
113
  resolve(result);
111
114
  };
@@ -114,6 +117,8 @@ export async function sendCommand(context, command, waitForDone = false, timeout
114
117
  return;
115
118
  settled = true;
116
119
  globalThis.clearTimeout(timer);
120
+ if (retainedPoller)
121
+ globalThis.clearInterval(retainedPoller);
117
122
  ws.close();
118
123
  reject(error);
119
124
  };
@@ -125,7 +130,11 @@ export async function sendCommand(context, command, waitForDone = false, timeout
125
130
  dispatch: ackedDispatch,
126
131
  events,
127
132
  });
133
+ let retainedLookupInFlight = false;
128
134
  const resolveFromRetainedTurnIfDone = (sessionId, turnId) => {
135
+ if (retainedLookupInFlight)
136
+ return;
137
+ retainedLookupInFlight = true;
129
138
  void context.sessionEvents(sessionId, { limit: 1000 })
130
139
  .then(({ events: retainedEvents }) => retainedEvents.filter((event) => event.turnId === turnId))
131
140
  .then(async (turnEvents) => {
@@ -142,7 +151,19 @@ export async function sendCommand(context, command, waitForDone = false, timeout
142
151
  events.sort((left, right) => left.id - right.id);
143
152
  finish(commandResultFromAck());
144
153
  })
145
- .catch(() => { });
154
+ .catch(() => { })
155
+ .finally(() => {
156
+ retainedLookupInFlight = false;
157
+ });
158
+ };
159
+ const startRetainedTurnPoller = (sessionId, turnId) => {
160
+ if (retainedPoller)
161
+ return;
162
+ resolveFromRetainedTurnIfDone(sessionId, turnId);
163
+ retainedPoller = globalThis.setInterval(() => {
164
+ if (!settled)
165
+ resolveFromRetainedTurnIfDone(sessionId, turnId);
166
+ }, 2_000);
146
167
  };
147
168
  const timer = globalThis.setTimeout(() => {
148
169
  fail(new ControllerClientError("Timed out waiting for command result", "COMMAND_TIMEOUT", {
@@ -182,7 +203,7 @@ export async function sendCommand(context, command, waitForDone = false, timeout
182
203
  finish(commandResultFromAck());
183
204
  }
184
205
  else if (targetSessionId && targetTurnId) {
185
- resolveFromRetainedTurnIfDone(targetSessionId, targetTurnId);
206
+ startRetainedTurnPoller(targetSessionId, targetTurnId);
186
207
  }
187
208
  return;
188
209
  }