@hasna/loops 0.4.5 → 0.4.7

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
@@ -726,7 +726,7 @@ function buildAgentInvocation(target) {
726
726
  if (target.model)
727
727
  args.push("--model", target.model);
728
728
  args.push(...target.extraArgs ?? []);
729
- args.push("agent", "start");
729
+ args.push("agent", "start", "--json");
730
730
  if (target.cwd)
731
731
  args.push("--cwd", target.cwd);
732
732
  args.push(target.prompt);
@@ -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;");
@@ -1950,9 +1953,12 @@ class Store {
1950
1953
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1951
1954
  if (rows.length === 0)
1952
1955
  throw new LoopNotFoundError(idOrName);
1953
- 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)
1954
1960
  throw new AmbiguousNameError(idOrName);
1955
- return rowToLoop(rows[0]);
1961
+ return rowToLoop(active[0]);
1956
1962
  }
1957
1963
  requireLoop(idOrName) {
1958
1964
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2293,6 +2299,7 @@ class Store {
2293
2299
  return this.transact(() => {
2294
2300
  const loop = this.requireLoop(idOrName);
2295
2301
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2302
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2296
2303
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2297
2304
  return res.changes > 0;
2298
2305
  });
@@ -2777,7 +2784,10 @@ class Store {
2777
2784
  return rowToGoal(row);
2778
2785
  }
2779
2786
  if (context.sourceType && context.sourceId) {
2780
- 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);
2781
2791
  if (row)
2782
2792
  return rowToGoal(row);
2783
2793
  }
@@ -3197,6 +3207,41 @@ class Store {
3197
3207
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3198
3208
  return run;
3199
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
+ }
3200
3245
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3201
3246
  return this.transact(() => {
3202
3247
  const now = nowIso();
@@ -3634,7 +3679,8 @@ class Store {
3634
3679
  AND ($daemonLeaseId IS NULL OR EXISTS (
3635
3680
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3636
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,
3637
- 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);
3638
3684
  const run = this.getRun(id);
3639
3685
  if (!run)
3640
3686
  throw new Error(`run not found after finalize: ${id}`);
@@ -4162,6 +4208,12 @@ class Store {
4162
4208
  }
4163
4209
  close() {
4164
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
+ }
4165
4217
  }
4166
4218
  }
4167
4219
 
@@ -4359,10 +4411,7 @@ async function stopDaemon(opts = {}) {
4359
4411
  const store = new Store(join4(dirname3(path), "loops.db"));
4360
4412
  try {
4361
4413
  const lease = store.getDaemonLease();
4362
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
4363
- removePid(path);
4364
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
4365
- }
4414
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
4366
4415
  try {
4367
4416
  process.kill(state.pid, "SIGTERM");
4368
4417
  } catch {
@@ -4382,11 +4431,14 @@ async function stopDaemon(opts = {}) {
4382
4431
  } catch {}
4383
4432
  await sleep(150);
4384
4433
  removePid(path);
4385
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4386
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4387
- sleep,
4388
- graceMs: opts.reapGraceMs
4389
- });
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
+ }
4390
4442
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
4391
4443
  } finally {
4392
4444
  store.close();
@@ -4724,7 +4776,12 @@ function notifySpawn(pid, opts) {
4724
4776
  if (!pid)
4725
4777
  return;
4726
4778
  opts.onSpawn?.(pid);
4727
- 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
+ });
4728
4785
  }
4729
4786
  function shellQuote(value) {
4730
4787
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5049,18 +5106,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5049
5106
  "agent",
5050
5107
  command,
5051
5108
  ...command === "logs" ? ["--limit", "20"] : [],
5109
+ "--json",
5052
5110
  agentId
5053
5111
  ];
5054
5112
  }
