@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.
@@ -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
 
@@ -4600,7 +4651,7 @@ ${failure.evidence.stderr}` : undefined
4600
4651
 
4601
4652
  `);
4602
4653
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
4603
- const tags = ["bug", "openloops", "loop-health", failure.classification];
4654
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
4604
4655
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
4605
4656
  return {
4606
4657
  title,
@@ -4615,7 +4666,7 @@ ${failure.evidence.stderr}` : undefined
4615
4666
  comment: ["todos", "comment", "<task-id>", description]
4616
4667
  },
4617
4668
  futureNativeUpsert: {
4618
- command: "todos upsert",
4669
+ command: "todos task upsert",
4619
4670
  fields: {
4620
4671
  title,
4621
4672
  description,
@@ -5044,7 +5095,12 @@ function notifySpawn(pid, opts) {
5044
5095
  if (!pid)
5045
5096
  return;
5046
5097
  opts.onSpawn?.(pid);
5047
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
5098
+ const startedMs = processStartTimeMs(pid);
5099
+ opts.onSpawnProcess?.({
5100
+ pid,
5101
+ pgid: pid,
5102
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
5103
+ });
5048
5104
  }
5049
5105
  function shellQuote(value) {
5050
5106
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5399,6 +5455,13 @@ function numberField(value, key) {
5399
5455
  function codewithAgentStatus(readJson) {
5400
5456
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5401
5457
  }
5458
+ function codewithAgentLastEventSeq(readJson) {
5459
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5460
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5461
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5462
+ return Math.max(agentSeq, snapshotSeq);
5463
+ return agentSeq ?? snapshotSeq;
5464
+ }
5402
5465
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5403
5466
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5404
5467
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5429,6 +5492,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5429
5492
  events
5430
5493
  }, null, 2);
5431
5494
  }
5495
+ function codewithAgentProgress(readJson) {
5496
+ const agent = recordField(readJson, "agent");
5497
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5498
+ return {
5499
+ provider: "codewith",
5500
+ agentId: stringField(agent, "agentId"),
5501
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5502
+ summary: stringField(statusSnapshot, "summary"),
5503
+ statusReason: stringField(agent, "statusReason"),
5504
+ threadId: stringField(agent, "threadId"),
5505
+ rolloutPath: stringField(agent, "rolloutPath"),
5506
+ pid: numberField(agent, "pid"),
5507
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5508
+ };
5509
+ }
5432
5510
  function sleep(ms) {
5433
5511
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5434
5512
  }
@@ -5463,6 +5541,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5463
5541
  if (!agentId) {
5464
5542
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5465
5543
  }
5544
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5466
5545
  const stopAgent = async () => {
5467
5546
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5468
5547
  cwd: spec.cwd,
@@ -5505,10 +5584,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5505
5584
  });
5506
5585
  }
5507
5586
  const status = codewithAgentStatus(lastReadJson);
5508
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5587
+ const fingerprint = JSON.stringify({
5588
+ status,
5589
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5590
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5591
+ });
5509
5592
  if (fingerprint !== lastFingerprint) {
5510
5593
  lastFingerprint = fingerprint;
5511
5594
  lastProgressAt = Date.now();
5595
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5512
5596
  }
5513
5597
  if (status === "completed" || status === "failed" || status === "cancelled") {
5514
5598
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6509,173 +6593,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
6509
6593
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
6510
6594
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
6511
6595
  }
6596
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
6597
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
6598
+ try {
6599
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
6600
+ } catch {}
6601
+ }
6512
6602
  const ordered = workflowExecutionOrder(workflow);
6513
6603
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
6514
6604
  let blockingError;
6515
6605
  let terminalStatus = "succeeded";
6516
- for (const step of ordered) {
6517
- if (store.isWorkflowRunTerminal(run.id)) {
6518
- terminalStatus = store.requireWorkflowRun(run.id).status;
6519
- blockingError = "workflow run was cancelled";
6520
- break;
6521
- }
6522
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6523
- if (pendingTimeout) {
6524
- terminalStatus = "timed_out";
6525
- blockingError = pendingTimeout;
6526
- break;
6527
- }
6528
- const existing = store.getWorkflowStepRun(run.id, step.id);
6529
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6530
- continue;
6531
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6532
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6533
- const dependencyStep = byId.get(dependencyId);
6534
- if (dependencyRun?.status === "succeeded")
6535
- return false;
6536
- return !dependencyStep?.continueOnFailure;
6537
- });
6538
- if (blockedBy) {
6539
- opts.beforePersist?.();
6540
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6541
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6542
- daemonLeaseId: opts.daemonLeaseId
6543
- });
6606
+ try {
6607
+ for (const step of ordered) {
6608
+ if (store.isWorkflowRunTerminal(run.id)) {
6609
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6610
+ blockingError = "workflow run was cancelled";
6611
+ break;
6612
+ }
6613
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6614
+ if (pendingTimeout) {
6615
+ terminalStatus = "timed_out";
6616
+ blockingError = pendingTimeout;
6617
+ break;
6618
+ }
6619
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6620
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
6621
+ continue;
6622
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
6623
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
6624
+ const dependencyStep = byId.get(dependencyId);
6625
+ if (dependencyRun?.status === "succeeded")
6626
+ return false;
6627
+ return !dependencyStep?.continueOnFailure;
6628
+ });
6629
+ if (blockedBy) {
6630
+ opts.beforePersist?.();
6631
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
6632
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
6633
+ daemonLeaseId: opts.daemonLeaseId
6634
+ });
6635
+ continue;
6636
+ }
6637
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6638
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6639
+ terminalStatus = "failed";
6544
6640
  continue;
6545
6641
  }
6546
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
6547
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
6548
- terminalStatus = "failed";
6549
- continue;
6550
- }
6551
- opts.beforePersist?.();
6552
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6553
- if (startedStep.status !== "running") {
6554
- terminalStatus = "failed";
6555
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
6556
- break;
6557
- }
6558
- const stepContext = goalExecutionContext({
6559
- loop: opts.loop,
6560
- loopRun: opts.loopRun,
6561
- scheduledFor: opts.scheduledFor,
6562
- workflow,
6563
- workflowRunId: run.id,
6564
- workflowStepId: step.id
6565
- });
6566
- let result;
6567
- const controller = new AbortController;
6568
- const externalAbort = () => controller.abort();
6569
- if (opts.signal?.aborted)
6570
- controller.abort();
6571
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
6572
- const cancelTimer = setInterval(() => {
6573
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
6642
+ opts.beforePersist?.();
6643
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
6644
+ if (startedStep.status !== "running") {
6645
+ terminalStatus = "failed";
6646
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
6647
+ break;
6648
+ }
6649
+ const stepContext = goalExecutionContext({
6650
+ loop: opts.loop,
6651
+ loopRun: opts.loopRun,
6652
+ scheduledFor: opts.scheduledFor,
6653
+ workflow,
6654
+ workflowRunId: run.id,
6655
+ workflowStepId: step.id
6656
+ });
6657
+ let result;
6658
+ const controller = new AbortController;
6659
+ const externalAbort = () => controller.abort();
6660
+ if (opts.signal?.aborted)
6574
6661
  controller.abort();
6575
- }, opts.cancelPollMs ?? 500);
6576
- cancelTimer.unref();
6577
- try {
6578
- if (step.goal) {
6579
- result = await runGoal(store, step.goal, {
6580
- ...opts,
6581
- model: opts.goalModel,
6582
- target: targetWithStepAccount(step),
6583
- signal: controller.signal,
6584
- context: stepContext
6585
- });
6586
- } else {
6587
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6588
- ...opts,
6589
- machine: opts.machine ?? opts.loop?.machine,
6590
- signal: controller.signal,
6591
- onSpawn: (pid) => {
6592
- opts.beforePersist?.();
6593
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6594
- opts.onSpawn?.(pid);
6595
- }
6662
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
6663
+ const cancelTimer = setInterval(() => {
6664
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
6665
+ controller.abort();
6666
+ }, opts.cancelPollMs ?? 500);
6667
+ cancelTimer.unref();
6668
+ try {
6669
+ if (step.goal) {
6670
+ result = await runGoal(store, step.goal, {
6671
+ ...opts,
6672
+ model: opts.goalModel,
6673
+ target: targetWithStepAccount(step),
6674
+ signal: controller.signal,
6675
+ context: stepContext
6676
+ });
6677
+ } else {
6678
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
6679
+ ...opts,
6680
+ machine: opts.machine ?? opts.loop?.machine,
6681
+ signal: controller.signal,
6682
+ onAgentProgress: (progress) => {
6683
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
6684
+ opts.beforePersist?.();
6685
+ store.recordWorkflowStepProgress(run.id, step.id, {
6686
+ stdout,
6687
+ payload: progress
6688
+ }, {
6689
+ daemonLeaseId: opts.daemonLeaseId
6690
+ });
6691
+ opts.onAgentProgress?.(progress);
6692
+ },
6693
+ onSpawn: (pid) => {
6694
+ opts.beforePersist?.();
6695
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
6696
+ opts.onSpawn?.(pid);
6697
+ }
6698
+ });
6699
+ }
6700
+ } catch (error) {
6701
+ const finishedAt2 = nowIso();
6702
+ result = {
6703
+ status: "failed",
6704
+ stdout: "",
6705
+ stderr: "",
6706
+ error: error instanceof Error ? error.message : String(error),
6707
+ startedAt: startedStep.startedAt ?? finishedAt2,
6708
+ finishedAt: finishedAt2,
6709
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6710
+ };
6711
+ } finally {
6712
+ clearInterval(cancelTimer);
6713
+ opts.signal?.removeEventListener("abort", externalAbort);
6714
+ }
6715
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6716
+ if (timeoutMessage && result.status === "failed") {
6717
+ result = { ...result, status: "timed_out", error: timeoutMessage };
6718
+ }
6719
+ if (store.isWorkflowRunTerminal(run.id)) {
6720
+ terminalStatus = store.requireWorkflowRun(run.id).status;
6721
+ blockingError = "workflow run was cancelled";
6722
+ break;
6723
+ }
6724
+ opts.beforePersist?.();
6725
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6726
+ if (blockedExit) {
6727
+ store.finalizeWorkflowStepRun(run.id, step.id, {
6728
+ status: "skipped",
6729
+ finishedAt: result.finishedAt,
6730
+ durationMs: result.durationMs,
6731
+ stdout: result.stdout,
6732
+ stderr: result.stderr,
6733
+ exitCode: result.exitCode,
6734
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
6735
+ }, {
6736
+ daemonLeaseId: opts.daemonLeaseId
6596
6737
  });
6738
+ continue;
6597
6739
  }
6598
- } catch (error) {
6599
- const finishedAt2 = nowIso();
6600
- result = {
6601
- status: "failed",
6602
- stdout: "",
6603
- stderr: "",
6604
- error: error instanceof Error ? error.message : String(error),
6605
- startedAt: startedStep.startedAt ?? finishedAt2,
6606
- finishedAt: finishedAt2,
6607
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
6608
- };
6609
- } finally {
6610
- clearInterval(cancelTimer);
6611
- opts.signal?.removeEventListener("abort", externalAbort);
6612
- }
6613
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
6614
- if (timeoutMessage && result.status === "failed") {
6615
- result = { ...result, status: "timed_out", error: timeoutMessage };
6616
- }
6617
- if (store.isWorkflowRunTerminal(run.id)) {
6618
- terminalStatus = store.requireWorkflowRun(run.id).status;
6619
- blockingError = "workflow run was cancelled";
6620
- break;
6621
- }
6622
- opts.beforePersist?.();
6623
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
6624
- if (blockedExit) {
6625
6740
  store.finalizeWorkflowStepRun(run.id, step.id, {
6626
- status: "skipped",
6741
+ status: result.status,
6627
6742
  finishedAt: result.finishedAt,
6628
6743
  durationMs: result.durationMs,
6629
6744
  stdout: result.stdout,
6630
6745
  stderr: result.stderr,
6631
6746
  exitCode: result.exitCode,
6632
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
6747
+ error: result.error
6633
6748
  }, {
6634
6749
  daemonLeaseId: opts.daemonLeaseId
6635
6750
  });
6636
- continue;
6751
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
6752
+ terminalStatus = result.status;
6753
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6754
+ break;
6755
+ }
6637
6756
  }
6638
- store.finalizeWorkflowStepRun(run.id, step.id, {
6639
- status: result.status,
6640
- finishedAt: result.finishedAt,
6641
- durationMs: result.durationMs,
6642
- stdout: result.stdout,
6643
- stderr: result.stderr,
6644
- exitCode: result.exitCode,
6645
- error: result.error
6757
+ if (terminalStatus !== "succeeded") {
6758
+ for (const step of ordered) {
6759
+ const existing = store.getWorkflowStepRun(run.id, step.id);
6760
+ if (existing?.status === "pending" || existing?.status === "running") {
6761
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
6762
+ daemonLeaseId: opts.daemonLeaseId
6763
+ });
6764
+ }
6765
+ }
6766
+ }
6767
+ const finishedAt = nowIso();
6768
+ if (store.isWorkflowRunTerminal(run.id)) {
6769
+ const terminalRun = store.requireWorkflowRun(run.id);
6770
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
6771
+ }
6772
+ opts.beforePersist?.();
6773
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6774
+ finishedAt,
6775
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6776
+ error: blockingError
6646
6777
  }, {
6647
6778
  daemonLeaseId: opts.daemonLeaseId
6648
6779
  });
6649
- if (result.status !== "succeeded" && !step.continueOnFailure) {
6650
- terminalStatus = result.status;
6651
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
6652
- break;
6653
- }
6654
- }
6655
- if (terminalStatus !== "succeeded") {
6656
- for (const step of ordered) {
6657
- const existing = store.getWorkflowStepRun(run.id, step.id);
6658
- if (existing?.status === "pending" || existing?.status === "running") {
6659
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
6780
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6781
+ } catch (error) {
6782
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
6783
+ throw error;
6784
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
6785
+ throw error;
6786
+ const message = error instanceof Error ? error.message : String(error);
6787
+ const finishedAt = nowIso();
6788
+ try {
6789
+ if (!store.isWorkflowRunTerminal(run.id)) {
6790
+ store.finalizeWorkflowRun(run.id, "failed", {
6791
+ finishedAt,
6792
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6793
+ error: message
6794
+ }, {
6660
6795
  daemonLeaseId: opts.daemonLeaseId
6661
6796
  });
6662
6797
  }
6663
- }
6664
- }
6665
- const finishedAt = nowIso();
6666
- if (store.isWorkflowRunTerminal(run.id)) {
6667
- const terminalRun = store.requireWorkflowRun(run.id);
6668
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
6798
+ } catch {}
6799
+ const current = store.getWorkflowRun(run.id) ?? run;
6800
+ const resultStatus = current.status === "running" ? "failed" : current.status;
6801
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
6669
6802
  }
6670
- opts.beforePersist?.();
6671
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
6672
- finishedAt,
6673
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
6674
- error: blockingError
6675
- }, {
6676
- daemonLeaseId: opts.daemonLeaseId
6677
- });
6678
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
6679
6803
  }
6680
6804
  function preflightWorkflow(workflow, opts = {}) {
6681
6805
  return workflowExecutionOrder(workflow).map((step) => {
@@ -7046,6 +7170,25 @@ function advanceOptions(deps) {
7046
7170
  onRun: deps.onRun
7047
7171
  };
7048
7172
  }
7173
+ var TERMINAL_RUN_STATUSES2 = new Set([
7174
+ "succeeded",
7175
+ "failed",
7176
+ "timed_out",
7177
+ "abandoned",
7178
+ "skipped"
7179
+ ]);
7180
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
7181
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
7182
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
7183
+ return;
7184
+ try {
7185
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
7186
+ } catch (error) {
7187
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
7188
+ return;
7189
+ throw error;
7190
+ }
7191
+ }
7049
7192
  async function runSlot(deps, loop, scheduledFor) {
7050
7193
  const now = deps.now?.() ?? new Date;
7051
7194
  deps.beforeRun?.(loop, scheduledFor);
@@ -7072,8 +7215,10 @@ async function runSlot(deps, loop, scheduledFor) {
7072
7215
  return;
7073
7216
  throw error;
7074
7217
  }
7075
- if (!claim)
7218
+ if (!claim) {
7219
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7076
7220
  return;
7221
+ }
7077
7222
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7078
7223
  deps.onRun?.(claim.run);
7079
7224
  const finalRun = await executeClaimedRun({
@@ -7119,8 +7264,10 @@ function claimSlot(deps, loop, scheduledFor) {
7119
7264
  return;
7120
7265
  throw error;
7121
7266
  }
7122
- if (!claim)
7267
+ if (!claim) {
7268
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
7123
7269
  return;
7270
+ }
7124
7271
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
7125
7272
  deps.onRun?.(claim.run);
7126
7273
  return claim;
@@ -7401,10 +7548,7 @@ async function stopDaemon(opts = {}) {
7401
7548
  const store = new Store(join5(dirname4(path), "loops.db"));
7402
7549
  try {
7403
7550
  const lease = store.getDaemonLease();
7404
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
7405
- removePid(path);
7406
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
7407
- }
7551
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
7408
7552
  try {
7409
7553
  process.kill(state.pid, "SIGTERM");
7410
7554
  } catch {
@@ -7424,11 +7568,14 @@ async function stopDaemon(opts = {}) {
7424
7568
  } catch {}
7425
7569
  await sleep2(150);
7426
7570
  removePid(path);
7427
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
7428
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
7429
- sleep: sleep2,
7430
- graceMs: opts.reapGraceMs
7431
- });
7571
+ let reapedPgids = [];
7572
+ if (leaseVerified && lease) {
7573
+ const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
7574
+ reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
7575
+ sleep: sleep2,
7576
+ graceMs: opts.reapGraceMs
7577
+ });
7578
+ }
7432
7579
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
7433
7580
  } finally {
7434
7581
  store.close();
@@ -7613,7 +7760,14 @@ async function runDaemon(opts = {}) {
7613
7760
  try {
7614
7761
  const liveInlineOwner = (run) => {
7615
7762
  const ownerPid = inlineRunnerOwnerPid(run.claimedBy);
7616
- return ownerPid !== undefined && isAlive(ownerPid);
7763
+ if (ownerPid === undefined || !isAlive(ownerPid))
7764
+ return false;
7765
+ const ownerStartMs = processStartTimeMs(ownerPid);
7766
+ const runStartMs = run.startedAt ? Date.parse(run.startedAt) : Number.NaN;
7767
+ if (ownerStartMs !== undefined && Number.isFinite(runStartMs) && ownerStartMs > runStartMs + START_TIME_TOLERANCE_MS) {
7768
+ return false;
7769
+ }
7770
+ return true;
7617
7771
  };
7618
7772
  const staleCandidates = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.leaseExpiresAt !== undefined && new Date(run.leaseExpiresAt).getTime() <= Date.now());
7619
7773
  const startup = claimDueRuns({ store, runnerId, daemonLeaseId: leaseId, maxClaims: 0 });
@@ -7807,7 +7961,7 @@ function enableStartup(result) {
7807
7961
  // package.json
7808
7962
  var package_default = {
7809
7963
  name: "@hasna/loops",
7810
- version: "0.4.4",
7964
+ version: "0.4.6",
7811
7965
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7812
7966
  type: "module",
7813
7967
  main: "dist/index.js",