clementine-agent 1.18.90 → 1.18.91

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.
@@ -4587,6 +4587,56 @@ export async function cmdDashboard(opts) {
4587
4587
  });
4588
4588
  // SSE events handler moved before auth middleware (see above)
4589
4589
  // ── POST routes (actions) ──────────────────────────────────────
4590
+ // PRD §10 / 1.18.91: cancel an in-flight cron run. The daemon runs every
4591
+ // cron job in-process (cron-running.json's `pid` is the daemon's PID — we
4592
+ // can't SIGTERM it without crashing everything else). Instead we look up
4593
+ // the job's AbortController in the gateway registry and abort it; the SDK
4594
+ // path on runAgentCron honors the signal and unwinds cleanly. The
4595
+ // CronScheduler's own catch path then writes the closing CronRunEntry
4596
+ // with status='error' + an "AbortError" message.
4597
+ app.post('/api/cron/run/:job/cancel', async (req, res) => {
4598
+ try {
4599
+ const jobName = req.params.job;
4600
+ if (!jobName) {
4601
+ res.status(400).json({ ok: false, error: 'job required' });
4602
+ return;
4603
+ }
4604
+ const gw = await getGateway();
4605
+ if (!gw || typeof gw.cancelCronJob !== 'function') {
4606
+ res.status(503).json({ ok: false, error: 'gateway not initialized — try again in a moment' });
4607
+ return;
4608
+ }
4609
+ const runningFile = path.join(BASE_DIR, 'cron-running.json');
4610
+ let runId;
4611
+ if (existsSync(runningFile)) {
4612
+ try {
4613
+ const entries = JSON.parse(readFileSync(runningFile, 'utf-8'));
4614
+ const match = Array.isArray(entries) ? entries.find((e) => String(e.jobName ?? '').toLowerCase() === jobName.toLowerCase()) : null;
4615
+ if (match?.runId)
4616
+ runId = match.runId;
4617
+ }
4618
+ catch { /* non-fatal — cancel can still proceed without runId */ }
4619
+ }
4620
+ const aborted = gw.cancelCronJob(jobName, 'cancelled-by-dashboard');
4621
+ if (!aborted) {
4622
+ res.status(404).json({ ok: false, error: `Job "${jobName}" is not running on this daemon` });
4623
+ return;
4624
+ }
4625
+ // Broadcast so other tabs drop the running card without polling.
4626
+ try {
4627
+ broadcastEvent({ type: 'cron_cancelled', data: { job: jobName, runId } });
4628
+ }
4629
+ catch { /* non-fatal */ }
4630
+ res.json({
4631
+ ok: true,
4632
+ message: `Cancel signal sent to "${jobName}". The runner will close the run within a few seconds.`,
4633
+ runId,
4634
+ });
4635
+ }
4636
+ catch (err) {
4637
+ res.status(500).json({ ok: false, error: String(err) });
4638
+ }
4639
+ });
4590
4640
  app.post('/api/cron/run/:job', (req, res) => {
4591
4641
  const jobName = req.params.job;
4592
4642
  try {
@@ -22682,6 +22732,24 @@ async function toggleCronJob(name) {
22682
22732
  } catch(e) { toast('Failed to toggle: ' + e, 'error'); }
22683
22733
  }
22684
22734
 
22735
+ // PRD §10 / 1.18.91: SIGTERM the in-flight runner for this cron job. The
22736
+ // daemon endpoint reads PID from cron-running.json (already written by
22737
+ // CronScheduler). The runner's signal handler closes the run gracefully —
22738
+ // the SSE cron_cancelled broadcast will refresh the card.
22739
+ async function cancelCronRun(name) {
22740
+ if (!confirm('Cancel the in-flight run for "' + name + '"? The runner will stop within a few seconds.')) return;
22741
+ try {
22742
+ var r = await apiFetch('/api/cron/run/' + encodeURIComponent(name) + '/cancel', { method: 'POST' });
22743
+ var d = await r.json();
22744
+ if (!r.ok || d.ok === false) {
22745
+ toast(d.error || ('Cancel failed (HTTP ' + r.status + ')'), 'error');
22746
+ return;
22747
+ }
22748
+ toast(d.message || ('Cancel signal sent to ' + name), 'success');
22749
+ setTimeout(refreshCron, 800);
22750
+ } catch(e) { toast('Failed to cancel: ' + e, 'error'); }
22751
+ }
22752
+
22685
22753
  async function toggleScheduledWorkflow(id) {
22686
22754
  try {
22687
22755
  var r = await apiFetch('/api/builder/workflows/' + encodeURIComponent(id));
@@ -23663,6 +23731,14 @@ function renderScheduledTaskCard(task) {
23663
23731
  badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
23664
23732
  badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
23665
23733
  var safeName = jsStr(task.name);
23734
+ // PRD §10 / 1.18.91: when a task is mid-flight, Run Now is meaningless and
23735
+ // would race against the concurrency lock; replace it with a Cancel button
23736
+ // that calls /api/cron/run/:job/cancel (SIGTERMs the runner via the PID
23737
+ // recorded in cron-running.json).
23738
+ var isRunning = task.health === 'running';
23739
+ var runOrCancelBtn = isRunning
23740
+ ? '<button class="btn-sm secondary btn-danger" onclick="cancelCronRun(\\x27' + safeName + '\\x27)" title="Stop this in-flight run (SIGTERM)">Cancel</button>'
23741
+ : '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>';
23666
23742
  return '<div class="' + cardCls + '" style="' + style + '">'
23667
23743
  + '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
23668
23744
  + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
@@ -23674,7 +23750,7 @@ function renderScheduledTaskCard(task) {
23674
23750
  + '<div class="task-card-badges">' + badges + '</div>'
23675
23751
  + '<div class="task-card-actions">'
23676
23752
  + '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>'
23677
- + '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>'
23753
+ + runOrCancelBtn
23678
23754
  + '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
23679
23755
  + '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
23680
23756
  + '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
@@ -30,6 +30,11 @@ export declare class Gateway {
30
30
  * `CronRunEntry`. Mirrors the `consumeLastTerminalReason` pattern so we
31
31
  * don't have to refactor `handleCronJob`'s positional return shape. */
32
32
  private _lastCronRunMetadata?;
33
+ /** PRD §10 / 1.18.91: registry of in-flight cron AbortControllers keyed by
34
+ * jobName. Lets the dashboard cancel endpoint abort an in-progress run
35
+ * without SIGTERMing the whole daemon. Populated/cleaned up by
36
+ * handleCronJob. */
37
+ private cronAbortControllers;
33
38
  /** Persisted set of channel keys the owner has approved. Loaded lazily. */
34
39
  private seenChannels;
35
40
  private _authFailCount;
@@ -178,6 +183,12 @@ export declare class Gateway {
178
183
  _mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, pinnedSkills?: string[], allowedTools?: string[], allowedMcpServers?: string[],
179
184
  /** Predictable (contract) mode — runner skips memory/team/auto-skills. */
180
185
  predictable?: boolean): Promise<string>;
186
+ /**
187
+ * PRD §10 / 1.18.91 — cancel an in-flight cron run by name. Returns true if
188
+ * an AbortController was found and abort() was called, false if nothing was
189
+ * registered (job wasn't running on this daemon). Safe to call repeatedly.
190
+ */
191
+ cancelCronJob(jobName: string, reason?: string): boolean;
181
192
  /**
182
193
  * Process a team message as an autonomous task — same multi-phase execution
183
194
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -112,6 +112,11 @@ export class Gateway {
112
112
  * `CronRunEntry`. Mirrors the `consumeLastTerminalReason` pattern so we
113
113
  * don't have to refactor `handleCronJob`'s positional return shape. */
114
114
  _lastCronRunMetadata;
115
+ /** PRD §10 / 1.18.91: registry of in-flight cron AbortControllers keyed by
116
+ * jobName. Lets the dashboard cancel endpoint abort an in-progress run
117
+ * without SIGTERMing the whole daemon. Populated/cleaned up by
118
+ * handleCronJob. */
119
+ cronAbortControllers = new Map();
115
120
  /** Persisted set of channel keys the owner has approved. Loaded lazily. */
116
121
  seenChannels = null;
117
122
  // Auth circuit breaker — suppresses repeated error spam after consecutive failures
@@ -1987,6 +1992,10 @@ export class Gateway {
1987
1992
  cronAc.abort();
1988
1993
  logger.warn({ jobName, wallMs }, 'Cron job hit wall-clock cap — aborting');
1989
1994
  }, wallMs);
1995
+ // PRD §10 / 1.18.91: register so the dashboard cancel endpoint can find
1996
+ // and abort this controller. Last-write-wins if a duplicate fires (the
1997
+ // concurrency lock prevents that for manual runs, but be defensive).
1998
+ this.cronAbortControllers.set(jobName, cronAc);
1990
1999
  try {
1991
2000
  logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
1992
2001
  const cronStart = Date.now();
@@ -2047,7 +2056,29 @@ export class Gateway {
2047
2056
  finally {
2048
2057
  clearTimeout(cronTimer);
2049
2058
  releaseLane();
2059
+ // PRD §10 / 1.18.91: deregister only if we're still the owner (a
2060
+ // theoretical re-entry could have replaced us; don't clobber).
2061
+ if (this.cronAbortControllers.get(jobName) === cronAc) {
2062
+ this.cronAbortControllers.delete(jobName);
2063
+ }
2064
+ }
2065
+ }
2066
+ /**
2067
+ * PRD §10 / 1.18.91 — cancel an in-flight cron run by name. Returns true if
2068
+ * an AbortController was found and abort() was called, false if nothing was
2069
+ * registered (job wasn't running on this daemon). Safe to call repeatedly.
2070
+ */
2071
+ cancelCronJob(jobName, reason = 'cancelled-by-dashboard') {
2072
+ const ac = this.cronAbortControllers.get(jobName);
2073
+ if (!ac)
2074
+ return false;
2075
+ if (!ac.signal.aborted) {
2076
+ try {
2077
+ ac.abort(reason);
2078
+ }
2079
+ catch { /* ignore */ }
2050
2080
  }
2081
+ return true;
2051
2082
  }
2052
2083
  // ── Team task execution ──────────────────────────────────────────────
2053
2084
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.90",
3
+ "version": "1.18.91",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",