5113
+ function extractFirstJsonObject(text) {
5114
+ let depth = 0;
5115
+ let start = -1;
5116
+ let inString = false;
5117
+ let escaped = false;
5118
+ for (let i = 0;i < text.length; i += 1) {
5119
+ const ch = text[i];
5120
+ if (inString) {
5121
+ if (escaped)
5122
+ escaped = false;
5123
+ else if (ch === "\\")
5124
+ escaped = true;
5125
+ else if (ch === '"')
5126
+ inString = false;
5127
+ continue;
5128
+ }
5129
+ if (ch === '"') {
5130
+ inString = true;
5131
+ } else if (ch === "{") {
5132
+ if (depth === 0)
5133
+ start = i;
5134
+ depth += 1;
5135
+ } else if (ch === "}") {
5136
+ if (depth > 0) {
5137
+ depth -= 1;
5138
+ if (depth === 0 && start !== -1)
5139
+ return text.slice(start, i + 1);
5140
+ }
5141
+ }
5142
+ }
5143
+ return;
5144
+ }
5055
5145
  function parseJsonOutput(stdout, label) {
5056
- try {
5057
- const value = JSON.parse(stdout || "{}");
5058
- if (!value || typeof value !== "object" || Array.isArray(value))
5059
- throw new Error("not an object");
5060
- return value;
5061
- } catch (error) {
5062
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5146
+ const raw = stdout || "{}";
5147
+ const candidates = [raw.trim()];
5148
+ const scanned = extractFirstJsonObject(raw);
5149
+ if (scanned && scanned !== raw.trim())
5150
+ candidates.push(scanned);
5151
+ let lastError;
5152
+ for (const candidate of candidates) {
5153
+ try {
5154
+ const value = JSON.parse(candidate);
5155
+ if (!value || typeof value !== "object" || Array.isArray(value))
5156
+ throw new Error("not an object");
5157
+ return value;
5158
+ } catch (error) {
5159
+ lastError = error;
5160
+ }
5063
5161
  }
5162
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5064
5163
  }
5065
5164
  function recordField(value, key) {
5066
5165
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -5079,6 +5178,13 @@ function numberField(value, key) {
5079
5178
  function codewithAgentStatus(readJson) {
5080
5179
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5081
5180
  }
5181
+ function codewithAgentLastEventSeq(readJson) {
5182
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5183
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5184
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5185
+ return Math.max(agentSeq, snapshotSeq);
5186
+ return agentSeq ?? snapshotSeq;
5187
+ }
5082
5188
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5083
5189
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5084
5190
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5109,6 +5215,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5109
5215
  events
5110
5216
  }, null, 2);
5111
5217
  }
5218
+ function codewithAgentProgress(readJson) {
5219
+ const agent = recordField(readJson, "agent");
5220
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5221
+ return {
5222
+ provider: "codewith",
5223
+ agentId: stringField(agent, "agentId"),
5224
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5225
+ summary: stringField(statusSnapshot, "summary"),
5226
+ statusReason: stringField(agent, "statusReason"),
5227
+ threadId: stringField(agent, "threadId"),
5228
+ rolloutPath: stringField(agent, "rolloutPath"),
5229
+ pid: numberField(agent, "pid"),
5230
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5231
+ };
5232
+ }
5112
5233
  function sleep(ms) {
5113
5234
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5114
5235
  }
@@ -5143,6 +5264,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5143
5264
  if (!agentId) {
5144
5265
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5145
5266
  }
5267
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5146
5268
  const stopAgent = async () => {
5147
5269
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5148
5270
  cwd: spec.cwd,
@@ -5185,10 +5307,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5185
5307
  });
5186
5308
  }
5187
5309
  const status = codewithAgentStatus(lastReadJson);
5188
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5310
+ const fingerprint = JSON.stringify({
5311
+ status,
5312
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5313
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5314
+ });
5189
5315
  if (fingerprint !== lastFingerprint) {
5190
5316
  lastFingerprint = fingerprint;
5191
5317
  lastProgressAt = Date.now();
5318
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5192
5319
  }
5193
5320
  if (status === "completed" || status === "failed" || status === "cancelled") {
5194
5321
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6114,7 +6241,7 @@ ${failure.evidence.stderr}` : undefined
6114
6241
 
6115
6242
  `);
