@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/sdk/index.js CHANGED
@@ -724,7 +724,7 @@ function buildAgentInvocation(target) {
724
724
  if (target.model)
725
725
  args.push("--model", target.model);
726
726
  args.push(...target.extraArgs ?? []);
727
- args.push("agent", "start");
727
+ args.push("agent", "start", "--json");
728
728
  if (target.cwd)
729
729
  args.push("--cwd", target.cwd);
730
730
  args.push(target.prompt);
@@ -1491,11 +1491,14 @@ function ensurePrivateStorePath(file) {
1491
1491
  class Store {
1492
1492
  db;
1493
1493
  rootDir;
1494
+ memoryRootDir;
1494
1495
  constructor(path) {
1495
1496
  const file = path ?? dbPath();
1496
1497
  if (file !== ":memory:")
1497
1498
  ensurePrivateStorePath(file);
1498
1499
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname2(file);
1500
+ if (file === ":memory:")
1501
+ this.memoryRootDir = this.rootDir;
1499
1502
  this.db = new Database(file);
1500
1503
  this.db.exec("PRAGMA foreign_keys = ON;");
1501
1504
  this.db.exec("PRAGMA busy_timeout = 5000;");
@@ -1948,9 +1951,12 @@ class Store {
1948
1951
  const rows = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 2").all(idOrName);
1949
1952
  if (rows.length === 0)
1950
1953
  throw new LoopNotFoundError(idOrName);
1951
- if (rows.length > 1)
1954
+ if (rows.length === 1)
1955
+ return rowToLoop(rows[0]);
1956
+ const active = this.db.query("SELECT * FROM loops WHERE name = ? AND archived_at IS NULL ORDER BY created_at DESC LIMIT 2").all(idOrName);
1957
+ if (active.length !== 1)
1952
1958
  throw new AmbiguousNameError(idOrName);
1953
- return rowToLoop(rows[0]);
1959
+ return rowToLoop(active[0]);
1954
1960
  }
1955
1961
  requireLoop(idOrName) {
1956
1962
  return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
@@ -2291,6 +2297,7 @@ class Store {
2291
2297
  return this.transact(() => {
2292
2298
  const loop = this.requireLoop(idOrName);
2293
2299
  this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
2300
+ this.db.query("DELETE FROM loop_runs WHERE loop_id = ?").run(loop.id);
2294
2301
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
2295
2302
  return res.changes > 0;
2296
2303
  });
@@ -2775,7 +2782,10 @@ class Store {
2775
2782
  return rowToGoal(row);
2776
2783
  }
2777
2784
  if (context.sourceType && context.sourceId) {
2778
- const row = this.db.query("SELECT * FROM goals WHERE source_type = ? AND source_id = ? ORDER BY created_at DESC LIMIT 1").get(context.sourceType, context.sourceId);
2785
+ const terminalPlaceholders = GOAL_TERMINAL.map(() => "?").join(", ");
2786
+ const row = this.db.query(`SELECT * FROM goals
2787
+ WHERE source_type = ? AND source_id = ? AND status NOT IN (${terminalPlaceholders})
2788
+ ORDER BY created_at DESC LIMIT 1`).get(context.sourceType, context.sourceId, ...GOAL_TERMINAL);
2779
2789
  if (row)
2780
2790
  return rowToGoal(row);
2781
2791
  }
@@ -3195,6 +3205,41 @@ class Store {
3195
3205
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
3196
3206
  return run;
3197
3207
  }
3208
+ recordWorkflowStepProgress(workflowRunId, stepId, progress, opts = {}) {
3209
+ const now = (opts.now ?? new Date).toISOString();
3210
+ this.db.exec("BEGIN IMMEDIATE");
3211
+ try {
3212
+ const res = this.db.query(`UPDATE workflow_step_runs
3213
+ SET stdout=COALESCE($stdout, stdout),
3214
+ stderr=COALESCE($stderr, stderr),
3215
+ updated_at=$updated
3216
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
3217
+ AND ($daemonLeaseId IS NULL OR EXISTS (
3218
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3219
+ ))`).run({
3220
+ $workflowRunId: workflowRunId,
3221
+ $stepId: stepId,
3222
+ $stdout: progress.stdout === undefined ? null : scrubbedOrNull(progress.stdout),
3223
+ $stderr: progress.stderr === undefined ? null : scrubbedOrNull(progress.stderr),
3224
+ $updated: now,
3225
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
3226
+ $now: now
3227
+ });
3228
+ if (res.changes === 1) {
3229
+ this.appendWorkflowEvent(workflowRunId, "step_progress", stepId, progress.payload);
3230
+ }
3231
+ this.db.exec("COMMIT");
3232
+ } catch (error) {
3233
+ try {
3234
+ this.db.exec("ROLLBACK");
3235
+ } catch {}
3236
+ throw error;
3237
+ }
3238
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
3239
+ if (!run)
3240
+ throw new Error(`workflow step run not found after progress update: ${workflowRunId}/${stepId}`);
3241
+ return run;
3242
+ }
3198
3243
  recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
3199
3244
  return this.transact(() => {
3200
3245
  const now = nowIso();
@@ -3632,7 +3677,8 @@ class Store {
3632
3677
  AND ($daemonLeaseId IS NULL OR EXISTS (
3633
3678
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3634
3679
  ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3635
- duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3680
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3681
+ WHERE id=$id AND status='running'`).run(params);
3636
3682
  const run = this.getRun(id);
3637
3683
  if (!run)
3638
3684
  throw new Error(`run not found after finalize: ${id}`);
@@ -4160,12 +4206,18 @@ class Store {
4160
4206
  }
4161
4207
  close() {
4162
4208
  this.db.close();
4209
+ if (this.memoryRootDir) {
4210
+ try {
4211
+ rmSync2(this.memoryRootDir, { recursive: true, force: true });
4212
+ } catch {}
4213
+ this.memoryRootDir = undefined;
4214
+ }
4163
4215
  }
4164
4216
  }
4165
4217
  // package.json
4166
4218
  var package_default = {
4167
4219
  name: "@hasna/loops",
4168
- version: "0.4.5",
4220
+ version: "0.4.7",
4169
4221
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4170
4222
  type: "module",
4171
4223
  main: "dist/index.js",
@@ -4623,10 +4675,7 @@ async function stopDaemon(opts = {}) {
4623
4675
  const store = new Store(join4(dirname3(path), "loops.db"));
4624
4676
  try {
4625
4677
  const lease = store.getDaemonLease();
4626
- if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
4627
- removePid(path);
4628
- return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
4629
- }
4678
+ const leaseVerified = Boolean(lease && lease.pid === state.pid && new Date(lease.expiresAt).getTime() > Date.now());
4630
4679
  try {
4631
4680
  process.kill(state.pid, "SIGTERM");
4632
4681
  } catch {
@@ -4646,11 +4695,14 @@ async function stopDaemon(opts = {}) {
4646
4695
  } catch {}
4647
4696
  await sleep(150);
4648
4697
  removePid(path);
4649
- const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4650
- const reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4651
- sleep,
4652
- graceMs: opts.reapGraceMs
4653
- });
4698
+ let reapedPgids = [];
4699
+ if (leaseVerified && lease) {
4700
+ const ownedRuns = store.listRuns({ status: "running", limit: 1000 }).filter((run) => run.claimedBy !== undefined && run.claimedBy.endsWith(`:${lease.id}`));
4701
+ reapedPgids = await reapProcessGroups(ownedRuns.map(toReapableProcess), {
4702
+ sleep,
4703
+ graceMs: opts.reapGraceMs
4704
+ });
4705
+ }
4654
4706
  return { wasRunning: true, stopped: !isAlive(state.pid, record?.startedAt), forced: true, pid: state.pid, reapedPgids };
4655
4707
  } finally {
4656
4708
  store.close();
@@ -4984,7 +5036,12 @@ function notifySpawn(pid, opts) {
4984
5036
  if (!pid)
4985
5037
  return;
4986
5038
  opts.onSpawn?.(pid);
4987
- opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
5039
+ const startedMs = processStartTimeMs(pid);
5040
+ opts.onSpawnProcess?.({
5041
+ pid,
5042
+ pgid: pid,
5043
+ processStartedAt: startedMs !== undefined ? new Date(startedMs).toISOString() : nowIso()
5044
+ });
4988
5045
  }
4989
5046
  function shellQuote(value) {
4990
5047
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -5309,18 +5366,60 @@ function codewithAgentControlArgs(target, command, agentId) {
5309
5366
  "agent",
5310
5367
  command,
5311
5368
  ...command === "logs" ? ["--limit", "20"] : [],
5369
+ "--json",
5312
5370
  agentId
5313
5371
  ];
5314
5372
  }
5373
+ function extractFirstJsonObject(text) {
5374
+ let depth = 0;
5375
+ let start = -1;
5376
+ let inString = false;
5377
+ let escaped = false;
5378
+ for (let i = 0;i < text.length; i += 1) {
5379
+ const ch = text[i];
5380
+ if (inString) {
5381
+ if (escaped)
5382
+ escaped = false;
5383
+ else if (ch === "\\")
5384
+ escaped = true;
5385
+ else if (ch === '"')
5386
+ inString = false;
5387
+ continue;
5388
+ }
5389
+ if (ch === '"') {
5390
+ inString = true;
5391
+ } else if (ch === "{") {
5392
+ if (depth === 0)
5393
+ start = i;
5394
+ depth += 1;
5395
+ } else if (ch === "}") {
5396
+ if (depth > 0) {
5397
+ depth -= 1;
5398
+ if (depth === 0 && start !== -1)
5399
+ return text.slice(start, i + 1);
5400
+ }
5401
+ }
5402
+ }
5403
+ return;
5404
+ }
5315
5405
  function parseJsonOutput(stdout, label) {
5316
- try {
5317
- const value = JSON.parse(stdout || "{}");
5318
- if (!value || typeof value !== "object" || Array.isArray(value))
5319
- throw new Error("not an object");
5320
- return value;
5321
- } catch (error) {
5322
- throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
5406
+ const raw = stdout || "{}";
5407
+ const candidates = [raw.trim()];
5408
+ const scanned = extractFirstJsonObject(raw);
5409
+ if (scanned && scanned !== raw.trim())
5410
+ candidates.push(scanned);
5411
+ let lastError;
5412
+ for (const candidate of candidates) {
5413
+ try {
5414
+ const value = JSON.parse(candidate);
5415
+ if (!value || typeof value !== "object" || Array.isArray(value))
5416
+ throw new Error("not an object");
5417
+ return value;
5418
+ } catch (error) {
5419
+ lastError = error;
5420
+ }
5323
5421
  }
5422
+ throw new Error(`${label} did not return JSON${lastError instanceof Error ? `: ${lastError.message}` : ""}`);
5324
5423
  }
5325
5424
  function recordField(value, key) {
5326
5425
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -5339,6 +5438,13 @@ function numberField(value, key) {
5339
5438
  function codewithAgentStatus(readJson) {
5340
5439
  return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
5341
5440
  }
5441
+ function codewithAgentLastEventSeq(readJson) {
5442
+ const agentSeq = numberField(recordField(readJson, "agent"), "lastEventSeq");
5443
+ const snapshotSeq = numberField(recordField(readJson, "statusSnapshot"), "lastEventSeq");
5444
+ if (agentSeq !== undefined && snapshotSeq !== undefined)
5445
+ return Math.max(agentSeq, snapshotSeq);
5446
+ return agentSeq ?? snapshotSeq;
5447
+ }
5342
5448
  function codewithAgentEvidence(startJson, readJson, logsJson) {
5343
5449
  const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
5344
5450
  const statusSnapshot = recordField(readJson, "statusSnapshot");
@@ -5369,6 +5475,21 @@ function codewithAgentEvidence(startJson, readJson, logsJson) {
5369
5475
  events
5370
5476
  }, null, 2);
5371
5477
  }
5478
+ function codewithAgentProgress(readJson) {
5479
+ const agent = recordField(readJson, "agent");
5480
+ const statusSnapshot = recordField(readJson, "statusSnapshot");
5481
+ return {
5482
+ provider: "codewith",
5483
+ agentId: stringField(agent, "agentId"),
5484
+ status: stringField(agent, "status") ?? stringField(statusSnapshot, "status"),
5485
+ summary: stringField(statusSnapshot, "summary"),
5486
+ statusReason: stringField(agent, "statusReason"),
5487
+ threadId: stringField(agent, "threadId"),
5488
+ rolloutPath: stringField(agent, "rolloutPath"),
5489
+ pid: numberField(agent, "pid"),
5490
+ lastEventSeq: codewithAgentLastEventSeq(readJson)
5491
+ };
5492
+ }
5372
5493
  function sleep(ms) {
5373
5494
  return new Promise((resolve3) => setTimeout(resolve3, ms));
5374
5495
  }
@@ -5403,6 +5524,7 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5403
5524
  if (!agentId) {
5404
5525
  return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
5405
5526
  }
5527
+ opts.onAgentProgress?.(codewithAgentProgress(startJson));
5406
5528
  const stopAgent = async () => {
5407
5529
  await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
5408
5530
  cwd: spec.cwd,
@@ -5445,10 +5567,15 @@ async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt)
5445
5567
  });
5446
5568
  }
5447
5569
  const status = codewithAgentStatus(lastReadJson);
5448
- const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
5570
+ const fingerprint = JSON.stringify({
5571
+ status,
5572
+ agentLastEventSeq: numberField(recordField(lastReadJson, "agent"), "lastEventSeq"),
5573
+ snapshot: recordField(lastReadJson, "statusSnapshot") ?? null
5574
+ });
5449
5575
  if (fingerprint !== lastFingerprint) {
5450
5576
  lastFingerprint = fingerprint;
5451
5577
  lastProgressAt = Date.now();
5578
+ opts.onAgentProgress?.(codewithAgentProgress(lastReadJson));
5452
5579
  }
5453
5580
  if (status === "completed" || status === "failed" || status === "cancelled") {
5454
5581
  const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
@@ -6376,7 +6503,7 @@ ${failure.evidence.stderr}` : undefined
6376
6503
 
6377
6504
  `);
6378
6505
  const dedupeKey = `openloops:${loop.id}:${failure.fingerprint}`;
6379
- const tags = ["bug", "openloops", "loop-health", failure.classification];
6506
+ const tags = ["bug", "openloops", "loops", "loop-health", failure.classification];
6380
6507
  const priority = failure.classification === "auth" || failure.classification === "rate_limit" ? "high" : "medium";
6381
6508
  return {
6382
6509
  title,
@@ -6391,7 +6518,7 @@ ${failure.evidence.stderr}` : undefined
6391
6518
  comment: ["todos", "comment", "<task-id>", description]
6392
6519
  },
6393
6520
  futureNativeUpsert: {
6394
- command: "todos upsert",
6521
+ command: "todos task upsert",
6395
6522
  fields: {
6396
6523
  title,
6397
6524
  description,
@@ -7595,173 +7722,213 @@ async function executeWorkflow(store, workflow, opts = {}) {
7595
7722
  if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
7596
7723
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), store.listWorkflowStepRuns(run.id), run.error);
7597
7724
  }
7725
+ const resumedRunningSteps = store.listWorkflowStepRuns(run.id).filter((step) => step.status === "running");
7726
+ if (resumedRunningSteps.length > 0 && resumedRunningSteps.every((step) => step.pid !== undefined)) {
7727
+ try {
7728
+ store.recoverWorkflowRun(run.id, "workflow run resumed after lease takeover");
7729
+ } catch {}
7730
+ }
7598
7731
  const ordered = workflowExecutionOrder(workflow);
7599
7732
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));
7600
7733
  let blockingError;
7601
7734
  let terminalStatus = "succeeded";
7602
- for (const step of ordered) {
7603
- if (store.isWorkflowRunTerminal(run.id)) {
7604
- terminalStatus = store.requireWorkflowRun(run.id).status;
7605
- blockingError = "workflow run was cancelled";
7606
- break;
7607
- }
7608
- const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7609
- if (pendingTimeout) {
7610
- terminalStatus = "timed_out";
7611
- blockingError = pendingTimeout;
7612
- break;
7613
- }
7614
- const existing = store.getWorkflowStepRun(run.id, step.id);
7615
- if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
7616
- continue;
7617
- const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
7618
- const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
7619
- const dependencyStep = byId.get(dependencyId);
7620
- if (dependencyRun?.status === "succeeded")
7621
- return false;
7622
- return !dependencyStep?.continueOnFailure;
7623
- });
7624
- if (blockedBy) {
7625
- opts.beforePersist?.();
7626
- if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
7627
- store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
7628
- daemonLeaseId: opts.daemonLeaseId
7629
- });
7735
+ try {
7736
+ for (const step of ordered) {
7737
+ if (store.isWorkflowRunTerminal(run.id)) {
7738
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7739
+ blockingError = "workflow run was cancelled";
7740
+ break;
7741
+ }
7742
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7743
+ if (pendingTimeout) {
7744
+ terminalStatus = "timed_out";
7745
+ blockingError = pendingTimeout;
7746
+ break;
7747
+ }
7748
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7749
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
7750
+ continue;
7751
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
7752
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
7753
+ const dependencyStep = byId.get(dependencyId);
7754
+ if (dependencyRun?.status === "succeeded")
7755
+ return false;
7756
+ return !dependencyStep?.continueOnFailure;
7757
+ });
7758
+ if (blockedBy) {
7759
+ opts.beforePersist?.();
7760
+ if (isBlockedStepRun(store.getWorkflowStepRun(run.id, blockedBy))) {
7761
+ store.skipWorkflowStepRun(run.id, step.id, `${BLOCKED_STEP_ERROR_PREFIX} upstream step ${blockedBy} was blocked`, {
7762
+ daemonLeaseId: opts.daemonLeaseId
7763
+ });
7764
+ continue;
7765
+ }
7766
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
7767
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
7768
+ terminalStatus = "failed";
7630
7769
  continue;
7631
7770
  }
7632
- store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
7633
- blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
7634
- terminalStatus = "failed";
7635
- continue;
7636
- }
7637
- opts.beforePersist?.();
7638
- const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7639
- if (startedStep.status !== "running") {
7640
- terminalStatus = "failed";
7641
- blockingError = `step ${step.id} could not start because workflow is no longer running`;
7642
- break;
7643
- }
7644
- const stepContext = goalExecutionContext({
7645
- loop: opts.loop,
7646
- loopRun: opts.loopRun,
7647
- scheduledFor: opts.scheduledFor,
7648
- workflow,
7649
- workflowRunId: run.id,
7650
- workflowStepId: step.id
7651
- });
7652
- let result;
7653
- const controller = new AbortController;
7654
- const externalAbort = () => controller.abort();
7655
- if (opts.signal?.aborted)
7656
- controller.abort();
7657
- opts.signal?.addEventListener("abort", externalAbort, { once: true });
7658
- const cancelTimer = setInterval(() => {
7659
- if (store.getWorkflowRun(run.id)?.status === "cancelled")
7771
+ opts.beforePersist?.();
7772
+ const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
7773
+ if (startedStep.status !== "running") {
7774
+ terminalStatus = "failed";
7775
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
7776
+ break;
7777
+ }
7778
+ const stepContext = goalExecutionContext({
7779
+ loop: opts.loop,
7780
+ loopRun: opts.loopRun,
7781
+ scheduledFor: opts.scheduledFor,
7782
+ workflow,
7783
+ workflowRunId: run.id,
7784
+ workflowStepId: step.id
7785
+ });
7786
+ let result;
7787
+ const controller = new AbortController;
7788
+ const externalAbort = () => controller.abort();
7789
+ if (opts.signal?.aborted)
7660
7790
  controller.abort();
7661
- }, opts.cancelPollMs ?? 500);
7662
- cancelTimer.unref();
7663
- try {
7664
- if (step.goal) {
7665
- result = await runGoal(store, step.goal, {
7666
- ...opts,
7667
- model: opts.goalModel,
7668
- target: targetWithStepAccount(step),
7669
- signal: controller.signal,
7670
- context: stepContext
7671
- });
7672
- } else {
7673
- result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7674
- ...opts,
7675
- machine: opts.machine ?? opts.loop?.machine,
7676
- signal: controller.signal,
7677
- onSpawn: (pid) => {
7678
- opts.beforePersist?.();
7679
- store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7680
- opts.onSpawn?.(pid);
7681
- }
7791
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
7792
+ const cancelTimer = setInterval(() => {
7793
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
7794
+ controller.abort();
7795
+ }, opts.cancelPollMs ?? 500);
7796
+ cancelTimer.unref();
7797
+ try {
7798
+ if (step.goal) {
7799
+ result = await runGoal(store, step.goal, {
7800
+ ...opts,
7801
+ model: opts.goalModel,
7802
+ target: targetWithStepAccount(step),
7803
+ signal: controller.signal,
7804
+ context: stepContext
7805
+ });
7806
+ } else {
7807
+ result = await executeTarget(targetWithStepAccount(step, opts.goalNodePrompt), executionMetadata(stepContext), {
7808
+ ...opts,
7809
+ machine: opts.machine ?? opts.loop?.machine,
7810
+ signal: controller.signal,
7811
+ onAgentProgress: (progress) => {
7812
+ const stdout = JSON.stringify({ agentProgress: progress }, null, 2);
7813
+ opts.beforePersist?.();
7814
+ store.recordWorkflowStepProgress(run.id, step.id, {
7815
+ stdout,
7816
+ payload: progress
7817
+ }, {
7818
+ daemonLeaseId: opts.daemonLeaseId
7819
+ });
7820
+ opts.onAgentProgress?.(progress);
7821
+ },
7822
+ onSpawn: (pid) => {
7823
+ opts.beforePersist?.();
7824
+ store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
7825
+ opts.onSpawn?.(pid);
7826
+ }
7827
+ });
7828
+ }
7829
+ } catch (error) {
7830
+ const finishedAt2 = nowIso();
7831
+ result = {
7832
+ status: "failed",
7833
+ stdout: "",
7834
+ stderr: "",
7835
+ error: error instanceof Error ? error.message : String(error),
7836
+ startedAt: startedStep.startedAt ?? finishedAt2,
7837
+ finishedAt: finishedAt2,
7838
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7839
+ };
7840
+ } finally {
7841
+ clearInterval(cancelTimer);
7842
+ opts.signal?.removeEventListener("abort", externalAbort);
7843
+ }
7844
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7845
+ if (timeoutMessage && result.status === "failed") {
7846
+ result = { ...result, status: "timed_out", error: timeoutMessage };
7847
+ }
7848
+ if (store.isWorkflowRunTerminal(run.id)) {
7849
+ terminalStatus = store.requireWorkflowRun(run.id).status;
7850
+ blockingError = "workflow run was cancelled";
7851
+ break;
7852
+ }
7853
+ opts.beforePersist?.();
7854
+ const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7855
+ if (blockedExit) {
7856
+ store.finalizeWorkflowStepRun(run.id, step.id, {
7857
+ status: "skipped",
7858
+ finishedAt: result.finishedAt,
7859
+ durationMs: result.durationMs,
7860
+ stdout: result.stdout,
7861
+ stderr: result.stderr,
7862
+ exitCode: result.exitCode,
7863
+ error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7864
+ }, {
7865
+ daemonLeaseId: opts.daemonLeaseId
7682
7866
  });
7867
+ continue;
7683
7868
  }
7684
- } catch (error) {
7685
- const finishedAt2 = nowIso();
7686
- result = {
7687
- status: "failed",
7688
- stdout: "",
7689
- stderr: "",
7690
- error: error instanceof Error ? error.message : String(error),
7691
- startedAt: startedStep.startedAt ?? finishedAt2,
7692
- finishedAt: finishedAt2,
7693
- durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
7694
- };
7695
- } finally {
7696
- clearInterval(cancelTimer);
7697
- opts.signal?.removeEventListener("abort", externalAbort);
7698
- }
7699
- const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
7700
- if (timeoutMessage && result.status === "failed") {
7701
- result = { ...result, status: "timed_out", error: timeoutMessage };
7702
- }
7703
- if (store.isWorkflowRunTerminal(run.id)) {
7704
- terminalStatus = store.requireWorkflowRun(run.id).status;
7705
- blockingError = "workflow run was cancelled";
7706
- break;
7707
- }
7708
- opts.beforePersist?.();
7709
- const blockedExit = result.status === "failed" && result.exitCode !== undefined && blockedExitCodesForStep(step).includes(result.exitCode);
7710
- if (blockedExit) {
7711
7869
  store.finalizeWorkflowStepRun(run.id, step.id, {
7712
- status: "skipped",
7870
+ status: result.status,
7713
7871
  finishedAt: result.finishedAt,
7714
7872
  durationMs: result.durationMs,
7715
7873
  stdout: result.stdout,
7716
7874
  stderr: result.stderr,
7717
7875
  exitCode: result.exitCode,
7718
- error: `${BLOCKED_STEP_ERROR_PREFIX} step ${step.id} exited with blocked exit code ${result.exitCode}`
7876
+ error: result.error
7719
7877
  }, {
7720
7878
  daemonLeaseId: opts.daemonLeaseId
7721
7879
  });
7722
- continue;
7880
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
7881
+ terminalStatus = result.status;
7882
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7883
+ break;
7884
+ }
7723
7885
  }
7724
- store.finalizeWorkflowStepRun(run.id, step.id, {
7725
- status: result.status,
7726
- finishedAt: result.finishedAt,
7727
- durationMs: result.durationMs,
7728
- stdout: result.stdout,
7729
- stderr: result.stderr,
7730
- exitCode: result.exitCode,
7731
- error: result.error
7886
+ if (terminalStatus !== "succeeded") {
7887
+ for (const step of ordered) {
7888
+ const existing = store.getWorkflowStepRun(run.id, step.id);
7889
+ if (existing?.status === "pending" || existing?.status === "running") {
7890
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7891
+ daemonLeaseId: opts.daemonLeaseId
7892
+ });
7893
+ }
7894
+ }
7895
+ }
7896
+ const finishedAt = nowIso();
7897
+ if (store.isWorkflowRunTerminal(run.id)) {
7898
+ const terminalRun = store.requireWorkflowRun(run.id);
7899
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7900
+ }
7901
+ opts.beforePersist?.();
7902
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7903
+ finishedAt,
7904
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7905
+ error: blockingError
7732
7906
  }, {
7733
7907
  daemonLeaseId: opts.daemonLeaseId
7734
7908
  });
7735
- if (result.status !== "succeeded" && !step.continueOnFailure) {
7736
- terminalStatus = result.status;
7737
- blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
7738
- break;
7739
- }
7740
- }
7741
- if (terminalStatus !== "succeeded") {
7742
- for (const step of ordered) {
7743
- const existing = store.getWorkflowStepRun(run.id, step.id);
7744
- if (existing?.status === "pending" || existing?.status === "running") {
7745
- store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
7909
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7910
+ } catch (error) {
7911
+ if (opts.daemonLeaseId && error instanceof Error && error.message === "daemon lease lost")
7912
+ throw error;
7913
+ if (error instanceof Error && error.message.includes("workflow step is not claimable"))
7914
+ throw error;
7915
+ const message = error instanceof Error ? error.message : String(error);
7916
+ const finishedAt = nowIso();
7917
+ try {
7918
+ if (!store.isWorkflowRunTerminal(run.id)) {
7919
+ store.finalizeWorkflowRun(run.id, "failed", {
7920
+ finishedAt,
7921
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7922
+ error: message
7923
+ }, {
7746
7924
  daemonLeaseId: opts.daemonLeaseId
7747
7925
  });
7748
7926
  }
7749
- }
7750
- }
7751
- const finishedAt = nowIso();
7752
- if (store.isWorkflowRunTerminal(run.id)) {
7753
- const terminalRun = store.requireWorkflowRun(run.id);
7754
- return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, store.listWorkflowStepRuns(run.id), terminalRun.error ?? blockingError);
7927
+ } catch {}
7928
+ const current = store.getWorkflowRun(run.id) ?? run;
7929
+ const resultStatus = current.status === "running" ? "failed" : current.status;
7930
+ return workflowResult(current, resultStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), current.error ?? message);
7755
7931
  }
7756
- opts.beforePersist?.();
7757
- const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
7758
- finishedAt,
7759
- durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
7760
- error: blockingError
7761
- }, {
7762
- daemonLeaseId: opts.daemonLeaseId
7763
- });
7764
- return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, store.listWorkflowStepRuns(run.id), blockingError);
7765
7932
  }
