feat(tasks): retry keeps the failed task (marked "Retried") + jumps to new run

Previously retry silently created a new task, left the failed one in place with
no link, and didn't take you to the retry. Now:

- The new task is linked to the failed one via `retryOf`; the server stamps the
  original `retriedBy` and KEEPS it (its log is the failure record — never
  deleted), so history survives and there's no confusing bare duplicate.
- The failed row shows a muted "↻ Retried" pill, hides its now-stale Retry
  button, and offers "View retry" to jump to the new run. The new run shows a
  "Retry of:" backlink to the original.
- After retrying, the UI selects the new task and opens its live log so you
  follow the retry instead of hunting for it.

retryOf rides through the existing POST /api/tasks (no new endpoint); createTask
gained an optional extra-body arg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-17 23:23:48 +01:00
parent 692e4a408a
commit 2005f2c742
5 changed files with 59 additions and 13 deletions

View File

@ -301,7 +301,7 @@ router.get('/:id', requireAuth, async (req, res) => {
// Create a task.
router.post('/', requireAuth, async (req, res) => {
try {
const { command, type = 'custom', app = null, config = '' } = req.body || {};
const { command, type = 'custom', app = null, config = '', retryOf = null } = req.body || {};
if (typeof command !== 'string' || !command.trim()) {
return res.status(400).json({ error: '`command` is required' });
}
@ -318,7 +318,10 @@ router.post('/', requireAuth, async (req, res) => {
completedAt: null,
heartbeatAt: null,
exitCode: null,
errorMessage: null
errorMessage: null,
// Set when this task is a retry of another — links the two so the UI can
// mark the original "Retried" and jump between them.
retryOf: (typeof retryOf === 'string' && isValidTaskId(retryOf)) ? retryOf : null
};
await writeTaskAtomic(id, task);
pokeFifo(id);
@ -327,6 +330,19 @@ router.post('/', requireAuth, async (req, res) => {
// fallback for this task until it terminates so a missed inotify
// event can't strand it as "running" in the UI.
armActiveTaskPoll(id);
// If this is a retry, stamp the original with `retriedBy` — we KEEP the
// failed task (its log is the failure record; we never delete it), just
// mark it as superseded. Only a terminal failed/cancelled task is stamped.
if (task.retryOf) {
try {
const orig = await readTask(task.retryOf);
if (orig && (orig.status === 'failed' || orig.status === 'cancelled')) {
const marked = { ...orig, retriedBy: id };
await writeTaskAtomic(task.retryOf, marked);
sseBroadcast('task.upsert', marked);
}
} catch (_) { /* original gone — nothing to link */ }
}
res.status(201).json(task);
} catch (err) {
res.status(500).json({ error: err.message });

View File

@ -343,6 +343,16 @@
color: #fca5a5;
}
/* "Retried" tag a muted, neutral pill sitting next to a failed status to show
the failure was superseded by a new run (the failed task is kept, not deleted,
as the failure record). Deliberately calm so it doesn't compete with FAILED. */
.task-status.task-retried {
background: rgba(148, 163, 184, 0.16);
border: 1px solid rgba(148, 163, 184, 0.45);
color: #cbd5e1;
animation: none;
}
.task-command {
/* Bright mint to match the .status-running / .status-completed pills.
The theme's --status-success (#28a745) reads muddy olive on nebula

View File

@ -9,24 +9,31 @@ Object.assign(TasksManager.prototype, {
throw new Error('Task not found');
}
// Create a new task with the same command
// Create a new task with the same command, linked to the failed one via
// `retryOf`. The server keeps the failed task (its log is the failure
// record) and stamps it `retriedBy` so the UI can mark it "Retried".
const newTask = await this.taskManager.createTask(
task.command,
task.type,
task.app,
task.config
task.config,
{ retryOf: task.id }
);
//// // console.log(`✅ Task retried: ${newTask.id}`);
// Refresh tasks to show the new one
// Refresh tasks to show both the new run and the now-"Retried" original,
// then jump to the new run and open its live log — the user follows the
// retry instead of hunting for it.
await this.loadTasks();
if (typeof this.selectTask === 'function') {
// Row exists after the re-render; select on the next frame to be safe.
requestAnimationFrame(() => this.selectTask(newTask.id));
}
if (window.notificationSystem) {
// Use the source task's type icon — retrying a backup shows 💾 etc.
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
window.notificationSystem.show('Task retried — following the new run.', 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error retrying task:', error);

View File

@ -175,11 +175,12 @@ Object.assign(TasksManager.prototype, {
${this.renderTaskIcons(task)}
<span class="task-title">${this.formatCommandForUser(task)}</span>
<span class="task-status ${statusClass}">${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}</span>
${task.retriedBy ? '<span class="task-status task-retried" title="Superseded by a newer run">↻ Retried</span>' : ''}
<span class="task-time">${timeAgo}</span>
${executionTime ? `<span class="task-duration">⏱️ ${executionTime}</span>` : ''}
</div>
<div class="task-actions">
${isFailed ? `
${isFailed && !task.retriedBy ? `
<button class="task-btn retry" onclick="event.stopPropagation(); retryTask('${task.id}')" title="Retry Task">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
@ -187,6 +188,14 @@ Object.assign(TasksManager.prototype, {
Retry
</button>
` : ''}
${task.retriedBy ? `
<button class="task-btn" onclick="event.stopPropagation(); window.tasksManager && tasksManager.selectTask('${task.retriedBy}')" title="View the retry of this task">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
<span class="task-btn-label">View retry</span>
</button>
` : ''}
<button class="task-btn toggle-details" onclick="event.stopPropagation(); toggleTaskDetails('${task.id}')" title="Toggle Task Details">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6,9 12,15 18,9"></polyline>
@ -218,6 +227,10 @@ Object.assign(TasksManager.prototype, {
<div class="meta-item">
<strong>Type:</strong> ${task.type || 'unknown'}
</div>
${task.retryOf ? `
<div class="meta-item">
<strong>Retry of:</strong> <a href="#" onclick="event.preventDefault(); event.stopPropagation(); window.tasksManager && tasksManager.selectTask('${task.retryOf}')" class="task-id-link">${task.retryOf}</a>
</div>` : ''}
<div class="meta-item">
<strong>App:</strong> ${task.app ? `<a href="/app/${task.app}" class="task-app-link" data-app-name="${task.app}">${task.app}</a>` : 'system'}
</div>

View File

@ -15,11 +15,11 @@ class TaskManager {
* within milliseconds but the POST response also contains the task,
* so callers that need the id immediately can use the return value.
*/
async createTask(command, type = 'custom', app = null, config = '') {
async createTask(command, type = 'custom', app = null, config = '', extra = {}) {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, type, app, config })
body: JSON.stringify({ command, type, app, config, ...extra })
});
if (!res.ok) throw new Error(`Failed to create task: HTTP ${res.status}`);
const task = await res.json();