6116
6243
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
6117
- const tags = ["bug", "openloops", "loop-health", failure.classification];
6244
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
6118
6245
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
6119
6246
  return {
6120
6247
  title,
@@ -6129,7 +6256,7 @@ ${failure.evidence.stderr}` : undefined
6129
6256
  comment: ["todos", "comment", "<task-id>", description]
6130
6257
  },
6131
6258
  futureNativeUpsert: {
6132
- command: "todos upsert",
6259
+ command: "todos task upsert",
6133
6260
  fields: {
6134
6261
  title,
6135
6262
  description,
@@ -6824,173 +6951,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
6824
6951
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
6825
6952
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
6826
6953
  }
6954
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
6955
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
6956
+ try {
6957
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
6958
+ } catch {}
6959
+ }
6827
6960
  const ordered = workflowExecutionOrder(workflow);
6828
6961
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
6829
6962
  let blockingError;
6830
6963
  let terminalStatus = "succeeded";
6831
- for (const step of ordered) {
6832
- if (store.isWorkflowRunTerminal(run.id)) {
6833
- terminalStatus = store.requireWorkflowRun(run.id).status;
6834
- blockingError = "workflow run was cancelled";
6835
- break;
6836
- }
6837
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6838
- if (pendingTimeout) {
6839
- terminalStatus = "timed_out";
6840
- blockingError = pendingTimeout;
6841
- break;
6842
- }
6843
- const existing = store.getWorkflowStepRun(run.id, step.id);
6844
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6845
- continue;
6846
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6847
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6848
- const dependencyStep = byId.get(dependencyId);
6849
- if (dependencyRun?.status === "succeeded")
6850
- return false;
6851
- return !dependencyStep?.continueOnFailure;
6852
- });
6853
- if (blockedBy) {
6854
- opts.beforePersist?.();
6855
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6856
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6857
- daemonLeaseId: opts.daemonLeaseId
6858
- });
6964
+ try {
6965
+ for (const step of ordered) {
6966
+ if (store.isWorkflowRunTerminal(run.id)) {
6967
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6968
+ blockingError = "workflow run was cancelled";
6969
+ break;
6970
+ }
6971
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6972
+ if (pendingTimeout) {
6973
+ terminalStatus = "timed_out";
6974
+ blockingError = pendingTimeout;
6975
+ break;
6976
+ }
6977
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6978
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6979
+ continue;
6980
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6981
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6982
+ const dependencyStep = byId.get(dependencyId);
6983
+ if (dependencyRun?.status === "succeeded")
6984
+ return false;
6985
+ return !dependencyStep?.continueOnFailure;
6986
+ });
6987
+ if (blockedBy) {
6988
+ opts.beforePersist?.();
6989
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6990
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6991
+ daemonLeaseId: opts.daemonLeaseId
6992
+ });
6993
+ continue;
6994
+ }
6995
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6996
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6997
+ terminalStatus = "failed";
6859
6998
  continue;
6860
6999
  }
6861
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6862
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6863
- terminalStatus = "failed";
6864
- continue;
6865
- }
6866
- opts.beforePersist?.();
6867
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6868
- if (startedStep.status !== "running") {
6869
- terminalStatus = "failed";
6870
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
6871
- break;
6872
- }
6873
- const stepContext = goalExecutionContext({
6874
- loop: opts.loop,
6875
- loopRun: opts.loopRun,
6876
- scheduledFor: opts.scheduledFor,
6877
- workflow,
6878
- workflowRunId: run.id,
6879
- workflowStepId: step.id
6880
- });
6881
- let result;
6882
- const controller = new AbortController;
6883
- const externalAbort = () => controller.abort();
6884
- if (opts.signal?.aborted)
6885
- controller.abort();
6886
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
6887
- const cancelTimer = setInterval(() => {
6888
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
7000
+ opts.beforePersist?.();
7001
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7002
+ if (startedStep.status !== "running") {
7003
+ terminalStatus = "failed";
7004
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
7005
+ break;
7006
+ }
7007
+ const stepContext = goalExecutionContext({
7008
+ loop: opts.loop,
7009
+ loopRun: opts.loopRun,
7010
+ scheduledFor: opts.scheduledFor,
7011
+ workflow,
7012
+ workflowRunId: run.id,
7013
+ workflowStepId: step.id
7014
+ });
7015
+ let result;
7016
+ const controller = new AbortController;
7017
+ const externalAbort = () => controller.abort();
7018
+ if (opts.signal?.aborted)
6889
7019
  controller.abort();
6890
- }, opts.cancelPollMs ?? 500);
6891
- cancelTimer.unref();
6892
- try {
6893
- if (step.goal) {
6894
- result = await runGoal(store, step.goal, {
6895
- ...opts,
6896
- model: opts.goalModel,
6897
- target: targetWithStepAccount(step),
6898
- signal: controller.signal,
6899
- context: stepContext
6900
- });
6901
- } else {
6902
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6903
- ...opts,
6904
- machine: opts.machine ?? opts.loop?.machine,
6905
- signal: controller.signal,
6906
- onSpawn: (pid) => {
6907
- opts.beforePersist?.();
6908
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6909
- opts.onSpawn?.(pid);
6910
- }
7020
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
7021
+ const cancelTimer = setInterval(() => {
7022
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
7023
+ controller.abort();
7024
+ }, opts.cancelPollMs ?? 500);
7025
+ cancelTimer.unref();
7026
+ try {
7027
+ if (step.goal) {
7028
+ result = await runGoal(store, step.goal, {
7029
+ ...opts,
7030
+ model: opts.goalModel,
7031
+ target: targetWithStepAccount(step),
7032
+ signal: controller.signal,
7033
+ context: stepContext
7034
+ });
7035
+ } else {
7036
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7037
+ ...opts,
7038
+ machine: opts.machine ?? opts.loop?.machine,
7039
+ signal: controller.signal,
7040
+ onAgentProgress: (progress) => {
7041
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
7042
+ opts.beforePersist?.();
7043
+ store.recordWorkflowStepProgress(run.id, step.id, {
7044
+ stdout,
7045
+ payload: progress
7046
+ }, {
7047
+ daemonLeaseId: opts.daemonLeaseId
7048
+ });
7049
+ opts.onAgentProgress?.(progress);
7050
+ },
7051
+ onSpawn: (pid) => {
7052
+ opts.beforePersist?.();
7053
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7054
+ opts.onSpawn?.(pid);
7055
+ }
7056
+ });
7057
+ }
7058
+ } catch (error) {
7059
+ const finishedAt2 = nowIso();
7060
+ result = {
7061
+ status: "failed",
7062
+ stdout: "",
7063
+ stderr: "",
7064
+ error: error instanceof Error ? error.message : String(error),
7065
+ startedAt: startedStep.startedAt ?? finishedAt2,
7066
+ finishedAt: finishedAt2,
7067
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7068
+ };
7069
+ } finally {
7070
+ clearInterval(cancelTimer);
7071
+ opts.signal?.removeEventListener("abort", externalAbort);
7072
+ }
7073
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7074
+ if (timeoutMessage && result.status === "failed") {
7075
+ result = { ...result, status: "timed_out", error: timeoutMessage };
7076
+ }
7077
+ if (store.isWorkflowRunTerminal(run.id)) {
7078
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7079
+ blockingError = "workflow run was cancelled";
7080
+ break;
7081
+ }
7082
+ opts.beforePersist?.();
7083
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7084
+ if (blockedExit) {
7085
+ store.finalizeWorkflowStepRun(run.id, step.id, {
7086
+ status: "skipped",
7087
+ finishedAt: result.finishedAt,
7088
+ durationMs: result.durationMs,
7089
+ stdout: result.stdout,
7090
+ stderr: result.stderr,
7091
+ exitCode: result.exitCode,
7092
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7093
+ }, {
7094
+ daemonLeaseId: opts.daemonLeaseId
6911
7095
  });
7096
+ continue;
6912
7097
  }
6913
- } catch (error) {
6914
- const finishedAt2 = nowIso();
6915
- result = {
6916
- status: "failed",
6917
- stdout: "",
6918
- stderr: "",
6919
- error: error instanceof Error ? error.message : String(error),
6920
- startedAt: startedStep.startedAt ?? finishedAt2,
6921
- finishedAt: finishedAt2,
6922
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6923
- };
6924
- } finally {
6925
- clearInterval(cancelTimer);
6926
- opts.signal?.removeEventListener("abort", externalAbort);
6927
- }
6928
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6929
- if (timeoutMessage && result.status === "failed") {
6930
- result = { ...result, status: "timed_out", error: timeoutMessage };
6931
- }
6932
- if (store.isWorkflowRunTerminal(run.id)) {
6933
- terminalStatus = store.requireWorkflowRun(run.id).status;
6934
- blockingError = "workflow run was cancelled";
6935
- break;
6936
- }
6937
- opts.beforePersist?.();
6938
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6939
- if (blockedExit) {
6940
7098
  store.finalizeWorkflowStepRun(run.id, step.id, {
6941
- status: "skipped",
7099
+ status: result.status,
6942
7100
  finishedAt: result.finishedAt,
6943
7101
  durationMs: result.durationMs,
6944
7102
  stdout: result.stdout,
6945
7103
  stderr: result.stderr,
6946
7104
  exitCode: result.exitCode,
6947
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7105
+ error: result.error
6948
7106
  }, {
6949
7107
  daemonLeaseId: opts.daemonLeaseId
6950
7108
  });
6951
- continue;
7109
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
7110
+ terminalStatus = result.status;
7111
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7112
+ break;
7113
+ }
6952
7114
  }
6953
- store.finalizeWorkflowStepRun(run.id, step.id, {
6954
- status: result.status,
6955
- finishedAt: result.finishedAt,
6956
- durationMs: result.durationMs,
6957
- stdout: result.stdout,
6958
- stderr: result.stderr,
6959
- exitCode: result.exitCode,
6960
- error: result.error
7115
+ if (terminalStatus !== "succeeded") {
7116
+ for (const step of ordered) {
7117
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7118
+ if (existing?.status === "pending" || existing?.status === "running") {
7119
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7120
+ daemonLeaseId: opts.daemonLeaseId
7121
+ });
7122
+ }
7123
+ }
7124
+ }
7125
+ const finishedAt = nowIso();
7126
+ if (store.isWorkflowRunTerminal(run.id)) {
7127
+ const terminalRun = store.requireWorkflowRun(run.id);
7128
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7129
+ }
7130
+ opts.beforePersist?.();
7131
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7132
+ finishedAt,
7133
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7134
+ error: blockingError
6961
7135
  }, {
6962
7136
  daemonLeaseId: opts.daemonLeaseId
6963
7137
  });
6964
- if (result.status !== "succeeded" && !step.continueOnFailure) {
6965
- terminalStatus = result.status;
6966
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6967
- break;
6968
- }
6969
- }
6970
- if (terminalStatus !== "succeeded") {
6971
- for (const step of ordered) {
6972
- const existing = store.getWorkflowStepRun(run.id, step.id);
6973
- if (existing?.status === "pending" || existing?.status === "running") {
6974
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7138
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7139
+ } catch (error) {
7140
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
7141
+ throw error;
7142
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
7143
+ throw error;
7144
+ const message = error instanceof Error ? error.message : String(error);
7145
+ const finishedAt = nowIso();
7146
+ try {
7147
+ if (!store.isWorkflowRunTerminal(run.id)) {
7148
+ store.finalizeWorkflowRun(run.id, "failed", {
7149
+ finishedAt,
7150
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7151
+ error: message
7152
+ }, {
6975
7153
  daemonLeaseId: opts.daemonLeaseId
6976
7154
  });
6977
7155
  }
6978
- }
6979
- }
6980
- const finishedAt = nowIso();
6981
- if (store.isWorkflowRunTerminal(run.id)) {
6982
- const terminalRun = store.requireWorkflowRun(run.id);
6983
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7156
+ } catch {}
7157
+ const current = store.getWorkflowRun(run.id) ?? run;
7158
+ const resultStatus = current.status === "running" ? "failed" : current.status;
7159
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
6984
7160
  }
6985
- opts.beforePersist?.();
6986
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6987
- finishedAt,
6988
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6989
- error: blockingError
6990
- }, {
6991
- daemonLeaseId: opts.daemonLeaseId
6992
- });
6993
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6994
7161
  }
6995
7162
  function preflightWorkflow(workflow, opts = {}) {
6996
7163
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7361,6 +7528,25 @@ function advanceOptions(deps) {
7361
7528
  onRun: deps.onRun
7362
7529
  };
7363
7530
  }
7531
+ var TERMINAL_RUN_STATUSES2 = new Set([
7532
+ "succeeded",
7533
+ "failed",
7534
+ "timed_out",
7535
+ "abandoned",
7536
+ "skipped"
7537
+ ]);
7538
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
7539
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
7540
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
7541
+ return;
7542
+ try {
7543
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
7544
+ } catch (error) {
7545
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
7546
+ return;
7547
+ throw error;
7548
+ }
7549
+ }
7364
7550
  async function runSlot(deps, loop, scheduledFor) {
7365
7551
  const now = deps.now?.() ?? new Date;
7366
7552
  deps.beforeRun?.(loop, scheduledFor);
@@ -7387,8 +7573,10 @@ async function runSlot(deps, loop, scheduledFor) {
7387
7573
  return;
7388
7574
  throw error;
7389
7575
  }
7390
- if (!claim)
7576
+ if (!claim) {
7577
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7391
7578
  return;
7579
+ }
7392
7580
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7393
7581
  deps.onRun?.(claim.run);
7394
7582
  const finalRun = await executeClaimedRun({
@@ -7434,8 +7622,10 @@ function claimSlot(deps, loop, scheduledFor) {
7434
7622
  return;
7435
7623
  throw error;
7436
7624
  }
7437
- if (!claim)
7625
+ if (!claim) {
7626
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7438
7627
  return;
7628
+ }
7439
7629
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7440
7630
  deps.onRun?.(claim.run);
7441
7631
  return claim;
@@ -7529,7 +7719,7 @@ async function tick(deps) {
7529
7719
  // package.json
7530
7720
  var package_default = {
7531
7721
  name: "@hasna/loops",
7532
- version: "0.4.5",
7722
+ version: "0.4.7",
7533
7723
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7534
7724
  type: "module",
7535
7725
  main: "dist/index.js",
@@ -8076,7 +8266,7 @@ var TOOL_REGISTRATIONS = [
8076
8266
  guarded: true,
8077
8267
  annotations: mutationAnnotations({ idempotent: true }),
8078
8268
  inputSchema: { idOrName: loopIdOrNameSchema },
8079
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "paused" })) }))
8269
+ handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "paused" })) }))
8080
8270
  },
8081
8271
  {
8082
8272
  name: "loops_resume",
@@ -8086,7 +8276,15 @@ var TOOL_REGISTRATIONS = [
8086
8276
  guarded: true,
8087
8277
  annotations: mutationAnnotations({ idempotent: true }),
8088
8278
  inputSchema: { idOrName: loopIdOrNameSchema },
8089
- handler: ({ idOrName }) => withStore((store) => ({ loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "active" })) }))
8279
+ handler: ({ idOrName }) => withStore((store) => {
8280
+ const loop = store.requireUniqueLoop(idOrName);
8281
+ let nextRunAt = loop.nextRunAt;
8282
+ if (!nextRunAt) {
8283
+ const now = new Date;
8284
+ nextRunAt = computeNextAfter(loop.schedule, now, now);
8285
+ }
8286
+ return { loop: publicLoop(store.updateLoop(loop.id, { status: "active", nextRunAt })) };
8287
+ })
8090
8288
  },
8091
8289
  {
8092
8290
  name: "loops_stop",
@@ -8097,7 +8295,7 @@ var TOOL_REGISTRATIONS = [
8097
8295
  annotations: mutationAnnotations({ idempotent: true }),
8098
8296
  inputSchema: { idOrName: loopIdOrNameSchema },
8099
8297
  handler: ({ idOrName }) => withStore((store) => ({
8100
- loop: publicLoop(store.updateLoop(store.requireLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8298
+ loop: publicLoop(store.updateLoop(store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined }))
8101
8299
  }))
8102
8300
  },
8103
8301
  {
@@ -8110,7 +8308,7 @@ var TOOL_REGISTRATIONS = [
8110
8308
  inputSchema: { idOrName: loopIdOrNameSchema },
8111
8309
  handler: ({ idOrName }) => withStore(async (store) => {
8112
8310
  const daemon = daemonStatus(store);
8113
- const result = await runLoopNow({ store, idOrName, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8311
+ const result = await runLoopNow({ store, idOrName: store.requireUniqueLoop(idOrName).id, runnerId: `mcp:${process.pid}`, mode: "schedule" });
8114
8312
  return {
8115
8313
  scheduledFor: result.scheduledFor,
8116
8314
  loop: publicLoop(result.loop),