7766
7933
  function preflightWorkflow(workflow, opts = {}) {
7767
7934
  return workflowExecutionOrder(workflow).map((step) => {
@@ -8132,6 +8299,25 @@ function advanceOptions(deps) {
8132
8299
  onRun: deps.onRun
8133
8300
  };
8134
8301
  }
8302
+ var TERMINAL_RUN_STATUSES2 = new Set([
8303
+ "succeeded",
8304
+ "failed",
8305
+ "timed_out",
8306
+ "abandoned",
8307
+ "skipped"
8308
+ ]);
8309
+ function repairWedgedTerminalSlot(deps, loop, scheduledFor, now) {
8310
+ const existing = deps.store.getRunBySlot(loop.id, scheduledFor);
8311
+ if (!existing || !TERMINAL_RUN_STATUSES2.has(existing.status))
8312
+ return;
8313
+ try {
8314
+ advanceLoop(deps.store, loop, existing, new Date(existing.finishedAt ?? now), existing.status === "succeeded", advanceOptions(deps));
8315
+ } catch (error) {
8316
+ if (deps.daemonLeaseId && isDaemonLeaseLost(error))
8317
+ return;
8318
+ throw error;
8319
+ }
8320
+ }
8135
8321
  async function runSlot(deps, loop, scheduledFor) {
8136
8322
  const now = deps.now?.() ?? new Date;
8137
8323
  deps.beforeRun?.(loop, scheduledFor);
@@ -8158,8 +8344,10 @@ async function runSlot(deps, loop, scheduledFor) {
8158
8344
  return;
8159
8345
  throw error;
8160
8346
  }
8161
- if (!claim)
8347
+ if (!claim) {
8348
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
8162
8349
  return;
8350
+ }
8163
8351
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
8164
8352
  deps.onRun?.(claim.run);
8165
8353
  const finalRun = await executeClaimedRun({
@@ -8205,8 +8393,10 @@ function claimSlot(deps, loop, scheduledFor) {
8205
8393
  return;
8206
8394
  throw error;
8207
8395
  }
8208
- if (!claim)
8396
+ if (!claim) {
8397
+ repairWedgedTerminalSlot(deps, loop, scheduledFor, now);
8209
8398
  return;
8399
+ }
8210
8400
  deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
8211
8401
  deps.onRun?.(claim.run);
8212
8402
  return claim;
@@ -8323,13 +8513,19 @@ class LoopsClient {
8323
8513
  return this.store.requireLoop(idOrName);
8324
8514
  }
8325
8515
  pause(idOrName) {
8326
- return this.store.updateLoop(this.get(idOrName).id, { status: "paused" });
8516
+ return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "paused" });
8327
8517
  }
8328
8518
  resume(idOrName) {
8329
- return this.store.updateLoop(this.get(idOrName).id, { status: "active" });
8519
+ const loop = this.store.requireUniqueLoop(idOrName);
8520
+ let nextRunAt = loop.nextRunAt;
8521
+ if (!nextRunAt) {
8522
+ const now = new Date;
8523
+ nextRunAt = computeNextAfter(loop.schedule, now, now);
8524
+ }
8525
+ return this.store.updateLoop(loop.id, { status: "active", nextRunAt });
8330
8526
  }
8331
8527
  stop(idOrName) {
8332
- return this.store.updateLoop(this.get(idOrName).id, { status: "stopped", nextRunAt: undefined });
8528
+ return this.store.updateLoop(this.store.requireUniqueLoop(idOrName).id, { status: "stopped", nextRunAt: undefined });
8333
8529
  }
8334
8530
  archive(idOrName) {
8335
8531
  return this.store.archiveLoop(idOrName);
@@ -8338,7 +8534,7 @@ class LoopsClient {
8338
8534
  return this.store.unarchiveLoop(idOrName);
8339
8535
  }
8340
8536
  delete(idOrName) {
8341
- return this.store.deleteLoop(idOrName);
8537
+ return this.store.deleteLoop(this.store.requireUniqueLoop(idOrName).id);
8342
8538
  }
8343
8539
  runs(idOrName, filters = {}) {
8344
8540
  let loopId;
@@ -8370,7 +8566,7 @@ class LoopsClient {
8370
8566
  return tick({ store: this.store, runnerId: this.runnerId });
8371
8567
  }
8372
8568
  async runNow(idOrName) {
8373
- const result = await runLoopNow({ store: this.store, idOrName, runnerId: this.runnerId });
8569
+ const result = await runLoopNow({ store: this.store, idOrName: this.store.requireUniqueLoop(idOrName).id, runnerId: this.runnerId });
8374
8570
  return result.run;
8375
8571
  }
8376
8572
  exportBundle(opts = {}) {