@ouro.bot/cli 0.1.0-alpha.825 → 0.1.0-alpha.827

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/changelog.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.827",
6
+ "changes": [
7
+ "Awaits advance their own cadence on dispatch, so a filed watch no longer fires forever without recording a check."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.826",
12
+ "changes": [
13
+ "Sanctuary: a download sitting at zero bytes is reported as stalled rather than as progress, with a blocklist-and-research repair."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.825",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.825",
2
+ "runtimeVersion": "0.1.0-alpha.827",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -73,6 +73,10 @@ class ServiceError extends Error {
73
73
  }
74
74
 
75
75
  const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
76
+ // Long enough that an ordinary slow start is not called dead, short enough that
77
+ // nobody waits a day for a release that was never going to arrive.
78
+ const STALL_AFTER_HOURS = 6
79
+
76
80
  const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
77
81
  const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
78
82
  const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
@@ -427,14 +431,35 @@ async function mediaRequestStatus(a) {
427
431
  const byDownload = new Map()
428
432
  for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
429
433
  const downloads = [...byDownload.values()]
434
+ const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
435
+ const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
436
+ // A torrent that has not moved a byte since it was grabbed is not slow, it is
437
+ // dead: no seeders, or a release the client cannot fetch. Radarr goes on
438
+ // reporting trackedDownloadStatus "ok" for these indefinitely, so a queue row
439
+ // on its own reads as healthy and any answer built from it reassures instead
440
+ // of acting. Measuring progress against age is what separates the two.
441
+ const now = Date.now()
442
+ const stalledItems = downloads
443
+ .filter((r) => (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && r.added
444
+ && (now - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
445
+ .map((r) => ({
446
+ title: r.title ?? null,
447
+ added_at: r.added ?? null,
448
+ age_hours: Math.round((now - Date.parse(r.added)) / 3_600_000),
449
+ size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
450
+ queue_id: r.id ?? null,
451
+ }))
430
452
  result.download = {
431
453
  active_downloads: downloads.length,
432
454
  active_items: queueRecs.length,
433
455
  states: [...new Set(downloads.map((r) => r.status))],
434
456
  tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
435
457
  errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
436
- size_left_bytes: downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0),
437
- size_total_bytes: downloads.reduce((s, r) => s + (r.size ?? 0), 0),
458
+ size_left_bytes: sizeLeft,
459
+ size_total_bytes: sizeTotal,
460
+ percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
461
+ stalled: stalledItems.length > 0,
462
+ stalled_items: stalledItems,
438
463
  }
439
464
 
440
465
  const chain = await chainHealth()
@@ -453,6 +478,15 @@ function diagnose({ shelf, result, chain, entity, kind }) {
453
478
  return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
454
479
  summary: "On the shelf and complete. Nothing is stuck." }
455
480
  }
481
+ if (result.download.stalled) {
482
+ const items = result.download.stalled_items
483
+ const oldest = Math.max(...items.map((i) => i.age_hours))
484
+ return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
485
+ likely_fix: "blocklist_and_research", human_action_required: false,
486
+ percent_complete: result.download.percent_complete,
487
+ detail: items,
488
+ summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
489
+ }
456
490
  if (result.download.active_downloads > 0) {
457
491
  const left = result.download.size_left_bytes
458
492
  const total = result.download.size_total_bytes
@@ -528,9 +562,23 @@ async function mediaDiagnoseAndFix(a) {
528
562
  ? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
529
563
  : (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
530
564
  commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
565
+ } else if (action === "blocklist_and_research") {
566
+ // Removing with blocklist=true is what stops the same dead release being
567
+ // grabbed straight back. The search that follows is then free to pick a
568
+ // different one.
569
+ const stalled = before.download.stalled_items ?? []
570
+ if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
571
+ human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
572
+ for (const item of stalled) {
573
+ if (item.queue_id === null) continue
574
+ const client = kind === "series" ? sonarr : radarr
575
+ await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
576
+ }
577
+ const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
578
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
531
579
  } else {
532
580
  return { action, result: "unsupported_action", before, after: null, human_action_required: true,
533
- human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, report_only.` }
581
+ human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
534
582
  }
535
583
 
536
584
  await new Promise((r) => setTimeout(r, 12_000))
@@ -553,6 +601,7 @@ function describeAction(action) {
553
601
  rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
554
602
  enable_monitoring: "Mark it monitored, then search.",
555
603
  force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
604
+ blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
556
605
  report_only: "Report only; change nothing.",
557
606
  }[action] ?? "Unknown action."
558
607
  }
@@ -625,7 +674,7 @@ const TOOLS = [
625
674
  },
626
675
  {
627
676
  name: "media_request_status",
628
- description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. Never infer the cause yourself — read diagnosis.summary.",
677
+ description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. A queue row is not proof of progress: a release sitting at zero bytes comes back as stalled, not as downloading. Never infer the cause yourself — read diagnosis.summary.",
629
678
  inputSchema: { type: "object", properties: {
630
679
  request_id: { type: "string", description: "e.g. jellyseerr:42" },
631
680
  tmdb_id: { type: "number" },
@@ -634,10 +683,10 @@ const TOOLS = [
634
683
  },
635
684
  {
636
685
  name: "media_diagnose_and_fix",
637
- description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
686
+ description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified, including 'blocklist_and_research' for a release that has stalled at zero bytes. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
638
687
  inputSchema: { type: "object", properties: {
639
688
  request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
640
- action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "report_only"] },
689
+ action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
641
690
  dry_run: { type: "boolean" },
642
691
  } },
643
692
  },
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.825</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.827</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readAwaitRuntimeState = readAwaitRuntimeState;
37
37
  exports.applyAwaitRuntimeState = applyAwaitRuntimeState;
38
38
  exports.writeAwaitRuntimeState = writeAwaitRuntimeState;
39
+ exports.recordAwaitDispatch = recordAwaitDispatch;
39
40
  exports.recordAwaitCheck = recordAwaitCheck;
40
41
  const path = __importStar(require("path"));
41
42
  const json_store_1 = require("../../arc/json-store");
@@ -89,6 +90,16 @@ function writeAwaitRuntimeState(agentRoot, name, partial) {
89
90
  meta: { agentRoot, name, last_checked: merged.last_checked, checked_count: merged.checked_count },
90
91
  });
91
92
  }
93
+ // The scheduler owns cadence, not the model. `recordAwaitCheck` is written only
94
+ // when a woken turn calls `resolve_await` with verdict "no", so an await whose
95
+ // turn answers in chat, errors, or simply never calls the tool leaves
96
+ // `last_checked` null forever: it reads as "never checked" on every
97
+ // reconciliation, re-fires, wakes the private runtime again and records
98
+ // nothing. Recording the dispatch itself keeps the cadence honest whatever the
99
+ // turn does; the observation from `resolve_await` still layers on top.
100
+ function recordAwaitDispatch(agentRoot, name, now) {
101
+ writeAwaitRuntimeState(agentRoot, name, { last_checked: now });
102
+ }
92
103
  function recordAwaitCheck(agentRoot, name, observation, now) {
93
104
  const existing = readAwaitRuntimeState(agentRoot, name);
94
105
  const nextCount = (existing?.checked_count ?? 0) + 1;
@@ -55,6 +55,7 @@ const habit_scheduler_1 = require("../habits/habit-scheduler");
55
55
  const habit_migration_1 = require("../habits/habit-migration");
56
56
  const await_scheduler_1 = require("../awaiting/await-scheduler");
57
57
  const await_expiry_1 = require("../awaiting/await-expiry");
58
+ const await_runtime_state_1 = require("../awaiting/await-runtime-state");
58
59
  const os_cron_deps_1 = require("./os-cron-deps");
59
60
  const os_cron_1 = require("./os-cron");
60
61
  const container_runtime_1 = require("./container-runtime");
@@ -801,7 +802,15 @@ void (0, daemon_bootstrap_startup_1.startDaemonAfterContainerCredentialBootstrap
801
802
  agent,
802
803
  awaitName,
803
804
  triggerSource: "await-scheduler",
804
- })).catch((error) => {
805
+ })).then(() => {
806
+ // Record the dispatch here so the cadence advances whatever the woken
807
+ // turn does. Without this the only writer of `last_checked` is the
808
+ // agent calling `resolve_await`, so a turn that does anything else
809
+ // leaves the await permanently "never checked" and it re-fires on
810
+ // every reconciliation. Recorded only on a successful wake: a failed
811
+ // dispatch is not a check.
812
+ (0, await_runtime_state_1.recordAwaitDispatch)(bundleRoot, awaitName, new Date().toISOString());
813
+ }).catch((error) => {
805
814
  emitAwaitPrivateWakeDispatchError({
806
815
  agent,
807
816
  awaitName,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.825",
3
+ "version": "0.1.0-alpha.827",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.825",
9
+ "version": "0.1.0-alpha.827",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.825",
3
+ "version": "0.1.0-alpha.827",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },