@themoltnet/agent-daemon 0.3.1 → 0.4.0

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.
Files changed (2) hide show
  1. package/dist/main.js +92 -6
  2. package/package.json +6 -6
package/dist/main.js CHANGED
@@ -6324,6 +6324,25 @@ function buildPromptForTask(task, ctx) {
6324
6324
  //#endregion
6325
6325
  //#region ../../libs/agent-runtime/src/reporters/api.ts
6326
6326
  /**
6327
+ * True when an error looks like the Keto "tuple lagged the read API"
6328
+ * race: HTTP 403 plus a server message starting with "Not authorized".
6329
+ * Used to gate the first-append retry to the narrow recoverable case.
6330
+ *
6331
+ * The server's task.service.ts returns errors of the form
6332
+ * `Not authorized to <verb> ...`
6333
+ * for every claimant-relation check (append messages, list messages,
6334
+ * heartbeat, complete, fail). Other Fastify 403s — auth-plugin
6335
+ * rejection, route-level guards — use different message shapes and
6336
+ * are not consistency-window flakes; retrying them just delays the
6337
+ * permanent failure surfacing.
6338
+ */
6339
+ function isKetoConsistencyLag403(err) {
6340
+ if (!err || typeof err !== "object") return false;
6341
+ if (err.statusCode !== 403) return false;
6342
+ const message = err.message ?? "";
6343
+ return /Not authorized/i.test(message);
6344
+ }
6345
+ /**
6327
6346
  * TaskReporter backed by the Tasks API via the SDK's TasksNamespace.
6328
6347
  *
6329
6348
  * - `open()` fires an immediate heartbeat (satisfies DBOS recv('started', 300s))
@@ -6349,6 +6368,15 @@ var ApiTaskReporter = class {
6349
6368
  * failures are never silently dropped by the batching layer.
6350
6369
  */
6351
6370
  pendingError = null;
6371
+ /**
6372
+ * Flips to `true` after the first successful `appendMessages` call.
6373
+ * Used to gate the 403-retry window in `flush` to the very first
6374
+ * append (the only point where the Keto `Task:claimant#Agent` tuple
6375
+ * can lag the read API). Once any append has succeeded, Keto is
6376
+ * known consistent for this task and a 403 thereafter is a real
6377
+ * authorization failure that should surface immediately.
6378
+ */
6379
+ firstAppendSucceeded = false;
6352
6380
  cancelController = new AbortController();
6353
6381
  observedCancelReason = null;
6354
6382
  maxBatchSize;
@@ -6376,6 +6404,7 @@ var ApiTaskReporter = class {
6376
6404
  }
6377
6405
  this.taskId = ctx.taskId;
6378
6406
  this.attemptN = ctx.attemptN;
6407
+ this.firstAppendSucceeded = false;
6379
6408
  await this.sendInitialHeartbeat();
6380
6409
  const intervalMs = this.opts.heartbeatIntervalMs ?? 6e4;
6381
6410
  if (intervalMs > 0) this.heartbeatTimer = setInterval(() => {
@@ -6421,7 +6450,8 @@ var ApiTaskReporter = class {
6421
6450
  const batch = this.buffer.splice(0, this.buffer.length);
6422
6451
  this.inFlight = (async () => {
6423
6452
  try {
6424
- await this.opts.tasks.appendMessages(this.taskId, this.attemptN, { messages: batch });
6453
+ await this.appendWithFirstCallRetry(batch);
6454
+ this.firstAppendSucceeded = true;
6425
6455
  } catch (err) {
6426
6456
  const overflowCap = this.maxBatchSize * 3;
6427
6457
  const restoredCount = batch.length;
@@ -6498,18 +6528,53 @@ var ApiTaskReporter = class {
6498
6528
  async sendInitialHeartbeat() {
6499
6529
  const maxAttempts = 5;
6500
6530
  const baseDelayMs = 100;
6501
- let lastErr;
6502
6531
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
6503
6532
  await this.sendHeartbeat();
6504
6533
  return;
6505
6534
  } catch (err) {
6506
- lastErr = err;
6507
6535
  if ((err && typeof err === "object" && "statusCode" in err ? err.statusCode : void 0) !== 403 || attempt === maxAttempts) throw err;
6508
6536
  await new Promise((resolve) => {
6509
6537
  setTimeout(resolve, baseDelayMs * attempt);
6510
6538
  });
6511
6539
  }
6512
- throw lastErr;
6540
+ }
6541
+ /**
6542
+ * Wrap `tasks.appendMessages` with a bounded 403-retry on the first
6543
+ * call only. Mirrors `sendInitialHeartbeat` — the same Keto
6544
+ * `Task:claimant#Agent` tuple race covers both endpoints, and a
6545
+ * batched-up first flush triggered immediately after `open()` (e.g.
6546
+ * a fast executor that records a `task_started` info message before
6547
+ * any heartbeat round-trip completes) can hit a 403 even though the
6548
+ * heartbeat itself eventually succeeds.
6549
+ *
6550
+ * Once `firstAppendSucceeded` is set we know Keto is consistent for
6551
+ * this task and any subsequent 403 is a real authorization failure
6552
+ * (e.g. another agent stole the claim) that must surface immediately.
6553
+ *
6554
+ * Retry budget is intentionally tight: the documented Keto
6555
+ * consistency window is "tens of milliseconds," so 100 ms + 200 ms
6556
+ * (3 attempts total) covers the realistic window with margin without
6557
+ * adding meaningful latency to the permanent-failure path (wrong
6558
+ * task ID, claim revoked, etc.). We also string-match the server's
6559
+ * `Not authorized` message to avoid retrying 403s that mean
6560
+ * something else (Fastify routes can 403 for non-auth reasons too).
6561
+ */
6562
+ async appendWithFirstCallRetry(batch) {
6563
+ if (this.firstAppendSucceeded) {
6564
+ await this.opts.tasks.appendMessages(this.taskId, this.attemptN, { messages: batch });
6565
+ return;
6566
+ }
6567
+ const maxAttempts = 3;
6568
+ const baseDelayMs = 100;
6569
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) try {
6570
+ await this.opts.tasks.appendMessages(this.taskId, this.attemptN, { messages: batch });
6571
+ return;
6572
+ } catch (err) {
6573
+ if (attempt === maxAttempts || !isKetoConsistencyLag403(err)) throw err;
6574
+ await new Promise((resolve) => {
6575
+ setTimeout(resolve, baseDelayMs * attempt);
6576
+ });
6577
+ }
6513
6578
  }
6514
6579
  async sendHeartbeat() {
6515
6580
  const body = this.opts.leaseTtlSec ? { leaseTtlSec: this.opts.leaseTtlSec } : {};
@@ -15607,6 +15672,26 @@ async function runOnce(argv) {
15607
15672
  sandbox: sandbox.path,
15608
15673
  taskId
15609
15674
  }, "agent-daemon.starting");
15675
+ let runtime = null;
15676
+ const onSignal = (sig) => {
15677
+ rootLogger.warn({
15678
+ signal: sig,
15679
+ taskId
15680
+ }, "agent-daemon.draining");
15681
+ runtime?.stop();
15682
+ ctx.agent.tasks.cancel(taskId, { reason: `runner_${sig.toLowerCase()}` }).catch((err) => {
15683
+ rootLogger.warn({
15684
+ err: err instanceof Error ? err.message : String(err),
15685
+ taskId
15686
+ }, "agent-daemon.cancel_on_signal_failed");
15687
+ });
15688
+ };
15689
+ process.on("SIGINT", () => {
15690
+ onSignal("SIGINT");
15691
+ });
15692
+ process.on("SIGTERM", () => {
15693
+ onSignal("SIGTERM");
15694
+ });
15610
15695
  try {
15611
15696
  const executeTask = createPiTaskExecutor({
15612
15697
  agentName: opts.agent,
@@ -15619,7 +15704,7 @@ async function runOnce(argv) {
15619
15704
  gh: createGhCliClient(),
15620
15705
  logger: rootLogger
15621
15706
  });
15622
- const [output] = await new AgentRuntime({
15707
+ runtime = new AgentRuntime({
15623
15708
  logger: rootLogger,
15624
15709
  source: new ApiTaskSource({
15625
15710
  agent: ctx.agent,
@@ -15639,7 +15724,8 @@ async function runOnce(argv) {
15639
15724
  log: (msg, err) => rootLogger.warn({ err }, msg)
15640
15725
  }),
15641
15726
  executeTask
15642
- }).start();
15727
+ });
15728
+ const [output] = await runtime.start();
15643
15729
  if (!output) {
15644
15730
  rootLogger.error({}, "agent-daemon.no_output");
15645
15731
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "description": "MoltNet agent daemon — claims and executes tasks (fulfill_brief, assess_brief) from the MoltNet task-service via Pi-headless. CLI: moltnet-agent.",
@@ -33,19 +33,19 @@
33
33
  "@opentelemetry/semantic-conventions": "^1.39.0",
34
34
  "pino": "^10.3.1",
35
35
  "pino-pretty": "^13.1.3",
36
- "@themoltnet/pi-extension": "0.13.4",
37
- "@themoltnet/agent-runtime": "0.10.0",
38
- "@themoltnet/sdk": "0.99.0"
36
+ "@themoltnet/agent-runtime": "0.11.0",
37
+ "@themoltnet/sdk": "0.99.0",
38
+ "@themoltnet/pi-extension": "0.13.5"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsx": "^4.7.0",
42
42
  "typescript": "^5.3.3",
43
43
  "vite": "^8.0.0",
44
44
  "vitest": "^3.0.0",
45
- "@moltnet/crypto-service": "0.1.0",
45
+ "@moltnet/bootstrap": "0.1.0",
46
46
  "@moltnet/database": "0.1.0",
47
47
  "@moltnet/tasks": "0.1.0",
48
- "@moltnet/bootstrap": "0.1.0"
48
+ "@moltnet/crypto-service": "0.1.0"
49
49
  },
50
50
  "nx": {
51
51
  "tags": [