clementine-agent 1.18.75 → 1.18.76

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.
@@ -70,14 +70,18 @@ export function evaluateWhen(c, ctx) {
70
70
  const lastRun = ctx.recentRuns[0];
71
71
  if (!lastRun)
72
72
  return false;
73
- const lastRunTime = new Date(lastRun.finishedAt).getTime();
73
+ // finishedAt is optional (in-progress runs are 'running' with no
74
+ // finishedAt). Fall back to startedAt so the rule still has a clock.
75
+ const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
74
76
  return ctx.nowMs - lastRunTime > c.ms;
75
77
  }
76
78
  case 'lastRunWithinMs': {
77
79
  const lastRun = ctx.recentRuns[0];
78
80
  if (!lastRun)
79
81
  return false;
80
- const lastRunTime = new Date(lastRun.finishedAt).getTime();
82
+ // finishedAt is optional (in-progress runs are 'running' with no
83
+ // finishedAt). Fall back to startedAt so the rule still has a clock.
84
+ const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
81
85
  return ctx.nowMs - lastRunTime <= c.ms;
82
86
  }
83
87
  case 'noRecentRuns':
@@ -185,7 +189,7 @@ const TEMPLATE_VARS = {
185
189
  const lastRun = ctx.recentRuns[0];
186
190
  if (!lastRun)
187
191
  return 0;
188
- const lastRunTime = new Date(lastRun.finishedAt).getTime();
192
+ const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
189
193
  const elapsed = ctx.nowMs - lastRunTime;
190
194
  const cooldown = 60 * 60 * 1000;
191
195
  return Math.max(0, Math.ceil((cooldown - elapsed) / 60_000));
@@ -75,7 +75,7 @@ function computeLegacyAdvice(jobName, job) {
75
75
  advice.skipReason = `${consecutiveErrors} consecutive errors — circuit breaker engaged`;
76
76
  return advice;
77
77
  }
78
- const lastRunTime = new Date(lastRun.finishedAt).getTime();
78
+ const lastRunTime = new Date(lastRun.finishedAt ?? lastRun.startedAt).getTime();
79
79
  const elapsed = Date.now() - lastRunTime;
80
80
  if (elapsed < CIRCUIT_BREAKER_COOLDOWN_MS) {
81
81
  advice.shouldSkip = true;
@@ -366,7 +366,7 @@ export class AgentBotClient {
366
366
  if (recent.length > 0) {
367
367
  const r = recent[0];
368
368
  const icon = r.status === 'ok' ? '\u2705' : r.status === 'error' ? '\u274C' : '\u23ED';
369
- const ago = Math.round((Date.now() - new Date(r.finishedAt).getTime()) / 60000);
369
+ const ago = Math.round((Date.now() - new Date(r.finishedAt ?? r.startedAt).getTime()) / 60000);
370
370
  lastRunLines.push(`${icon} ${display} \u2014 ${formatAgentDuration(ago)} ago`);
371
371
  }
372
372
  else {
package/dist/cli/cron.js CHANGED
@@ -48,7 +48,7 @@ export async function cmdCronList() {
48
48
  const status = job.enabled ? 'enabled' : 'disabled';
49
49
  const recent = runLog.readRecent(job.name, 1);
50
50
  const lastRun = recent.length > 0
51
- ? `last run: ${recent[0].finishedAt.slice(0, 16).replace('T', ' ')} (${recent[0].status})`
51
+ ? `last run: ${(recent[0].finishedAt ?? recent[0].startedAt).slice(0, 16).replace('T', ' ')} (${recent[0].status})`
52
52
  : 'never run';
53
53
  const errors = runLog.consecutiveErrors(job.name);
54
54
  const errorTag = errors > 0 ? ` [${errors} consecutive error(s)]` : '';
@@ -1350,20 +1350,13 @@ function getCronJobs() {
1350
1350
  }
1351
1351
  catch { /* ignore */ }
1352
1352
  }
1353
- // Attach recent run history
1354
- const runsDir = path.join(BASE_DIR, 'cron', 'runs');
1353
+ // Attach recent run history. Single source of truth via CronRunLog.readRecent
1354
+ // same path the new /api/cron/runs cross-job endpoint uses, so per-card
1355
+ // last-run and the Recent History zone never disagree.
1356
+ const log = new CronRunLog();
1355
1357
  const enriched = jobs.map((job) => {
1356
1358
  const name = String(job.name ?? '');
1357
- const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_');
1358
- const logPath = path.join(runsDir, `${safe}.jsonl`);
1359
- let recentRuns = [];
1360
- if (existsSync(logPath)) {
1361
- try {
1362
- const lines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
1363
- recentRuns = lines.slice(-10).map((l) => JSON.parse(l)).reverse();
1364
- }
1365
- catch { /* ignore */ }
1366
- }
1359
+ const recentRuns = log.readRecent(name, 10);
1367
1360
  return { ...job, recentRuns };
1368
1361
  });
1369
1362
  return { jobs: enriched };
@@ -4597,6 +4590,25 @@ export async function cmdDashboard(opts) {
4597
4590
  app.post('/api/cron/run/:job', (req, res) => {
4598
4591
  const jobName = req.params.job;
4599
4592
  try {
4593
+ // Concurrency lock (1.18.76+): the CronScheduler persists a sidecar
4594
+ // ~/.clementine/cron-running.json with one entry per in-flight job.
4595
+ // If a manual Run Now collides with an already-running job (whether
4596
+ // from another Run Now click or a scheduled tick), reject 409 so the
4597
+ // user gets immediate feedback instead of two parallel runs racing
4598
+ // each other into the same outputs.
4599
+ try {
4600
+ const runningFile = path.join(BASE_DIR, 'cron-running.json');
4601
+ if (existsSync(runningFile)) {
4602
+ const entries = JSON.parse(readFileSync(runningFile, 'utf-8'));
4603
+ const collision = Array.isArray(entries) && entries.find(e => String(e.jobName ?? '').toLowerCase() === jobName.toLowerCase());
4604
+ if (collision) {
4605
+ const since = collision.startedAt ? ` (running since ${collision.startedAt})` : '';
4606
+ res.status(409).json({ ok: false, error: `Job "${jobName}" is already running${since}` });
4607
+ return;
4608
+ }
4609
+ }
4610
+ }
4611
+ catch { /* if the sidecar is unreadable, fall through and let the runner handle dedup */ }
4600
4612
  const child = spawn('node', [DIST_ENTRY, 'cron', 'run', jobName], {
4601
4613
  detached: true,
4602
4614
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -6584,8 +6596,18 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6584
6596
  }
6585
6597
  jobs[idx].enabled = !jobs[idx].enabled;
6586
6598
  writeCronFileAt(cronFile, parsed, jobs);
6587
- const state = jobs[idx].enabled ? 'enabled' : 'disabled';
6588
- res.json({ ok: true, message: `${jobName} is now ${state}` });
6599
+ const enabled = jobs[idx].enabled;
6600
+ const state = enabled ? 'enabled' : 'disabled';
6601
+ // Broadcast so other open dashboard tabs flip the toggle without polling.
6602
+ try {
6603
+ broadcastEvent({ type: 'cron_toggled', data: { job: bareJobName, enabled } });
6604
+ }
6605
+ catch { /* non-fatal */ }
6606
+ // The CronScheduler's watchFile polls CRON.md every 2s and reloads
6607
+ // automatically (cron-scheduler.ts:756 watchCronFile), so the toggle
6608
+ // takes effect within 2 seconds without an explicit reload here. We
6609
+ // surface a hint to the user about that latency in the response.
6610
+ res.json({ ok: true, message: `${jobName} is now ${state}`, enabled });
6589
6611
  }
6590
6612
  catch (err) {
6591
6613
  res.status(500).json({ error: String(err) });
@@ -23441,7 +23463,7 @@ async function openTraceViewer(jobName) {
23441
23463
  traceData = d.traces || [];
23442
23464
 
23443
23465
  if (traceData.length === 0) {
23444
- document.getElementById('trace-content').innerHTML = '<div style="padding:20px;color:var(--text-muted)">No traces recorded yet. Traces are captured on the next run.</div>';
23466
+ document.getElementById('trace-content').innerHTML = '<div style="padding:24px;color:var(--text-muted);line-height:1.6"><div style="font-weight:500;color:var(--text-secondary);margin-bottom:8px">No traces recorded yet</div><div style="font-size:12px">Traces are captured per run and show every tool call the agent made.<br/>If a recent run errored before any tool was invoked, see the <strong>Recent history</strong> zone on the Tasks page for the error message.</div></div>';
23445
23467
  document.getElementById('trace-run-selector').innerHTML = '';
23446
23468
  return;
23447
23469
  }
@@ -23493,7 +23515,7 @@ function renderTrace(idx) {
23493
23515
  + '</div>';
23494
23516
  }
23495
23517
 
23496
- document.getElementById('trace-content').innerHTML = html || '<div style="padding:20px;color:var(--text-muted)">Empty trace</div>';
23518
+ document.getElementById('trace-content').innerHTML = html || '<div style="padding:24px;color:var(--text-muted);line-height:1.6"><div style="font-weight:500;color:var(--text-secondary);margin-bottom:8px">This run produced no tool calls</div><div style="font-size:12px">The run started but didn\\x27t reach any tool — it likely errored before the agent acted, or was a no-op response.<br/>Check <strong>Recent history</strong> on the Tasks page for the error message or output preview from this run.</div></div>';
23497
23519
  }
23498
23520
 
23499
23521
  // ── Inline Trace (Execution tab) ──
@@ -85,12 +85,20 @@ export function classifyRunHealth(entry) {
85
85
  requiresApproval: true,
86
86
  };
87
87
  }
88
- if (entry.status === 'error' || entry.status === 'retried') {
88
+ // 'timeout' and 'lost' are explicit terminal failure states (1.18.76+).
89
+ // 'timeout' = the job exceeded max_hours. 'lost' = the daemon-boot sweep
90
+ // closed an orphaned 'running' entry that never got a finishedAt because
91
+ // the daemon crashed mid-run.
92
+ if (entry.status === 'error' || entry.status === 'retried' || entry.status === 'timeout' || entry.status === 'lost') {
89
93
  return {
90
94
  ...base,
91
95
  status: 'failed',
92
96
  evidence: compactEvidence(entry.error, entry.outputPreview, terminalReason ? `terminalReason=${terminalReason}` : undefined),
93
- recommendedAction: 'Inspect the latest error and current job definition before applying a fix.',
97
+ recommendedAction: entry.status === 'lost'
98
+ ? 'The daemon crashed before this run completed. Check the daemon logs around the startedAt timestamp; if this repeats, lower max_hours or investigate the underlying job.'
99
+ : entry.status === 'timeout'
100
+ ? 'The job exceeded its time budget. Lower max_hours, narrow the prompt, or split into smaller steps.'
101
+ : 'Inspect the latest error and current job definition before applying a fix.',
94
102
  requiresApproval: true,
95
103
  };
96
104
  }
package/dist/types.d.ts CHANGED
@@ -392,8 +392,17 @@ export type TerminalReason = 'blocking_limit' | 'rapid_refill_breaker' | 'prompt
392
392
  export interface CronRunEntry {
393
393
  jobName: string;
394
394
  startedAt: string;
395
- finishedAt: string;
396
- status: 'ok' | 'error' | 'retried' | 'skipped';
395
+ /** Optional: in-progress runs are appended with status='running' before the
396
+ * finishedAt is known. The runner replaces or supersedes the entry on
397
+ * completion. The stale-running sweep emits a closing 'lost' entry for
398
+ * any 'running' row whose startedAt has aged past the deadline. */
399
+ finishedAt?: string;
400
+ /** 'ok' | 'error' | 'retried' | 'skipped' are terminal. 'running' is in-flight.
401
+ * 'timeout' fires when max_hours is exceeded. 'lost' is appended by the
402
+ * daemon-boot stale sweep when a 'running' entry has no companion close
403
+ * (daemon likely crashed mid-run). */
404
+ status: 'ok' | 'error' | 'retried' | 'skipped' | 'running' | 'timeout' | 'lost';
405
+ /** 0 for in-progress 'running' rows; populated when terminal. */
397
406
  durationMs: number;
398
407
  error?: string;
399
408
  errorType?: 'transient' | 'permanent';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.75",
3
+ "version": "1.18.76",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",