@hasna/loops 0.4.4 → 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;");
@@ -1640,7 +1643,6 @@ class Store {
1640
1643
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
1641
1644
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1642
1645
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1643
- CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
1644
1646
 
1645
1647
  CREATE TABLE IF NOT EXISTS daemon_lease (
1646
1648
  id TEXT PRIMARY KEY,
@@ -1949,9 +1951,12 @@ class Store {
1949
1951
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1950
1952
  if (rows.length === 0)
1951
1953
  throw new LoopNotFoundError(idOrName);
1952
- 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)
1953
1958
  throw new AmbiguousNameError(idOrName);
1954
- return rowToLoop(rows[0]);
1959
+ return rowToLoop(active[0]);
1955
1960
  }
1956
1961
  requireLoop(idOrName) {
1957
1962
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2292,6 +2297,7 @@ class Store {
2292
2297
  return this.transact(() => {
2293
2298
  const loop = this.requireLoop(idOrName);
2294
2299
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2300
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2295
2301
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2296
2302
  return res.changes > 0;
2297
2303
  });
@@ -2776,7 +2782,10 @@ class Store {
2776
2782
  return rowToGoal(row);
2777
2783
  }
2778
2784
  if (context.sourceType && context.sourceId) {
2779
- 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);
2780
2789
  if (row)
2781
2790
  return rowToGoal(row);
2782
2791
  }
@@ -3196,6 +3205,41 @@ class Store {
3196
3205
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3197
3206
  return run;
3198
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
+ }
3199
3243
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3200
3244
  return this.transact(() => {
3201
3245
  const now = nowIso();
@@ -3633,7 +3677,8 @@ class Store {
3633
3677
  AND ($daemonLeaseId IS NULL OR EXISTS (
3634
3678
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3635
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,
3636
- 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);
3637
3682
  const run = this.getRun(id);
3638
3683
  if (!run)
3639
3684
  throw new Error(`run not found after finalize: ${id}`);
@@ -4161,12 +4206,18 @@ class Store {
4161
4206
  }
4162
4207
  close() {
4163
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
+ }
4164
4215
  }
4165
4216
  }
4166
4217
  // package.json
4167
4218
  var package_default = {
4168
4219
  name: "@hasna/loops",
4169
- version: "0.4.4",
4220
+ version: "0.4.6",
4170
4221
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4171
4222
  type: "module",
4172
4223
  main: "dist/index.js",
@@ -4625,10 +4676,7 @@ async function stopDaemon(opts = {}) {
4625
4676
  const store = new Store(join4(dirname3(path), "loops.db"));
4626
4677
  try {
4627
4678
  const lease = store.getDaemonLease();
4628
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
4629
- removePid(path);
4630
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
4631
- }
4679
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
4632
4680
  try {
4633
4681
  process.kill(state.pid, "SIGTERM");
4634
4682
  } catch {
@@ -4648,11 +4696,14 @@ async function stopDaemon(opts = {}) {
4648
4696
  } catch {}
4649
4697
  await sleep(150);
4650
4698
  removePid(path);
4651
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4652
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4653
- sleep,
4654
- graceMs: opts.reapGraceMs
4655
- });
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
+ }
4656
4707
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
4657
4708
  } finally {
4658
4709
  store.close();
@@ -4990,7 +5041,12 @@ function notifySpawn(pid, opts) {
4990
5041
  if (!pid)
4991
5042
  return;
4992
5043
  opts.onSpawn?.(pid);
4993
- 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
+ });
4994
5050
  }
4995
5051
  function shellQuote(value) {
4996
5052
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5345,6 +5401,13 @@ function numberField(value, key) {
5345
5401
  function codewithAgentStatus(readJson) {
5346
5402
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5347
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
+ }
5348
5411
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5349
5412
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5350
5413
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5375,6 +5438,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5375
5438
  events
5376
5439
  }, null, 2);
5377
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
+ }
5378
5456
  function sleep(ms) {
5379
5457
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5380
5458
  }
@@ -5409,6 +5487,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5409
5487
  if (!agentId) {
5410
5488
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5411
5489
  }
5490
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5412
5491
  const stopAgent = async () => {
5413
5492
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5414
5493
  cwd: spec.cwd,
@@ -5451,10 +5530,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5451
5530
  });
5452
5531
  }
5453
5532
  const status = codewithAgentStatus(lastReadJson);
5454
- 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
+ });
5455
5538
  if (fingerprint !== lastFingerprint) {
5456
5539
  lastFingerprint = fingerprint;
5457
5540
  lastProgressAt = Date.now();
5541
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5458
5542
  }
