clementine-agent 1.18.89 → 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.
- package/dist/cli/dashboard.js +191 -2
- package/dist/gateway/router.d.ts +11 -0
- package/dist/gateway/router.js +31 -0
- package/package.json +1 -1
package/dist/cli/dashboard.js
CHANGED
|
@@ -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 {
|
|
@@ -20217,7 +20267,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
20217
20267
|
<h4>What it does</h4>
|
|
20218
20268
|
<p class="cron-section-desc">The instruction the agent receives. Long prompts are fine — drag the corner to resize.</p>
|
|
20219
20269
|
<div class="form-group">
|
|
20220
|
-
<label class="form-label">
|
|
20270
|
+
<label class="form-label" style="display:flex;align-items:center;gap:8px">
|
|
20271
|
+
Prompt
|
|
20272
|
+
<a href="#" id="cron-prompt-history-link" onclick="event.preventDefault();openPromptHistory()" style="display:none;font-size:11px;font-weight:normal;color:var(--text-muted);text-decoration:none;border-bottom:1px dotted var(--border)">View prompt history</a>
|
|
20273
|
+
</label>
|
|
20221
20274
|
<textarea id="cron-prompt" class="cron-prompt-textarea" placeholder="What should the AI do when this task runs?"></textarea>
|
|
20222
20275
|
<div class="form-hint">The instruction sent to the AI agent when this task fires.</div>
|
|
20223
20276
|
</div>
|
|
@@ -20450,6 +20503,29 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
20450
20503
|
|
|
20451
20504
|
<!-- (legacy standalone Preview modal removed in 1.18.70 — preview now lives as a tab inside the cron modal) -->
|
|
20452
20505
|
|
|
20506
|
+
<!-- ═══ Prompt History Modal — PRD §11 / 1.18.90 ═══
|
|
20507
|
+
Lists every past prompt revision for the currently-edited task.
|
|
20508
|
+
Each row shows ts + changedBy + a preview, expandable to full
|
|
20509
|
+
content. "Restore" copies the old prompt back into the editor —
|
|
20510
|
+
it does NOT auto-save, so the user reviews + clicks Save Changes
|
|
20511
|
+
to commit. The current draft in the editor is preserved if the
|
|
20512
|
+
user closes without restoring. -->
|
|
20513
|
+
<div class="modal-overlay" id="prompt-history-modal">
|
|
20514
|
+
<div class="modal" style="max-width:720px;width:96vw;max-height:88vh;display:flex;flex-direction:column">
|
|
20515
|
+
<div class="modal-header">
|
|
20516
|
+
<h3>Prompt history</h3>
|
|
20517
|
+
<button class="btn-ghost btn-sm" onclick="closePromptHistory()">×</button>
|
|
20518
|
+
</div>
|
|
20519
|
+
<div class="modal-body" style="padding:0;flex:1;min-height:0;overflow-y:auto">
|
|
20520
|
+
<div id="prompt-history-list" style="padding:18px"></div>
|
|
20521
|
+
</div>
|
|
20522
|
+
<div class="modal-footer">
|
|
20523
|
+
<span style="flex:1;font-size:11px;color:var(--text-muted)">Restore copies a version into the editor. You still need to click <strong>Save Changes</strong> to commit it.</span>
|
|
20524
|
+
<button onclick="closePromptHistory()">Close</button>
|
|
20525
|
+
</div>
|
|
20526
|
+
</div>
|
|
20527
|
+
</div>
|
|
20528
|
+
|
|
20453
20529
|
<!-- ═══ MCP Server Edit Modal — PRD Phase 2.1 ═══ -->
|
|
20454
20530
|
<div class="modal-overlay" id="mcp-edit-modal">
|
|
20455
20531
|
<div class="modal" style="max-width:640px;width:96vw">
|
|
@@ -22656,6 +22732,24 @@ async function toggleCronJob(name) {
|
|
|
22656
22732
|
} catch(e) { toast('Failed to toggle: ' + e, 'error'); }
|
|
22657
22733
|
}
|
|
22658
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
|
+
|
|
22659
22753
|
async function toggleScheduledWorkflow(id) {
|
|
22660
22754
|
try {
|
|
22661
22755
|
var r = await apiFetch('/api/builder/workflows/' + encodeURIComponent(id));
|
|
@@ -23637,6 +23731,14 @@ function renderScheduledTaskCard(task) {
|
|
|
23637
23731
|
badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
|
|
23638
23732
|
badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
|
|
23639
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>';
|
|
23640
23742
|
return '<div class="' + cardCls + '" style="' + style + '">'
|
|
23641
23743
|
+ '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
|
|
23642
23744
|
+ '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
|
|
@@ -23648,7 +23750,7 @@ function renderScheduledTaskCard(task) {
|
|
|
23648
23750
|
+ '<div class="task-card-badges">' + badges + '</div>'
|
|
23649
23751
|
+ '<div class="task-card-actions">'
|
|
23650
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>'
|
|
23651
|
-
+
|
|
23753
|
+
+ runOrCancelBtn
|
|
23652
23754
|
+ '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
|
|
23653
23755
|
+ '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
|
|
23654
23756
|
+ '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
|
|
@@ -25965,6 +26067,87 @@ async function loadCronPreviewIntoTab(jobName) {
|
|
|
25965
26067
|
// Mark the preview as stale (call after save so next tab visit refetches).
|
|
25966
26068
|
function markCronPreviewDirty() { _cronPreviewLoadedFor = null; }
|
|
25967
26069
|
|
|
26070
|
+
// ── PRD §11 / 1.18.90: Prompt history viewer ────────────────────────
|
|
26071
|
+
// Reads /api/cron/:job/prompt-history (already populated on every PUT
|
|
26072
|
+
// since 1.18.x). Each version is the OLD prompt at the moment of a save,
|
|
26073
|
+
// so rolling back means picking the version that was AFTER the change
|
|
26074
|
+
// you want to undo (or the current saved value if you want the most
|
|
26075
|
+
// recent committed state).
|
|
26076
|
+
async function openPromptHistory() {
|
|
26077
|
+
if (!editingCronJob) {
|
|
26078
|
+
toast('Save the task first, then prompt history will be available.', 'info');
|
|
26079
|
+
return;
|
|
26080
|
+
}
|
|
26081
|
+
var modal = document.getElementById('prompt-history-modal');
|
|
26082
|
+
var list = document.getElementById('prompt-history-list');
|
|
26083
|
+
if (!modal || !list) return;
|
|
26084
|
+
list.innerHTML = '<div style="padding:24px;color:var(--text-muted);text-align:center">Loading prompt history…</div>';
|
|
26085
|
+
modal.classList.add('show');
|
|
26086
|
+
try {
|
|
26087
|
+
var r = await apiFetch('/api/cron/' + encodeURIComponent(editingCronJob) + '/prompt-history');
|
|
26088
|
+
var d = await r.json();
|
|
26089
|
+
var versions = (d && d.versions) || [];
|
|
26090
|
+
if (versions.length === 0) {
|
|
26091
|
+
list.innerHTML = '<div style="padding:36px 24px;color:var(--text-muted);text-align:center;line-height:1.6">'
|
|
26092
|
+
+ '<div style="font-weight:500;color:var(--text-secondary);margin-bottom:8px">No prior prompt versions yet</div>'
|
|
26093
|
+
+ '<div style="font-size:12px">A version is recorded each time you save a different prompt. The first edit you make to this task will create version 1.</div>'
|
|
26094
|
+
+ '</div>';
|
|
26095
|
+
return;
|
|
26096
|
+
}
|
|
26097
|
+
var html = '<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Newest first. Each entry is the prompt as it was BEFORE that save — restoring rolls the editor back to the version that was active before that change landed.</div>';
|
|
26098
|
+
for (var i = 0; i < versions.length; i++) {
|
|
26099
|
+
var v = versions[i];
|
|
26100
|
+
var ts = v.timestamp ? new Date(v.timestamp).toLocaleString() : 'unknown time';
|
|
26101
|
+
var who = v.changedBy || 'dashboard';
|
|
26102
|
+
var prompt = String(v.prompt || '');
|
|
26103
|
+
var preview = prompt.slice(0, 200).replace(/\\s+/g, ' ');
|
|
26104
|
+
var rowId = 'pmt-hist-' + i;
|
|
26105
|
+
var promptB64 = btoa(unescape(encodeURIComponent(prompt))); // safe transport for restore
|
|
26106
|
+
html += '<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:10px">';
|
|
26107
|
+
html += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">';
|
|
26108
|
+
html += '<span style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">v' + esc(v.version || (versions.length - i)) + '</span>';
|
|
26109
|
+
html += '<span style="font-size:12px;color:var(--text-primary)">' + esc(ts) + '</span>';
|
|
26110
|
+
html += '<span style="font-size:11px;color:var(--text-muted)">· by ' + esc(who) + '</span>';
|
|
26111
|
+
html += '<span style="flex:1"></span>';
|
|
26112
|
+
html += '<button class="btn-sm" onclick="document.getElementById(\\x27' + rowId + '\\x27).style.display=document.getElementById(\\x27' + rowId + '\\x27).style.display===\\x27none\\x27?\\x27block\\x27:\\x27none\\x27" style="font-size:11px;padding:3px 8px">Show full</button>';
|
|
26113
|
+
html += '<button class="btn-sm btn-primary" onclick="restorePromptVersion(\\x27' + promptB64 + '\\x27)" style="font-size:11px;padding:3px 10px">Restore</button>';
|
|
26114
|
+
html += '</div>';
|
|
26115
|
+
html += '<div style="font-size:12px;color:var(--text-secondary);line-height:1.5;font-family:\\x27JetBrains Mono\\x27,monospace">' + esc(preview) + (prompt.length > 200 ? '…' : '') + '</div>';
|
|
26116
|
+
html += '<pre id="' + rowId + '" style="display:none;margin-top:10px;font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;background:var(--bg-tertiary);border:1px solid var(--border);padding:10px;border-radius:6px;white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto">' + esc(prompt) + '</pre>';
|
|
26117
|
+
html += '</div>';
|
|
26118
|
+
}
|
|
26119
|
+
list.innerHTML = html;
|
|
26120
|
+
} catch (e) {
|
|
26121
|
+
list.innerHTML = '<div style="padding:24px;color:var(--red)">Failed to load history: ' + esc(String(e)) + '</div>';
|
|
26122
|
+
}
|
|
26123
|
+
}
|
|
26124
|
+
|
|
26125
|
+
function closePromptHistory() {
|
|
26126
|
+
var modal = document.getElementById('prompt-history-modal');
|
|
26127
|
+
if (modal) modal.classList.remove('show');
|
|
26128
|
+
}
|
|
26129
|
+
|
|
26130
|
+
// Restore copies the old prompt back into the editor's textarea. Doesn't
|
|
26131
|
+
// auto-save — the user must click Save Changes to commit, which keeps the
|
|
26132
|
+
// dirty-guard semantics intact and gives them a chance to back out.
|
|
26133
|
+
function restorePromptVersion(promptB64) {
|
|
26134
|
+
try {
|
|
26135
|
+
var prompt = decodeURIComponent(escape(atob(promptB64)));
|
|
26136
|
+
var ta = document.getElementById('cron-prompt');
|
|
26137
|
+
if (!ta) return;
|
|
26138
|
+
if (ta.value !== prompt && ta.value.trim() && !confirm('Replace the current prompt with this restored version? Your unsaved edits will be discarded.')) {
|
|
26139
|
+
return;
|
|
26140
|
+
}
|
|
26141
|
+
ta.value = prompt;
|
|
26142
|
+
// Switch to the Prompt tab so the user sees what just changed.
|
|
26143
|
+
if (typeof switchCronConfigTab === 'function') switchCronConfigTab('prompt');
|
|
26144
|
+
closePromptHistory();
|
|
26145
|
+
toast('Prompt restored. Click Save Changes to commit it.', 'info');
|
|
26146
|
+
} catch (e) {
|
|
26147
|
+
toast('Failed to restore: ' + String(e), 'error');
|
|
26148
|
+
}
|
|
26149
|
+
}
|
|
26150
|
+
|
|
25968
26151
|
// PRD Phase 1.3a: inner Configure pane tabs. Sets the data-active-config-tab
|
|
25969
26152
|
// attribute on #cron-tab-configure; CSS handles section visibility via
|
|
25970
26153
|
// data-config-tab attributes on each section. JS-light by design.
|
|
@@ -26260,6 +26443,9 @@ function openCreateCronModal(agentSlug) {
|
|
|
26260
26443
|
if (lastRunBtn) lastRunBtn.setAttribute('disabled', 'disabled');
|
|
26261
26444
|
var runOnceBtn = document.getElementById('cron-run-once-btn');
|
|
26262
26445
|
if (runOnceBtn) runOnceBtn.style.display = 'none';
|
|
26446
|
+
// PRD §11 / 1.18.90: prompt history link only meaningful for existing tasks.
|
|
26447
|
+
var historyLinkNew = document.getElementById('cron-prompt-history-link');
|
|
26448
|
+
if (historyLinkNew) historyLinkNew.style.display = 'none';
|
|
26263
26449
|
var host = document.getElementById('cron-legacy-banner-host');
|
|
26264
26450
|
if (host) host.innerHTML = '';
|
|
26265
26451
|
// Reset the "Use a cron expression" link in case it was hidden last time.
|
|
@@ -26360,6 +26546,9 @@ function openEditCronModal(jobName) {
|
|
|
26360
26546
|
// Show "Run task once" only for saved tasks.
|
|
26361
26547
|
var runOnceBtnEdit = document.getElementById('cron-run-once-btn');
|
|
26362
26548
|
if (runOnceBtnEdit) runOnceBtnEdit.style.display = '';
|
|
26549
|
+
// 1.18.90: surface prompt history link for saved tasks.
|
|
26550
|
+
var historyLinkEdit = document.getElementById('cron-prompt-history-link');
|
|
26551
|
+
if (historyLinkEdit) historyLinkEdit.style.display = '';
|
|
26363
26552
|
// Render the most recent run from the loaded job into the Last run tab so
|
|
26364
26553
|
// the user sees something the moment they switch to it (rather than a
|
|
26365
26554
|
// dead empty pane). The pane updates live when Run task once fires.
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -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
|
package/dist/gateway/router.js
CHANGED
|
@@ -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
|
/**
|