@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/mcp/index.js CHANGED
@@ -1493,11 +1493,14 @@ function ensurePrivateStorePath(file) {
1493
1493
  class Store {
1494
1494
  db;
1495
1495
  rootDir;
1496
+ memoryRootDir;
1496
1497
  constructor(path) {
1497
1498
  const file = path ?? dbPath();
1498
1499
  if (file !== ":memory:")
1499
1500
  ensurePrivateStorePath(file);
1500
1501
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1502
+ if (file === ":memory:")
1503
+ this.memoryRootDir = this.rootDir;
1501
1504
  this.db = new Database(file);
1502
1505
  this.db.exec("PRAGMA foreign_keys = ON;");
1503
1506
  this.db.exec("PRAGMA busy_timeout = 5000;");
@@ -1642,7 +1645,6 @@ class Store {
1642
1645
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
1643
1646
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1644
1647
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1645
- CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
1646
1648
 
1647
1649
  CREATE TABLE IF NOT EXISTS daemon_lease (
1648
1650
  id TEXT PRIMARY KEY,
@@ -1951,9 +1953,12 @@ class Store {
1951
1953
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1952
1954
  if (rows.length === 0)
1953
1955
  throw new LoopNotFoundError(idOrName);
1954
- if (rows.length > 1)
1956
+ if (rows.length === 1)
1957
+ return rowToLoop(rows[0]);
1958
+ const active = this.db.query("SELECT * FROM loops WHERE name = ? AND archived_at IS NULL ORDER BY created_at DESC LIMIT 2").all(idOrName);
1959
+ if (active.length !== 1)
1955
1960
  throw new AmbiguousNameError(idOrName);
1956
- return rowToLoop(rows[0]);
1961
+ return rowToLoop(active[0]);
1957
1962
  }
1958
1963
  requireLoop(idOrName) {
1959
1964
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2294,6 +2299,7 @@ class Store {
2294
2299
  return this.transact(() => {
2295
2300
  const loop = this.requireLoop(idOrName);
2296
2301
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2302
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2297
2303
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2298
2304
  return res.changes > 0;
2299
2305
  });
@@ -2778,7 +2784,10 @@ class Store {
2778
2784
  return rowToGoal(row);
2779
2785
  }
2780
2786
  if (context.sourceType && context.sourceId) {
2781
- 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);
2787
+ const terminalPlaceholders = GOAL_TERMINAL.map(() => "?").join(", ");
2788
+ const row = this.db.query(`SELECT * FROM goals
2789
+ WHERE source_type = ? AND source_id = ? AND status NOT IN (${terminalPlaceholders})
2790
+ ORDER BY created_at DESC LIMIT 1`).get(context.sourceType, context.sourceId, ...GOAL_TERMINAL);
2782
2791
  if (row)
2783
2792
  return rowToGoal(row);
2784
2793
  }
@@ -3198,6 +3207,41 @@ class Store {
3198
3207
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3199
3208
  return run;
3200
3209
  }
3210
+ recordWorkflowStepProgress(workflowRunId, stepId, progress, opts = {}) {
3211
+ const now = (opts.now ?? new Date).toISOString();
3212
+ this.db.exec("BEGIN IMMEDIATE");
3213
+ try {
3214
+ const res = this.db.query(`UPDATE workflow_step_runs
3215
+ SET stdout=COALESCE($stdout, stdout),
3216
+ stderr=COALESCE($stderr, stderr),
3217
+ updated_at=$updated
3218
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3219
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3220
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3221
+ ))`).run({
3222
+ $workflowRunId: workflowRunId,
3223
+ $stepId: stepId,
3224
+ $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3225
+ $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3226
+ $updated: now,
3227
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3228
+ $now: now
3229
+ });
3230
+ if (res.changes === 1) {
3231
+ this.appendWorkflowEvent(workflowRunId, "step_progress", stepId, progress.payload);
3232
+ }
3233
+ this.db.exec("COMMIT");
3234
+ } catch (error) {
3235
+ try {
3236
+ this.db.exec("ROLLBACK");
3237
+ } catch {}
3238
+ throw error;
3239
+ }
3240
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3241
+ if (!run)
3242
+ throw new Error(`workflow step run not found after progress update: ${workflowRunId}/${stepId}`);
3243
+ return run;
3244
+ }
3201
3245
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3202
3246
  return this.transact(() => {
3203
3247
  const now = nowIso();
@@ -3635,7 +3679,8 @@ class Store {
3635
3679
  AND ($daemonLeaseId IS NULL OR EXISTS (
3636
3680
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3637
3681
  ))`).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,
3638
- duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3682
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3683
+ WHERE id=$id AND status='running'`).run(params);
3639
3684
  const run = this.getRun(id);
3640
3685
  if (!run)
3641
3686
  throw new Error(`run not found after finalize: ${id}`);
@@ -4163,6 +4208,12 @@ class Store {
4163
4208
  }
4164
4209
  close() {
4165
4210
  this.db.close();
4211
+ if (this.memoryRootDir) {
4212
+ try {
4213
+ rmSync2(this.memoryRootDir, { recursive: true, force: true });
4214
+ } catch {}
4215
+ this.memoryRootDir = undefined;
4216
+ }
4166
4217
  }
4167
4218
  }
4168
4219
 
@@ -4360,10 +4411,7 @@ async function stopDaemon(opts = {}) {
4360
4411
  const store = new Store(join4(dirname3(path), "loops.db"));
4361
4412
  try {
4362
4413
  const lease = store.getDaemonLease();
4363
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
4364
- removePid(path);
4365
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
4366
- }
4414
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
4367
4415
  try {
4368
4416
  process.kill(state.pid, "SIGTERM");
4369
4417
  } catch {
@@ -4383,11 +4431,14 @@ async function stopDaemon(opts = {}) {
4383
4431
  } catch {}
4384
4432
  await sleep(150);
4385
4433
  removePid(path);
4386
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4387
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4388
- sleep,
4389
- graceMs: opts.reapGraceMs
4390
- });
4434
+ let reapedPgids = [];
4435
+ if (leaseVerified && lease) {
4436
+ const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4437
+ reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4438
+ sleep,
4439
+ graceMs: opts.reapGraceMs
4440
+ });
4441
+ }
4391
4442
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
4392
4443
  } finally {
4393
4444
  store.close();
@@ -4725,7 +4776,12 @@ function notifySpawn(pid, opts) {
4725
4776
  if (!pid)
4726
4777
  return;
4727
4778
  opts.onSpawn?.(pid);
4728
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
4779
+ const startedMs = processStartTimeMs(pid);
4780
+ opts.onSpawnProcess?.({
4781
+ pid,
4782
+ pgid: pid,
4783
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
4784
+ });
4729
4785
  }
4730
4786
  function shellQuote(value) {
4731
4787
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5080,6 +5136,13 @@ function numberField(value, key) {
5080
5136
  function codewithAgentStatus(readJson) {
5081
5137
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5082
5138
  }
5139
+ function codewithAgentLastEventSeq(readJson) {
5140
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5141
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5142
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5143
+ return Math.max(agentSeq, snapshotSeq);
5144
+ return agentSeq ?? snapshotSeq;
5145
+ }
5083
5146
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5084
5147
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5085
5148
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5110,6 +5173,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5110
5173
  events
5111
5174
  }, null, 2);
5112
5175
  }
5176
+ function codewithAgentProgress(readJson) {
5177
+ const agent = recordField(readJson, "agent");
5178
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5179
+ return {
5180
+ provider: "codewith",
5181
+ agentId: stringField(agent, "agentId"),
5182
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5183
+ summary: stringField(statusSnapshot, "summary"),
5184
+ statusReason: stringField(agent, "statusReason"),
5185
+ threadId: stringField(agent, "threadId"),
5186
+ rolloutPath: stringField(agent, "rolloutPath"),
5187
+ pid: numberField(agent, "pid"),
5188
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5189
+ };
5190
+ }
5113
5191
  function sleep(ms) {
5114
5192
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5115
5193
  }
@@ -5144,6 +5222,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5144
5222
  if (!agentId) {
5145
5223
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5146
5224
  }
5225
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5147
5226
  const stopAgent = async () => {
5148
5227
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5149
5228
  cwd: spec.cwd,
@@ -5186,10 +5265,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5186
5265
  });
5187
5266
  }
5188
5267
  const status = codewithAgentStatus(lastReadJson);
5189
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5268
+ const fingerprint = JSON.stringify({
5269
+ status,
5270
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5271
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5272
+ });
5190
5273
  if (fingerprint !== lastFingerprint) {
5191
5274
  lastFingerprint = fingerprint;
5192
5275
  lastProgressAt = Date.now();
5276
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5193
5277
  }
5194
5278
  if (status === "completed" || status === "failed" || status === "cancelled") {
5195
5279
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6115,7 +6199,7 @@ ${failure.evidence.stderr}` : undefined
6115
6199
 
6116
6200
  `);
6117
6201
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
6118
- const tags = ["bug", "openloops", "loop-health", failure.classification];
6202
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
6119
6203
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
6120
6204
  return {
6121
6205
  title,
@@ -6130,7 +6214,7 @@ ${failure.evidence.stderr}` : undefined
6130
6214
  comment: ["todos", "comment", "<task-id>", description]
6131
6215
  },
6132
6216
  futureNativeUpsert: {
6133
- command: "todos upsert",
6217
+ command: "todos task upsert",
6134
6218
  fields: {
6135
6219
  title,
6136
6220
  description,
@@ -6825,173 +6909,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
6825
6909
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
6826
6910
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
6827
6911
  }
6912
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
6913
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
6914
+ try {
6915
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
6916
+ } catch {}
6917
+ }
6828
6918
  const ordered = workflowExecutionOrder(workflow);
6829
6919
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
6830
6920
  let blockingError;
6831
6921
  let terminalStatus = "succeeded";
6832
- for (const step of ordered) {
6833
- if (store.isWorkflowRunTerminal(run.id)) {
6834
- terminalStatus = store.requireWorkflowRun(run.id).status;
6835
- blockingError = "workflow run was cancelled";
6836
- break;
6837
- }
6838
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6839
- if (pendingTimeout) {
6840
- terminalStatus = "timed_out";
6841
- blockingError = pendingTimeout;
6842
- break;
6843
- }
6844
- const existing = store.getWorkflowStepRun(run.id, step.id);
6845
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6846
- continue;
6847
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6848
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6849
- const dependencyStep = byId.get(dependencyId);
6850
- if (dependencyRun?.status === "succeeded")
6851
- return false;
6852
- return !dependencyStep?.continueOnFailure;
6853
- });
6854
- if (blockedBy) {
6855
- opts.beforePersist?.();
6856
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6857
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6858
- daemonLeaseId: opts.daemonLeaseId
6859
- });
6922
+ try {
6923
+ for (const step of ordered) {
6924
+ if (store.isWorkflowRunTerminal(run.id)) {
6925
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6926
+ blockingError = "workflow run was cancelled";
6927
+ break;
6928
+ }
6929
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6930
+ if (pendingTimeout) {
6931
+ terminalStatus = "timed_out";
6932
+ blockingError = pendingTimeout;
6933
+ break;
6934
+ }
6935
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6936
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6937
+ continue;
6938
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6939
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6940
+ const dependencyStep = byId.get(dependencyId);
6941
+ if (dependencyRun?.status === "succeeded")
6942
+ return false;
6943
+ return !dependencyStep?.continueOnFailure;
6944
+ });
6945
+ if (blockedBy) {
6946
+ opts.beforePersist?.();
6947
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6948
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6949
+ daemonLeaseId: opts.daemonLeaseId
6950
+ });
6951
+ continue;
6952
+ }
6953
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6954
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6955
+ terminalStatus = "failed";
6860
6956
  continue;
6861
6957
  }
6862
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6863
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6864
- terminalStatus = "failed";
6865
- continue;
6866
- }
6867
- opts.beforePersist?.();
6868
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6869
- if (startedStep.status !== "running") {
6870
- terminalStatus = "failed";
6871
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
6872
- break;
6873
- }
6874
- const stepContext = goalExecutionContext({
6875
- loop: opts.loop,
6876
- loopRun: opts.loopRun,
6877
- scheduledFor: opts.scheduledFor,
6878
- workflow,
6879
- workflowRunId: run.id,
6880
- workflowStepId: step.id
6881
- });
6882
- let result;
6883
- const controller = new AbortController;
6884
- const externalAbort = () => controller.abort();
6885
- if (opts.signal?.aborted)
6886
- controller.abort();
6887
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
6888
- const cancelTimer = setInterval(() => {
6889
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
6958
+ opts.beforePersist?.();
6959
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6960
+ if (startedStep.status !== "running") {
6961
+ terminalStatus = "failed";
6962
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
6963
+ break;
6964
+ }
6965
+ const stepContext = goalExecutionContext({
6966
+ loop: opts.loop,
6967
+ loopRun: opts.loopRun,
6968
+ scheduledFor: opts.scheduledFor,
6969
+ workflow,
6970
+ workflowRunId: run.id,
6971
+ workflowStepId: step.id
6972
+ });
6973
+ let result;
6974
+ const controller = new AbortController;
6975
+ const externalAbort = () => controller.abort();
6976
+ if (opts.signal?.aborted)
6890
6977
  controller.abort();
6891
- }, opts.cancelPollMs ?? 500);
6892
- cancelTimer.unref();
6893
- try {
6894
- if (step.goal) {
6895
- result = await runGoal(store, step.goal, {
6896
- ...opts,
6897
- model: opts.goalModel,
6898
- target: targetWithStepAccount(step),
6899
- signal: controller.signal,
6900
- context: stepContext
6901
- });
6902
- } else {
6903
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6904
- ...opts,
6905
- machine: opts.machine ?? opts.loop?.machine,
6906
- signal: controller.signal,
6907
- onSpawn: (pid) => {
6908
- opts.beforePersist?.();
6909
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6910
- opts.onSpawn?.(pid);
6911
- }
6978
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
6979
+ const cancelTimer = setInterval(() => {
6980
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
6981
+ controller.abort();
6982
+ }, opts.cancelPollMs ?? 500);
6983
+ cancelTimer.unref();
6984
+ try {
6985
+ if (step.goal) {
6986
+ result = await runGoal(store, step.goal, {
6987
+ ...opts,
6988
+ model: opts.goalModel,
6989
+ target: targetWithStepAccount(step),
6990
+ signal: controller.signal,
6991
+ context: stepContext
6992
+ });
6993
+ } else {
6994
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6995
+ ...opts,
6996
+ machine: opts.machine ?? opts.loop?.machine,
6997
+ signal: controller.signal,
6998
+ onAgentProgress: (progress) => {
6999
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
7000
+ opts.beforePersist?.();
7001
+ store.recordWorkflowStepProgress(run.id, step.id, {
7002
+ stdout,
7003
+ payload: progress
7004
+ }, {
7005
+ daemonLeaseId: opts.daemonLeaseId
7006
+ });
7007
+ opts.onAgentProgress?.(progress);
7008
+ },
7009
+ onSpawn: (pid) => {
7010
+ opts.beforePersist?.();
7011
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7012
+ opts.onSpawn?.(pid);
7013
+ }
7014
+ });
7015
+ }
7016
+ } catch (error) {
7017
+ const finishedAt2 = nowIso();
7018
+ result = {
7019
+ status: "failed",
7020
+ stdout: "",
7021
+ stderr: "",
7022
+ error: error instanceof Error ? error.message : String(error),
7023
+ startedAt: startedStep.startedAt ?? finishedAt2,
7024
+ finishedAt: finishedAt2,
7025
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7026
+ };
7027
+ } finally {
7028
+ clearInterval(cancelTimer);
7029
+ opts.signal?.removeEventListener("abort", externalAbort);
7030
+ }
7031
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7032
+ if (timeoutMessage && result.status === "failed") {
7033
+ result = { ...result, status: "timed_out", error: timeoutMessage };
7034
+ }
7035
+ if (store.isWorkflowRunTerminal(run.id)) {
7036
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7037
+ blockingError = "workflow run was cancelled";
7038
+ break;
7039
+ }
7040
+ opts.beforePersist?.();
7041
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7042
+ if (blockedExit) {
7043
+ store.finalizeWorkflowStepRun(run.id, step.id, {
7044
+ status: "skipped",
7045
+ finishedAt: result.finishedAt,
7046
+ durationMs: result.durationMs,
7047
+ stdout: result.stdout,
7048
+ stderr: result.stderr,
7049
+ exitCode: result.exitCode,
7050
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7051
+ }, {
7052
+ daemonLeaseId: opts.daemonLeaseId
6912
7053
  });
7054
+ continue;
6913
7055
  }
6914
- } catch (error) {
6915
- const finishedAt2 = nowIso();
6916
- result = {
6917
- status: "failed",
6918
- stdout: "",
6919
- stderr: "",
6920
- error: error instanceof Error ? error.message : String(error),
6921
- startedAt: startedStep.startedAt ?? finishedAt2,
6922
- finishedAt: finishedAt2,
6923
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6924
- };
6925
- } finally {
6926
- clearInterval(cancelTimer);
6927
- opts.signal?.removeEventListener("abort", externalAbort);
6928
- }
6929
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6930
- if (timeoutMessage && result.status === "failed") {
6931
- result = { ...result, status: "timed_out", error: timeoutMessage };
6932
- }
6933
- if (store.isWorkflowRunTerminal(run.id)) {
6934
- terminalStatus = store.requireWorkflowRun(run.id).status;
6935
- blockingError = "workflow run was cancelled";
6936
- break;
6937
- }
6938
- opts.beforePersist?.();
6939
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6940
- if (blockedExit) {
6941
7056
  store.finalizeWorkflowStepRun(run.id, step.id, {
6942
- status: "skipped",
7057
+ status: result.status,
6943
7058
  finishedAt: result.finishedAt,
6944
7059
  durationMs: result.durationMs,
6945
7060
  stdout: result.stdout,
6946
7061
  stderr: result.stderr,
6947
7062
  exitCode: result.exitCode,
6948
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7063
+ error: result.error
6949
7064
  }, {
6950
7065
  daemonLeaseId: opts.daemonLeaseId
6951
7066
  });
6952
- continue;
7067
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
7068
+ terminalStatus = result.status;
7069
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7070
+ break;
7071
+ }
6953
7072
  }
6954
- store.finalizeWorkflowStepRun(run.id, step.id, {
6955
- status: result.status,
6956
- finishedAt: result.finishedAt,
6957
- durationMs: result.durationMs,
6958
- stdout: result.stdout,
6959
- stderr: result.stderr,
6960
- exitCode: result.exitCode,
6961
- error: result.error
7073
+ if (terminalStatus !== "succeeded") {
7074
+ for (const step of ordered) {
7075
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7076
+ if (existing?.status === "pending" || existing?.status === "running") {
7077
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7078
+ daemonLeaseId: opts.daemonLeaseId
7079
+ });
7080
+ }
7081
+ }
7082
+ }
7083
+ const finishedAt = nowIso();
7084
+ if (store.isWorkflowRunTerminal(run.id)) {
7085
+ const terminalRun = store.requireWorkflowRun(run.id);
7086
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7087
+ }
7088
+ opts.beforePersist?.();
7089
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7090
+ finishedAt,
7091
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7092
+ error: blockingError
6962
7093
  }, {
6963
7094
  daemonLeaseId: opts.daemonLeaseId
6964
7095
  });
6965
- if (result.status !== "succeeded" && !step.continueOnFailure) {
6966
- terminalStatus = result.status;
6967
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6968
- break;
6969
- }
6970
- }
6971
- if (terminalStatus !== "succeeded") {
6972
- for (const step of ordered) {
6973
- const existing = store.getWorkflowStepRun(run.id, step.id);
6974
- if (existing?.status === "pending" || existing?.status === "running") {
6975
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7096
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7097
+ } catch (error) {
7098
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
7099
+ throw error;
7100
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
7101
+ throw error;
7102
+ const message = error instanceof Error ? error.message : String(error);
7103
+ const finishedAt = nowIso();
7104
+ try {
7105
+ if (!store.isWorkflowRunTerminal(run.id)) {
7106
+ store.finalizeWorkflowRun(run.id, "failed", {
7107
+ finishedAt,
7108
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7109
+ error: message
7110
+ }, {
6976
7111
  daemonLeaseId: opts.daemonLeaseId
6977
7112
  });
6978
7113
  }
6979
- }
6980
- }
6981
- const finishedAt = nowIso();
6982
- if (store.isWorkflowRunTerminal(run.id)) {
6983
- const terminalRun = store.requireWorkflowRun(run.id);
6984
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7114
+ } catch {}
7115
+ const current = store.getWorkflowRun(run.id) ?? run;
7116
+ const resultStatus = current.status === "running" ? "failed" : current.status;
7117
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
6985
7118
  }
6986
- opts.beforePersist?.();
6987
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6988
- finishedAt,
6989
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6990
- error: blockingError
6991
- }, {
6992
- daemonLeaseId: opts.daemonLeaseId
6993
- });
6994
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6995
7119
  }
6996
7120
  function preflightWorkflow(workflow, opts = {}) {
6997
7121
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7362,6 +7486,25 @@ function advanceOptions(deps) {
7362
7486
  onRun: deps.onRun
7363
7487
  };
7364
7488
  }
7489
+ var TERMINAL_RUN_STATUSES2 = new Set([
7490
+ "succeeded",
7491
+ "failed",
7492
+ "timed_out",
7493
+ "abandoned",
7494
+ "skipped"
7495
+ ]);
7496
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
7497
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
7498
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
7499
+ return;
7500
+ try {
7501
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
7502
+ } catch (error) {
7503
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
7504
+ return;
7505
+ throw error;
7506
+ }
7507
+ }
7365
7508
  async function runSlot(deps, loop, scheduledFor) {
7366
7509
  const now = deps.now?.() ?? new Date;
7367
7510
  deps.beforeRun?.(loop, scheduledFor);
@@ -7388,8 +7531,10 @@ async function runSlot(deps, loop, scheduledFor) {
7388
7531
  return;
7389
7532
  throw error;
7390
7533
  }
7391
- if (!claim)
7534
+ if (!claim) {
7535
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7392
7536
  return;
7537
+ }
7393
7538
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7394
7539
  deps.onRun?.(claim.run);
7395
7540
  const finalRun = await executeClaimedRun({
@@ -7435,8 +7580,10 @@ function claimSlot(deps, loop, scheduledFor) {
7435
7580
  return;
7436
7581
  throw error;
7437
7582
  }
7438
- if (!claim)
7583
+ if (!claim) {
7584
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7439
7585
  return;
7586
+ }
7440
7587
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7441
7588
  deps.onRun?.(claim.run);
7442
7589
  return claim;
@@ -7530,7 +7677,7 @@ async function tick(deps) {
7530
7677
  // package.json
7531
7678
  var package_default = {
7532
7679
  name: "@hasna/loops",
7533
- version: "0.4.4",
7680
+ version: "0.4.6",
7534
7681
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7535
7682
  type: "module",
7536
7683
  main: "dist/index.js",
@@ -8077,7 +8224,7 @@ var TOOL_REGISTRATIONS = [
8077
8224
  guarded: true,
8078
8225
  annotations: mutationAnnotations({ idempotent: true }),
8079
8226
  inputSchema: { idOrName: loopIdOrNameSchema },
8080
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "paused" })) }))
8227
+ handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "paused" })) }))
8081
8228
  },
8082
8229
  {
8083
8230
  name: "loops_resume",
@@ -8087,7 +8234,15 @@ var TOOL_REGISTRATIONS = [
8087
8234
  guarded: true,
8088
8235
  annotations: mutationAnnotations({ idempotent: true }),
8089
8236
  inputSchema: { idOrName: loopIdOrNameSchema },
8090
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "active" })) }))
8237
+ handler: ({ idOrName }) => withStore((store) => {
8238
+ const loop = store.requireUniqueLoop(idOrName);
8239
+ let nextRunAt = loop.nextRunAt;
8240
+ if (!nextRunAt) {
8241
+ const now = new Date;
8242
+ nextRunAt = computeNextAfter(loop.schedule, now, now);
8243
+ }
8244
+ return { loop: publicLoop(store.updateLoop(loop.id, { status: "active", nextRunAt })) };
8245
+ })
8091
8246
  },
8092
8247
  {
8093
8248
  name: "loops_stop",
@@ -8098,7 +8253,7 @@ var TOOL_REGISTRATIONS = [
8098
8253
  annotations: mutationAnnotations({ idempotent: true }),
8099
8254
  inputSchema: { idOrName: loopIdOrNameSchema },
8100
8255
  handler: ({ idOrName }) => withStore((store) => ({
8101
- loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8256
+ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8102
8257
  }))
8103
8258
  },
8104
8259
  {
@@ -8111,7 +8266,7 @@ var TOOL_REGISTRATIONS = [
8111
8266
  inputSchema: { idOrName: loopIdOrNameSchema },
8112
8267
  handler: ({ idOrName }) => withStore(async (store) => {
8113
8268
  const daemon = daemonStatus(store);
8114
- const result = await runLoopNow({ store, idOrName, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8269
+ const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8115
8270
  return {
8116
8271
  scheduledFor: result.scheduledFor,
8117
8272
  loop: publicLoop(result.loop),