5459
5543
  if (status === "completed" || status === "failed" || status === "cancelled") {
5460
5544
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6380,7 +6464,7 @@ ${failure.evidence.stderr}` : undefined
6380
6464
 
6381
6465
  `);
6382
6466
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
6383
- const tags = ["bug", "openloops", "loop-health", failure.classification];
6467
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
6384
6468
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
6385
6469
  return {
6386
6470
  title,
@@ -6395,7 +6479,7 @@ ${failure.evidence.stderr}` : undefined
6395
6479
  comment: ["todos", "comment", "<task-id>", description]
6396
6480
  },
6397
6481
  futureNativeUpsert: {
6398
- command: "todos upsert",
6482
+ command: "todos task upsert",
6399
6483
  fields: {
6400
6484
  title,
6401
6485
  description,
@@ -7090,173 +7174,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
7090
7174
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
7091
7175
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
7092
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
+ }
7093
7183
  const ordered = workflowExecutionOrder(workflow);
7094
7184
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
7095
7185
  let blockingError;
7096
7186
  let terminalStatus = "succeeded";
7097
- for (const step of ordered) {
7098
- if (store.isWorkflowRunTerminal(run.id)) {
7099
- terminalStatus = store.requireWorkflowRun(run.id).status;
7100
- blockingError = "workflow run was cancelled";
7101
- break;
7102
- }
7103
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7104
- if (pendingTimeout) {
7105
- terminalStatus = "timed_out";
7106
- blockingError = pendingTimeout;
7107
- break;
7108
- }
7109
- const existing = store.getWorkflowStepRun(run.id, step.id);
7110
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
7111
- continue;
7112
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
7113
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
7114
- const dependencyStep = byId.get(dependencyId);
7115
- if (dependencyRun?.status === "succeeded")
7116
- return false;
7117
- return !dependencyStep?.continueOnFailure;
7118
- });
7119
- if (blockedBy) {
7120
- opts.beforePersist?.();
7121
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
7122
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
7123
- daemonLeaseId: opts.daemonLeaseId
7124
- });
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";
7125
7221
  continue;
7126
7222
  }
7127
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
7128
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
7129
- terminalStatus = "failed";
7130
- continue;
7131
- }
7132
- opts.beforePersist?.();
7133
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7134
- if (startedStep.status !== "running") {
7135
- terminalStatus = "failed";
7136
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
7137
- break;
7138
- }
7139
- const stepContext = goalExecutionContext({
7140
- loop: opts.loop,
7141
- loopRun: opts.loopRun,
7142
- scheduledFor: opts.scheduledFor,
7143
- workflow,
7144
- workflowRunId: run.id,
7145
- workflowStepId: step.id
7146
- });
7147
- let result;
7148
- const controller = new AbortController;
7149
- const externalAbort = () => controller.abort();
7150
- if (opts.signal?.aborted)
7151
- controller.abort();
7152
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
7153
- const cancelTimer = setInterval(() => {
7154
- 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)
7155
7242
  controller.abort();
7156
- }, opts.cancelPollMs ?? 500);
7157
- cancelTimer.unref();
7158
- try {
7159
- if (step.goal) {
7160
- result = await runGoal(store, step.goal, {
7161
- ...opts,
7162
- model: opts.goalModel,
7163
- target: targetWithStepAccount(step),
7164
- signal: controller.signal,
7165
- context: stepContext
7166
- });
7167
- } else {
7168
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7169
- ...opts,
7170
- machine: opts.machine ?? opts.loop?.machine,
7171
- signal: controller.signal,
7172
- onSpawn: (pid) => {
7173
- opts.beforePersist?.();
7174
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7175
- opts.onSpawn?.(pid);
7176
- }
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
7177
7318
  });
7319
+ continue;
7178
7320
  }
7179
- } catch (error) {
7180
- const finishedAt2 = nowIso();
7181
- result = {
7182
- status: "failed",
7183
- stdout: "",
7184
- stderr: "",
7185
- error: error instanceof Error ? error.message : String(error),
7186
- startedAt: startedStep.startedAt ?? finishedAt2,
7187
- finishedAt: finishedAt2,
7188
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7189
- };
7190
- } finally {
7191
- clearInterval(cancelTimer);
7192
- opts.signal?.removeEventListener("abort", externalAbort);
7193
- }
7194
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7195
- if (timeoutMessage && result.status === "failed") {
7196
- result = { ...result, status: "timed_out", error: timeoutMessage };
7197
- }
7198
- if (store.isWorkflowRunTerminal(run.id)) {
7199
- terminalStatus = store.requireWorkflowRun(run.id).status;
7200
- blockingError = "workflow run was cancelled";
7201
- break;
7202
- }
7203
- opts.beforePersist?.();
7204
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7205
- if (blockedExit) {
7206
7321
  store.finalizeWorkflowStepRun(run.id, step.id, {
7207
- status: "skipped",
7322
+ status: result.status,
7208
7323
  finishedAt: result.finishedAt,
7209
7324
  durationMs: result.durationMs,
7210
7325
  stdout: result.stdout,
7211
7326
  stderr: result.stderr,
7212
7327
  exitCode: result.exitCode,
7213
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7328
+ error: result.error
7214
7329
  }, {
7215
7330
  daemonLeaseId: opts.daemonLeaseId
7216
7331
  });
7217
- 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
+ }
7218
7337
  }
7219
- store.finalizeWorkflowStepRun(run.id, step.id, {
7220
- status: result.status,
7221
- finishedAt: result.finishedAt,
7222
- durationMs: result.durationMs,
7223
- stdout: result.stdout,
7224
- stderr: result.stderr,
7225
- exitCode: result.exitCode,
7226
- 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
7227
7358
  }, {
7228
7359
  daemonLeaseId: opts.daemonLeaseId
7229
7360
  });
7230
- if (result.status !== "succeeded" && !step.continueOnFailure) {
7231
- terminalStatus = result.status;
7232
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7233
- break;
7234
- }
7235
- }
7236
- if (terminalStatus !== "succeeded") {
7237
- for (const step of ordered) {
7238
- const existing = store.getWorkflowStepRun(run.id, step.id);
7239
- if (existing?.status === "pending" || existing?.status === "running") {
7240
- 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
+ }, {
7241
7376
  daemonLeaseId: opts.daemonLeaseId
7242
7377
  });
7243
7378
  }
7244
- }
7245
- }
7246
- const finishedAt = nowIso();
7247
- if (store.isWorkflowRunTerminal(run.id)) {
7248
- const terminalRun = store.requireWorkflowRun(run.id);
7249
- 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);
7250
7383
  }
7251
- opts.beforePersist?.();
7252
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7253
- finishedAt,
7254
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7255
- error: blockingError
7256
- }, {
7257
- daemonLeaseId: opts.daemonLeaseId
7258
- });
7259
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7260
7384
  }
7261
7385
  function preflightWorkflow(workflow, opts = {}) {
7262
7386
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7627,6 +7751,25 @@ function advanceOptions(deps) {
7627
7751
  onRun: deps.onRun
7628
7752
  };
7629
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
+ }
7630
7773
  async function runSlot(deps, loop, scheduledFor) {
7631
7774
  const now = deps.now?.() ?? new Date;
7632
7775
  deps.beforeRun?.(loop, scheduledFor);
@@ -7653,8 +7796,10 @@ async function runSlot(deps, loop, scheduledFor) {
7653
7796
  return;
7654
7797
  throw error;
7655
7798
  }
7656
- if (!claim)
7799
+ if (!claim) {
7800
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7657
7801
  return;
7802
+ }
7658
7803
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7659
7804
  deps.onRun?.(claim.run);
7660
7805
  const finalRun = await executeClaimedRun({
@@ -7700,8 +7845,10 @@ function claimSlot(deps, loop, scheduledFor) {
7700
7845
  return;
7701
7846
  throw error;
7702
7847
  }
7703
- if (!claim)
7848
+ if (!claim) {
7849
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7704
7850
  return;
7851
+ }
7705
7852
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7706
7853
  deps.onRun?.(claim.run);
7707
7854
  return claim;
@@ -8212,7 +8359,7 @@ var TOOL_REGISTRATIONS = [
8212
8359
  guarded: true,
8213
8360
  annotations: mutationAnnotations({ idempotent: true }),
8214
8361
  inputSchema: { idOrName: loopIdOrNameSchema },
8215
- 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" })) }))
8216
8363
  },
8217
8364
  {
8218
8365
  name: "loops_resume",
@@ -8222,7 +8369,15 @@ var TOOL_REGISTRATIONS = [
8222
8369
  guarded: true,
8223
8370
  annotations: mutationAnnotations({ idempotent: true }),
8224
8371
  inputSchema: { idOrName: loopIdOrNameSchema },
8225
- 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
+ })
8226
8381
  },
8227
8382
  {
8228
8383
  name: "loops_stop",
@@ -8233,7 +8388,7 @@ var TOOL_REGISTRATIONS = [
8233
8388
  annotations: mutationAnnotations({ idempotent: true }),
8234
8389
  inputSchema: { idOrName: loopIdOrNameSchema },
8235
8390
  handler: ({ idOrName }) => withStore((store) => ({
8236
- 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 }))
8237
8392
  }))
8238
8393
  },
8239
8394
  {
@@ -8246,7 +8401,7 @@ var TOOL_REGISTRATIONS = [
8246
8401
  inputSchema: { idOrName: loopIdOrNameSchema },
8247
8402
  handler: ({ idOrName }) => withStore(async (store) => {
8248
8403
  const daemon = daemonStatus(store);
8249
- 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" });
8250
8405
  return {
8251
8406
  scheduledFor: result.scheduledFor,
8252
8407
  loop: publicLoop(result.loop),
@@ -8949,13 +9104,19 @@ class LoopsClient {
8949
9104
  return this.store.requireLoop(idOrName);
8950
9105
  }
8951
9106
  pause(idOrName) {
8952
- return this.store.updateLoop(this.get(idOrName).id, { status: "paused" });
9107
+ return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "paused" });
8953
9108
  }
8954
9109
  resume(idOrName) {
8955
- 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 });
8956
9117
  }
8957
9118
  stop(idOrName) {
8958
- 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 });
8959
9120
  }
8960
9121
  archive(idOrName) {
8961
9122
  return this.store.archiveLoop(idOrName);
@@ -8964,7 +9125,7 @@ class LoopsClient {
8964
9125
  return this.store.unarchiveLoop(idOrName);
8965
9126
  }
8966
9127
  delete(idOrName) {
8967
- return this.store.deleteLoop(idOrName);
9128
+ return this.store.deleteLoop(this.store.requireUniqueLoop(idOrName).id);
8968
9129
  }
8969
9130
  runs(idOrName, filters = {}) {
8970
9131
  let loopId;
@@ -8996,7 +9157,7 @@ class LoopsClient {
8996
9157
  return tick({ store: this.store, runnerId: this.runnerId });
8997
9158
  }
8998
9159
  async runNow(idOrName) {
8999
- 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 });
9000
9161
  return result.run;
9001
9162
  }
9002
9163
  exportBundle(opts = {}) {
@@ -9914,6 +10075,9 @@ function roleFragment(role, flow) {
9914
10075
  function goalHeaderFragment(goal, role, flow) {
9915
10076
  return [`/goal ${goal}`, "", roleFragment(role, flow)];
9916
10077
  }
10078
+ function boundedStepHeaderFragment(objective, role, flow) {
10079
+ return [`Objective: ${objective}`, "", roleFragment(role, flow)];
10080
+ }
9917
10081
  function adversarialReviewFragment(inspect, focus) {
9918
10082
  return `Use fresh context. Inspect ${inspect}. Act as an adversarial reviewer focused on ${focus}.`;
9919
10083
  }
@@ -11046,7 +11210,7 @@ function renderTaskLifecycleWorkflow(input) {
11046
11210
  const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
11047
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.`;
11048
11212
  const triagePrompt = [
11049
- ...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"),
11050
11214
  shared,
11051
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.",
11052
11216
  "Do not implement repo changes in this step.",
@@ -11058,7 +11222,7 @@ function renderTaskLifecycleWorkflow(input) {
11058
11222
  ].join(`
11059
11223
  `);
11060
11224
  const plannerPrompt = [
11061
- ...goalHeaderFragment(`Plan todos task ${input.taskId} before implementation.`, "planner", "lifecycle"),
11225
+ ...boundedStepHeaderFragment(`Plan todos task ${input.taskId} before implementation.`, "planner", "lifecycle"),
11062
11226
  shared,
11063
11227
  "Read the triage comment and current task details.",
11064
11228
  `If the task is ready for implementation, add a task comment whose first line is exactly: ${gateMarker("planner", "go")}`,
@@ -11069,7 +11233,7 @@ function renderTaskLifecycleWorkflow(input) {
11069
11233
  ].join(`
11070
11234
  `);
11071
11235
  const workerPrompt = [
11072
- ...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"),
11073
11237
  shared,
11074
11238
  todosStartLine(todosProjectPath, input.taskId),
11075
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.",
@@ -11078,7 +11242,7 @@ function renderTaskLifecycleWorkflow(input) {
11078
11242
  ].filter(Boolean).join(`
11079
11243
  `);
11080
11244
  const verifierPrompt = [
11081
- ...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"),
11082
11246
  shared,
11083
11247
  todosVerificationLine(todosProjectPath, input.taskId),
11084
11248
  todosDoneLine(todosProjectPath, input.taskId),