From b4f83fd69e751cc99d010ea9c30b05b037b8af0f Mon Sep 17 00:00:00 2001 From: Khaliq Date: Tue, 12 May 2026 23:51:49 +0200 Subject: [PATCH 1/2] feat(agent): switch ricky scheduled agent to published @agent-relay/agent Replaces the file: workspace link to ../cloud-runtime-run with the published `@agent-relay/agent@^6.0.18` package and verifies the scheduled monitor agent against the published Context, AgentHandle, and cron.tick event shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 ++ package-lock.json | 554 +++++++++++++++++++++++++++++++++++- package.json | 1 + src/agent.ts | 7 + src/scheduled-agent.test.ts | 81 ++++++ src/scheduled-agent.ts | 145 ++++++++++ 6 files changed, 798 insertions(+), 7 deletions(-) create mode 100644 src/agent.ts create mode 100644 src/scheduled-agent.test.ts create mode 100644 src/scheduled-agent.ts diff --git a/README.md b/README.md index 7f124c3f..8935a6a5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Ricky is designed around co-equal interfaces and onboarding surfaces. Current im - **CLI** — implemented as the primary local command surface. - **Local / BYOH** — implemented for local workflow generation, artifact execution, background monitoring, and status checks. - **Cloud API** — partially implemented for Cloud generation request/response contracts and CLI connection/status flows. +- **Proactive runtime agent** — implemented for scheduled background-run monitoring via `@agent-relay/agent`. - **Slack** — planned; no source handler is currently implemented in this repo. - **Web** — planned; no browser surface is currently implemented in this repo. @@ -102,6 +103,22 @@ ricky status --run When generation does not run the artifact, the CLI prints the artifact path plus foreground and background run commands. +## Proactive Runtime Monitoring + +Ricky now exposes a proactive runtime entrypoint at [src/agent.ts](/Users/khaliqgant/Projects/AgentWorkforce/ricky/src/agent.ts). The scheduled agent wakes every 5 minutes with `agent({ schedule: "*/5 * * * *" })`, scans Ricky's persisted background-run state, and posts terminal run updates to `RICKY_MONITOR_CHANNEL` (default `#ricky`). + +Environment knobs for the scheduled agent: +- `RICKY_WORKSPACE_ID` — Relaycast/relayfile workspace name for the agent runtime. Defaults to `ricky`. +- `RICKY_MONITOR_CHANNEL` — channel that receives proactive run updates. Defaults to `#ricky`. +- `RICKY_MONITOR_REPO_ROOT` — repo root whose persisted run-state tree should be monitored. Defaults to the current working directory. +- `RICKY_STATE_HOME` — optional override for the base state directory Ricky already uses for background runs. + +Run it locally with: + +```sh +tsx src/agent.ts +``` + ## CLI Onboarding Ricky's CLI should be intentionally welcoming and user-friendly. diff --git a/package-lock.json b/package-lock.json index 73407e35..f52710c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@agent-assistant/turn-context": "^0.4.31", + "@agent-relay/agent": "^6.0.18", "@agent-relay/cloud": "^6.0.15", "@agent-relay/sdk": "^6.0.15", "@agentworkforce/harness-kit": "^0.19.0", @@ -752,6 +753,42 @@ "integrity": "sha512-yRT0YMMwskDg5aEvwn6iUDQZxgYqD8AtbIL3dnb+cdHkyd5hoKDi9rQiHq7Mi+yBg/s9ja4cGks9kI1pNLemQA==", "license": "MIT" }, + "node_modules/@agent-relay/agent": { + "version": "6.0.18", + "resolved": "https://registry.npmjs.org/@agent-relay/agent/-/agent-6.0.18.tgz", + "integrity": "sha512-qSLTgMojzATyxKAWhNSSEwtrBTHc8SLkG6wikBz790W6KpWnM59hbJhpgYN03s4U7jxZK5C3yPqd0p1NyVWVVQ==", + "dependencies": { + "@agent-relay/events": "6.0.18", + "@relaycast/sdk": "1.1.2", + "@relayfile/sdk": "^0.7.2" + } + }, + "node_modules/@agent-relay/agent/node_modules/@relaycast/sdk": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.2.tgz", + "integrity": "sha512-s0QsKUd5UNS8PUZIds8EdIUJUSOTEN9cn6oQTY41rvdvcOu9UI8+TH0rP4LAYCqwutRP6v3KDmF1s/6qwk4LPg==", + "dependencies": { + "@relaycast/types": "1.1.2", + "zod": "^4.3.6" + } + }, + "node_modules/@agent-relay/agent/node_modules/@relaycast/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@relaycast/types/-/types-1.1.2.tgz", + "integrity": "sha512-Lfx2oGDMpFNJx1AZNU7xaAE+3Fs5ZrUmVgAjZ2pMiHlbJx2gOHxpB57cQDjZY+JG4bM7C4Ao2JTOy3FqXbXlww==", + "dependencies": { + "zod": "^4.3.6" + } + }, + "node_modules/@agent-relay/agent/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@agent-relay/broker-darwin-arm64": { "version": "6.0.15", "resolved": "https://registry.npmjs.org/@agent-relay/broker-darwin-arm64/-/broker-darwin-arm64-6.0.15.tgz", @@ -840,6 +877,45 @@ "zod-to-json-schema": "^3.23.1" } }, + "node_modules/@agent-relay/events": { + "version": "6.0.18", + "resolved": "https://registry.npmjs.org/@agent-relay/events/-/events-6.0.18.tgz", + "integrity": "sha512-GjWYY/OXGEEdFCZrBK2uBacpO87d8fLOjw48TEhUFxY0J1FGGNe77g/jq0r8GzgDlb0+HG/g6oyBWHBdmwNDcQ==", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.207.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + }, + "peerDependencies": { + "@relayfile/adapter-github": "^0.2.5", + "@relayfile/adapter-jira": "^0.2.8", + "@relayfile/adapter-linear": "^0.2.5", + "@relayfile/adapter-notion": "^0.2.7", + "@relayfile/adapter-slack": "^0.2.7" + }, + "peerDependenciesMeta": { + "@relayfile/adapter-github": { + "optional": true + }, + "@relayfile/adapter-jira": { + "optional": true + }, + "@relayfile/adapter-linear": { + "optional": true + }, + "@relayfile/adapter-notion": { + "optional": true + }, + "@relayfile/adapter-slack": { + "optional": true + } + } + }, "node_modules/@agent-relay/github-primitive": { "version": "6.0.15", "resolved": "https://registry.npmjs.org/@agent-relay/github-primitive/-/github-primitive-6.0.15.tgz", @@ -1934,6 +2010,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -2744,6 +2821,439 @@ ], "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", + "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.207.0.tgz", + "integrity": "sha512-HSRBzXHIC7C8UfPQdu15zEEoBGv0yWkhEwxqgPCHVUKUQ9NLHVGXkVrf65Uaj7UwmAkC1gQfkuVYvLlD//AnUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.207.0", + "@opentelemetry/otlp-transformer": "0.207.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", + "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.207.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", + "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.207.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", + "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.207.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@relaycast/sdk": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@relaycast/sdk/-/sdk-1.1.5.tgz", @@ -2780,21 +3290,21 @@ } }, "node_modules/@relayfile/core": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.5.3.tgz", - "integrity": "sha512-4R18T2XreImy/Hn+Jd9vcO4hy5Q8lmI63QklhBnSyj1hV0kTnPvSMuozDIFq+jEZtExg4LQqFmju0wmElwWrYQ==", + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@relayfile/core/-/core-0.7.11.tgz", + "integrity": "sha512-4gkBmmsdLGEe2a4ZPO84eYFyiKNpD6GymYBg5Awsr/dnwRnBqhe2W8F6rEjdxRpa0Wi1cARFYcsfV4/B4SifOA==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@relayfile/sdk": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.5.3.tgz", - "integrity": "sha512-9r9rybXK/f2n195oaGS7uDnAtwSFKuJ8jlWkMFawsjTZVXUYZIMg867TXdl9dbcQ8bUUnP4SGsqt8QeeyyXJ1Q==", + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@relayfile/sdk/-/sdk-0.7.11.tgz", + "integrity": "sha512-cOxlp+N7rDEcAhvzX/J97ZTvzMLfBmKDzxzLz4rTnP9n3e3EwZRl02lJWese1caVBVBbHeUa9Ds7AHwg1x8uqA==", "license": "MIT", "dependencies": { - "@relayfile/core": "0.5.3" + "@relayfile/core": "0.7.11" }, "engines": { "node": ">=18" @@ -5131,6 +5641,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5934,6 +6450,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", diff --git a/package.json b/package.json index 5230863e..5c7dd7e7 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@agent-assistant/turn-context": "^0.4.31", + "@agent-relay/agent": "^6.0.18", "@agent-relay/cloud": "^6.0.15", "@agent-relay/sdk": "^6.0.15", "@agentworkforce/harness-kit": "^0.19.0", diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 00000000..5c8ec32d --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,7 @@ +import { createRickyScheduledAgent } from "./scheduled-agent.js"; + +export * from "./scheduled-agent.js"; + +const rickyAgent = createRickyScheduledAgent(); + +export default rickyAgent; diff --git a/src/scheduled-agent.test.ts b/src/scheduled-agent.test.ts new file mode 100644 index 00000000..170a3297 --- /dev/null +++ b/src/scheduled-agent.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + listPersistedRunStates, + renderRunMonitorAlert, + shouldNotifyRunState, +} from "./scheduled-agent.js"; +import type { LocalRunMonitorState } from "./surfaces/cli/flows/local-run-monitor.js"; + +describe("ricky scheduled agent helpers", () => { + let stateRoot: string | undefined; + + afterEach(async () => { + if (stateRoot) { + await import("node:fs/promises").then(({ rm }) => rm(stateRoot!, { recursive: true, force: true })); + stateRoot = undefined; + } + }); + + it("loads persisted background run state from disk", async () => { + stateRoot = await mkdtemp(join(tmpdir(), "ricky-agent-state-")); + const runDir = join(stateRoot, "run-1"); + await mkdir(runDir, { recursive: true }); + await writeFile( + join(runDir, "state.json"), + JSON.stringify({ + runId: "run-1", + status: "failed", + artifactPath: "workflows/generated/checks.ts", + artifactDir: runDir, + statePath: join(runDir, "state.json"), + logPath: join(runDir, "run.log"), + evidencePath: join(runDir, "evidence.json"), + fixesPath: join(runDir, "fixes.json"), + reattachCommand: "ricky status --run run-1", + response: { + ok: false, + artifacts: [], + logs: ["failed"], + warnings: ["failed"], + nextActions: [], + exitCode: 1, + }, + } satisfies LocalRunMonitorState), + "utf8", + ); + + const runs = await listPersistedRunStates(stateRoot); + expect(runs).toHaveLength(1); + expect(runs[0]?.runId).toBe("run-1"); + }); + + it("renders actionable alerts for terminal monitor states", () => { + const run = { + runId: "run-2", + status: "blocked", + artifactPath: "workflows/generated/release.ts", + artifactDir: "/tmp/run-2", + statePath: "/tmp/run-2/state.json", + logPath: "/tmp/run-2/run.log", + evidencePath: "/tmp/run-2/evidence.json", + fixesPath: "/tmp/run-2/fixes.json", + reattachCommand: "ricky status --run run-2", + response: { + ok: false, + artifacts: [], + logs: [], + warnings: ["agent-relay missing"], + nextActions: [], + exitCode: 1, + }, + } satisfies LocalRunMonitorState; + + expect(shouldNotifyRunState(run)).toBe(true); + expect(renderRunMonitorAlert(run, "/repo")).toContain("Ricky monitor: background workflow needs attention."); + expect(renderRunMonitorAlert(run, "/repo")).toContain("ricky status --run run-2"); + }); +}); diff --git a/src/scheduled-agent.ts b/src/scheduled-agent.ts new file mode 100644 index 00000000..0b833abd --- /dev/null +++ b/src/scheduled-agent.ts @@ -0,0 +1,145 @@ +import { agent, type AgentHandle, type Context } from "@agent-relay/agent"; +import { readdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { localRunStateRoot, repoStateKey } from "./shared/state-paths.js"; +import type { LocalRunMonitorState } from "./surfaces/cli/flows/local-run-monitor.js"; + +const DEFAULT_RICKY_WORKSPACE = "ricky"; +const DEFAULT_MONITOR_CHANNEL = "#ricky"; +const DEFAULT_MONITOR_SCHEDULE = "*/5 * * * *"; + +export interface RickyScheduledAgentOptions { + env?: NodeJS.ProcessEnv; + repoRoot?: string; + monitorChannel?: string; + schedule?: string; +} + +export function defaultRickyWorkspace(env: NodeJS.ProcessEnv = process.env): string { + const workspace = env.RICKY_WORKSPACE_ID?.trim(); + return workspace && workspace.length > 0 ? workspace : DEFAULT_RICKY_WORKSPACE; +} + +export function defaultRickyMonitorChannel(env: NodeJS.ProcessEnv = process.env): string { + const channel = env.RICKY_MONITOR_CHANNEL?.trim(); + return channel && channel.length > 0 ? channel : DEFAULT_MONITOR_CHANNEL; +} + +export function defaultRickyRepoRoot(env: NodeJS.ProcessEnv = process.env): string { + return resolve(env.RICKY_MONITOR_REPO_ROOT?.trim() || process.cwd()); +} + +export async function listPersistedRunStates(stateRoot: string): Promise { + try { + const entries = await readdir(stateRoot, { withFileTypes: true }); + const runs = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const statePath = join(stateRoot, entry.name, "state.json"); + try { + return JSON.parse(await readFile(statePath, "utf8")) as LocalRunMonitorState; + } catch { + return null; + } + }), + ); + + return runs + .filter((run): run is LocalRunMonitorState => run !== null) + .sort((left, right) => left.runId.localeCompare(right.runId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } +} + +export function shouldNotifyRunState(state: LocalRunMonitorState): boolean { + return state.status === "blocked" || state.status === "failed" || state.status === "completed"; +} + +export function renderRunMonitorAlert(state: LocalRunMonitorState, repoRoot: string): string { + const execution = state.response?.execution; + const outcome = + execution?.evidence?.outcome_summary + ?? state.response?.warnings?.[0] + ?? state.response?.logs?.[0] + ?? "No additional detail recorded."; + + const lines = [ + state.status === "completed" + ? "Ricky monitor: background workflow completed." + : "Ricky monitor: background workflow needs attention.", + "", + `Repo: ${repoRoot}`, + `Run id: ${state.runId}`, + `Status: ${state.status}`, + `Artifact: ${state.artifactPath}`, + `Outcome: ${outcome}`, + `Evidence: ${state.evidencePath}`, + `Fixes: ${state.fixesPath}`, + `Next: ${state.reattachCommand}`, + ]; + + if (execution?.execution.command) { + lines.splice(6, 0, `Command: ${execution.execution.command}`); + } + + return lines.join("\n"); +} + +export async function checkPersistedRuns( + ctx: Context, + input: { + stateRoot: string; + repoRoot: string; + monitorChannel: string; + }, +): Promise { + const runs = await listPersistedRunStates(input.stateRoot); + for (const run of runs) { + if (!shouldNotifyRunState(run)) { + continue; + } + + await ctx.once( + `ricky-monitor:${repoStateKey(input.repoRoot)}:${run.runId}:${run.status}`, + async () => { + await ctx.messages.post( + input.monitorChannel, + renderRunMonitorAlert(run, input.repoRoot), + ); + return true; + }, + ); + } +} + +export function createRickyScheduledAgent( + options: RickyScheduledAgentOptions = {}, +): AgentHandle { + const env = options.env ?? process.env; + const repoRoot = resolve(options.repoRoot ?? defaultRickyRepoRoot(env)); + const stateRoot = localRunStateRoot(repoRoot, env); + const monitorChannel = options.monitorChannel ?? defaultRickyMonitorChannel(env); + + return agent({ + workspace: defaultRickyWorkspace(env), + name: "ricky-monitor", + schedule: options.schedule ?? DEFAULT_MONITOR_SCHEDULE, + onEvent: async (ctx, event) => { + if (event.type !== "cron.tick") { + return; + } + + await checkPersistedRuns(ctx, { + stateRoot, + repoRoot, + monitorChannel, + }); + }, + }); +} From 9ec1324ebb19d357e893b86335988df75c7945ca Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 13 May 2026 00:02:15 +0200 Subject: [PATCH 2/2] fix(agent): address PR #101 review feedback - Scheduled agent now scans both the XDG state root and the in-repo `.workflow-artifacts/ricky-local-runs/` tree. The local-run flow at `surfaces/cli/flows/local-workflow-flow.ts` writes to the in-repo path by default, so the prior single-root scan was guaranteed to return zero runs and the monitor would never alert. - Dedupe runs by `runId` across roots, preferring the first occurrence. - Add a test that exercises dual-root scanning + dedupe, and a separate test that locks in the `completed` terminal-status alert wording. - Replace the machine-local README link with a repo-relative path and document the legacy artifact root. - Add docstrings to every new public export in `scheduled-agent.ts` for the CodeRabbit docstring-coverage gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +- src/scheduled-agent.test.ts | 125 ++++++++++++++++++++++++------------ src/scheduled-agent.ts | 72 ++++++++++++++++++--- 3 files changed, 151 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 8935a6a5..5df90134 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,12 @@ When generation does not run the artifact, the CLI prints the artifact path plus ## Proactive Runtime Monitoring -Ricky now exposes a proactive runtime entrypoint at [src/agent.ts](/Users/khaliqgant/Projects/AgentWorkforce/ricky/src/agent.ts). The scheduled agent wakes every 5 minutes with `agent({ schedule: "*/5 * * * *" })`, scans Ricky's persisted background-run state, and posts terminal run updates to `RICKY_MONITOR_CHANNEL` (default `#ricky`). +Ricky now exposes a proactive runtime entrypoint at [`src/agent.ts`](./src/agent.ts). The scheduled agent wakes every 5 minutes with `agent({ schedule: "*/5 * * * *" })`, scans Ricky's persisted background-run state, and posts terminal run updates to `RICKY_MONITOR_CHANNEL` (default `#ricky`). Environment knobs for the scheduled agent: - `RICKY_WORKSPACE_ID` — Relaycast/relayfile workspace name for the agent runtime. Defaults to `ricky`. - `RICKY_MONITOR_CHANNEL` — channel that receives proactive run updates. Defaults to `#ricky`. -- `RICKY_MONITOR_REPO_ROOT` — repo root whose persisted run-state tree should be monitored. Defaults to the current working directory. +- `RICKY_MONITOR_REPO_ROOT` — repo root whose persisted run-state tree should be monitored. Defaults to the current working directory. Both `/.workflow-artifacts/ricky-local-runs/` and the XDG state-home tree are scanned. - `RICKY_STATE_HOME` — optional override for the base state directory Ricky already uses for background runs. Run it locally with: diff --git a/src/scheduled-agent.test.ts b/src/scheduled-agent.test.ts index 170a3297..4cc6ca62 100644 --- a/src/scheduled-agent.test.ts +++ b/src/scheduled-agent.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -8,62 +8,83 @@ import { renderRunMonitorAlert, shouldNotifyRunState, } from "./scheduled-agent.js"; +import type { LocalExecutionStageResult } from "./local/entrypoint.js"; import type { LocalRunMonitorState } from "./surfaces/cli/flows/local-run-monitor.js"; +function buildRunState( + runId: string, + status: LocalRunMonitorState["status"], + overrides: Partial = {}, +): LocalRunMonitorState { + const base: LocalRunMonitorState = { + runId, + status, + artifactPath: `workflows/generated/${runId}.ts`, + artifactDir: `/tmp/${runId}`, + statePath: `/tmp/${runId}/state.json`, + logPath: `/tmp/${runId}/run.log`, + evidencePath: `/tmp/${runId}/evidence.json`, + fixesPath: `/tmp/${runId}/fixes.json`, + reattachCommand: `ricky status --run ${runId}`, + response: { + ok: status === "completed", + artifacts: [], + logs: [], + warnings: [], + nextActions: [], + exitCode: status === "completed" ? 0 : 1, + }, + }; + return { ...base, ...overrides }; +} + +async function writePersistedRun(root: string, state: LocalRunMonitorState): Promise { + const runDir = join(root, state.runId); + await mkdir(runDir, { recursive: true }); + await writeFile(join(runDir, "state.json"), JSON.stringify(state), "utf8"); +} + describe("ricky scheduled agent helpers", () => { - let stateRoot: string | undefined; + const cleanupRoots: string[] = []; afterEach(async () => { - if (stateRoot) { - await import("node:fs/promises").then(({ rm }) => rm(stateRoot!, { recursive: true, force: true })); - stateRoot = undefined; + while (cleanupRoots.length) { + const root = cleanupRoots.pop()!; + await rm(root, { recursive: true, force: true }); } }); + async function makeStateRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "ricky-agent-state-")); + cleanupRoots.push(root); + return root; + } + it("loads persisted background run state from disk", async () => { - stateRoot = await mkdtemp(join(tmpdir(), "ricky-agent-state-")); - const runDir = join(stateRoot, "run-1"); - await mkdir(runDir, { recursive: true }); - await writeFile( - join(runDir, "state.json"), - JSON.stringify({ - runId: "run-1", - status: "failed", - artifactPath: "workflows/generated/checks.ts", - artifactDir: runDir, - statePath: join(runDir, "state.json"), - logPath: join(runDir, "run.log"), - evidencePath: join(runDir, "evidence.json"), - fixesPath: join(runDir, "fixes.json"), - reattachCommand: "ricky status --run run-1", - response: { - ok: false, - artifacts: [], - logs: ["failed"], - warnings: ["failed"], - nextActions: [], - exitCode: 1, - }, - } satisfies LocalRunMonitorState), - "utf8", - ); + const stateRoot = await makeStateRoot(); + await writePersistedRun(stateRoot, buildRunState("run-1", "failed")); const runs = await listPersistedRunStates(stateRoot); expect(runs).toHaveLength(1); expect(runs[0]?.runId).toBe("run-1"); }); + it("scans multiple state roots and dedupes by run id", async () => { + const xdgRoot = await makeStateRoot(); + const artifactRoot = await makeStateRoot(); + await writePersistedRun(xdgRoot, buildRunState("run-shared", "failed")); + await writePersistedRun(artifactRoot, buildRunState("run-shared", "blocked")); + await writePersistedRun(artifactRoot, buildRunState("run-artifact-only", "completed")); + + const runs = await listPersistedRunStates([xdgRoot, artifactRoot]); + expect(runs.map((run) => run.runId)).toEqual(["run-artifact-only", "run-shared"]); + expect(runs.find((run) => run.runId === "run-shared")?.status).toBe("failed"); + }); + it("renders actionable alerts for terminal monitor states", () => { - const run = { - runId: "run-2", - status: "blocked", + const run = buildRunState("run-2", "blocked", { artifactPath: "workflows/generated/release.ts", artifactDir: "/tmp/run-2", - statePath: "/tmp/run-2/state.json", - logPath: "/tmp/run-2/run.log", - evidencePath: "/tmp/run-2/evidence.json", - fixesPath: "/tmp/run-2/fixes.json", - reattachCommand: "ricky status --run run-2", response: { ok: false, artifacts: [], @@ -72,10 +93,34 @@ describe("ricky scheduled agent helpers", () => { nextActions: [], exitCode: 1, }, - } satisfies LocalRunMonitorState; + }); expect(shouldNotifyRunState(run)).toBe(true); expect(renderRunMonitorAlert(run, "/repo")).toContain("Ricky monitor: background workflow needs attention."); expect(renderRunMonitorAlert(run, "/repo")).toContain("ricky status --run run-2"); }); + + it("renders a completion alert for completed runs", () => { + const execution = { + execution: { command: "ricky run workflows/generated/done.ts" }, + evidence: { outcome_summary: "all checks green" }, + } as unknown as LocalExecutionStageResult; + const run = buildRunState("run-3", "completed", { + response: { + ok: true, + artifacts: [], + logs: ["done"], + warnings: [], + nextActions: [], + exitCode: 0, + execution, + }, + }); + + expect(shouldNotifyRunState(run)).toBe(true); + const rendered = renderRunMonitorAlert(run, "/repo"); + expect(rendered).toContain("Ricky monitor: background workflow completed."); + expect(rendered).toContain("Outcome: all checks green"); + expect(rendered).toContain("Command: ricky run workflows/generated/done.ts"); + }); }); diff --git a/src/scheduled-agent.ts b/src/scheduled-agent.ts index 0b833abd..49534493 100644 --- a/src/scheduled-agent.ts +++ b/src/scheduled-agent.ts @@ -9,28 +9,47 @@ const DEFAULT_RICKY_WORKSPACE = "ricky"; const DEFAULT_MONITOR_CHANNEL = "#ricky"; const DEFAULT_MONITOR_SCHEDULE = "*/5 * * * *"; +/** Tuning knobs accepted by {@link createRickyScheduledAgent}. */ export interface RickyScheduledAgentOptions { + /** Environment used to resolve workspace, channel, and state-root overrides. Defaults to `process.env`. */ env?: NodeJS.ProcessEnv; + /** Repo whose persisted run-state should be scanned. Defaults to {@link defaultRickyRepoRoot}. */ repoRoot?: string; + /** Channel where terminal-state alerts are posted. Defaults to {@link defaultRickyMonitorChannel}. */ monitorChannel?: string; + /** Cron expression for the wake-up cadence. Defaults to every 5 minutes. */ schedule?: string; } +/** Resolves the Relaycast/relayfile workspace name from `RICKY_WORKSPACE_ID`. */ export function defaultRickyWorkspace(env: NodeJS.ProcessEnv = process.env): string { const workspace = env.RICKY_WORKSPACE_ID?.trim(); return workspace && workspace.length > 0 ? workspace : DEFAULT_RICKY_WORKSPACE; } +/** Resolves the proactive-monitor channel from `RICKY_MONITOR_CHANNEL`. */ export function defaultRickyMonitorChannel(env: NodeJS.ProcessEnv = process.env): string { const channel = env.RICKY_MONITOR_CHANNEL?.trim(); return channel && channel.length > 0 ? channel : DEFAULT_MONITOR_CHANNEL; } +/** Resolves the repo root to monitor from `RICKY_MONITOR_REPO_ROOT` or the current working directory. */ export function defaultRickyRepoRoot(env: NodeJS.ProcessEnv = process.env): string { return resolve(env.RICKY_MONITOR_REPO_ROOT?.trim() || process.cwd()); } -export async function listPersistedRunStates(stateRoot: string): Promise { +/** + * Legacy in-repo artifact location where `startLocalRunMonitor` writes + * `state.json` when callers do not pass a `stateRoot` override. The + * scheduled agent must keep scanning this path because the production + * CLI flow at `src/surfaces/cli/flows/local-workflow-flow.ts` does not + * pass `stateRoot` and therefore lands here. + */ +export function legacyLocalArtifactRunStateRoot(repoRoot: string): string { + return resolve(repoRoot, ".workflow-artifacts", "ricky-local-runs"); +} + +async function readRunStatesInRoot(stateRoot: string): Promise { try { const entries = await readdir(stateRoot, { withFileTypes: true }); const runs = await Promise.all( @@ -46,9 +65,7 @@ export async function listPersistedRunStates(stateRoot: string): Promise run !== null) - .sort((left, right) => left.runId.localeCompare(right.runId)); + return runs.filter((run): run is LocalRunMonitorState => run !== null); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; @@ -57,10 +74,35 @@ export async function listPersistedRunStates(stateRoot: string): Promise { + const roots = Array.isArray(stateRoot) ? stateRoot : [stateRoot as string]; + const byRunId = new Map(); + for (const root of roots) { + for (const run of await readRunStatesInRoot(root)) { + if (!byRunId.has(run.runId)) { + byRunId.set(run.runId, run); + } + } + } + return [...byRunId.values()].sort((left, right) => left.runId.localeCompare(right.runId)); +} + +/** Returns true when a run state has reached a terminal status worth alerting on. */ export function shouldNotifyRunState(state: LocalRunMonitorState): boolean { return state.status === "blocked" || state.status === "failed" || state.status === "completed"; } +/** Renders a multi-line monitor alert summarizing a terminal background run. */ export function renderRunMonitorAlert(state: LocalRunMonitorState, repoRoot: string): string { const execution = state.response?.execution; const outcome = @@ -91,15 +133,20 @@ export function renderRunMonitorAlert(state: LocalRunMonitorState, repoRoot: str return lines.join("\n"); } +/** + * Scans every supplied state root for persisted runs and posts a one-shot + * alert per `(repo, runId, status)` triple via `ctx.once`, so duplicate + * ticks or peer replicas do not produce duplicate notifications. + */ export async function checkPersistedRuns( ctx: Context, input: { - stateRoot: string; + stateRoots: readonly string[]; repoRoot: string; monitorChannel: string; }, ): Promise { - const runs = await listPersistedRunStates(input.stateRoot); + const runs = await listPersistedRunStates(input.stateRoots); for (const run of runs) { if (!shouldNotifyRunState(run)) { continue; @@ -118,12 +165,21 @@ export async function checkPersistedRuns( } } +/** + * Creates the proactive Ricky monitor agent. Wakes on the configured cron + * schedule, scans both the XDG state root and the in-repo + * `.workflow-artifacts/ricky-local-runs/` tree, and posts a single alert + * per terminal run via `ctx.once`. + */ export function createRickyScheduledAgent( options: RickyScheduledAgentOptions = {}, ): AgentHandle { const env = options.env ?? process.env; const repoRoot = resolve(options.repoRoot ?? defaultRickyRepoRoot(env)); - const stateRoot = localRunStateRoot(repoRoot, env); + const stateRoots = [ + localRunStateRoot(repoRoot, env), + legacyLocalArtifactRunStateRoot(repoRoot), + ]; const monitorChannel = options.monitorChannel ?? defaultRickyMonitorChannel(env); return agent({ @@ -136,7 +192,7 @@ export function createRickyScheduledAgent( } await checkPersistedRuns(ctx, { - stateRoot, + stateRoots, repoRoot, monitorChannel, });