@hasna/loops 0.4.5 → 0.4.6

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/index.js CHANGED
@@ -1491,11 +1491,14 @@ function ensurePrivateStorePath(file) {
1491
1491
  class Store {
1492
1492
  db;
1493
1493
  rootDir;
1494
+ memoryRootDir;
1494
1495
  constructor(path) {
1495
1496
  const file = path ?? dbPath();
1496
1497
  if (file !== ":memory:")
1497
1498
  ensurePrivateStorePath(file);
1498
1499
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1500
+ if (file === ":memory:")
1501
+ this.memoryRootDir = this.rootDir;
1499
1502
  this.db = new Database(file);
1500
1503
  this.db.exec("PRAGMA foreign_keys = ON;");
1501
1504
  this.db.exec("PRAGMA busy_timeout = 5000;");
@@ -1948,9 +1951,12 @@ class Store {
1948
1951
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1949
1952
  if (rows.length === 0)
1950
1953
  throw new LoopNotFoundError(idOrName);
1951
- if (rows.length > 1)
1954
+ if (rows.length === 1)
1955
+ return rowToLoop(rows[0]);
1956
+ const active = this.db.query("SELECT * FROM loops WHERE name = ? AND archived_at IS NULL ORDER BY created_at DESC LIMIT 2").all(idOrName);
1957
+ if (active.length !== 1)
1952
1958
  throw new AmbiguousNameError(idOrName);
1953
- return rowToLoop(rows[0]);
1959
+ return rowToLoop(active[0]);
1954
1960
  }
1955
1961
  requireLoop(idOrName) {
1956
1962
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2291,6 +2297,7 @@ class Store {
2291
2297
  return this.transact(() => {
2292
2298
  const loop = this.requireLoop(idOrName);
2293
2299
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2300
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2294
2301
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2295
2302
  return res.changes > 0;
2296
2303
  });
@@ -2775,7 +2782,10 @@ class Store {
2775
2782
  return rowToGoal(row);
2776
2783
  }
2777
2784
  if (context.sourceType && context.sourceId) {
2778
- const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
2785
+ const terminalPlaceholders = GOAL_TERMINAL.map(() => "?").join(", ");
2786
+ const row = this.db.query(`SELECT * FROM goals
2787
+ WHERE source_type = ? AND source_id = ? AND status NOT IN (${terminalPlaceholders})
2788
+ ORDER BY created_at DESC LIMIT 1`).get(context.sourceType, context.sourceId, ...GOAL_TERMINAL);
2779
2789
  if (row)
2780
2790
  return rowToGoal(row);
2781
2791
  }
@@ -3195,6 +3205,41 @@ class Store {
3195
3205
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3196
3206
  return run;
3197
3207
  }
3208
+ recordWorkflowStepProgress(workflowRunId, stepId, progress, opts = {}) {
3209
+ const now = (opts.now ?? new Date).toISOString();
3210
+ this.db.exec("BEGIN IMMEDIATE");
3211
+ try {
3212
+ const res = this.db.query(`UPDATE workflow_step_runs
3213
+ SET stdout=COALESCE($stdout, stdout),
3214
+ stderr=COALESCE($stderr, stderr),
3215
+ updated_at=$updated
3216
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3217
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3218
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3219
+ ))`).run({
3220
+ $workflowRunId: workflowRunId,
3221
+ $stepId: stepId,
3222
+ $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3223
+ $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3224
+ $updated: now,
3225
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3226
+ $now: now
3227
+ });
3228
+ if (res.changes === 1) {
3229
+ this.appendWorkflowEvent(workflowRunId, "step_progress", stepId, progress.payload);
3230
+ }
3231
+ this.db.exec("COMMIT");
3232
+ } catch (error) {
3233
+ try {
3234
+ this.db.exec("ROLLBACK");
3235
+ } catch {}
3236
+ throw error;
3237
+ }
3238
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3239
+ if (!run)
3240
+ throw new Error(`workflow step run not found after progress update: ${workflowRunId}/${stepId}`);
3241
+ return run;
3242
+ }
3198
3243
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3199
3244
  return this.transact(() => {
3200
3245
  const now = nowIso();
@@ -3632,7 +3677,8 @@ class Store {
3632
3677
  AND ($daemonLeaseId IS NULL OR EXISTS (
3633
3678
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3634
3679
  ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3635
- duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3680
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3681
+ WHERE id=$id AND status='running'`).run(params);
3636
3682
  const run = this.getRun(id);
3637
3683
  if (!run)
3638
3684
  throw new Error(`run not found after finalize: ${id}`);
@@ -4160,12 +4206,18 @@ class Store {
4160
4206
  }
4161
4207
  close() {
4162
4208
  this.db.close();
4209
+ if (this.memoryRootDir) {
4210
+ try {
4211
+ rmSync2(this.memoryRootDir, { recursive: true, force: true });
4212
+ } catch {}
4213
+ this.memoryRootDir = undefined;
4214
+ }
4163
4215
  }
4164
4216
  }
4165
4217
  // package.json
4166
4218
  var package_default = {
4167
4219
  name: "@hasna/loops",
4168
- version: "0.4.5",
4220
+ version: "0.4.6",
4169
4221
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4170
4222
  type: "module",
4171
4223
  main: "dist/index.js",
@@ -4624,10 +4676,7 @@ async function stopDaemon(opts = {}) {
4624
4676
  const store = new Store(join4(dirname3(path), "loops.db"));
4625
4677
  try {
4626
4678
  const lease = store.getDaemonLease();
4627
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
4628
- removePid(path);
4629
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
4630
- }
4679
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
4631
4680
  try {
4632
4681
  process.kill(state.pid, "SIGTERM");
4633
4682
  } catch {
@@ -4647,11 +4696,14 @@ async function stopDaemon(opts = {}) {
4647
4696
  } catch {}
4648
4697
  await sleep(150);
4649
4698
  removePid(path);
4650
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4651
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4652
- sleep,
4653
- graceMs: opts.reapGraceMs
4654
- });
4699
+ let reapedPgids = [];
4700
+ if (leaseVerified && lease) {
4701
+ const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4702
+ reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4703
+ sleep,
4704
+ graceMs: opts.reapGraceMs
4705
+ });
4706
+ }
4655
4707
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
4656
4708
  } finally {
4657
4709
  store.close();
@@ -4989,7 +5041,12 @@ function notifySpawn(pid, opts) {
4989
5041
  if (!pid)
4990
5042
  return;
4991
5043
  opts.onSpawn?.(pid);
4992
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
5044
+ const startedMs = processStartTimeMs(pid);
5045
+ opts.onSpawnProcess?.({
5046
+ pid,
5047
+ pgid: pid,
5048
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
5049
+ });
4993
5050
  }
4994
5051
  function shellQuote(value) {
4995
5052
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5344,6 +5401,13 @@ function numberField(value, key) {
5344
5401
  function codewithAgentStatus(readJson) {
5345
5402
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5346
5403
  }
5404
+ function codewithAgentLastEventSeq(readJson) {
5405
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5406
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5407
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5408
+ return Math.max(agentSeq, snapshotSeq);
5409
+ return agentSeq ?? snapshotSeq;
5410
+ }
5347
5411
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5348
5412
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5349
5413
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5374,6 +5438,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5374
5438
  events
5375
5439
  }, null, 2);
5376
5440
  }
5441
+ function codewithAgentProgress(readJson) {
5442
+ const agent = recordField(readJson, "agent");
5443
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5444
+ return {
5445
+ provider: "codewith",
5446
+ agentId: stringField(agent, "agentId"),
5447
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5448
+ summary: stringField(statusSnapshot, "summary"),
5449
+ statusReason: stringField(agent, "statusReason"),
5450
+ threadId: stringField(agent, "threadId"),
5451
+ rolloutPath: stringField(agent, "rolloutPath"),
5452
+ pid: numberField(agent, "pid"),
5453
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5454
+ };
5455
+ }
5377
5456
  function sleep(ms) {
5378
5457
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5379
5458
  }
@@ -5408,6 +5487,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5408
5487
  if (!agentId) {
5409
5488
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5410
5489
  }
5490
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5411
5491
  const stopAgent = async () => {
5412
5492
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5413
5493
  cwd: spec.cwd,
@@ -5450,10 +5530,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5450
5530
  });
5451
5531
  }
5452
5532
  const status = codewithAgentStatus(lastReadJson);
5453
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5533
+ const fingerprint = JSON.stringify({
5534
+ status,
5535
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5536
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5537
+ });
5454
5538
  if (fingerprint !== lastFingerprint) {
5455
5539
  lastFingerprint = fingerprint;
5456
5540
  lastProgressAt = Date.now();
5541
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5457
5542
  }
5458
5543
  if (status === "completed" || status === "failed" || status === "cancelled") {
5459
5544
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6379,7 +6464,7 @@ ${failure.evidence.stderr}` : undefined
6379
6464
 
6380
6465
  `);
6381
6466
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
6382
- const tags = ["bug", "openloops", "loop-health", failure.classification];
6467
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
6383
6468
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
6384
6469
  return {
6385
6470
  title,
@@ -6394,7 +6479,7 @@ ${failure.evidence.stderr}` : undefined
6394
6479
  comment: ["todos", "comment", "<task-id>", description]
6395
6480
  },
6396
6481
  futureNativeUpsert: {
6397
- command: "todos upsert",
6482
+ command: "todos task upsert",
6398
6483
  fields: {
6399
6484
  title,
6400
6485
  description,
@@ -7089,173 +7174,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
7089
7174
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
7090
7175
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
7091
7176
  }
7177
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
7178
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
7179
+ try {
7180
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
7181
+ } catch {}
7182
+ }
7092
7183
  const ordered = workflowExecutionOrder(workflow);
7093
7184
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
7094
7185
  let blockingError;
7095
7186
  let terminalStatus = "succeeded";
7096
- for (const step of ordered) {
7097
- if (store.isWorkflowRunTerminal(run.id)) {
7098
- terminalStatus = store.requireWorkflowRun(run.id).status;
7099
- blockingError = "workflow run was cancelled";
7100
- break;
7101
- }
7102
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7103
- if (pendingTimeout) {
7104
- terminalStatus = "timed_out";
7105
- blockingError = pendingTimeout;
7106
- break;
7107
- }
7108
- const existing = store.getWorkflowStepRun(run.id, step.id);
7109
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
7110
- continue;
7111
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
7112
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
7113
- const dependencyStep = byId.get(dependencyId);
7114
- if (dependencyRun?.status === "succeeded")
7115
- return false;
7116
- return !dependencyStep?.continueOnFailure;
7117
- });
7118
- if (blockedBy) {
7119
- opts.beforePersist?.();
7120
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
7121
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
7122
- daemonLeaseId: opts.daemonLeaseId
7123
- });
7187
+ try {
7188
+ for (const step of ordered) {
7189
+ if (store.isWorkflowRunTerminal(run.id)) {
7190
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7191
+ blockingError = "workflow run was cancelled";
7192
+ break;
7193
+ }
7194
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7195
+ if (pendingTimeout) {
7196
+ terminalStatus = "timed_out";
7197
+ blockingError = pendingTimeout;
7198
+ break;
7199
+ }
7200
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7201
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
7202
+ continue;
7203
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
7204
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
7205
+ const dependencyStep = byId.get(dependencyId);
7206
+ if (dependencyRun?.status === "succeeded")
7207
+ return false;
7208
+ return !dependencyStep?.continueOnFailure;
7209
+ });
7210
+ if (blockedBy) {
7211
+ opts.beforePersist?.();
7212
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
7213
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
7214
+ daemonLeaseId: opts.daemonLeaseId
7215
+ });
7216
+ continue;
7217
+ }
7218
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
7219
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
7220
+ terminalStatus = "failed";
7124
7221
  continue;
7125
7222
  }
7126
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
7127
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
7128
- terminalStatus = "failed";
7129
- continue;
7130
- }
7131
- opts.beforePersist?.();
7132
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7133
- if (startedStep.status !== "running") {
7134
- terminalStatus = "failed";
7135
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
7136
- break;
7137
- }
7138
- const stepContext = goalExecutionContext({
7139
- loop: opts.loop,
7140
- loopRun: opts.loopRun,
7141
- scheduledFor: opts.scheduledFor,
7142
- workflow,
7143
- workflowRunId: run.id,
7144
- workflowStepId: step.id
7145
- });
7146
- let result;
7147
- const controller = new AbortController;
7148
- const externalAbort = () => controller.abort();
7149
- if (opts.signal?.aborted)
7150
- controller.abort();
7151
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
7152
- const cancelTimer = setInterval(() => {
7153
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
7223
+ opts.beforePersist?.();
7224
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7225
+ if (startedStep.status !== "running") {
7226
+ terminalStatus = "failed";
7227
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
7228
+ break;
7229
+ }
7230
+ const stepContext = goalExecutionContext({
7231
+ loop: opts.loop,
7232
+ loopRun: opts.loopRun,
7233
+ scheduledFor: opts.scheduledFor,
7234
+ workflow,
7235
+ workflowRunId: run.id,
7236
+ workflowStepId: step.id
7237
+ });
7238
+ let result;
7239
+ const controller = new AbortController;
7240
+ const externalAbort = () => controller.abort();
7241
+ if (opts.signal?.aborted)
7154
7242
  controller.abort();
7155
- }, opts.cancelPollMs ?? 500);
7156
- cancelTimer.unref();
7157
- try {
7158
- if (step.goal) {
7159
- result = await runGoal(store, step.goal, {
7160
- ...opts,
7161
- model: opts.goalModel,
7162
- target: targetWithStepAccount(step),
7163
- signal: controller.signal,
7164
- context: stepContext
7165
- });
7166
- } else {
7167
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7168
- ...opts,
7169
- machine: opts.machine ?? opts.loop?.machine,
7170
- signal: controller.signal,
7171
- onSpawn: (pid) => {
7172
- opts.beforePersist?.();
7173
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7174
- opts.onSpawn?.(pid);
7175
- }
7243
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
7244
+ const cancelTimer = setInterval(() => {
7245
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
7246
+ controller.abort();
7247
+ }, opts.cancelPollMs ?? 500);
7248
+ cancelTimer.unref();
7249
+ try {
7250
+ if (step.goal) {
7251
+ result = await runGoal(store, step.goal, {
7252
+ ...opts,
7253
+ model: opts.goalModel,
7254
+ target: targetWithStepAccount(step),
7255
+ signal: controller.signal,
7256
+ context: stepContext
7257
+ });
7258
+ } else {
7259
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7260
+ ...opts,
7261
+ machine: opts.machine ?? opts.loop?.machine,
7262
+ signal: controller.signal,
7263
+ onAgentProgress: (progress) => {
7264
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
7265
+ opts.beforePersist?.();
7266
+ store.recordWorkflowStepProgress(run.id, step.id, {
7267
+ stdout,
7268
+ payload: progress
7269
+ }, {
7270
+ daemonLeaseId: opts.daemonLeaseId
7271
+ });
7272
+ opts.onAgentProgress?.(progress);
7273
+ },
7274
+ onSpawn: (pid) => {
7275
+ opts.beforePersist?.();
7276
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7277
+ opts.onSpawn?.(pid);
7278
+ }
7279
+ });
7280
+ }
7281
+ } catch (error) {
7282
+ const finishedAt2 = nowIso();
7283
+ result = {
7284
+ status: "failed",
7285
+ stdout: "",
7286
+ stderr: "",
7287
+ error: error instanceof Error ? error.message : String(error),
7288
+ startedAt: startedStep.startedAt ?? finishedAt2,
7289
+ finishedAt: finishedAt2,
7290
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7291
+ };
7292
+ } finally {
7293
+ clearInterval(cancelTimer);
7294
+ opts.signal?.removeEventListener("abort", externalAbort);
7295
+ }
7296
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7297
+ if (timeoutMessage && result.status === "failed") {
7298
+ result = { ...result, status: "timed_out", error: timeoutMessage };
7299
+ }
7300
+ if (store.isWorkflowRunTerminal(run.id)) {
7301
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7302
+ blockingError = "workflow run was cancelled";
7303
+ break;
7304
+ }
7305
+ opts.beforePersist?.();
7306
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7307
+ if (blockedExit) {
7308
+ store.finalizeWorkflowStepRun(run.id, step.id, {
7309
+ status: "skipped",
7310
+ finishedAt: result.finishedAt,
7311
+ durationMs: result.durationMs,
7312
+ stdout: result.stdout,
7313
+ stderr: result.stderr,
7314
+ exitCode: result.exitCode,
7315
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7316
+ }, {
7317
+ daemonLeaseId: opts.daemonLeaseId
7176
7318
  });
7319
+ continue;
7177
7320
  }
7178
- } catch (error) {
7179
- const finishedAt2 = nowIso();
7180
- result = {
7181
- status: "failed",
7182
- stdout: "",
7183
- stderr: "",
7184
- error: error instanceof Error ? error.message : String(error),
7185
- startedAt: startedStep.startedAt ?? finishedAt2,
7186
- finishedAt: finishedAt2,
7187
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7188
- };
7189
- } finally {
7190
- clearInterval(cancelTimer);
7191
- opts.signal?.removeEventListener("abort", externalAbort);
7192
- }
7193
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7194
- if (timeoutMessage && result.status === "failed") {
7195
- result = { ...result, status: "timed_out", error: timeoutMessage };
7196
- }
7197
- if (store.isWorkflowRunTerminal(run.id)) {
7198
- terminalStatus = store.requireWorkflowRun(run.id).status;
7199
- blockingError = "workflow run was cancelled";
7200
- break;
7201
- }
7202
- opts.beforePersist?.();
7203
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7204
- if (blockedExit) {
7205
7321
  store.finalizeWorkflowStepRun(run.id, step.id, {
7206
- status: "skipped",
7322
+ status: result.status,
7207
7323
  finishedAt: result.finishedAt,
7208
7324
  durationMs: result.durationMs,
7209
7325
  stdout: result.stdout,
7210
7326
  stderr: result.stderr,
7211
7327
  exitCode: result.exitCode,
7212
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7328
+ error: result.error
7213
7329
  }, {
7214
7330
  daemonLeaseId: opts.daemonLeaseId
7215
7331
  });
7216
- continue;
7332
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
7333
+ terminalStatus = result.status;
7334
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7335
+ break;
7336
+ }
7217
7337
  }
7218
- store.finalizeWorkflowStepRun(run.id, step.id, {
7219
- status: result.status,
7220
- finishedAt: result.finishedAt,
7221
- durationMs: result.durationMs,
7222
- stdout: result.stdout,
7223
- stderr: result.stderr,
7224
- exitCode: result.exitCode,
7225
- error: result.error
7338
+ if (terminalStatus !== "succeeded") {
7339
+ for (const step of ordered) {
7340
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7341
+ if (existing?.status === "pending" || existing?.status === "running") {
7342
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7343
+ daemonLeaseId: opts.daemonLeaseId
7344
+ });
7345
+ }
7346
+ }
7347
+ }
7348
+ const finishedAt = nowIso();
7349
+ if (store.isWorkflowRunTerminal(run.id)) {
7350
+ const terminalRun = store.requireWorkflowRun(run.id);
7351
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7352
+ }
7353
+ opts.beforePersist?.();
7354
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7355
+ finishedAt,
7356
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7357
+ error: blockingError
7226
7358
  }, {
7227
7359
  daemonLeaseId: opts.daemonLeaseId
7228
7360
  });
7229
- if (result.status !== "succeeded" && !step.continueOnFailure) {
7230
- terminalStatus = result.status;
7231
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7232
- break;
7233
- }
7234
- }
7235
- if (terminalStatus !== "succeeded") {
7236
- for (const step of ordered) {
7237
- const existing = store.getWorkflowStepRun(run.id, step.id);
7238
- if (existing?.status === "pending" || existing?.status === "running") {
7239
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7361
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7362
+ } catch (error) {
7363
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
7364
+ throw error;
7365
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
7366
+ throw error;
7367
+ const message = error instanceof Error ? error.message : String(error);
7368
+ const finishedAt = nowIso();
7369
+ try {
7370
+ if (!store.isWorkflowRunTerminal(run.id)) {
7371
+ store.finalizeWorkflowRun(run.id, "failed", {
7372
+ finishedAt,
7373
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7374
+ error: message
7375
+ }, {
7240
7376
  daemonLeaseId: opts.daemonLeaseId
7241
7377
  });
7242
7378
  }
7243
- }
7244
- }
7245
- const finishedAt = nowIso();
7246
- if (store.isWorkflowRunTerminal(run.id)) {
7247
- const terminalRun = store.requireWorkflowRun(run.id);
7248
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7379
+ } catch {}
7380
+ const current = store.getWorkflowRun(run.id) ?? run;
7381
+ const resultStatus = current.status === "running" ? "failed" : current.status;
7382
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
7249
7383
  }
7250
- opts.beforePersist?.();
7251
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7252
- finishedAt,
7253
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7254
- error: blockingError
7255
- }, {
7256
- daemonLeaseId: opts.daemonLeaseId
7257
- });
7258
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7259
7384
  }
7260
7385
  function preflightWorkflow(workflow, opts = {}) {
7261
7386
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7626,6 +7751,25 @@ function advanceOptions(deps) {
7626
7751
  onRun: deps.onRun
7627
7752
  };
7628
7753
  }
7754
+ var TERMINAL_RUN_STATUSES2 = new Set([
7755
+ "succeeded",
7756
+ "failed",
7757
+ "timed_out",
7758
+ "abandoned",
7759
+ "skipped"
7760
+ ]);
7761
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
7762
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
7763
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
7764
+ return;
7765
+ try {
7766
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
7767
+ } catch (error) {
7768
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
7769
+ return;
7770
+ throw error;
7771
+ }
7772
+ }
7629
7773
  async function runSlot(deps, loop, scheduledFor) {
7630
7774
  const now = deps.now?.() ?? new Date;
7631
7775
  deps.beforeRun?.(loop, scheduledFor);
@@ -7652,8 +7796,10 @@ async function runSlot(deps, loop, scheduledFor) {
7652
7796
  return;
7653
7797
  throw error;
7654
7798
  }
7655
- if (!claim)
7799
+ if (!claim) {
7800
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7656
7801
  return;
7802
+ }
7657
7803
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7658
7804
  deps.onRun?.(claim.run);
7659
7805
  const finalRun = await executeClaimedRun({
@@ -7699,8 +7845,10 @@ function claimSlot(deps, loop, scheduledFor) {
7699
7845
  return;
7700
7846
  throw error;
7701
7847
  }
7702
- if (!claim)
7848
+ if (!claim) {
7849
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7703
7850
  return;
7851
+ }
7704
7852
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7705
7853
  deps.onRun?.(claim.run);
7706
7854
  return claim;
@@ -8211,7 +8359,7 @@ var TOOL_REGISTRATIONS = [
8211
8359
  guarded: true,
8212
8360
  annotations: mutationAnnotations({ idempotent: true }),
8213
8361
  inputSchema: { idOrName: loopIdOrNameSchema },
8214
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "paused" })) }))
8362
+ handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "paused" })) }))
8215
8363
  },
8216
8364
  {
8217
8365
  name: "loops_resume",
@@ -8221,7 +8369,15 @@ var TOOL_REGISTRATIONS = [
8221
8369
  guarded: true,
8222
8370
  annotations: mutationAnnotations({ idempotent: true }),
8223
8371
  inputSchema: { idOrName: loopIdOrNameSchema },
8224
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "active" })) }))
8372
+ handler: ({ idOrName }) => withStore((store) => {
8373
+ const loop = store.requireUniqueLoop(idOrName);
8374
+ let nextRunAt = loop.nextRunAt;
8375
+ if (!nextRunAt) {
8376
+ const now = new Date;
8377
+ nextRunAt = computeNextAfter(loop.schedule, now, now);
8378
+ }
8379
+ return { loop: publicLoop(store.updateLoop(loop.id, { status: "active", nextRunAt })) };
8380
+ })
8225
8381
  },
8226
8382
  {
8227
8383
  name: "loops_stop",
@@ -8232,7 +8388,7 @@ var TOOL_REGISTRATIONS = [
8232
8388
  annotations: mutationAnnotations({ idempotent: true }),
8233
8389
  inputSchema: { idOrName: loopIdOrNameSchema },
8234
8390
  handler: ({ idOrName }) => withStore((store) => ({
8235
- loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8391
+ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8236
8392
  }))
8237
8393
  },
8238
8394
  {
@@ -8245,7 +8401,7 @@ var TOOL_REGISTRATIONS = [
8245
8401
  inputSchema: { idOrName: loopIdOrNameSchema },
8246
8402
  handler: ({ idOrName }) => withStore(async (store) => {
8247
8403
  const daemon = daemonStatus(store);
8248
- const result = await runLoopNow({ store, idOrName, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8404
+ const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8249
8405
  return {
8250
8406
  scheduledFor: result.scheduledFor,
8251
8407
  loop: publicLoop(result.loop),
@@ -8948,13 +9104,19 @@ class LoopsClient {
8948
9104
  return this.store.requireLoop(idOrName);
8949
9105
  }
8950
9106
  pause(idOrName) {
8951
- return this.store.updateLoop(this.get(idOrName).id, { status: "paused" });
9107
+ return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "paused" });
8952
9108
  }
8953
9109
  resume(idOrName) {
8954
- return this.store.updateLoop(this.get(idOrName).id, { status: "active" });
9110
+ const loop = this.store.requireUniqueLoop(idOrName);
9111
+ let nextRunAt = loop.nextRunAt;
9112
+ if (!nextRunAt) {
9113
+ const now = new Date;
9114
+ nextRunAt = computeNextAfter(loop.schedule, now, now);
9115
+ }
9116
+ return this.store.updateLoop(loop.id, { status: "active", nextRunAt });
8955
9117
  }
8956
9118
  stop(idOrName) {
8957
- return this.store.updateLoop(this.get(idOrName).id, { status: "stopped", nextRunAt: undefined });
9119
+ return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined });
8958
9120
  }
8959
9121
  archive(idOrName) {
8960
9122
  return this.store.archiveLoop(idOrName);
@@ -8963,7 +9125,7 @@ class LoopsClient {
8963
9125
  return this.store.unarchiveLoop(idOrName);
8964
9126
  }
8965
9127
  delete(idOrName) {
8966
- return this.store.deleteLoop(idOrName);
9128
+ return this.store.deleteLoop(this.store.requireUniqueLoop(idOrName).id);
8967
9129
  }
8968
9130
  runs(idOrName, filters = {}) {
8969
9131
  let loopId;
@@ -8995,7 +9157,7 @@ class LoopsClient {
8995
9157
  return tick({ store: this.store, runnerId: this.runnerId });
8996
9158
  }
8997
9159
  async runNow(idOrName) {
8998
- const result = await runLoopNow({ store: this.store, idOrName, runnerId: this.runnerId });
9160
+ const result = await runLoopNow({ store: this.store, idOrName: this.store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
8999
9161
  return result.run;
9000
9162
  }
9001
9163
  exportBundle(opts = {}) {
@@ -9913,6 +10075,9 @@ function roleFragment(role, flow) {
9913
10075
  function goalHeaderFragment(goal, role, flow) {
9914
10076
  return [`/goal ${goal}`, "", roleFragment(role, flow)];
9915
10077
  }
10078
+ function boundedStepHeaderFragment(objective, role, flow) {
10079
+ return [`Objective: ${objective}`, "", roleFragment(role, flow)];
10080
+ }
9916
10081
  function adversarialReviewFragment(inspect, focus) {
9917
10082
  return `Use fresh context. Inspect ${inspect}. Act as an adversarial reviewer focused on ${focus}.`;
9918
10083
  }
@@ -11045,7 +11210,7 @@ function renderTaskLifecycleWorkflow(input) {
11045
11210
  const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
11046
11211
  const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
11047
11212
  const triagePrompt = [
11048
- ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
11213
+ ...boundedStepHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
11049
11214
  shared,
11050
11215
  "Decide whether the task is eligible for loop execution. Check status, dependencies, duplicate tasks, no-auto/manual/approval metadata, project path, acceptance criteria, and whether the requested work should be split before implementation.",
11051
11216
  "Do not implement repo changes in this step.",
@@ -11057,7 +11222,7 @@ function renderTaskLifecycleWorkflow(input) {
11057
11222
  ].join(`
11058
11223
  `);
11059
11224
  const plannerPrompt = [
11060
- ...goalHeaderFragment(`Plan todos task ${input.taskId} before implementation.`, "planner", "lifecycle"),
11225
+ ...boundedStepHeaderFragment(`Plan todos task ${input.taskId} before implementation.`, "planner", "lifecycle"),
11061
11226
  shared,
11062
11227
  "Read the triage comment and current task details.",
11063
11228
  `If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
@@ -11068,7 +11233,7 @@ function renderTaskLifecycleWorkflow(input) {
11068
11233
  ].join(`
11069
11234
  `);
11070
11235
  const workerPrompt = [
11071
- ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
11236
+ ...boundedStepHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
11072
11237
  shared,
11073
11238
  todosStartLine(todosProjectPath, input.taskId),
11074
11239
  "Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
@@ -11077,7 +11242,7 @@ function renderTaskLifecycleWorkflow(input) {
11077
11242
  ].filter(Boolean).join(`
11078
11243
  `);
11079
11244
  const verifierPrompt = [
11080
- ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
11245
+ ...boundedStepHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
11081
11246
  shared,
11082
11247
  todosVerificationLine(todosProjectPath, input.taskId),
11083
11248
  todosDoneLine(todosProjectPath, input.taskId),