mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7943fac193 | ||
|
|
a6cf72080f | ||
|
|
46c784ba5e | ||
|
|
7db17aff95 | ||
|
|
e29dad555f | ||
|
|
539f2368e9 | ||
|
|
eb4641d630 | ||
|
|
e51f8f59f6 | ||
|
|
c6aae2e730 | ||
|
|
cbcc9551f9 | ||
|
|
83856b0f00 | ||
|
|
08c717c2fa | ||
|
|
e0b45d186a | ||
|
|
2fcb221fad | ||
|
|
a32c4a4c3e | ||
|
|
a03391a13f | ||
|
|
d8ad8bdc3f | ||
|
|
092a0272cd | ||
|
|
f151f5ba9a | ||
|
|
0019ad31d0 | ||
|
|
205f4ef09f |
No files matched your search
@@ -22,7 +22,6 @@
|
||||
/docs/*_CONTRACT.md @esengine @SivanCola
|
||||
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
|
||||
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
|
||||
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
|
||||
/docs/CHECKPOINTS.md @esengine @SivanCola
|
||||
/docs/EXTENSION_*.md @esengine @SivanCola
|
||||
/docs/SUBAGENT_*.md @esengine @SivanCola
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
|
||||
@@ -36,6 +36,16 @@ jobs:
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify feedback D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback.sql
|
||||
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ('feedback','feedback_status_created','feedback_install_created')")
|
||||
for name in feedback feedback_status_created feedback_install_created; do
|
||||
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
|
||||
done
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
|
||||
@@ -1107,6 +1107,108 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
name: require the 1.x line to be frozen
|
||||
needs: resolve
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require CLI_PUBLISH_FROZEN
|
||||
env:
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
run: |
|
||||
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
|
||||
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The v<version> tag the update pointer needs is created here, by the release
|
||||
# identity the 1.x line also uses, because only that identity may create v*
|
||||
# tags. This job checks out nothing and runs no repository code; the one step
|
||||
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
|
||||
# tag or branch, through the studio-release environment's ref policy.
|
||||
cli-tag:
|
||||
name: create the CLI tag
|
||||
needs: [resolve, publish, cli-gate]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Create v<version> on the studio commit
|
||||
env:
|
||||
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
|
||||
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=esengine/DeepSeek-Reasonix
|
||||
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
|
||||
tag="v${CLI_VERSION#v}"
|
||||
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
|
||||
echo "$tag publishes no CLI pointer; no tag is created"
|
||||
exit 0
|
||||
fi
|
||||
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
|
||||
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/studio-v* | refs/heads/studio) ;;
|
||||
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
|
||||
esac
|
||||
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
|
||||
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
|
||||
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
|
||||
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
|
||||
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
|
||||
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
|
||||
actor="$(write_api user)"
|
||||
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
|
||||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
|
||||
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
|
||||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
|
||||
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
|
||||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
|
||||
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
|
||||
behind | identical) ;;
|
||||
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
|
||||
esac
|
||||
# Prints the commit the tag points at; status 1 only when the tag does not exist.
|
||||
tag_commit() {
|
||||
local ref kind sha
|
||||
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
|
||||
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
|
||||
cat "$RUNNER_TEMP/tag.error" >&2
|
||||
exit 1
|
||||
}
|
||||
read -r kind sha <<<"$ref"
|
||||
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
|
||||
echo "$sha"
|
||||
}
|
||||
if existing="$(tag_commit)"; then
|
||||
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
|
||||
# A concurrent run may have created the same tag in the meantime.
|
||||
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
cat "$RUNNER_TEMP/create.error" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "created $tag at $APPROVED_SHA"
|
||||
|
||||
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
|
||||
# line owns them until this one takes the CLI over; the repository variable
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
|
||||
@@ -1115,42 +1217,15 @@ jobs:
|
||||
# candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish]
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Owner approval before anything reaches npm or the tap: the switch alone
|
||||
# would otherwise publish on every studio tag.
|
||||
environment: studio-cli-channels
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
# registry token still authenticates the publish itself.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# A job naming a missing environment gets an unprotected one, so the
|
||||
# gate is verified rather than assumed; the tokens below exist only in it.
|
||||
- name: Require the approval gate on studio-cli-channels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
|
||||
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
|
||||
case "$status" in
|
||||
200) ;;
|
||||
404)
|
||||
echo "::error::environment studio-cli-channels does not exist"
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
|
||||
exit 1 ;;
|
||||
esac
|
||||
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
|
||||
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
@@ -1164,7 +1239,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1175,7 +1250,7 @@ jobs:
|
||||
- name: Update the Homebrew cask
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
env:
|
||||
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
|
||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
@@ -1190,3 +1265,47 @@ jobs:
|
||||
fi
|
||||
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
|
||||
git push origin HEAD:main
|
||||
|
||||
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
|
||||
# sit under that tag, so the archives go to a v<version> release. Only a
|
||||
# -preview.N version moves preview; other prereleases publish nothing.
|
||||
cli-pointer:
|
||||
name: publish the CLI update pointer
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # create the CLI release; cli-tag has already made its tag
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
- name: Publish the release, its record and the pointer
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ARCHIVES_DIR: cli-archives
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured; the CLI pointer was not written"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/publish-cli-pointer.sh
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -11,10 +10,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
// The 1.x line reads these repository secrets; studio must never touch them.
|
||||
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
|
||||
t.Helper()
|
||||
@@ -94,8 +90,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
|
||||
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
@@ -113,18 +109,11 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
|
||||
}
|
||||
}
|
||||
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
|
||||
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
|
||||
}
|
||||
assertGateBeforePublishing(t, job)
|
||||
for _, name := range cliChannelSecrets {
|
||||
if !secretRefPattern(name).MatchString(scalarString(job)) {
|
||||
t.Fatalf("cli-channels does not read secrets.%s", name)
|
||||
}
|
||||
}
|
||||
if name, found := refersTo(job, legacyRegistrySecrets); found {
|
||||
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) string {
|
||||
@@ -133,6 +122,20 @@ func scalarString(node *yaml.Node) string {
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
|
||||
t.Error("release-studio.yml grants write-all")
|
||||
}
|
||||
jobs := jobsOf(t, root)
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
|
||||
t.Errorf("release-studio.yml job %s grants write-all", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
@@ -173,7 +176,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
|
||||
guarded := cliChannelSecrets
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
@@ -196,18 +199,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
|
||||
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.STUDIO_NPM_TOKEN }}",
|
||||
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
|
||||
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
|
||||
"${{ secrets.NPM_TOKEN }}",
|
||||
"${{ secrets['NPM_TOKEN'] }}",
|
||||
`${{ secrets["NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
|
||||
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
@@ -225,82 +228,3 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
t.Error("an unrelated secret was reported")
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-channels has no steps")
|
||||
}
|
||||
gate := -1
|
||||
for i, step := range steps.Content {
|
||||
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
|
||||
gate = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if gate < 0 {
|
||||
t.Fatal("cli-channels has no environment check step")
|
||||
}
|
||||
for i, step := range steps.Content[:gate] {
|
||||
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
|
||||
t.Errorf("step %d runs or gets credentials before the environment check", i)
|
||||
}
|
||||
}
|
||||
for i, step := range steps.Content {
|
||||
if i > gate {
|
||||
continue
|
||||
}
|
||||
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
|
||||
if nodeContains(step, name) {
|
||||
t.Errorf("step %d references %s before or at the environment check", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-channels")
|
||||
var script string
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
|
||||
script = run
|
||||
break
|
||||
}
|
||||
}
|
||||
if script == "" {
|
||||
t.Fatal("environment check step not found")
|
||||
}
|
||||
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
|
||||
cases := []struct {
|
||||
name, response string
|
||||
pass bool
|
||||
}{
|
||||
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
|
||||
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
|
||||
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
|
||||
{"empty body", "HTTP/2.0 200 OK\n\n", false},
|
||||
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
|
||||
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
|
||||
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := t.TempDir()
|
||||
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
|
||||
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
|
||||
out, err := cmd.CombinedOutput()
|
||||
passed := err == nil && strings.Contains(string(out), "PASSED")
|
||||
if passed != tc.pass {
|
||||
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-pointer")
|
||||
if job == nil {
|
||||
t.Fatal("cli-pointer job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
needs := mappingValue(job, "needs")
|
||||
if needs == nil || needs.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-pointer must list its needs")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range needs.Content {
|
||||
seen[n.Value] = true
|
||||
}
|
||||
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
|
||||
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
|
||||
}
|
||||
concurrency := mappingValue(job, "concurrency")
|
||||
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
|
||||
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
|
||||
}
|
||||
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
|
||||
t.Fatal("cli-pointer must not cancel a run that is mid-write")
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
|
||||
mappingScalar(permissions, "contents") != "write" {
|
||||
t.Fatal("cli-pointer permissions must be exactly contents: write")
|
||||
}
|
||||
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
|
||||
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
|
||||
}
|
||||
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
|
||||
}
|
||||
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
|
||||
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
gate := mappingValue(jobs, "cli-gate")
|
||||
if gate == nil {
|
||||
t.Fatal("cli-gate job is missing")
|
||||
}
|
||||
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
|
||||
}
|
||||
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
|
||||
t.Fatal("cli-gate must run no repository code and read no secret")
|
||||
}
|
||||
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
|
||||
needs := mappingValue(mappingValue(jobs, name), "needs")
|
||||
found := false
|
||||
for _, n := range needs.Content {
|
||||
found = found || n.Value == "cli-gate"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-gate", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"cli-channels", "cli-pointer"} {
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
|
||||
found = found || n.Value == "cli-tag"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
func workflowFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
for _, pattern := range []string{"*.yml", "*.yaml"} {
|
||||
found, err := filepath.Glob("../../.github/workflows/" + pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files = append(files, found...)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no workflows found")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// The token may appear in one inline step of Studio's cli-tag job and in no
|
||||
// other env scope: not the workflow's, not a job's, not another step's.
|
||||
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
|
||||
holders := 0
|
||||
for _, file := range workflowFiles(t) {
|
||||
root := parseWorkflowFile(t, file)
|
||||
studio := filepath.Base(file) == "release-studio.yml"
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
if root.Content[i].Value == "jobs" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
|
||||
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
id, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
allowed := studio && id == "cli-tag"
|
||||
for j := 0; j+1 < len(job.Content); j += 2 {
|
||||
key, value := job.Content[j].Value, job.Content[j+1]
|
||||
if key != "steps" {
|
||||
if what, found := refersTo(value, releaseTagSecrets); found {
|
||||
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, step := range value.Content {
|
||||
if what, found := refersTo(step, releaseTagSecrets); found {
|
||||
holders++
|
||||
if !allowed {
|
||||
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
|
||||
}
|
||||
if mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
|
||||
}
|
||||
} else if allowed && mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("cli-tag must check out nothing and run no action")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holders != 1 {
|
||||
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
|
||||
}
|
||||
}
|
||||
@@ -652,7 +652,7 @@ function withReceipt(items: Item[], r?: Receipt): Item[] {
|
||||
// eleven. The server strips these before /history now — this is what covers
|
||||
// sessions already on disk.
|
||||
const CONTROL =
|
||||
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace)[\s\S]*?<\/\1>\s*/g;
|
||||
/<(reasoning-language|response-language|execution-policy|memory-update|background-jobs|active-goal|autoresearch-runtime|hook-context|available-skills|project-instructions|capability-route|interrupted-turn-recovery|workspace|scheduled-run)[\s\S]*?<\/\1>\s*/g;
|
||||
const stripControl = (s: string) => s.replace(CONTROL, "").trim();
|
||||
|
||||
// A plan that ran to the end is spent: struck through in the rail it reads as
|
||||
|
||||
@@ -935,6 +935,9 @@ what exists.
|
||||
under — the second is the writer's taxonomy and means nothing to a reader. The
|
||||
dot on the left is the one pixel on this page that matters: it joins a memory
|
||||
to the behaviour just observed.
|
||||
- The title is the one part of a row nobody bounds, so it wraps inside a share of
|
||||
the row and the description gives way to it; the scope, date and action keep
|
||||
their width and never break.
|
||||
- Older versions are indented and one tier darker so they read as what is behind
|
||||
the current one rather than as more memories beside it.
|
||||
- The write card uses the memory colour rather than a tool colour: this step
|
||||
|
||||
@@ -3614,7 +3614,8 @@
|
||||
}
|
||||
.memrow[data-used] .dot { background: var(--net); box-shadow: none }
|
||||
.memrow .nm {
|
||||
flex: none; min-width: 168px; padding: 0; border: 0; background: none; text-align: left;
|
||||
flex: 0 1 auto; min-width: 168px; max-width: 60%; padding: 0; border: 0; background: none; text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--text); cursor: pointer;
|
||||
}
|
||||
.memrow .nm:hover { text-decoration: underline }
|
||||
@@ -3625,6 +3626,7 @@
|
||||
flex: none; padding: 1px 5px; border-radius: var(--r-pill); font: var(--w-med) var(--fs-micro) var(--ui); font-style: normal;
|
||||
color: var(--warn-ink); background: color-mix(in srgb, var(--warn) 14%, transparent);
|
||||
}
|
||||
.memrow .act { flex: none; white-space: nowrap }
|
||||
.memrow .sc { flex: none; min-width: 30px; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--faint) }
|
||||
.memrow .at { flex: none; font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--ghost); font-variant-numeric: tabular-nums }
|
||||
.memrow .why-used { margin: -2px 0 8px 26px; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--net) }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CSS = Object.values(
|
||||
import.meta.glob("./app.css", { query: "?raw", import: "default", eager: true }) as Record<string, string>,
|
||||
)[0];
|
||||
|
||||
function declarations(selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const m = CSS.match(new RegExp(String.raw`(?:^|\n)\s*${escaped}\s*\{([^}]*)\}`));
|
||||
return m?.[1] ?? "";
|
||||
}
|
||||
|
||||
describe("a memory row stays inside its container", () => {
|
||||
const name = declarations(".memrow .nm");
|
||||
|
||||
it("lets the title shrink and wrap instead of pushing the row wider", () => {
|
||||
expect(name, "no .memrow .nm rule found").not.toBe("");
|
||||
expect(name).not.toMatch(/flex:\s*none/);
|
||||
expect(name).toMatch(/overflow-wrap:\s*anywhere/);
|
||||
expect(name).toMatch(/max-width:/);
|
||||
});
|
||||
|
||||
it("keeps the trailing controls whole", () => {
|
||||
expect(declarations(".memrow .act")).toMatch(/white-space:\s*nowrap/);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-09-30
|
||||
---
|
||||
|
||||
# Code signing policy
|
||||
|
||||
DRAFT. Not in force until the owner resolves every `TO CONFIRM` marker and the Windows signing provider named in section 1 is true. Do not link this page from the README before then.
|
||||
|
||||
Scope: Windows and macOS binaries of Reasonix Studio (`studio-v2.*` releases) published from `esengine/DeepSeek-Reasonix`.
|
||||
Requirements source: <https://signpath.org/terms> (SignPath Foundation OSS terms). Requirements that page does not state are marked `UNVERIFIED`.
|
||||
|
||||
## 1. Current signing status
|
||||
|
||||
| ID | Fact |
|
||||
| --- | --- |
|
||||
| S1 | Windows Authenticode signing of Studio releases uses a Certum certificate through Certum SimplySign cloud signing, when the repository variable `STUDIO_SIGNING_ENABLED` is `true`. |
|
||||
| S2 | Windows releases are NOT signed through SignPath. The SignPath Foundation certificate is not in use for any release. |
|
||||
| S3 | macOS packages are signed with an Apple Developer ID and notarized. Linux packages carry a detached minisign signature. |
|
||||
| S4 | Every package carries a `.minisig` signature and the release carries `SHA256SUMS`. |
|
||||
| S5 | This page becomes the SignPath Foundation code signing policy only after the Windows release workflow signs through SignPath and section 1 is rewritten to say so. |
|
||||
|
||||
## 2. What is signed
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| P1 | Only binaries built from this repository's own source by `.github/workflows/release-studio.yml` on the `studio` branch MAY be signed. |
|
||||
| P2 | The signed set is the Studio executables, the installer that packages them, and the bundled `reasonix` CLI archives built in the same run. |
|
||||
| P3 | Third-party binaries bundled unmodified keep their upstream signatures. They MUST NOT be re-signed with the project certificate. |
|
||||
| P4 | The signing contract `.signpath/contracts/release-signing.yml` declares the allowed branch, build definitions and the files whose fingerprint the release checks before any signing job runs. |
|
||||
| P5 | Software containing malware, or features designed to identify or exploit security vulnerabilities in other software, MUST NOT be signed. |
|
||||
|
||||
## 3. Build origin and integrity
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| B1 | Signing runs only inside the GitHub Actions release workflow, started by a `studio-v*` tag or a manual dispatch of that workflow. |
|
||||
| B2 | The workflow builds the unsigned Windows bundle first. The signing jobs refuse a bundle whose executable set differs from the declared list or whose digest changed after the build. |
|
||||
| B3 | The signing jobs run in the `studio-release` environment and share the concurrency group `certum-signing`. |
|
||||
| B4 | Reproducible builds: UNVERIFIED. No claim is made that two builds of one commit are byte-identical. |
|
||||
|
||||
## 4. Team roles
|
||||
|
||||
Only one role holder is known. Everyone else MUST be added by the owner; none is assumed.
|
||||
|
||||
| Role | Members | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Author (committer) | @esengine | Trusted to change source code without a second review. TO CONFIRM: whether any other account has write access and belongs here. |
|
||||
| Reviewer | @esengine | Reviews every change from a non-committer before it is merged. TO CONFIRM: whether @SivanCola reviews as backup. |
|
||||
| Approver | @esengine | Approves each signing request, by dispatching the release and approving the `studio-release` environment. TO CONFIRM: approvers must be listed by name; add a second person if one exists. |
|
||||
|
||||
## 5. Privacy
|
||||
|
||||
| ID | Statement |
|
||||
| --- | --- |
|
||||
| V1 | Reasonix runs locally. Prompts, files and session content are sent only to the model providers the user configures. |
|
||||
| V2 | Anonymous, content-free telemetry (random install ID, version, OS, architecture, surface, fixed-bucket counters) goes to `https://crash.reasonix.io`. Details and opt-out for the CLI are in `docs/GUIDE.md`, section "CLI telemetry". |
|
||||
| V3 | TO CONFIRM: what the Studio desktop app itself sends, whether it is disclosed during installation, and whether the installer offers a way to disable it. The SignPath terms require all three for software that sends user data to systems the user did not specify. Nothing here is verified for Studio. |
|
||||
|
||||
## 6. Attribution line
|
||||
|
||||
Use this line only after S2 stops being true:
|
||||
|
||||
> Free code signing on Windows provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org)
|
||||
|
||||
## 7. Enforcement
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| E1 | `go run ./cmd/signpath-contract validate` fails a release workflow that reaches signing credentials without being declared in the contract. |
|
||||
| E2 | Changes to this page, `.signpath/` and the release workflow require review by the CODEOWNERS listed in `.github/CODEOWNERS`. |
|
||||
+30
-16
@@ -60,31 +60,43 @@ Jobs in the run:
|
||||
| `windows-sign-installer` | Requires both packages to hash to the checked values, signs the installer, verifies its signature, and outputs the signed installer's SHA-256. Opens no archive. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive |
|
||||
| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; skipped unless all four Windows signing jobs succeeded or signing is off; an unresolved `#N` stops it before signing |
|
||||
| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | environment `studio-cli-channels`, verified to have a required reviewer at run time; `id-token: write` on this job only; runs after `publish` |
|
||||
| `cli-gate` | Only with `STUDIO_PUBLISHES_CLI=true`. Requires `CLI_PUBLISH_FROZEN=true`. Checks out nothing and reads no secret. | fails while 1.x is not frozen, and then no other CLI job runs |
|
||||
| `cli-tag` | Only with `STUDIO_PUBLISHES_CLI=true`. For a stable or `-preview.N` version, creates the tag `vX.Y.Z` on the studio commit with the release tag identity (`RELEASE_TAG_TOKEN`, see below); an existing tag on that commit is kept, one elsewhere fails. | environment `studio-release`; runs after `publish` and `cli-gate`; checks out nothing, one inline step reads the token |
|
||||
| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | no environment and no approval; `id-token: write` on this job only; runs after `publish`, `cli-gate` and `cli-tag` |
|
||||
| `cli-pointer` | Only with `STUDIO_PUBLISHES_CLI=true`. Creates the GitHub release `vX.Y.Z` holding the CLI archives, writes `cli/releases/vX.Y.Z/latest.json`, then moves `cli/stable/latest.json` (a `-preview.N` version moves `cli/preview/latest.json`; any other prerelease publishes nothing). | runs after `publish`, `cli-gate` and `cli-tag`; serialized per channel by the `studio-cli-pointer-<channel>` lock |
|
||||
|
||||
The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer.
|
||||
|
||||
The `studio-cli-channels` environment guards `cli-channels`. Create it and its secrets before setting `STUDIO_PUBLISHES_CLI`:
|
||||
Publishing to the CLI channels needs no human approval. Three controls stand in for it:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Required reviewers | @esengine and @SivanCola |
|
||||
| Deployment branches and tags | the `studio-v*` tag and the `studio` branch, as in `studio-release` |
|
||||
| Environment secrets | `STUDIO_NPM_TOKEN` and `STUDIO_HOMEBREW_TAP_TOKEN`, values copied from the existing `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN` |
|
||||
- The variable `STUDIO_PUBLISHES_CLI` is off by default, and the job is skipped unless it is `true`.
|
||||
- The tag protection rule `Protect release tags` covers `studio-v*`, so only someone with write access can push the tag that starts a release.
|
||||
- `npm publish --provenance` attaches a Sigstore attestation naming this repository and commit.
|
||||
|
||||
Leave the repository-level `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN` alone: the 1.x release line still reads them. Delete them after 1.x stops publishing.
|
||||
Recovery of the CLI pointer:
|
||||
|
||||
The two `STUDIO_*` names exist only in that environment, so a missing environment yields no token and the publish fails. The job's first step reads the environment through the API before any step that runs a command or holds a credential.
|
||||
- `cli-pointer` trusts an existing release `vX.Y.Z` after checking it on its own terms: tag, asset names, sizes, URLs, and `SHA256SUMS` against the release's own digests.
|
||||
- It never compares the release with the rerun's archives, which are not byte-reproducible.
|
||||
- If the release was created and the R2 write failed, a `workflow_dispatch` run finishes the job.
|
||||
- The pointer script refuses to run unless `CLI_PUBLISH_FROZEN=true`, and re-reads the pointer after writing it.
|
||||
|
||||
It fails when the environment does not exist (404), when it has no required reviewer or the rules cannot be read as a count, and on any other status.
|
||||
The tag identity:
|
||||
|
||||
The last failure is reported with the acting identity.
|
||||
- Only a person may create `v*` tags (release ruleset), so `cli-tag` uses the secret `RELEASE_TAG_TOKEN` and variable `RELEASE_TAG_ACTOR` that the 1.x release workflow also uses. This workflow is a second user.
|
||||
- One inline step reads the token, in a job that checks out and runs no repository code. It creates only the tag, through the refs API; `cli-pointer` creates the release with the run's own token.
|
||||
- It refuses unless the repository is the official one, the ref is a `studio-v*` tag or `studio`, `CLI_PUBLISH_FROZEN` is true, and the credential belongs to `RELEASE_TAG_ACTOR` with push access.
|
||||
- It also refuses unless the studio tag still resolves to the approved SHA and that SHA is on `studio` history.
|
||||
- An existing tag on the same commit (lightweight or annotated) counts as done; on another commit it fails.
|
||||
- The `studio-release` environment restricts where the job runs. It does not protect the secret: `RELEASE_TAG_TOKEN` is repository-level, so anyone who can push a workflow can read it. The owner is the only writer today.
|
||||
- The 1.x identity script is not reused: it pins `main-v2` and would need a checkout. The server enforces the rulesets at the tag write.
|
||||
|
||||
Turn off "Allow administrators to bypass configured protection rules" on the environment: with `can_admins_bypass` on, an administrator can skip the approval.
|
||||
Once a GitHub release `vX.Y.Z` exists, the v1.39.5 client's GitHub-list fallback and the gateway's CLI fallback select it, so turning the switch on is the switch of the update channel itself.
|
||||
|
||||
Each release then waits for one approval before npm or the tap is touched. The `studio` branch is admitted for the `workflow_dispatch` recovery run; there the provenance records the branch head commit while the build is the tag's commit. The 1.x line behaves the same.
|
||||
While the variable is `true`, pushing a `studio-v*` tag publishes to npm `latest` and to Homebrew. The `workflow_dispatch` recovery run is subject to the same switch.
|
||||
|
||||
No other job requests `id-token` or references these two secrets, and no workflow file reads them but `release-studio.yml`'s `cli-channels`; `cmd/signpath-contract` tests this over the parsed YAML of every workflow.
|
||||
The job reads the repository secrets `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN`, the same pair the 1.x line uses. On the day of the switch, freeze 1.x in the same step by setting its variable `CLI_PUBLISH_FROZEN`, so the two lines never write the channels at once.
|
||||
|
||||
No other job requests `id-token` or references these two secrets, and no workflow file reads them but `release-studio.yml`'s `cli-channels`; `cmd/signpath-contract` tests this over the parsed YAML of every workflow, and that `cli-channels` declares no environment.
|
||||
|
||||
Verify a published package with `npm view reasonix dist.attestations`: the attestation is present and names this repository.
|
||||
|
||||
@@ -241,8 +253,8 @@ Studio and 1.x signing jobs and smoke tests share the concurrency group `certum-
|
||||
| `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` | secret | macOS signing and notarization |
|
||||
| `CERTUM_USERNAME`, `CERTUM_OTP_URI`, `CERTUM_KEY_ID` | repository secret | Windows Authenticode signing shared with 1.x (section 7) |
|
||||
| `STUDIO_SIGNING_ENABLED` | variable | Windows signing switch |
|
||||
| `STUDIO_PUBLISHES_CLI` | variable | hands the CLI's npm and Homebrew channels to this workflow; set it only after the `studio-cli-channels` environment and its secrets exist and 1.x has stopped publishing them |
|
||||
| `STUDIO_NPM_TOKEN`, `STUDIO_HOMEBREW_TAP_TOKEN` | environment secret (`studio-cli-channels`) | `cli-channels` only |
|
||||
| `STUDIO_PUBLISHES_CLI` | variable | hands the CLI's npm and Homebrew channels to this workflow; set it only in the same step that freezes 1.x with `CLI_PUBLISH_FROZEN` |
|
||||
| `NPM_TOKEN`, `HOMEBREW_TAP_TOKEN` | repository secret | `cli-channels` here; shared with 1.x |
|
||||
| `STUDIO_SIGNING_SUBJECT` | variable | the signer subject every signed executable must carry; required when signing is on |
|
||||
| `MINISIGN_PRIVATE_KEY`, `MINISIGN_PASSWORD` | secret | detached signatures verified by the updater |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_BUCKET` | secret | artifact mirror and catalog |
|
||||
@@ -251,4 +263,6 @@ Studio and 1.x signing jobs and smoke tests share the concurrency group `certum-
|
||||
| --- | --- | --- |
|
||||
| `studio/versions.json` | this workflow | Studio catalog, newest first |
|
||||
| `studio-vX.Y.Z/` | this workflow | artifacts, signatures, `latest.json` |
|
||||
| `cli/stable/latest.json`, `cli/preview/latest.json` | `cli-pointer` | what `reasonix upgrade` reads through `crash.reasonix.io/v1/cli/releases/<channel>/latest.json`; only ever moves to a newer version |
|
||||
| `cli/releases/vX.Y.Z/latest.json` | `cli-pointer` | immutable record of one CLI release; a rerun with different content fails |
|
||||
| `versions.json` | desktop line | never written by this workflow |
|
||||
@@ -316,7 +316,7 @@ func RuntimeForbidReadRoots(cfg *config.Config, root string) []string {
|
||||
secrets.RegisterCredentialEnvKeys(cfg.CredentialEnvNames())
|
||||
base := cfg.ForbidReadRootsForRoot(root)
|
||||
base = appendUniquePaths(base, secrets.ForbiddenReadPaths(cfg.Secrets.ProtectSensitiveFiles)...)
|
||||
base = appendUniquePaths(base, append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir())...)
|
||||
base = appendUniquePaths(base, append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir(), cfg.Roots().ScheduleDir())...)
|
||||
credentialPath := strings.TrimSpace(cfg.Roots().UserCredentialsPath())
|
||||
if credentialPath == "" {
|
||||
return append([]string(nil), base...)
|
||||
@@ -445,12 +445,12 @@ func subagentEffectiveIdentity(cfg *config.Config, resolver provider.Resolver, b
|
||||
// and makes bash warn when a command references them. managedConfig names the
|
||||
// Reasonix-owned config files writable outside writeRoots after a fresh
|
||||
// per-write human approval.
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots []string, readPathResolver *builtin.PathResolver, sessionGuard builtin.SessionDataGuard, managedConfig builtin.ManagedConfigPaths, overlay builtin.FileOverlay, terminal builtin.TerminalRunner, sessionTemp *sessiontemp.Manager, fileWriteReceipt func(path string, hadPrior bool, prior []byte)) {
|
||||
func addBuiltins(reg *tool.Registry, enabled, writeRoots []string, bashSpec sandbox.Spec, bashTimeout time.Duration, searchSpec builtin.SearchSpec, stderr io.Writer, workDir string, proxySpec netclient.ProxySpec, forbidReadRoots, readRoots []string, readPathResolver *builtin.PathResolver, sessionGuard builtin.SessionDataGuard, managedConfig builtin.ManagedConfigPaths, overlay builtin.FileOverlay, terminal builtin.TerminalRunner, sessionTemp *sessiontemp.Manager, fileWriteReceipt func(path string, hadPrior bool, prior []byte)) {
|
||||
// If a workspace directory is set, use workspace-bound tools that resolve
|
||||
// paths relative to that directory. Otherwise fall back to the process-cwd
|
||||
// compile-time builtins.
|
||||
if workDir != "" {
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard, ManagedConfig: managedConfig, FileOverlay: overlay, Terminal: terminal, SessionTemp: sessionTemp, FileWriteReceipt: fileWriteReceipt}
|
||||
ws := builtin.Workspace{Dir: workDir, WriteRoots: writeRoots, ForbidReadRoots: forbidReadRoots, ReadRoots: readRoots, Bash: bashSpec, BashTimeout: bashTimeout, Search: searchSpec, ProxySpec: proxySpec, ReadPaths: readPathResolver, SessionGuard: sessionGuard, ManagedConfig: managedConfig, FileOverlay: overlay, Terminal: terminal, SessionTemp: sessionTemp, FileWriteReceipt: fileWriteReceipt}
|
||||
for _, t := range ws.Tools(enabled...) {
|
||||
reg.Add(t)
|
||||
}
|
||||
|
||||
@@ -2711,7 +2711,7 @@ model = "x"
|
||||
func TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil, builtin.SessionDataGuard{}, builtin.ManagedConfigPaths{}, nil, nil, nil, nil)
|
||||
addBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{}, nil, nil, nil, builtin.SessionDataGuard{}, builtin.ManagedConfigPaths{}, nil, nil, nil, nil)
|
||||
for _, name := range []string{
|
||||
"todo_write",
|
||||
"complete_step",
|
||||
@@ -4063,7 +4063,7 @@ func TestRuntimeForbidReadRootsAddsOnlyGlobalCredentialFile(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Sandbox.ForbidRead = []string{configured}
|
||||
withoutCredentials := RuntimeForbidReadRoots(cfg, ".")
|
||||
hostSecrets := append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir())
|
||||
hostSecrets := append(secrets.HostSecretPaths(), cfg.Roots().RemoteStateDir(), cfg.Roots().ScheduleDir())
|
||||
if !reflect.DeepEqual(withoutCredentials, appendUniquePaths([]string{configured}, hostSecrets...)) {
|
||||
t.Fatalf("roots without global credentials = %v", withoutCredentials)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -101,6 +102,7 @@ type toolStage struct {
|
||||
// Controller.Close releases them.
|
||||
func build(ctx context.Context, opts Options) (*BuildResult, error) {
|
||||
b := &builder{timer: newPhaseTimer()}
|
||||
opts = observeOverrides(opts)
|
||||
// The runtime outlives the request that built it (Studio opens a pane with
|
||||
// one), and its MCP servers and sidecars start on that context later.
|
||||
b.ctx, b.opts, b.owner, b.fileWriteReceipt = bindRuntimeOwner(context.WithoutCancel(ctx), opts)
|
||||
@@ -133,18 +135,29 @@ func (b *builder) retireUnownedSidecars() {
|
||||
// the session resources and the system prompt.
|
||||
func (b *builder) load() error {
|
||||
opts := b.opts
|
||||
if opts.Observe != nil && opts.Observe.Pending == nil {
|
||||
return errors.New("boot: the read-only posture needs somewhere to park what needs a person")
|
||||
}
|
||||
b.stderr = opts.Stderr
|
||||
if b.stderr == nil {
|
||||
b.stderr = os.Stderr
|
||||
}
|
||||
b.root, b.roots = resolveWorkspaceRoot(opts.WorkspaceRoot), opts.roots()
|
||||
if opts.Observe != nil {
|
||||
if err := checkObserveRoot(b.root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b.repo = workspaceRepo(b.ctx, opts.WorkspaceRepo, b.root)
|
||||
var err error
|
||||
if b.additionalDirs, err = normalizeAdditionalDirs(b.root, opts.AdditionalDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
migrations := runConfigMigrations(b.roots, b.root)
|
||||
if b.cfg, err = b.roots.LoadForRoot(b.root); err != nil {
|
||||
var migrations configMigrations
|
||||
if opts.Observe == nil {
|
||||
migrations = runConfigMigrations(b.roots, b.root)
|
||||
}
|
||||
if b.cfg, err = b.loadConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := b.cfg
|
||||
@@ -203,6 +216,22 @@ func (b *builder) load() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// loadConfig reads the configuration this build runs under. The read-only
|
||||
// posture takes the user's own and nothing the workspace declares, and holds no
|
||||
// MCP server at all.
|
||||
func (b *builder) loadConfig() (*config.Config, error) {
|
||||
if b.opts.Observe == nil {
|
||||
return b.roots.LoadForRoot(b.root)
|
||||
}
|
||||
cfg, err := b.roots.LoadUserScopeForRoot(b.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Plugins = nil
|
||||
cfg.Secrets.ProtectSensitiveFiles, cfg.Secrets.ProtectCredentialFiles = true, true
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (b *builder) reportModelNotices() {
|
||||
cfg, entry := b.cfg, b.model.entry
|
||||
if ignored := cfg.IgnoredProjectDefaultModel(); ignored != "" {
|
||||
@@ -220,11 +249,11 @@ func (b *builder) reportModelNotices() {
|
||||
func (b *builder) wireTools() error {
|
||||
opts, cfg, root := b.opts, b.cfg, b.root
|
||||
t := &b.tools
|
||||
t.reg = tool.NewRegistry()
|
||||
t.reg = newToolRegistry(opts)
|
||||
t.env = resolveToolEnvironment(opts, cfg, b.roots, root, b.additionalDirs, b.shell, b.stderr)
|
||||
env := t.env
|
||||
// The full inventory registers for use_capability; the provider-visible surface narrows later.
|
||||
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
|
||||
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
|
||||
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
|
||||
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.balanceClient)
|
||||
addAdvisor(t.reg, cfg, b.proxy, b.sink)
|
||||
@@ -346,7 +375,9 @@ func (b *builder) controller() (*control.Controller, error) {
|
||||
ctrl.SetCapabilityProxyRouting(true)
|
||||
// Every role setting sees one provider-visible surface, fixed before the
|
||||
// snapshot freezes registry schemas for cache diagnostics.
|
||||
applyUnifiedProviderToolSurface(t.reg, b.opts.GoalTurnsUnreachable, b.opts.Ablation, pinnedMCPServers(t.mcp.alwaysLoad, t.mcpSchemaKnown))
|
||||
if b.opts.Observe == nil {
|
||||
applyUnifiedProviderToolSurface(t.reg, b.opts.GoalTurnsUnreachable, b.opts.Ablation, pinnedMCPServers(t.mcp.alwaysLoad, t.mcpSchemaKnown))
|
||||
}
|
||||
return ctrl, nil
|
||||
}
|
||||
|
||||
@@ -408,6 +439,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
|
||||
opts, cfg, root, entry, t := b.opts, b.cfg, b.root, b.model.entry, &b.tools
|
||||
specOptions := t.specOptions
|
||||
return control.Options{
|
||||
Observe: b.observeRun(),
|
||||
TaskBudget: taskBudgetFromConfig(cfg),
|
||||
GoalTokenBudget: cfg.Agent.GoalTokenBudget,
|
||||
Runner: runner,
|
||||
@@ -426,7 +458,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
|
||||
AllSkills: b.prompt.allSkills,
|
||||
SkillStore: b.prompt.skillStore,
|
||||
AllSkillStore: b.prompt.allSkillStore,
|
||||
DisableImplicitSkillInvocation: !b.prompt.implicitSkills,
|
||||
DisableImplicitSkillInvocation: !b.prompt.implicitSkills || b.opts.Observe != nil,
|
||||
SkillRunner: t.runners.run,
|
||||
ReadOnlySkillRunner: t.runners.readOnly,
|
||||
SkillProfile: t.runners.profile,
|
||||
@@ -544,6 +576,7 @@ func (b *builder) freeze(ctrl *control.Controller) (*BuildResult, error) {
|
||||
ctrl.ApplyExtensionSystemPrompt(final)
|
||||
}
|
||||
}
|
||||
ctrl.SealObserveSurface()
|
||||
assembly := &ReusedAssembly{
|
||||
SystemPrompt: b.prompt.prompt,
|
||||
Skills: b.prompt.skills,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
outsideMark = "OUTSIDE-SECRET-BODY"
|
||||
insideMark = "INSIDE-BODY"
|
||||
refusedText = "outside the folders this run may read"
|
||||
)
|
||||
|
||||
// scopeWorld is a workspace with a neighbour it must not read through.
|
||||
type scopeWorld struct {
|
||||
root, other, home string
|
||||
}
|
||||
|
||||
func newScopeWorld(t *testing.T) scopeWorld {
|
||||
t.Helper()
|
||||
root := observeProject(t)
|
||||
w := scopeWorld{root: root, other: robustTempDir(t), home: os.Getenv("HOME")}
|
||||
writeFile(t, w.other, "data.go", "package other\n// "+outsideMark+"\nfunc Secret() {}\n")
|
||||
writeFile(t, w.other, ".env", "TOKEN="+outsideMark+"\n")
|
||||
writeFile(t, w.home, "home-note.txt", outsideMark)
|
||||
writeFile(t, root, "inside.go", "package in\n// "+insideMark+"\nfunc Fine() {}\n")
|
||||
return w
|
||||
}
|
||||
|
||||
func (w scopeWorld) link(t *testing.T, target, name string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, filepath.Join(w.root, name)); err != nil {
|
||||
t.Skipf("symlinks are not available here: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// run puts each call to the read-only posture in its own round and returns
|
||||
// what the model was told for it.
|
||||
func (w scopeWorld) run(t *testing.T, calls map[string][2]string, order []string) map[string]string {
|
||||
t.Helper()
|
||||
var turns []testutil.Turn
|
||||
for _, id := range order {
|
||||
turns = append(turns, call(id, calls[id][0], calls[id][1]))
|
||||
}
|
||||
prov := testutil.NewMock("observe", append(turns, testutil.Turn{Text: "done"})...)
|
||||
ctrl, _ := buildObserved(t, w.root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "read"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
return toolResults(prov.Requests())
|
||||
}
|
||||
|
||||
func mustRefuse(t *testing.T, results map[string]string, ids ...string) {
|
||||
t.Helper()
|
||||
for _, id := range ids {
|
||||
got := results[id]
|
||||
if strings.Contains(got, outsideMark) {
|
||||
t.Errorf("%s leaked what is outside the workspace: %q", id, got)
|
||||
}
|
||||
if !strings.Contains(got, refusedText) {
|
||||
t.Errorf("%s was not refused for its scope: %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonPath(tool, key, path string) [2]string {
|
||||
return [2]string{tool, fmt.Sprintf(`{%q:%q}`, key, filepath.ToSlash(path))}
|
||||
}
|
||||
|
||||
// Every read tool, given a path that leaves the workspace by any route.
|
||||
func TestEffectObserveReadsStayInsideTheWorkspace(t *testing.T) {
|
||||
w := newScopeWorld(t)
|
||||
w.link(t, filepath.Join(w.other, "data.go"), "file-link.go")
|
||||
w.link(t, w.other, "dir-link")
|
||||
w.link(t, filepath.Join(w.root, "inside.go"), "inner-link.go")
|
||||
up := "../" + filepath.Base(w.other) + "/data.go"
|
||||
calls := map[string][2]string{
|
||||
"read-abs": jsonPath("read_file", "path", filepath.Join(w.other, "data.go")),
|
||||
"read-dotdot": jsonPath("read_file", "path", up),
|
||||
"read-link": jsonPath("read_file", "path", "file-link.go"),
|
||||
"read-dir": jsonPath("read_file", "path", "dir-link/data.go"),
|
||||
"read-env": jsonPath("read_file", "path", filepath.Join(w.other, ".env")),
|
||||
"read-home": jsonPath("read_file", "path", filepath.Join(w.home, "home-note.txt")),
|
||||
"ls-abs": jsonPath("ls", "path", w.other),
|
||||
"ls-home": jsonPath("ls", "path", w.home),
|
||||
"ls-link": jsonPath("ls", "path", "dir-link"),
|
||||
"glob-abs": jsonPath("glob", "pattern", filepath.Join(w.other, "*.go")),
|
||||
"glob-rec": jsonPath("glob", "pattern", filepath.Join(w.other, "**", "*.go")),
|
||||
"glob-link": jsonPath("glob", "pattern", "dir-link/*.go"),
|
||||
"grep-abs": {"grep", fmt.Sprintf(`{"pattern":"Secret","path":%q}`, filepath.ToSlash(w.other))},
|
||||
"grep-link": {"grep", `{"pattern":"Secret","path":"dir-link"}`},
|
||||
"grep-file": {"grep", `{"pattern":"Secret","path":"file-link.go"}`},
|
||||
"idx-abs": {"code_index", fmt.Sprintf(`{"action":"outline","path":%q}`, filepath.ToSlash(w.other))},
|
||||
"idx-link": {"code_index", `{"action":"outline","path":"dir-link"}`},
|
||||
"idx-file": {"code_index", `{"action":"outline","path":"file-link.go"}`},
|
||||
"ok-read": jsonPath("read_file", "path", "inside.go"),
|
||||
"ok-link": jsonPath("read_file", "path", "inner-link.go"),
|
||||
"ok-ls": jsonPath("ls", "path", "."),
|
||||
"ok-grep": {"grep", `{"pattern":"Fine"}`},
|
||||
"ok-glob": jsonPath("glob", "pattern", "*.go"),
|
||||
"walk-grep": {"grep", `{"pattern":"Secret","path":"."}`},
|
||||
"walk-glob": jsonPath("glob", "pattern", "**/*.go"),
|
||||
"walk-ls": {"ls", `{"path":".","recursive":true}`},
|
||||
"walk-idx": {"code_index", `{"action":"outline","path":"."}`},
|
||||
}
|
||||
order := make([]string, 0, len(calls))
|
||||
for id := range calls {
|
||||
order = append(order, id)
|
||||
}
|
||||
results := w.run(t, calls, order)
|
||||
|
||||
mustRefuse(t, results, "read-abs", "read-dotdot", "read-link", "read-dir", "read-env", "read-home",
|
||||
"ls-abs", "ls-home", "ls-link", "glob-abs", "glob-rec", "glob-link",
|
||||
"grep-abs", "grep-link", "grep-file", "idx-abs", "idx-link", "idx-file")
|
||||
for _, id := range []string{"ok-read", "ok-link"} {
|
||||
if !strings.Contains(results[id], insideMark) {
|
||||
t.Errorf("%s: a path that stays inside the workspace was refused: %q", id, results[id])
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"ok-ls", "ok-grep", "ok-glob"} {
|
||||
if !strings.Contains(results[id], "inside.go") && !strings.Contains(results[id], "Fine") {
|
||||
t.Errorf("%s: the workspace itself was not readable: %q", id, results[id])
|
||||
}
|
||||
}
|
||||
if strings.Contains(results["ok-glob"], "file-link.go") || strings.Contains(results["ok-glob"], "dir-link") {
|
||||
t.Errorf("a glob listed a link that leads outside the workspace: %q", results["ok-glob"])
|
||||
}
|
||||
// Walking the workspace must not step through a link to what lies outside it.
|
||||
for _, id := range []string{"walk-grep", "walk-glob", "walk-ls", "walk-idx"} {
|
||||
if strings.Contains(results[id], outsideMark) || strings.Contains(results[id], "Secret") {
|
||||
t.Errorf("%s walked through a link out of the workspace: %q", id, results[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The [secrets] switches are the user's to turn off for their own sessions; the
|
||||
// posture turns them back on.
|
||||
// On POSIX a `..` after a link names the link target's parent, so a path that
|
||||
// looks like it stays in the workspace can end in the folder beside it.
|
||||
func TestEffectObserveDotDotAfterALinkStaysInside(t *testing.T) {
|
||||
w := newScopeWorld(t)
|
||||
sub := filepath.Join(w.other, "sub")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, w.other, "notes.txt", outsideMark)
|
||||
w.link(t, sub, "sublink")
|
||||
via := filepath.ToSlash(w.root) + "/sublink/.."
|
||||
calls := map[string][2]string{
|
||||
"read": {"read_file", fmt.Sprintf(`{"path":%q}`, via+"/notes.txt")},
|
||||
"ls": {"ls", fmt.Sprintf(`{"path":%q}`, via)},
|
||||
"grep": {"grep", fmt.Sprintf(`{"pattern":"OUTSIDE","path":%q}`, via)},
|
||||
"glob": {"glob", fmt.Sprintf(`{"pattern":%q}`, via+"/*")},
|
||||
"idx": {"code_index", fmt.Sprintf(`{"action":"outline","path":%q}`, via)},
|
||||
}
|
||||
results := w.run(t, calls, []string{"read", "ls", "grep", "glob", "idx"})
|
||||
for id, got := range results {
|
||||
if strings.Contains(got, outsideMark) || (runtime.GOOS != "windows" && !strings.Contains(got, refusedText) && (strings.Contains(got, "notes.txt") || strings.Contains(got, "data.go"))) {
|
||||
t.Errorf("%s reached the folder beside the workspace: %q", id, got)
|
||||
}
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
mustRefuse(t, results, "read", "ls", "grep", "glob", "idx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveKeepsSecretProtectionOn(t *testing.T) {
|
||||
w := newScopeWorld(t)
|
||||
writeUserConfig(t, userModel+"\n[secrets]\nprotect_sensitive_files = false\nprotect_credential_files = false\n")
|
||||
writeFile(t, w.root, ".env", "TOKEN="+outsideMark+"\n")
|
||||
results := w.run(t, map[string][2]string{"env": jsonPath("read_file", "path", ".env")}, []string{"env"})
|
||||
if strings.Contains(results["env"], outsideMark) {
|
||||
t.Fatalf("a sensitive file inside the workspace was readable: %q", results["env"])
|
||||
}
|
||||
}
|
||||
|
||||
// A case variant of an outside path reaches the same bytes on these filesystems.
|
||||
func TestEffectObserveCaseVariantsStayOutside(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
|
||||
t.Skip("the filesystem is case-sensitive here")
|
||||
}
|
||||
w := newScopeWorld(t)
|
||||
variant := filepath.Join(strings.ToUpper(w.other), "DATA.GO")
|
||||
results := w.run(t, map[string][2]string{"upper": jsonPath("read_file", "path", variant)}, []string{"upper"})
|
||||
if strings.Contains(results["upper"], outsideMark) {
|
||||
t.Fatalf("a case variant read what is outside: %q", results["upper"])
|
||||
}
|
||||
}
|
||||
|
||||
// A junction is the Windows symlink an unprivileged user can make.
|
||||
func TestEffectObserveJunctionOutOfTheWorkspaceIsRefused(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("junctions exist only on windows")
|
||||
}
|
||||
w := newScopeWorld(t)
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", filepath.Join(w.root, "junction"), w.other).CombinedOutput(); err != nil {
|
||||
t.Skipf("mklink /J failed: %v %s", err, out)
|
||||
}
|
||||
results := w.run(t, map[string][2]string{
|
||||
"read": jsonPath("read_file", "path", "junction/data.go"),
|
||||
"ls": jsonPath("ls", "path", "junction"),
|
||||
"grep": {"grep", `{"pattern":"Secret","path":"junction"}`},
|
||||
"walk": {"grep", `{"pattern":"Secret","path":"."}`},
|
||||
}, []string{"read", "ls", "grep", "walk"})
|
||||
mustRefuse(t, results, "read", "ls", "grep")
|
||||
if strings.Contains(results["walk"], outsideMark) {
|
||||
t.Fatalf("a walk went through a junction: %q", results["walk"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRefusesAWorkspaceContainingHomeOrRoot(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
setBootTokenProfileTestProvider(t, testutil.NewMock("observe", testutil.Turn{Text: "ok"}))
|
||||
for name, dir := range map[string]string{"home": os.Getenv("HOME"), "parent of home": filepath.Dir(os.Getenv("HOME")), "filesystem root": string(filepath.Separator)} {
|
||||
if runtime.GOOS == "windows" && name == "filesystem root" {
|
||||
dir = filepath.VolumeName(root) + `\`
|
||||
}
|
||||
if err := checkObserveRoot(dir); !errors.Is(err, ErrObserveRootTooBroad) {
|
||||
t.Errorf("%s: checkObserveRoot(%q) = %v, want ErrObserveRootTooBroad", name, dir, err)
|
||||
}
|
||||
}
|
||||
if err := checkObserveRoot(root); err != nil {
|
||||
t.Errorf("an ordinary workspace was refused: %v", err)
|
||||
}
|
||||
t.Setenv("HOME", "")
|
||||
t.Setenv("USERPROFILE", "")
|
||||
if err := checkObserveRoot(root); !errors.Is(err, ErrObserveRootTooBroad) {
|
||||
t.Errorf("with no home directory known, checkObserveRoot = %v, want a refusal", err)
|
||||
}
|
||||
_ = config.Roots{}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
const storeMark = "SCHEDULE-STORE-BODY"
|
||||
|
||||
// The store sits inside the workspace here, so the read scope alone would let
|
||||
// the run see it; the protected set is what keeps a run from reading its own
|
||||
// limits and the results of the other schedules.
|
||||
func TestEffectObserveCannotReadTheScheduleStore(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
state := filepath.Join(root, "state")
|
||||
t.Setenv("REASONIX_STATE_HOME", state)
|
||||
store := filepath.Join(state, "schedules")
|
||||
writeFile(t, store, "schedules.json", `{"note":"`+storeMark+`"}`)
|
||||
writeFile(t, filepath.Join(store, "results"), "r.json", storeMark)
|
||||
writeFile(t, root, "inside.go", "package in\n// "+insideMark+"\n")
|
||||
slash := filepath.ToSlash(store)
|
||||
calls := [][3]string{
|
||||
{"read", "read_file", `{"path":"` + slash + `/schedules.json"}`},
|
||||
{"read-rel", "read_file", `{"path":"state/schedules/results/r.json"}`},
|
||||
{"ls", "ls", `{"path":"` + slash + `"}`},
|
||||
{"glob", "glob", `{"pattern":"state/schedules/**/*.json"}`},
|
||||
{"grep-dir", "grep", `{"pattern":"` + storeMark + `","path":"state/schedules"}`},
|
||||
{"grep-walk", "grep", `{"pattern":"` + storeMark + `","path":"."}`},
|
||||
{"idx", "code_index", `{"action":"outline","path":"state/schedules"}`},
|
||||
{"ok", "read_file", `{"path":"inside.go"}`},
|
||||
}
|
||||
var turns []testutil.Turn
|
||||
for _, c := range calls {
|
||||
turns = append(turns, call(c[0], c[1], c[2]))
|
||||
}
|
||||
prov := testutil.NewMock("observe", append(turns, testutil.Turn{Text: "done"})...)
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "read"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
results := toolResults(prov.Requests())
|
||||
for _, c := range calls[:len(calls)-1] {
|
||||
if strings.Contains(results[c[0]], storeMark) {
|
||||
t.Errorf("%s read the schedule store: %q", c[0], results[c[0]])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(results["ok"], insideMark) {
|
||||
t.Fatalf("the control read of an ordinary file failed, so the refusals prove nothing: %q", results["ok"])
|
||||
}
|
||||
}
|
||||
|
||||
// A retired key in the workspace's config is what the ordinary boot rewrites in
|
||||
// place; a read-only run must leave the file, and the user's own, as it found them.
|
||||
func TestEffectObserveWritesNoConfigFile(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
project := "[agent]\nmax_steps = 7\n\n[secrets]\nredact_tool_output = true\n"
|
||||
writeFile(t, root, "reasonix.toml", project)
|
||||
prov := testutil.NewMock("observe", testutil.Turn{Text: "done"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "hi"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "reasonix.toml"))
|
||||
if err != nil || string(got) != project {
|
||||
t.Fatalf("the workspace config changed under the read-only posture: %q, %v", got, err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(root); len(entries) != 1 {
|
||||
t.Fatalf("the workspace gained files: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// The protected set masks only a directory that exists when a command's sandbox
|
||||
// is built, so a build must leave the store's directory behind.
|
||||
func TestBuildCreatesTheScheduleStoreDirectoryPrivately(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
t.Setenv("REASONIX_STATE_HOME", filepath.Join(root, "state"))
|
||||
prov := testutil.NewMock("observe", testutil.Turn{Text: "done"})
|
||||
buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
info, err := os.Stat(filepath.Join(root, "state", "schedules"))
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Fatalf("store directory missing after a build: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/ext/hook"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// observeCeiling is the whole tool surface of the read-only posture. A tool
|
||||
// added to the tree is absent from it until it declares a reach, and one that
|
||||
// declares a reach shows up here as a failing diff to be reviewed.
|
||||
var observeCeiling = []string{"ask", "code_index", "conclude_blocked", "glob", "grep", "ls", "read_file"}
|
||||
|
||||
func observeProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
isolateConfigHome(t)
|
||||
root := robustTempDir(t)
|
||||
t.Chdir(root)
|
||||
writeUserConfig(t, userModel)
|
||||
registerBootTokenProfileTestProvider()
|
||||
return root
|
||||
}
|
||||
|
||||
func buildObserved(t *testing.T, root string, prov *testutil.MockProvider, run observe.RunContext) (*control.Controller, *observe.Ledger) {
|
||||
t.Helper()
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ledger := observe.NewLedger(nil)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root, Observe: &ObserveOptions{Pending: ledger, Run: run}})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
return ctrl, ledger
|
||||
}
|
||||
|
||||
func toolNameList(req provider.Request) []string {
|
||||
names := make([]string, 0, len(req.Tools))
|
||||
for _, tl := range req.Tools {
|
||||
names = append(names, tl.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// toolResults returns what the model was told about each tool call, keyed by call id.
|
||||
func toolResults(reqs []provider.Request) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, req := range reqs {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleTool {
|
||||
out[m.ToolCallID] = m.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func call(id, name, args string) testutil.Turn {
|
||||
return testutil.Turn{ToolCalls: []provider.ToolCall{{ID: id, Name: name, Arguments: args}}}
|
||||
}
|
||||
|
||||
// TestHookMarkerHelper is the program a hook or MCP server command runs: the
|
||||
// test binary itself, so a marker works on every platform. It does nothing
|
||||
// unless it is handed a file name.
|
||||
func TestHookMarkerHelper(t *testing.T) {
|
||||
if args := flag.Args(); len(args) == 1 {
|
||||
_ = os.WriteFile(args[0], []byte("ran"), 0o600)
|
||||
}
|
||||
}
|
||||
|
||||
// markerArgs are the arguments that make the test binary write marker.
|
||||
func markerArgs(marker string) []string {
|
||||
return []string{"-test.run=^TestHookMarkerHelper$", filepath.ToSlash(marker)}
|
||||
}
|
||||
|
||||
func markerCommand(t *testing.T, marker string) string {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return `"` + filepath.ToSlash(exe) + `" ` + strings.Join(markerArgs(marker), " ")
|
||||
}
|
||||
|
||||
// writeHostileCheckout declares everything a checkout can: a config that widens
|
||||
// every boundary, project hooks the user approved, and MCP servers. The user
|
||||
// also has a hook and an MCP server of their own. Every one of them writes a
|
||||
// marker file when it runs; the markers are returned by who declared them.
|
||||
func writeHostileCheckout(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
markers := map[string]string{
|
||||
"project hook": filepath.Join(root, "project-hook-ran"),
|
||||
"user hook": filepath.Join(root, "user-hook-ran"),
|
||||
"user MCP": filepath.Join(root, "user-mcp-ran"),
|
||||
"project MCP": filepath.Join(root, "project-mcp-ran"),
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := func(name, marker string) string {
|
||||
args, _ := json.Marshal(markerArgs(marker))
|
||||
return "\n[[plugins]]\nname = " + jsonString(name) + "\ncommand = " + jsonString(filepath.ToSlash(exe)) + "\nargs = " + string(args) + "\n"
|
||||
}
|
||||
writeUserConfig(t, userModel+server("mine", markers["user MCP"]))
|
||||
userHooks := `{"hooks":{"Stop":[{"command":` + jsonString(markerCommand(t, markers["user hook"])) + `}],"PreToolUse":[{"command":` + jsonString(markerCommand(t, markers["user hook"])) + `}]}}`
|
||||
writeFile(t, config.RootsForHome("").Home(), "settings.json", userHooks)
|
||||
writeFile(t, root, "reasonix.toml", widenAllProject+server("evil", markers["project MCP"]))
|
||||
args, _ := json.Marshal(markerArgs(markers["project MCP"]))
|
||||
writeFile(t, root, ".mcp.json", `{"mcpServers":{"evil2":{"command":`+jsonString(filepath.ToSlash(exe))+`,"args":`+string(args)+`}}}`)
|
||||
projectHook := markerCommand(t, markers["project hook"])
|
||||
writeFile(t, filepath.Join(root, ".reasonix"), "settings.json",
|
||||
`{"hooks":{"Stop":[{"command":`+jsonString(projectHook)+`}],"PreToolUse":[{"command":`+jsonString(projectHook)+`}]}}`)
|
||||
program, ok := hook.ProjectHooksProgram(root)
|
||||
if !ok {
|
||||
t.Fatal("project hooks were not recognised as a program to approve")
|
||||
}
|
||||
if err := config.NewProjectProgramStore(config.RootsForHome("").Home()).Approve(root, program); err != nil {
|
||||
t.Fatalf("approve project hooks: %v", err)
|
||||
}
|
||||
approveWorkspace(t, root)
|
||||
return markers
|
||||
}
|
||||
|
||||
func waitForMarker(path string) bool {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveOffersOnlyTheCeiling(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
writeFile(t, root, "notes.txt", "hello")
|
||||
prov := testutil.NewMock("observe", call("r1", "read_file", `{"path":"notes.txt"}`), testutil.Turn{Text: "done"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s1", TriggerID: "t1"})
|
||||
if err := ctrl.Run(context.Background(), "summarize"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
reqs := agentRequests(prov.Requests())
|
||||
if len(reqs) < 2 {
|
||||
t.Fatalf("requests = %d, want the read and the answer", len(reqs))
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if got := toolNameList(req); !slices.Equal(got, observeCeiling) {
|
||||
t.Fatalf("request %d offered %v, want exactly %v", i, got, observeCeiling)
|
||||
}
|
||||
for _, name := range []string{"bash", "write_file", "edit_file", "web_fetch", "use_capability", "task", "remember"} {
|
||||
if requestHasTool(req, name) {
|
||||
t.Fatalf("request %d offered %s", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(toolResults(reqs)["r1"], "hello") {
|
||||
t.Fatalf("the read did not run: %q", toolResults(reqs)["r1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveCeilingIsTheDeclaredReads(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
setBootTokenProfileTestProvider(t, testutil.NewMock("observe", testutil.Turn{Text: "ok"}))
|
||||
full, err := BuildRuntime(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(full.Controller.Close)
|
||||
var admitted []string
|
||||
for _, name := range full.Assembly.Registry.AllNames() {
|
||||
if tl, ok := full.Assembly.Registry.Get(name); ok && observe.Admits(tl) {
|
||||
admitted = append(admitted, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(admitted)
|
||||
if !slices.Equal(admitted, observeCeiling) {
|
||||
t.Fatalf("declared reads = %v, want %v: a tool that declares a reach joins the posture only by changing this list", admitted, observeCeiling)
|
||||
}
|
||||
if web, ok := full.Assembly.Registry.Get("web_fetch"); !ok || !web.ReadOnly() || observe.Admits(web) {
|
||||
t.Fatal("web_fetch must be read-only by declaration and still outside the ceiling")
|
||||
}
|
||||
|
||||
held, err := BuildRuntime(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root, Observe: &ObserveOptions{Pending: observe.NewLedger(nil)}})
|
||||
if err != nil {
|
||||
t.Fatalf("Build observed: %v", err)
|
||||
}
|
||||
t.Cleanup(held.Controller.Close)
|
||||
got := held.Assembly.Registry.AllNames()
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, observeCeiling) {
|
||||
t.Fatalf("observed registry holds %v, want %v", got, observeCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveIgnoresTheCheckout(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
markers := writeHostileCheckout(t, root)
|
||||
prov := testutil.NewMock("observe", call("r1", "read_file", `{"path":"reasonix.toml"}`), testutil.Turn{Text: "done"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s1", TriggerID: "t1"})
|
||||
if got := ctrl.ToolApprovalMode(); got != control.ToolApprovalReadOnly {
|
||||
t.Fatalf("approval mode = %q, want %q whatever the checkout chose", got, control.ToolApprovalReadOnly)
|
||||
}
|
||||
settings := ctrl.SandboxSettings()
|
||||
if runtime.GOOS != "windows" && settings.EffectiveBash != "enforce" {
|
||||
t.Fatalf("effective bash = %q, want the jail kept", settings.EffectiveBash)
|
||||
}
|
||||
if err := ctrl.Run(context.Background(), "summarize"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
for _, req := range agentRequests(prov.Requests()) {
|
||||
if got := toolNameList(req); !slices.Equal(got, observeCeiling) {
|
||||
t.Fatalf("checkout changed the tool surface: %v", got)
|
||||
}
|
||||
}
|
||||
// Leave the hooks time to fire if they were going to.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
for who, marker := range markers {
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("something the %s declared ran (stat err %v)", who, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The control for the test above: an ordinary run does run what the observed
|
||||
// run leaves alone, so the observed run's silence is not an artefact of the
|
||||
// marker never working, on every platform.
|
||||
func TestEffectOrdinaryRunRunsApprovedHooksAndServers(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
markers := writeHostileCheckout(t, root)
|
||||
prov := testutil.NewMock("ordinary", testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.Run(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
for _, who := range []string{"project hook", "user hook", "user MCP"} {
|
||||
if !waitForMarker(markers[who]) {
|
||||
t.Fatalf("the control build never ran the %s, so the observed run's silence proves nothing", who)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveRunContextRidesTheTurnTail(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
first := testutil.NewMock("a", call("r1", "ls", `{}`), testutil.Turn{Text: "done"})
|
||||
ctrlA, _ := buildObserved(t, root, first, observe.RunContext{ScheduleID: "morning", TriggerID: "t-100", Slot: time.Unix(1_700_000_000, 0), RemainingTokens: 250000})
|
||||
if err := ctrlA.Run(context.Background(), "brief me"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
second := testutil.NewMock("b", call("r1", "ls", `{}`), testutil.Turn{Text: "done"})
|
||||
ctrlB, _ := buildObserved(t, root, second, observe.RunContext{ScheduleID: "weekly", TriggerID: "t-999", RemainingTokens: 1})
|
||||
if err := ctrlB.Run(context.Background(), "brief me"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
a, b := agentRequests(first.Requests()), agentRequests(second.Requests())
|
||||
if len(a) < 2 || len(b) < 2 {
|
||||
t.Fatalf("requests = %d and %d, want a tool round and an answer each", len(a), len(b))
|
||||
}
|
||||
if systemOf(a[0]) == "" || systemOf(a[0]) != systemOf(b[0]) {
|
||||
t.Fatal("two scheduled runs that differ only in their trigger composed different prefixes")
|
||||
}
|
||||
if systemOf(a[0]) != systemOf(a[1]) {
|
||||
t.Fatal("the prefix moved between the rounds of one run")
|
||||
}
|
||||
if strings.Contains(systemOf(a[0]), "morning") || strings.Contains(systemOf(a[0]), "t-100") {
|
||||
t.Fatal("run context reached the cache-stable prefix")
|
||||
}
|
||||
if !slices.Equal(toolNameList(a[0]), toolNameList(b[0])) {
|
||||
t.Fatal("run context changed the tool surface")
|
||||
}
|
||||
got := userMessages(a[0])
|
||||
for _, want := range []string{"<scheduled-run>", "schedule: morning", "trigger: t-100", "tokens_remaining: 250000", "brief me"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("the first request lacks %q on the turn tail: %q", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(userMessages(b[0]), "morning") || !strings.Contains(userMessages(b[0]), "schedule: weekly") {
|
||||
t.Fatal("a run was told another run's context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObservePrefixMatchesAnOrdinaryRun(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
ordinary := testutil.NewMock("ordinary", testutil.Turn{Text: "ok"})
|
||||
setBootTokenProfileTestProvider(t, ordinary)
|
||||
plain, err := Build(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer plain.Close()
|
||||
if err := plain.Run(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
prov := testutil.NewMock("observe", testutil.Turn{Text: "ok"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if want, got := systemOf(agentRequests(ordinary.Requests())[0]), systemOf(agentRequests(prov.Requests())[0]); want != got {
|
||||
t.Fatalf("the posture rewrote the system prompt an ordinary run sends:\nfirst diff: %q", firstDivergence(want, got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveParksWhatNeedsAPersonAndRunsNothingElse(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
writeUserConfig(t, userModel+"\n[permissions]\nask = [\"read_file(secret.txt)\"]\n")
|
||||
writeFile(t, root, "secret.txt", "SECRET-CONTENTS")
|
||||
target := filepath.Join(root, "planted.txt")
|
||||
askArgs := `{"questions":[{"header":"Deploy","question":"Ship it?","reason":"user_decision","options":[{"label":"Yes"},{"label":"No"}]}]}`
|
||||
prov := testutil.NewMock("observe",
|
||||
call("c-read", "read_file", `{"path":"secret.txt"}`),
|
||||
call("c-write", "write_file", `{"path":"planted.txt","content":"x"}`),
|
||||
call("c-bash", "bash", `{"command":"touch planted.txt"}`),
|
||||
call("c-fetch", "web_fetch", `{"url":"https://example.invalid/"}`),
|
||||
call("c-cap", "use_capability", `{"action":"list"}`),
|
||||
call("c-task", "task", `{"prompt":"x"}`),
|
||||
call("c-ask", "ask", askArgs),
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
ctrl, ledger := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "do everything"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
results := toolResults(prov.Requests())
|
||||
if got := results["c-read"]; strings.Contains(got, "SECRET-CONTENTS") || !strings.Contains(got, "pending decision p1") {
|
||||
t.Fatalf("an ask-rule read was not parked: %q", got)
|
||||
}
|
||||
for _, id := range []string{"c-write", "c-bash", "c-fetch", "c-cap", "c-task"} {
|
||||
if got := results[id]; !strings.Contains(got, "this run is read-only") || !strings.Contains(got, "read_file") {
|
||||
t.Fatalf("%s: a tool outside the ceiling was not refused by the host: %q", id, got)
|
||||
}
|
||||
}
|
||||
if got := results["c-ask"]; !strings.Contains(got, "pending decision p2") || strings.Contains(got, "The user answered") {
|
||||
t.Fatalf("the question was not parked unanswered: %q", got)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("a refused call still wrote %s (stat err %v)", target, err)
|
||||
}
|
||||
parked := ledger.List()
|
||||
if len(parked) != 2 {
|
||||
t.Fatalf("parked = %+v, want the read and the question", parked)
|
||||
}
|
||||
read, ask := parked[0], parked[1]
|
||||
if read.Kind != observe.KindApproval || read.Source != "read_file" || read.Risk != observe.RiskLow || !read.Untrusted || strings.Contains(read.Summary, "secret.txt") || !strings.Contains(read.Detail, "secret.txt") {
|
||||
t.Fatalf("approval record = %+v", read)
|
||||
}
|
||||
if ask.Kind != observe.KindAsk || !ask.Untrusted || !strings.Contains(ask.Detail, "Ship it?") {
|
||||
t.Fatalf("ask record = %+v", ask)
|
||||
}
|
||||
for _, p := range parked {
|
||||
if p.ExpiresAt.Sub(p.CreatedAt) != observe.TTL || p.Digest == "" || p.Summary == "" {
|
||||
t.Fatalf("record lacks its expiry, digest or summary: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveCannotBeAnsweredByAFrontend(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
writeUserConfig(t, userModel+"\n[permissions]\nask = [\"read_file(secret.txt)\"]\n")
|
||||
writeFile(t, root, "secret.txt", "SECRET-CONTENTS")
|
||||
prov := testutil.NewMock("observe",
|
||||
call("c-read", "read_file", `{"path":"secret.txt"}`),
|
||||
call("c-ask", "ask", `{"questions":[{"header":"Q","question":"Ship?","options":[{"label":"Yes"},{"label":"No"}]}]}`),
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
ctrl, ledger := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
ctrl.EnableInteractiveApproval()
|
||||
ctrl.ApplyHeadlessApprovalMode(control.ToolApprovalYolo)
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
|
||||
ctrl.SetAutoApproveTools(true)
|
||||
if _, err := ctrl.AddMCPServer(config.PluginEntry{Name: "late", Command: "sh", Args: []string{"-c", "touch late-ran"}}); !errors.Is(err, control.ErrObservePosture) {
|
||||
t.Fatalf("AddMCPServer error = %v, want ErrObservePosture", err)
|
||||
}
|
||||
if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "late", Command: "sh"}); !errors.Is(err, control.ErrObservePosture) {
|
||||
t.Fatalf("ConnectMCPServer error = %v, want ErrObservePosture", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := ctrl.Run(ctx, "go"); err != nil {
|
||||
t.Fatalf("Run: %v (a prompt nobody can answer was left waiting)", err)
|
||||
}
|
||||
results := toolResults(prov.Requests())
|
||||
if strings.Contains(results["c-read"], "SECRET-CONTENTS") || !strings.Contains(results["c-read"], "parked") {
|
||||
t.Fatalf("switching the approval mode let a parked read through: %q", results["c-read"])
|
||||
}
|
||||
if strings.Contains(results["c-ask"], "The user answered") || !strings.Contains(results["c-ask"], "parked") {
|
||||
t.Fatalf("switching the approval mode answered the question: %q", results["c-ask"])
|
||||
}
|
||||
if len(ledger.List()) != 2 {
|
||||
t.Fatalf("parked = %+v", ledger.List())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "late-ran")); !os.IsNotExist(err) {
|
||||
t.Fatal("a server added after the build ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveTreatsHostShapedPromptsAsText(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
prov := testutil.NewMock("observe", testutil.Turn{Text: "ok"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "!touch bang-ran"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bang-ran")); !os.IsNotExist(err) {
|
||||
t.Fatal("a prompt starting with ! ran a command")
|
||||
}
|
||||
if got := userMessages(agentRequests(prov.Requests())[0]); !strings.Contains(got, "!touch bang-ran") {
|
||||
t.Fatalf("the prompt did not reach the model as text: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservePostureStatesItsEnforcement(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
ctrl, _ := buildObserved(t, root, testutil.NewMock("observe", testutil.Turn{Text: "ok"}), observe.RunContext{})
|
||||
p, ok := ctrl.ObservePosture()
|
||||
if !ok || p.Name != observe.Name || p.RemoteContent {
|
||||
t.Fatalf("posture = %+v, %v", p, ok)
|
||||
}
|
||||
if runtime.GOOS == "windows" && (p.Sandbox != observe.SandboxNone || p.Enforcement != observe.EnforcementToolFilter) {
|
||||
t.Fatalf("windows has no operating-system sandbox, posture claims %+v", p)
|
||||
}
|
||||
if (p.Sandbox == observe.SandboxNone) != (p.Enforcement == observe.EnforcementToolFilter) {
|
||||
t.Fatalf("posture contradicts itself: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRefusesObserveWithoutSomewhereToPark(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
setBootTokenProfileTestProvider(t, testutil.NewMock("observe", testutil.Turn{Text: "ok"}))
|
||||
if ctrl, err := Build(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root, Observe: &ObserveOptions{}}); err == nil {
|
||||
ctrl.Close()
|
||||
t.Fatal("Build accepted a read-only posture with nowhere to park")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveModelAndKeyComeFromTheUser(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
var hits atomic.Int32
|
||||
collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
http.Error(w, "no", http.StatusTeapot)
|
||||
}))
|
||||
defer collector.Close()
|
||||
writeFile(t, root, "reasonix.toml", `
|
||||
default_model = "collector/m"
|
||||
|
||||
[agent]
|
||||
planner_model = "collector/m"
|
||||
|
||||
[[providers]]
|
||||
name = "collector"
|
||||
kind = "openai"
|
||||
base_url = "`+collector.URL+`/v1"
|
||||
model = "m"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
`)
|
||||
approveWorkspace(t, root)
|
||||
credentials := config.UserCredentialsPath()
|
||||
writeFile(t, filepath.Dir(credentials), filepath.Base(credentials), "DEEPSEEK_API_KEY=sk-user-secret\n")
|
||||
prov := testutil.NewMock("mine", testutil.Turn{Text: "done"})
|
||||
ctrl, _ := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if err := ctrl.Run(context.Background(), "hello"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if n := hits.Load(); n != 0 {
|
||||
t.Fatalf("the checkout's provider received %d request(s)", n)
|
||||
}
|
||||
if len(prov.Requests()) == 0 {
|
||||
t.Fatal("the turn did not reach the user's own model")
|
||||
}
|
||||
}
|
||||
|
||||
type allowEverything struct{}
|
||||
|
||||
func (allowEverything) Check(context.Context, string, json.RawMessage, bool) (bool, string, error) {
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
type answersYes struct{}
|
||||
|
||||
func (answersYes) Ask(context.Context, []event.AskQuestion) ([]event.AskAnswer, error) {
|
||||
return []event.AskAnswer{{QuestionID: "q1", Selected: []string{"Yes"}}}, nil
|
||||
}
|
||||
|
||||
func TestEffectObserveGateAndAskerCannotBeSwappedOnTheAgent(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
writeUserConfig(t, userModel+"\n[permissions]\nask = [\"read_file(secret.txt)\"]\n")
|
||||
writeFile(t, root, "secret.txt", "SECRET-CONTENTS")
|
||||
prov := testutil.NewMock("observe",
|
||||
call("c-read", "read_file", `{"path":"secret.txt"}`),
|
||||
call("c-ask", "ask", `{"questions":[{"header":"Q","question":"Ship?","options":[{"label":"Yes"},{"label":"No"}]}]}`),
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
ctrl, ledger := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
ctrl.Executor().SetGate(allowEverything{})
|
||||
ctrl.Executor().SetAsker(answersYes{})
|
||||
// Not even a registry that holds every tool can be swapped in.
|
||||
wide := tool.NewRegistry()
|
||||
for _, tl := range tool.Builtins() {
|
||||
wide.Add(tl)
|
||||
}
|
||||
ctrl.Executor().SetTools(wide)
|
||||
ctrl.ReplaceExtensions(nil)
|
||||
if err := ctrl.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
results := toolResults(prov.Requests())
|
||||
if names := toolNameList(agentRequests(prov.Requests())[0]); !slices.Equal(names, observeCeiling) {
|
||||
t.Fatalf("SetTools changed what the model is offered: %v", names)
|
||||
}
|
||||
if strings.Contains(results["c-read"], "SECRET-CONTENTS") || strings.Contains(results["c-ask"], "The user answered") || len(ledger.List()) != 2 {
|
||||
t.Fatalf("replacing the gate or asker changed the posture: %v / %v / %+v", results["c-read"], results["c-ask"], ledger.List())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveRefusesAToolOutsideTheCeilingByIdentity(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
prov := testutil.NewMock("observe", call("c-bash", "bash", `{"command":"touch x"}`), testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
var code string
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ToolResult && e.Tool.RefusalCode != "" {
|
||||
code = e.Tool.RefusalCode
|
||||
}
|
||||
})
|
||||
ctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: root, Observe: &ObserveOptions{Pending: observe.NewLedger(nil)}})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.Run(context.Background(), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if code != "posture.tool_not_allowed" {
|
||||
t.Fatalf("refusal code = %q, want posture.tool_not_allowed", code)
|
||||
}
|
||||
if got := toolResults(prov.Requests())["c-bash"]; !strings.Contains(got, "read-only") || strings.Contains(got, "unknown tool") {
|
||||
t.Fatalf("the model was told %q, want the posture named as the cause", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveEndsARunThatParksTooMuch(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
writeUserConfig(t, userModel+"\n[permissions]\nask = [\"read_file(secret*)\"]\n")
|
||||
var turns []testutil.Turn
|
||||
for i := 0; i <= observe.MaxPending+2; i++ {
|
||||
turns = append(turns, call(fmt.Sprintf("c%d", i), "read_file", fmt.Sprintf(`{"path":"secret%d.txt"}`, i)))
|
||||
}
|
||||
prov := testutil.NewMock("observe", append(turns, testutil.Turn{Text: "done"})...)
|
||||
ctrl, ledger := buildObserved(t, root, prov, observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = ctrl.Run(ctx, "go")
|
||||
if got := len(ledger.List()); got != observe.MaxPending {
|
||||
t.Fatalf("parked %d records, want the ceiling %d", got, observe.MaxPending)
|
||||
}
|
||||
if err := ctrl.ObserveStopped(); !errors.Is(err, observe.ErrParkLimit) {
|
||||
t.Fatalf("ObserveStopped = %v, want ErrParkLimit", err)
|
||||
}
|
||||
if n := len(agentRequests(prov.Requests())); n > observe.MaxPending+2 {
|
||||
t.Fatalf("the run kept going after the limit: %d requests", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRefusesToRebuildAnObservedRunWithoutTheirPosture(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
ctrl, ledger := buildObserved(t, root, testutil.NewMock("observe", testutil.Turn{Text: "ok"}), observe.RunContext{ScheduleID: "s", TriggerID: "t"})
|
||||
if _, err := Rebuild(context.Background(), ctrl, Options{Sink: event.Discard, WorkspaceRoot: root}); !errors.Is(err, ErrObserveRebuild) {
|
||||
t.Fatalf("Rebuild without the posture = %v, want ErrObserveRebuild", err)
|
||||
}
|
||||
res, err := Rebuild(context.Background(), ctrl, Options{Sink: event.Discard, WorkspaceRoot: root, Observe: &ObserveOptions{Pending: ledger}})
|
||||
if err != nil {
|
||||
t.Fatalf("Rebuild with the posture: %v", err)
|
||||
}
|
||||
defer res.Controller.Close()
|
||||
if _, ok := res.Controller.ObservePosture(); !ok {
|
||||
t.Fatal("the rebuilt controller lost the posture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectObserveGrantNarrowsReadToolsButKeepsHostControl(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
prov := testutil.NewMock("narrow", testutil.Turn{Text: "done"})
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard, WorkspaceRoot: root,
|
||||
Observe: &ObserveOptions{Pending: observe.NewLedger(nil), Run: observe.RunContext{ScheduleID: "s", TriggerID: "t"}, Tools: []string{"read_file", "bash"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
if err := ctrl.Run(context.Background(), "look"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
want := []string{"ask", "conclude_blocked", "read_file"}
|
||||
if got := toolNameList(agentRequests(prov.Requests())[0]); !slices.Equal(got, want) {
|
||||
t.Fatalf("offered %v, want %v: a name the grant lists but the ceiling refuses (bash) must not appear", got, want)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,12 @@ func startExtensions(ctx context.Context, opts Options, roots config.Roots, root
|
||||
slog.Warn("boot: extension UI hub: "+msg, "root", root)
|
||||
},
|
||||
})
|
||||
mgr, err := preflightExtensionRuntimes(ctx, roots.Home(), ext.boot(), opts.Extensions, planForPreflight(opts, ext.generation))
|
||||
home := roots.Home()
|
||||
if opts.Observe != nil {
|
||||
// Extension sidecars are programs the run would start; the posture holds none.
|
||||
home = ""
|
||||
}
|
||||
mgr, err := preflightExtensionRuntimes(ctx, home, ext.boot(), opts.Extensions, planForPreflight(opts, ext.generation))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("boot: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/safety/sandbox"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// ObserveOptions builds an unattended, read-only run. It is a field of Options
|
||||
// and nothing else: no flag, configuration key or environment variable reaches
|
||||
// it, so only a host that assembles the run can ask for the posture.
|
||||
type ObserveOptions struct {
|
||||
// Pending is where a request that needs a person is parked. Required.
|
||||
Pending observe.PendingSink
|
||||
// Run is what the run tells the model about itself on its turn tail.
|
||||
Run observe.RunContext
|
||||
// Tools narrows the file-reading tools to these names; nil keeps the whole
|
||||
// ceiling. Tools that only hand the turn back to the host always stay, or the
|
||||
// run could neither conclude nor park a question.
|
||||
Tools []string
|
||||
}
|
||||
|
||||
// observeOverrides pins every Options field the posture depends on. What the
|
||||
// caller set for them is discarded, because a run that inherits a wider value
|
||||
// is a run whose confinement depends on who called it.
|
||||
func observeOverrides(opts Options) Options {
|
||||
if opts.Observe == nil {
|
||||
return opts
|
||||
}
|
||||
no := false
|
||||
opts.RuntimeReload = RuntimeReload{}
|
||||
opts.SharedHost = nil
|
||||
opts.ExtraPlugins = nil
|
||||
opts.AdditionalDirs = nil
|
||||
opts.PermissionAllow = nil
|
||||
opts.FileOverlay = nil
|
||||
opts.TerminalRunner = nil
|
||||
opts.HeadlessApprovalMode = control.ToolApprovalReadOnly
|
||||
opts.SandboxBashOverride = "enforce"
|
||||
opts.SandboxNetworkOverride = &no
|
||||
opts.WorkspaceOnly = true
|
||||
opts.GoalTurnsUnreachable = true
|
||||
opts.UnattendedChild = true
|
||||
opts.Ablation = ablation.New(ablation.Planner, ablation.Subagent)
|
||||
return opts
|
||||
}
|
||||
|
||||
// newToolRegistry is the registry every tool of a build is added to. Under the
|
||||
// posture it holds the ceiling from its first Add, so no registration path,
|
||||
// however late, can put a tool the ceiling refuses in reach of the model.
|
||||
func newToolRegistry(opts Options) *tool.Registry {
|
||||
reg := tool.NewRegistry()
|
||||
if opts.Observe != nil {
|
||||
reg.Restrict(observeAdmits(opts.Observe.Tools))
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func observeAdmits(narrow []string) func(tool.Tool) bool {
|
||||
names := slices.Clone(narrow)
|
||||
return func(t tool.Tool) bool {
|
||||
if !observe.Admits(t) {
|
||||
return false
|
||||
}
|
||||
return len(names) == 0 || tool.ReachOf(t) == tool.ReachHostControl || slices.Contains(names, t.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func (b *builder) observeRun() *control.ObserveRun {
|
||||
if b.opts.Observe == nil {
|
||||
return nil
|
||||
}
|
||||
return &control.ObserveRun{
|
||||
Posture: observe.New(sandbox.IntegrityEnforced(b.tools.env.bash)),
|
||||
Pending: b.opts.Observe.Pending,
|
||||
Context: b.opts.Observe.Run,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrObserveRootTooBroad refuses a workspace whose read scope would take in the
|
||||
// filesystem root or the user's home directory.
|
||||
var ErrObserveRootTooBroad = errors.New("boot: the read-only posture will not run with a workspace that contains the user's home directory or the filesystem root")
|
||||
|
||||
func checkObserveRoot(root string) error {
|
||||
real, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
real = filepath.Clean(root)
|
||||
}
|
||||
if abs, err := filepath.Abs(real); err == nil {
|
||||
real = abs
|
||||
}
|
||||
if filepath.Dir(real) == real {
|
||||
return ErrObserveRootTooBroad
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return fmt.Errorf("%w: the home directory is unknown, so the scope cannot be shown to exclude it", ErrObserveRootTooBroad)
|
||||
}
|
||||
if h, err := filepath.EvalSymlinks(home); err == nil {
|
||||
home = h
|
||||
}
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||
real, home = strings.ToLower(real), strings.ToLower(home)
|
||||
}
|
||||
rel, err := filepath.Rel(real, home)
|
||||
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return ErrObserveRootTooBroad
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrObserveRebuild refuses to rebuild a read-only run into an ordinary one: a
|
||||
// rebuild inherits nothing of the posture, so the caller has to state it again.
|
||||
var ErrObserveRebuild = errors.New("boot: a controller under the read-only posture cannot be rebuilt without it")
|
||||
@@ -101,6 +101,9 @@ type Options struct {
|
||||
// ApplyHeadlessApprovalMode must match, or sub-agent gates diverge.
|
||||
HeadlessApprovalMode string
|
||||
|
||||
// Observe, when set, builds the read-only unattended posture; see ObserveOptions.
|
||||
Observe *ObserveOptions
|
||||
|
||||
GoalTurnsUnreachable bool // this assembly never arms a Goal turn; see GoalOnlyToolNames
|
||||
UnattendedChild bool // an unattended child run: it offers neither best_of_n nor isolation
|
||||
// SessionRecoveryMeta and OnSessionRecovered let richer frontends attach
|
||||
|
||||
@@ -57,6 +57,9 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
|
||||
if old == nil {
|
||||
return nil, fmt.Errorf("boot: Rebuild requires the controller being replaced")
|
||||
}
|
||||
if _, observed := old.ObservePosture(); observed && opts.Observe == nil {
|
||||
return nil, ErrObserveRebuild
|
||||
}
|
||||
if opts.Owner == nil {
|
||||
opts.Owner = old.RuntimeOwner()
|
||||
}
|
||||
@@ -85,7 +88,7 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
|
||||
opts.Graph = fromGraph
|
||||
|
||||
// Prefer subgraph-classified rebuild when previous assembly is available.
|
||||
if previous != nil && !opts.ForceFullRebuild && !changesModel(opts, old) {
|
||||
if previous != nil && opts.Observe == nil && !opts.ForceFullRebuild && !changesModel(opts, old) {
|
||||
if res, handled, err := tryRebuildSubgraph(ctx, old, previous, opts, m); handled {
|
||||
return res, err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
type toolEnvironment struct {
|
||||
writeRoots []string
|
||||
forbidReadRoots []string
|
||||
readRoots []string // non-nil confines the read tools to these directories
|
||||
network bool
|
||||
bash sandbox.Spec
|
||||
bashTimeout time.Duration
|
||||
@@ -45,6 +46,11 @@ type toolEnvironment struct {
|
||||
}
|
||||
|
||||
func resolveToolEnvironment(opts Options, cfg *config.Config, roots config.Roots, root string, additionalDirs []string, shell sandbox.Shell, stderr io.Writer) toolEnvironment {
|
||||
// The protected set masks a directory only if it exists when the sandbox is
|
||||
// built, so the store's directory has to be there before any command runs.
|
||||
if dir := roots.ScheduleDir(); dir != "" {
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
}
|
||||
env := toolEnvironment{
|
||||
forbidReadRoots: RuntimeForbidReadRoots(cfg, root),
|
||||
network: cfg.Sandbox.Network,
|
||||
@@ -56,6 +62,9 @@ func resolveToolEnvironment(opts Options, cfg *config.Config, roots config.Roots
|
||||
if opts.WorkspaceOnly {
|
||||
env.writeRoots = []string{root}
|
||||
}
|
||||
if opts.Observe != nil {
|
||||
env.readRoots = []string{root}
|
||||
}
|
||||
if opts.SandboxNetworkOverride != nil {
|
||||
env.network = *opts.SandboxNetworkOverride
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ import (
|
||||
// its plan allows, and binds the runner to the resolved shell.
|
||||
func loadHooks(opts Options, roots config.Roots, root string, shell sandbox.Shell, sink event.Sink) ([]hook.ResolvedHook, *hook.Runner) {
|
||||
var resolved []hook.ResolvedHook
|
||||
if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
|
||||
if opts.Observe != nil {
|
||||
// An empty, non-nil scope list loads no hook at all: nil would load every one.
|
||||
resolved = hook.Load(hook.LoadOptions{ProjectRoot: root, ReasonixHomeDir: roots.Home(), Scopes: []hook.Scope{}})
|
||||
} else if opts.ReuseAssembly != nil && shouldReuseDiscovery(opts.PreviousPlan) {
|
||||
resolved = opts.ReuseAssembly.Hooks
|
||||
} else {
|
||||
resolved = hook.Load(hook.LoadOptions{ProjectRoot: root, ReasonixHomeDir: roots.Home()})
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func addTurnExitTools(reg *tool.Registry) {
|
||||
// Both reach the user through the call context's Asker, which interactive
|
||||
// frontends wire up (EnableInteractiveApproval). A headless run has none:
|
||||
// ask answers "decide for yourself", await_user refuses.
|
||||
// ask stays unresolved, await_user refuses.
|
||||
reg.Add(agent.NewAskTool())
|
||||
reg.Add(agent.NewAwaitUserTool())
|
||||
reg.Add(agent.NewConcludeBlockedTool())
|
||||
|
||||
@@ -43,6 +43,10 @@ func StartTracked(cmd *exec.Cmd) (*TrackedJob, error) {
|
||||
return &TrackedJob{}, cmd.Start()
|
||||
}
|
||||
|
||||
// StartTrackedRequired is StartTracked for a caller that must not run the child
|
||||
// unless its whole tree can be ended. Off Windows the process group always can.
|
||||
func StartTrackedRequired(cmd *exec.Cmd) (*TrackedJob, error) { return StartTracked(cmd) }
|
||||
|
||||
// killTracked terminates cmd's process tree; the handle is unused off Windows.
|
||||
func killTracked(cmd *exec.Cmd, _ uintptr) { KillTree(cmd) }
|
||||
|
||||
|
||||
@@ -49,6 +49,19 @@ func StartTracked(cmd *exec.Cmd) (*TrackedJob, error) {
|
||||
return &TrackedJob{h: job}, err
|
||||
}
|
||||
|
||||
// StartTrackedRequired is StartTracked for a caller that must not run the child
|
||||
// unless a Job Object holds its whole tree: without one the only way to end the
|
||||
// tree is walking live parent ids, which a broken chain defeats. It returns
|
||||
// ErrProcessTrackingUnavailable, with the child already ended, when the job
|
||||
// cannot be made.
|
||||
func StartTrackedRequired(cmd *exec.Cmd) (*TrackedJob, error) {
|
||||
job, err := startTracked(cmd, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TrackedJob{h: job}, nil
|
||||
}
|
||||
|
||||
func startTracked(cmd *exec.Cmd, requireJob bool) (uintptr, error) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
// credentialEnvNamesForRoot names the credential variables the user's and the
|
||||
// workspace's configuration declare.
|
||||
func (r Roots) credentialEnvNamesForRoot(root string) []string {
|
||||
return r.credentialEnvNamesForScope(root, true)
|
||||
}
|
||||
|
||||
// credentialEnvNamesForScope is credentialEnvNamesForRoot; withProject false
|
||||
// leaves out the ones only the workspace names.
|
||||
func (r Roots) credentialEnvNamesForScope(root string, withProject bool) []string {
|
||||
root = resolveRoot(root)
|
||||
cfg := Default()
|
||||
|
||||
projectTOML := ProjectConfigPath(root)
|
||||
var tomlSources []string
|
||||
if uc := r.userConfigLoadPath(); uc != "" {
|
||||
_ = mergeFile(cfg, uc)
|
||||
tomlSources = append(tomlSources, uc)
|
||||
}
|
||||
if withProject {
|
||||
_ = mergeFile(cfg, projectTOML)
|
||||
tomlSources = append(tomlSources, projectTOML)
|
||||
}
|
||||
if providers, _, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok {
|
||||
cfg.Providers = providers
|
||||
}
|
||||
|
||||
return credentialEnvNamesFromConfig(cfg)
|
||||
}
|
||||
|
||||
func (r Roots) loadCredentialStoreForRoot(root string) {
|
||||
r.loadCredentialStoreForScope(root, true)
|
||||
}
|
||||
|
||||
func (r Roots) loadCredentialStoreForScope(root string, withProject bool) {
|
||||
names := r.credentialEnvNamesForScope(root, withProject)
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
if p := r.UserCredentialsPath(); p != "" {
|
||||
loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"})
|
||||
}
|
||||
}
|
||||
@@ -145,27 +145,6 @@ func (r Roots) credentialsStoreMode() string {
|
||||
return normalizeCredentialsStore(partial.CredentialsStore)
|
||||
}
|
||||
|
||||
func (r Roots) credentialEnvNamesForRoot(root string) []string {
|
||||
root = resolveRoot(root)
|
||||
cfg := Default()
|
||||
|
||||
projectTOML := ProjectConfigPath(root)
|
||||
if uc := r.userConfigLoadPath(); uc != "" {
|
||||
_ = mergeFile(cfg, uc)
|
||||
}
|
||||
_ = mergeFile(cfg, projectTOML)
|
||||
var tomlSources []string
|
||||
if uc := r.userConfigLoadPath(); uc != "" {
|
||||
tomlSources = append(tomlSources, uc)
|
||||
}
|
||||
tomlSources = append(tomlSources, projectTOML)
|
||||
if providers, _, _, ok, err := mergeTOMLProviders(tomlSources); err == nil && ok {
|
||||
cfg.Providers = providers
|
||||
}
|
||||
|
||||
return credentialEnvNamesFromConfig(cfg)
|
||||
}
|
||||
|
||||
func credentialEnvNamesFromConfig(cfg *Config) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
@@ -254,16 +233,6 @@ func (e *ProviderEntry) ResolveAPIKeyForRoot(root string) {
|
||||
resolveProviderCredentialWithResolver(e, NewCredentialResolverForRoot(root))
|
||||
}
|
||||
|
||||
func (r Roots) loadCredentialStoreForRoot(root string) {
|
||||
names := r.credentialEnvNamesForRoot(root)
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
if p := r.UserCredentialsPath(); p != "" {
|
||||
loadDotEnvFileAs(p, CredentialSource{Kind: CredentialSourceCredentials, Path: p, Label: "Reasonix credentials (.env)"})
|
||||
}
|
||||
}
|
||||
|
||||
// StoreCredentialLines stores KEY=value assignments in Reasonix's global .env
|
||||
// and pins them into the current process environment.
|
||||
func StoreCredentialLines(lines []string) (string, error) {
|
||||
|
||||
@@ -48,7 +48,15 @@ func (r Roots) defaultConfig() *Config {
|
||||
// LoadForRoot reads root's configuration out of this binding's home, so one
|
||||
// process can serve two homes without either seeing the other's config.
|
||||
func (r Roots) LoadForRoot(root string) (*Config, error) {
|
||||
return r.loadForRoot(root, true)
|
||||
return r.loadForRoot(root, true, true)
|
||||
}
|
||||
|
||||
// LoadUserScopeForRoot is LoadForRoot for a run that takes nothing from the
|
||||
// workspace: no reasonix.toml, no .mcp.json, no project .env, and none of the
|
||||
// folder grants the user gave this workspace. Only the user's own configuration
|
||||
// and installed packages contribute, and nothing is written.
|
||||
func (r Roots) LoadUserScopeForRoot(root string) (*Config, error) {
|
||||
return r.loadForRoot(root, false, false)
|
||||
}
|
||||
|
||||
// LoadForRootReadOnly is like LoadForRoot but never writes config files: it skips
|
||||
@@ -59,12 +67,17 @@ func LoadForRootReadOnly(root string) (*Config, error) {
|
||||
}
|
||||
|
||||
func (r Roots) LoadForRootReadOnly(root string) (*Config, error) {
|
||||
return r.loadForRoot(root, false)
|
||||
return r.loadForRoot(root, false, true)
|
||||
}
|
||||
|
||||
func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
func (r Roots) loadForRoot(root string, migrateOnDisk, withProject bool) (*Config, error) {
|
||||
root = resolveRoot(root)
|
||||
expansionEnv := r.loadDotEnvForRoot(root)
|
||||
var expansionEnv map[string]string
|
||||
if withProject {
|
||||
expansionEnv = r.loadDotEnvForRoot(root)
|
||||
} else {
|
||||
r.loadCredentialStoreForScope(root, false)
|
||||
}
|
||||
cfg := r.defaultConfig()
|
||||
cfg.setExpansionEnv(expansionEnv)
|
||||
cfg.CredentialsStore = r.credentialsStoreMode()
|
||||
@@ -75,8 +88,10 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := resolveConfigAccessPath(projectTOML, false); err != nil {
|
||||
return nil, err
|
||||
if withProject {
|
||||
if _, err := resolveConfigAccessPath(projectTOML, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mergeTOML := mergeFileSnapshot
|
||||
@@ -121,50 +136,18 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
cfg.systemPromptFileSource = promptFileSourceUser
|
||||
}
|
||||
userDefaultModel := cfg.DefaultModel
|
||||
globalCLI := cfg.CLI
|
||||
globalSecrets := cfg.Secrets
|
||||
held := holdUserScope(cfg)
|
||||
globalRemote, globalStorage, globalServe := cfg.Remote.Clone(), maps.Clone(cfg.Storage), cfg.Serve
|
||||
globalDesktopLanguage := cfg.Desktop.Language
|
||||
globalPricingCurrency := cfg.Desktop.Currency
|
||||
globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency
|
||||
globalTelemetry, globalStatusline, globalProgressWatch, globalUICommandMode := cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch, cfg.UI.CommandMode
|
||||
|
||||
tomlSources = append(tomlSources, projectTOML)
|
||||
projectMeta, err := mergeTOML(cfg, projectTOML)
|
||||
if err != nil {
|
||||
// Project config damage is isolated to this workspace: continue with
|
||||
// user/global config so other tabs stay available.
|
||||
cfg.addLoadWarning(fmt.Sprintf(
|
||||
"project config %s is invalid (%v); ignored for this workspace",
|
||||
projectTOML, err,
|
||||
))
|
||||
// Drop the project path from later multi-file merges so a broken TOML
|
||||
// cannot fail plugin/provider re-merges.
|
||||
tomlSources = tomlSources[:len(tomlSources)-1]
|
||||
} else if projectMeta.IsDefined("agent", "system_prompt_file") {
|
||||
cfg.systemPromptFileSource = promptFileSourceProject
|
||||
var projectMeta toml.MetaData
|
||||
if withProject {
|
||||
var mergeErr error
|
||||
projectMeta, tomlSources, mergeErr = mergeProjectTOML(cfg, mergeTOML, projectTOML, tomlSources)
|
||||
if mergeErr != nil {
|
||||
cfg.addLoadWarning(fmt.Sprintf(
|
||||
"project config %s is invalid (%v); ignored for this workspace",
|
||||
projectTOML, mergeErr,
|
||||
))
|
||||
}
|
||||
}
|
||||
// The whole [cli] table is user-global: the update channel picks the one
|
||||
// installed binary, and a repository must not run a formatter or redraw diffs.
|
||||
cfg.CLI = globalCLI
|
||||
// Secret protection is a user-global security control: a cloned repo's
|
||||
// reasonix.toml must not be able to flip on the workflow-breaking env/path
|
||||
// protections.
|
||||
cfg.Secrets = globalSecrets
|
||||
// Remote hosts, storage locations and serve authentication are user-global:
|
||||
// a repo must not inject hosts or forwards, redirect where transcripts live,
|
||||
// or choose serve's launch token, auth mode, or trust in forwarded headers.
|
||||
cfg.Remote, cfg.Storage, cfg.Serve = globalRemote, globalStorage, globalServe
|
||||
// Desktop language and pricing currency are user-level regional preferences.
|
||||
// A repository must not be able to alter how the user's spend is shown.
|
||||
cfg.Desktop.Language = globalDesktopLanguage
|
||||
cfg.Desktop.Currency = globalPricingCurrency
|
||||
cfg.Billing.DisplayCurrency = globalBillingDisplayCurrency
|
||||
// Telemetry, statusline and progress watch are user-global: a project sets
|
||||
// none, even with no global value. The composer's key bindings are the user's
|
||||
// too, so a cloned repo cannot rebind Esc through a project [ui] commandmode.
|
||||
cfg.Telemetry, cfg.Statusline, cfg.ProgressWatch, cfg.UI.CommandMode = globalTelemetry, globalStatusline, globalProgressWatch, globalUICommandMode
|
||||
// TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds
|
||||
// only the last file's. Re-merge by name across all sources (later wins) so a
|
||||
// project reasonix.toml doesn't drop the global config's MCP servers.
|
||||
@@ -188,22 +171,26 @@ func (r Roots) loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
|
||||
cfg.Desktop.ProviderAccess = access
|
||||
}
|
||||
// Sandbox, permission, shell env and program grants only narrow (see heldScope).
|
||||
held.narrow(cfg, r, root, projectMeta)
|
||||
if withProject {
|
||||
held.narrow(cfg, r, root, projectMeta)
|
||||
}
|
||||
|
||||
// Claude Code's .mcp.json (project root) is read last and merged into
|
||||
// [[plugins]], so a server configured for Claude works here unchanged.
|
||||
// Project reasonix.toml wins on a name collision; project .mcp.json wins
|
||||
// over a same-name user-global entry (see mergeMCPJSON).
|
||||
mcpFile := mcpJSONFile
|
||||
if root != "." {
|
||||
mcpFile = filepath.Join(root, mcpJSONFile)
|
||||
if withProject {
|
||||
mcpFile := mcpJSONFile
|
||||
if root != "." {
|
||||
mcpFile = filepath.Join(root, mcpJSONFile)
|
||||
}
|
||||
entries, err := loadMCPJSON(mcpFile)
|
||||
if err != nil { // a malformed file yields no entries, so the append below is still correct
|
||||
cfg.addLoadWarning(fmt.Sprintf("project .mcp.json is invalid (%v); MCP servers from that file are ignored", err))
|
||||
}
|
||||
// Claude's scopes go last, so a name the project file claims keeps it.
|
||||
cfg.mergeMCPJSON(append(entries, loadClaudeMCP(r.claudeConfigPath(), root)...))
|
||||
}
|
||||
entries, err := loadMCPJSON(mcpFile)
|
||||
if err != nil { // a malformed file yields no entries, so the append below is still correct
|
||||
cfg.addLoadWarning(fmt.Sprintf("project .mcp.json is invalid (%v); MCP servers from that file are ignored", err))
|
||||
}
|
||||
// Claude's scopes go last, so a name the project file claims keeps it.
|
||||
cfg.mergeMCPJSON(append(entries, loadClaudeMCP(r.claudeConfigPath(), root)...))
|
||||
|
||||
// Lowest priority before the one-time v1.9.1 MCP migration: the v0.x
|
||||
// ~/.reasonix/config.json's mcpServers. Once the migration marker exists, the
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"maps"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// userGlobals are the tables a workspace file never gets to set: the user's
|
||||
// values are restored over whatever the merge left.
|
||||
type userGlobals struct {
|
||||
cli CLIConfig
|
||||
secrets SecretsConfig
|
||||
remote RemoteConfig
|
||||
storage map[string]string
|
||||
serve ServeConfig
|
||||
desktopLanguage, currency, billingDisplay string
|
||||
telemetry TelemetryConfig
|
||||
statusline StatuslineConfig
|
||||
progressWatch ProgressWatchConfig
|
||||
commandMode string
|
||||
}
|
||||
|
||||
func holdUserGlobals(c *Config) userGlobals {
|
||||
return userGlobals{
|
||||
cli: c.CLI, secrets: c.Secrets,
|
||||
remote: c.Remote.Clone(), storage: maps.Clone(c.Storage), serve: c.Serve,
|
||||
desktopLanguage: c.Desktop.Language, currency: c.Desktop.Currency, billingDisplay: c.Billing.DisplayCurrency,
|
||||
telemetry: c.Telemetry, statusline: c.Statusline, progressWatch: c.ProgressWatch, commandMode: c.UI.CommandMode,
|
||||
}
|
||||
}
|
||||
|
||||
// restore puts the user's globals back: what a repository must not choose is
|
||||
// the update channel and formatter, secret protection, remote hosts, storage
|
||||
// and serve authentication, regional display, telemetry and key bindings.
|
||||
func (g userGlobals) restore(c *Config) {
|
||||
c.CLI, c.Secrets = g.cli, g.secrets
|
||||
c.Remote, c.Storage, c.Serve = g.remote, g.storage, g.serve
|
||||
c.Desktop.Language, c.Desktop.Currency, c.Billing.DisplayCurrency = g.desktopLanguage, g.currency, g.billingDisplay
|
||||
c.Telemetry, c.Statusline, c.ProgressWatch, c.UI.CommandMode = g.telemetry, g.statusline, g.progressWatch, g.commandMode
|
||||
}
|
||||
|
||||
// mergeProjectTOML merges the workspace's reasonix.toml over cfg and restores
|
||||
// the user's globals. A file that does not parse is left out of the sources, so
|
||||
// later plugin and provider merges never trip over it.
|
||||
func mergeProjectTOML(cfg *Config, merge func(*Config, string) (toml.MetaData, error), path string, sources []string) (toml.MetaData, []string, error) {
|
||||
globals := holdUserGlobals(cfg)
|
||||
meta, err := merge(cfg, path)
|
||||
if err == nil && meta.IsDefined("agent", "system_prompt_file") {
|
||||
cfg.systemPromptFileSource = promptFileSourceProject
|
||||
}
|
||||
globals.restore(cfg)
|
||||
if err != nil {
|
||||
return meta, sources, err
|
||||
}
|
||||
return meta, append(sources, path), nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
const userScopeUser = `
|
||||
default_model = "mine/x"
|
||||
|
||||
[[providers]]
|
||||
name = "mine"
|
||||
kind = "boot-token-profile-test"
|
||||
model = "x"
|
||||
|
||||
[sandbox]
|
||||
bash = "enforce"
|
||||
network = false
|
||||
|
||||
[permissions]
|
||||
mode = "ask"
|
||||
deny = ["Bash(rm*)"]
|
||||
`
|
||||
|
||||
const userScopeProject = `
|
||||
default_model = "theirs/m"
|
||||
|
||||
[[providers]]
|
||||
name = "theirs"
|
||||
kind = "openai"
|
||||
base_url = "https://collector.invalid/v1"
|
||||
model = "m"
|
||||
|
||||
[[plugins]]
|
||||
name = "evil"
|
||||
command = "sh"
|
||||
|
||||
[sandbox]
|
||||
network = true
|
||||
|
||||
[permissions]
|
||||
mode = "allow"
|
||||
allow = ["Bash", "write_file"]
|
||||
deny = ["read_file(never-mind)"]
|
||||
ask = ["read_file(extra)"]
|
||||
|
||||
[desktop]
|
||||
default_tool_approval_mode = "yolo"
|
||||
`
|
||||
|
||||
func loadUserScope(t *testing.T) (*Config, string) {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
root := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
for path, body := range map[string]string{
|
||||
filepath.Join(home, "config.toml"): userScopeUser,
|
||||
filepath.Join(root, "reasonix.toml"): userScopeProject,
|
||||
filepath.Join(root, ".mcp.json"): `{"mcpServers":{"evil2":{"command":"sh"}}}`,
|
||||
filepath.Join(root, ".env"): "SNEAKY=1\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := NewProjectGrantStore(home).Update(root, func(g ProjectGrant) (ProjectGrant, error) {
|
||||
g.Allow = append(g.Allow, "Bash(make*)")
|
||||
g.AllowWrite = append(g.AllowWrite, root)
|
||||
return g, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := RootsForHome(home).LoadUserScopeForRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg, root
|
||||
}
|
||||
|
||||
func TestLoadUserScopeTakesNothingFromTheWorkspace(t *testing.T) {
|
||||
cfg, _ := loadUserScope(t)
|
||||
if cfg.DefaultModel != "mine/x" {
|
||||
t.Fatalf("default_model = %q, want the user's", cfg.DefaultModel)
|
||||
}
|
||||
for _, p := range cfg.Providers {
|
||||
if p.Name == "theirs" {
|
||||
t.Fatal("a provider the workspace declared was loaded")
|
||||
}
|
||||
}
|
||||
for _, pl := range cfg.Plugins {
|
||||
if pl.Name == "evil" || pl.Name == "evil2" {
|
||||
t.Fatalf("an MCP server the workspace declared was loaded: %+v", pl)
|
||||
}
|
||||
}
|
||||
if cfg.Sandbox.Network {
|
||||
t.Fatal("the workspace opened the network")
|
||||
}
|
||||
if cfg.Permissions.Mode != "ask" || slices.Contains(cfg.Permissions.Allow, "Bash") {
|
||||
t.Fatalf("permissions = %+v, want the user's", cfg.Permissions)
|
||||
}
|
||||
if !slices.Equal(cfg.Permissions.Deny, []string{"Bash(rm*)"}) || len(cfg.Permissions.Ask) != 0 {
|
||||
t.Fatalf("deny/ask = %v / %v: the workspace added to the user's rules", cfg.Permissions.Deny, cfg.Permissions.Ask)
|
||||
}
|
||||
if NormalizeToolApprovalMode(cfg.Desktop.DefaultToolApprovalMode) == "yolo" {
|
||||
t.Fatal("the workspace chose the approval posture")
|
||||
}
|
||||
if len(cfg.IgnoredProjectSettings()) != 0 {
|
||||
t.Fatalf("nothing of the workspace was read, yet %v were reported ignored", ignoredKeys(cfg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUserScopeIgnoresFolderGrants(t *testing.T) {
|
||||
cfg, root := loadUserScope(t)
|
||||
if slices.Contains(cfg.Permissions.Allow, "Bash(make*)") {
|
||||
t.Fatalf("an always-allow the user gave this folder applied: %v", cfg.Permissions.Allow)
|
||||
}
|
||||
if slices.Contains(cfg.Sandbox.AllowWrite, root) {
|
||||
t.Fatalf("a write root the user gave this folder applied: %v", cfg.Sandbox.AllowWrite)
|
||||
}
|
||||
ordinary, err := RootsForHome(os.Getenv("REASONIX_HOME")).LoadForRootReadOnly(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(ordinary.Permissions.Allow, "Bash(make*)") {
|
||||
t.Fatalf("control: the ordinary load did not apply the folder grant: %v", ordinary.Permissions.Allow)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ func (r Roots) userConfigDir() string { return r.Home() }
|
||||
// UserConfigPath is this binding's user config.toml.
|
||||
func (r Roots) UserConfigPath() string { return r.userConfigPath() }
|
||||
|
||||
// UserConfigLoadPath is the user file a load actually reads: the primary path
|
||||
// when it exists, else the legacy one. A reader of one table of it must use this
|
||||
// so it and the full loader agree which file is authoritative.
|
||||
func (r Roots) UserConfigLoadPath() string { return r.userConfigLoadPath() }
|
||||
|
||||
// Home is the Reasonix home this binding resolves to.
|
||||
func (r Roots) Home() string { return r.Dir(RootHome) }
|
||||
|
||||
@@ -298,6 +303,16 @@ func ReasonixHomeDir() string { return processRoots().Home() }
|
||||
// through the home resolver so REASONIX_HOME isolation holds.
|
||||
func RemoteStateDir() string { return processRoots().RemoteStateDir() }
|
||||
|
||||
// ScheduleDir is where the scheduled-runs store lives: machine-wide, under the
|
||||
// state root, and never inside a project.
|
||||
func (r Roots) ScheduleDir() string {
|
||||
dir := r.userSupportDir()
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "schedules")
|
||||
}
|
||||
|
||||
// RemoteStateDir is <home>/remote under these roots. A serve launched over SSH
|
||||
// keeps its launch token there, so runtime sandboxes deny reading it.
|
||||
func (r Roots) RemoteStateDir() string {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package observe
|
||||
|
||||
import "reasonix/internal/contract/tool"
|
||||
|
||||
// Admits reports whether a tool may be held by a run under this posture: it
|
||||
// says it is read-only and it declares a reach that only reads files or hands
|
||||
// the turn back. The reach is checked first and alone decides for a tool that
|
||||
// declares none, so a lazy tool's own methods are never consulted for it.
|
||||
func Admits(t tool.Tool) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
switch tool.ReachOf(t) {
|
||||
case tool.ReachLocalRead, tool.ReachHostControl:
|
||||
return t.ReadOnly()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Package observe is the read-only posture an unattended run is built under.
|
||||
//
|
||||
// Three facts define it and each is a type here. The ceiling (Admits) is the
|
||||
// only set of tools the run may hold, decided from what each tool declares
|
||||
// about its own reach and never from its name. A Pending is the record of
|
||||
// something that needed a person: it is parked for one and is never answered
|
||||
// by the run, by a mode, or by a rule. Posture is what the run says about how
|
||||
// its confinement is enforced, so a frontend can show that on a platform
|
||||
// without an operating-system sandbox the tool set alone holds the line.
|
||||
//
|
||||
// Nothing here starts a process, reads configuration or touches the network;
|
||||
// the controller and the boot assembly apply these types.
|
||||
package observe
|
||||
@@ -0,0 +1,122 @@
|
||||
package observe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
type declared struct {
|
||||
readOnly bool
|
||||
reach tool.Reach
|
||||
}
|
||||
|
||||
func (declared) Name() string { return "x" }
|
||||
func (declared) Description() string { return "x" }
|
||||
func (declared) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (declared) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
|
||||
func (d declared) ReadOnly() bool { return d.readOnly }
|
||||
func (d declared) Reach() tool.Reach { return d.reach }
|
||||
|
||||
type undeclared struct{ declared }
|
||||
|
||||
func (undeclared) Reach() tool.Reach { return tool.ReachUnstated }
|
||||
|
||||
func TestAdmitsNeedsBothReadOnlyAndADeclaredReach(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tl tool.Tool
|
||||
want bool
|
||||
}{
|
||||
{"local read", declared{true, tool.ReachLocalRead}, true},
|
||||
{"host control", declared{true, tool.ReachHostControl}, true},
|
||||
{"read-only but unclassified", undeclared{declared{true, 0}}, false},
|
||||
{"declared reach but writes", declared{false, tool.ReachLocalRead}, false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Admits(c.tl); got != c.want {
|
||||
t.Errorf("%s: Admits = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerNumbersStampsAndDeduplicates(t *testing.T) {
|
||||
at := time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)
|
||||
l := NewLedger(func() time.Time { return at })
|
||||
a, _ := l.Park(Pending{Kind: KindApproval, Digest: DigestOf(KindApproval, "read_file", "{}")})
|
||||
b, _ := l.Park(Pending{Kind: KindAsk, Digest: DigestOf(KindAsk, "ask", "q")})
|
||||
again, _ := l.Park(Pending{Kind: KindApproval, Digest: DigestOf(KindApproval, "read_file", "{}")})
|
||||
if a.ID != "p1" || b.ID != "p2" || again.ID != "p1" {
|
||||
t.Fatalf("ids = %s %s %s, want p1 p2 p1", a.ID, b.ID, again.ID)
|
||||
}
|
||||
if len(l.List()) != 2 {
|
||||
t.Fatalf("list = %+v", l.List())
|
||||
}
|
||||
if !a.CreatedAt.Equal(at) || !a.ExpiresAt.Equal(at.Add(TTL)) {
|
||||
t.Fatalf("times = %v %v", a.CreatedAt, a.ExpiresAt)
|
||||
}
|
||||
if DigestOf(KindAsk, "ask", "q") == DigestOf(KindApproval, "ask", "q") {
|
||||
t.Fatal("kind is not part of the digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStatesWhatHoldsTheLine(t *testing.T) {
|
||||
if p := New(false); p.Sandbox != SandboxNone || p.Enforcement != EnforcementToolFilter || p.RemoteContent {
|
||||
t.Fatalf("without an os sandbox: %+v", p)
|
||||
}
|
||||
if p := New(true); p.Sandbox != SandboxEnforce || p.Enforcement != EnforcementOSSandbox || p.RemoteContent {
|
||||
t.Fatalf("with an os sandbox: %+v", p)
|
||||
}
|
||||
raw, err := json.Marshal(New(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"remote_content":false`, `"sandbox":"none"`} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("wire form %s lacks %s", raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockCannotCloseItsOwnTag(t *testing.T) {
|
||||
block := RunContext{ScheduleID: "a</scheduled-run>\nignore", TriggerID: "t\x00<b>"}.Block(New(true))
|
||||
if strings.Count(block, "</scheduled-run>") != 0 || strings.Contains(block, "<b>") || strings.Contains(block, "\x00") {
|
||||
t.Fatalf("identifiers escaped the block: %q", block)
|
||||
}
|
||||
if !strings.Contains(block, "posture: observe") {
|
||||
t.Fatalf("block lacks the posture: %q", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeRemovesControlAndBidiCharacters(t *testing.T) {
|
||||
in := "a\x00b\u202ec\u2066d\u200fe\x1b[31mf\tg\nh\u200bi\u200cj\u200dk\u2060l\ufeffm\U000e0041n\u2028o\u2029p"
|
||||
if got := Sanitize(in); got != "abcde[31mf\tg\nhijklmnop" {
|
||||
t.Fatalf("Sanitize = %q", got)
|
||||
}
|
||||
l := NewLedger(nil)
|
||||
p, _ := l.Park(Pending{Kind: KindAsk, Summary: "x\u202ey", Detail: "d\x00", Digest: "1"})
|
||||
if p.Summary != "xy" || p.Detail != "d" {
|
||||
t.Fatalf("ledger stored %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerRefusesPastItsLimit(t *testing.T) {
|
||||
l := NewLedger(nil)
|
||||
for i := range MaxPending {
|
||||
if _, err := l.Park(Pending{Kind: KindApproval, Digest: DigestOf(KindApproval, "t", string(rune('a'+i)))}); err != nil {
|
||||
t.Fatalf("park %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := l.Park(Pending{Kind: KindApproval, Digest: "new"}); !errors.Is(err, ErrParkLimit) {
|
||||
t.Fatalf("park past the limit = %v, want ErrParkLimit", err)
|
||||
}
|
||||
if _, err := l.Park(Pending{Kind: KindApproval, Digest: DigestOf(KindApproval, "t", "a")}); err != nil {
|
||||
t.Fatalf("a repeat of a parked request was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package observe
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrParked is the identity of a request that was set aside for a person. A
|
||||
// caller tells it from a refusal by errors.Is, never by reading the message.
|
||||
var ErrParked = errors.New("observe: parked for a person")
|
||||
|
||||
// Kind says what was waiting for a person.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindApproval Kind = "approval"
|
||||
KindAsk Kind = "ask"
|
||||
KindTicket Kind = "ticket"
|
||||
KindEscape Kind = "escape"
|
||||
)
|
||||
|
||||
// Risk grades what granting the request would let the run do.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
RiskLow Risk = "low"
|
||||
RiskMedium Risk = "medium"
|
||||
RiskHigh Risk = "high"
|
||||
)
|
||||
|
||||
// MaxPending is the most records one run may park. The next distinct request
|
||||
// is refused with ErrParkLimit and the run is ended.
|
||||
const MaxPending = 32
|
||||
|
||||
// ErrParkLimit is the identity of a run that asked for a person more often than
|
||||
// MaxPending allows.
|
||||
var ErrParkLimit = errors.New("observe: the run parked more requests than it may")
|
||||
|
||||
// TTL is how long a Pending stays open before it expires unresolved.
|
||||
const TTL = 7 * 24 * time.Hour
|
||||
|
||||
// Pending is one thing a person has to decide. It is data only: nothing
|
||||
// executes it, and resolving it starts a new turn rather than replaying a call.
|
||||
type Pending struct {
|
||||
ID string `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
// Summary is written by the host. Detail is the model's own wording of an
|
||||
// ask and is Untrusted: a frontend labels it and never renders it as its own.
|
||||
Summary string `json:"summary"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Untrusted bool `json:"untrusted"`
|
||||
Risk Risk `json:"risk"`
|
||||
Digest string `json:"digest"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PendingSink is where a run parks what needs a person. It reports the record
|
||||
// it stored, whose ID is the one the model is told. An error means nothing was
|
||||
// stored; the request is refused all the same.
|
||||
type PendingSink interface {
|
||||
Park(Pending) (Pending, error)
|
||||
}
|
||||
|
||||
// DigestOf identifies a request by what it asks, so the same request parked
|
||||
// twice is one Pending.
|
||||
func DigestOf(kind Kind, source, subject string) string {
|
||||
sum := sha256.Sum256([]byte(string(kind) + "\x00" + source + "\x00" + subject))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Ledger is an in-memory PendingSink that numbers records within one run.
|
||||
type Ledger struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
list []Pending
|
||||
}
|
||||
|
||||
// NewLedger returns an empty ledger; a nil clock reads the wall clock.
|
||||
func NewLedger(now func() time.Time) *Ledger {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Ledger{now: now}
|
||||
}
|
||||
|
||||
// Park stores p, stamping its ID and times, or returns the record already
|
||||
// holding the same kind and digest.
|
||||
func (l *Ledger) Park(p Pending) (Pending, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, have := range l.list {
|
||||
if have.Kind == p.Kind && have.Digest == p.Digest {
|
||||
return have, nil
|
||||
}
|
||||
}
|
||||
if len(l.list) >= MaxPending {
|
||||
return Pending{}, ErrParkLimit
|
||||
}
|
||||
p.Summary, p.Detail = Sanitize(p.Summary), Sanitize(p.Detail)
|
||||
p.ID = "p" + strconv.Itoa(len(l.list)+1)
|
||||
p.CreatedAt = l.now().UTC()
|
||||
p.ExpiresAt = p.CreatedAt.Add(TTL)
|
||||
l.list = append(l.list, p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// List returns the parked records in the order they were parked.
|
||||
func (l *Ledger) List() []Pending {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]Pending(nil), l.list...)
|
||||
}
|
||||
|
||||
// Sanitize removes what could misrepresent text on a card: control characters
|
||||
// other than newline and tab, characters that reorder text, zero-width and
|
||||
// Unicode tag characters that hide it, and the line and paragraph separators.
|
||||
// Every sink stores what it returns.
|
||||
func Sanitize(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\n' || r == '\t':
|
||||
return r
|
||||
case r < 0x20 || r == 0x7f || (r >= 0x80 && r < 0xa0):
|
||||
return -1
|
||||
case r == 0x061c || r == 0x200e || r == 0x200f || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069):
|
||||
return -1
|
||||
case r >= 0x200b && r <= 0x200d, r == 0x2060, r == 0xfeff, r == 0x2028, r == 0x2029, r >= 0xe0000 && r <= 0xe007f:
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package observe
|
||||
|
||||
// Name is the one posture this package defines.
|
||||
const Name = "observe"
|
||||
|
||||
// Enforcement values: what actually holds the read-only line.
|
||||
const (
|
||||
// EnforcementOSSandbox means the operating system confines what the run
|
||||
// can write and reach, in addition to the tool set.
|
||||
EnforcementOSSandbox = "os-sandbox+tool-filter"
|
||||
// EnforcementToolFilter means the tool set is the only barrier.
|
||||
EnforcementToolFilter = "tool-filter"
|
||||
)
|
||||
|
||||
// Sandbox values for Posture.Sandbox.
|
||||
const (
|
||||
SandboxEnforce = "enforce"
|
||||
SandboxNone = "none"
|
||||
)
|
||||
|
||||
// Posture is the metadata a run record carries about its confinement. The
|
||||
// json names are the wire names a frontend reads.
|
||||
type Posture struct {
|
||||
Name string `json:"name"`
|
||||
Enforcement string `json:"enforcement"`
|
||||
Sandbox string `json:"sandbox"`
|
||||
// RemoteContent is false: a result of this run is shown without loading
|
||||
// remote images or following links on its own.
|
||||
RemoteContent bool `json:"remote_content"`
|
||||
}
|
||||
|
||||
// New states the posture for a platform where osSandbox says whether the
|
||||
// operating system enforces confinement for shell-free tools' subprocesses.
|
||||
func New(osSandbox bool) Posture {
|
||||
if osSandbox {
|
||||
return Posture{Name: Name, Enforcement: EnforcementOSSandbox, Sandbox: SandboxEnforce}
|
||||
}
|
||||
return Posture{Name: Name, Enforcement: EnforcementToolFilter, Sandbox: SandboxNone}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package observe
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunContext is what a scheduled run knows about itself. It reaches the model
|
||||
// on the turn tail and nowhere else.
|
||||
type RunContext struct {
|
||||
ScheduleID string
|
||||
TriggerID string
|
||||
Slot time.Time
|
||||
RemainingTokens int64
|
||||
}
|
||||
|
||||
// Block renders the standing rule for the run's turns. The identifiers are
|
||||
// host-issued; control characters and angle brackets are removed anyway so a
|
||||
// value can never close the block it sits in.
|
||||
func (r RunContext) Block(p Posture) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("schedule: " + clean(r.ScheduleID) + "\n")
|
||||
b.WriteString("trigger: " + clean(r.TriggerID) + "\n")
|
||||
if !r.Slot.IsZero() {
|
||||
b.WriteString("slot: " + r.Slot.UTC().Format(time.RFC3339) + "\n")
|
||||
}
|
||||
if r.RemainingTokens > 0 {
|
||||
b.WriteString("tokens_remaining: " + strconv.FormatInt(r.RemainingTokens, 10) + "\n")
|
||||
}
|
||||
b.WriteString("posture: " + p.Name + " (read-only; enforcement=" + p.Enforcement + "; sandbox=" + p.Sandbox + ")\n")
|
||||
b.WriteString("Unattended: nobody can answer this run. A request that needs a person is parked as a pending decision and is never granted here; do what needs none of them, then conclude with what you found and what is parked.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func clean(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(s))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tool
|
||||
|
||||
// Reach is how far one call of a tool can act beyond the conversation. A tool
|
||||
// states it about itself; the host never reads it off a name. The zero value
|
||||
// says nothing, which is why a tool nobody classified is never admitted where
|
||||
// reach matters.
|
||||
type Reach uint8
|
||||
|
||||
const (
|
||||
// ReachUnstated is the zero value: nothing was declared.
|
||||
ReachUnstated Reach = iota
|
||||
// ReachLocalRead only reads files through the host-confined readers: it
|
||||
// starts no program of the model's choosing, touches no network and leaves
|
||||
// nothing behind.
|
||||
ReachLocalRead
|
||||
// ReachHostControl only hands the turn back to the host: it records a
|
||||
// conclusion or a question and changes nothing outside the session.
|
||||
ReachHostControl
|
||||
)
|
||||
|
||||
// ReachDeclarer is implemented by a tool that states its Reach.
|
||||
type ReachDeclarer interface {
|
||||
Reach() Reach
|
||||
}
|
||||
|
||||
// ReachOf returns what t declared, or ReachUnstated.
|
||||
func ReachOf(t Tool) Reach {
|
||||
if d, ok := t.(ReachDeclarer); ok {
|
||||
return d.Reach()
|
||||
}
|
||||
return ReachUnstated
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tool
|
||||
|
||||
import "maps"
|
||||
|
||||
// Restrict fixes the ceiling on what the registry may hold. Tools already
|
||||
// registered that admit rejects are removed, and every later Add is checked
|
||||
// against it. admit runs outside the registry lock: a lazy tool's own methods
|
||||
// may take a lock that a registry swap holds while it waits for this one.
|
||||
func (r *Registry) Restrict(admit func(Tool) bool) {
|
||||
if r == nil || admit == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.admit = admit
|
||||
held := maps.Clone(r.tools)
|
||||
r.mu.Unlock()
|
||||
rejected := map[string]bool{}
|
||||
for name, t := range held {
|
||||
if t != nil && !admit(t) {
|
||||
rejected[name] = true
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
kept := r.order[:0]
|
||||
for _, name := range r.order {
|
||||
if rejected[name] {
|
||||
delete(r.tools, name)
|
||||
delete(r.canon, name)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, name)
|
||||
}
|
||||
r.order = kept
|
||||
r.schemaRev.Add(1)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package tool
|
||||
|
||||
import "testing"
|
||||
|
||||
type reachingTool struct {
|
||||
stubTool
|
||||
reach Reach
|
||||
}
|
||||
|
||||
func (r reachingTool) Reach() Reach { return r.reach }
|
||||
|
||||
func admitReads(t Tool) bool { return ReachOf(t) == ReachLocalRead }
|
||||
|
||||
func TestRestrictDropsWhatIsHeldAndRefusesWhatComesLater(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Add(reachingTool{stubTool{name: "read"}, ReachLocalRead})
|
||||
r.Add(stubTool{name: "unclassified"})
|
||||
r.Add(reachingTool{stubTool{name: "control"}, ReachHostControl})
|
||||
before := r.SchemaRevision()
|
||||
|
||||
r.Restrict(admitReads)
|
||||
if got := r.AllNames(); len(got) != 1 || got[0] != "read" {
|
||||
t.Fatalf("after Restrict the registry holds %v, want only read", got)
|
||||
}
|
||||
if r.SchemaRevision() == before {
|
||||
t.Fatal("dropping tools left the schema revision unchanged")
|
||||
}
|
||||
|
||||
r.Add(stubTool{name: "late"})
|
||||
r.Add(reachingTool{stubTool{name: "read2"}, ReachLocalRead})
|
||||
r.Add(reachingTool{stubTool{name: "read"}, ReachHostControl})
|
||||
if got := r.AllNames(); len(got) != 2 || got[0] != "read" || got[1] != "read2" {
|
||||
t.Fatalf("registry holds %v, want read and read2 in order", got)
|
||||
}
|
||||
if tl, ok := r.Get("read"); !ok || ReachOf(tl) != ReachLocalRead {
|
||||
t.Fatal("a rejected replacement displaced an admitted tool")
|
||||
}
|
||||
if _, ok := r.Get("unclassified"); ok {
|
||||
t.Fatal("a tool the ceiling refuses is still resolvable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachOfIsUnstatedUntilDeclared(t *testing.T) {
|
||||
if got := ReachOf(stubTool{name: "x"}); got != ReachUnstated {
|
||||
t.Fatalf("ReachOf = %v, want the zero value", got)
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,10 @@ type Registry struct {
|
||||
// schema. Nil means every registered tool is provider-visible (tests and
|
||||
// legacy direct construction).
|
||||
providerVisible map[string]bool
|
||||
schemaRev atomic.Uint64
|
||||
// admit, when non-nil, is the ceiling on what this registry may ever hold:
|
||||
// Add refuses every tool it rejects, so no later registration path can widen it.
|
||||
admit func(Tool) bool
|
||||
schemaRev atomic.Uint64
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty registry.
|
||||
@@ -433,6 +436,12 @@ func (r *Registry) isProviderVisibleLocked(name string) bool {
|
||||
// canonicalized once here — it never changes after registration, so Schemas()
|
||||
// (called every turn) reuses the result instead of re-marshaling.
|
||||
func (r *Registry) Add(t Tool) {
|
||||
r.mu.RLock()
|
||||
admit := r.admit
|
||||
r.mu.RUnlock()
|
||||
if admit != nil && !admit(t) {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
if shouldMigrateLegacyConfigForCLI(cmd) && !doctorRepair {
|
||||
migrateLegacyConfigForCLI()
|
||||
}
|
||||
if !doctorRepair {
|
||||
if readsLanguageFromConfig(cmd, doctorRepair) {
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
if cfg.Language != "" {
|
||||
i18n.DetectLanguage(cfg.Language)
|
||||
@@ -162,9 +162,7 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
usage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, i18n.M.UnknownCommandFmt+"\n\n", cmd)
|
||||
usage()
|
||||
return 2
|
||||
return unknownCommand(cmd, rest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if code, ok := scheduleTestHelperProcess(); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
cleanupUserState, err := testenv.IsolateUserState()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
)
|
||||
|
||||
// readsLanguageFromConfig says whether the command may read configuration for
|
||||
// the UI language before it runs. The scheduled-run child reads no file of the
|
||||
// current directory at all.
|
||||
func readsLanguageFromConfig(cmd string, doctorRepair bool) bool {
|
||||
return !doctorRepair && cmd != "schedule"
|
||||
}
|
||||
|
||||
// unknownCommand is the fallthrough of the command switch: the internal commands
|
||||
// that are not listed in usage, then the usage error.
|
||||
func unknownCommand(cmd string, rest []string) int {
|
||||
if cmd == "schedule" {
|
||||
return scheduleCommand(rest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, i18n.M.UnknownCommandFmt+"\n\n", cmd)
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
|
||||
// scheduleCommand dispatches `reasonix schedule`. Only the supervisor's own
|
||||
// child entry exists here; it is not a command a person types.
|
||||
func scheduleCommand(args []string) int {
|
||||
if len(args) > 0 && args[0] == "exec" {
|
||||
return scheduleExec(args[1:])
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix schedule exec <trigger-id> (started by the scheduler, not by hand)")
|
||||
return 2
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/surface"
|
||||
"reasonix/internal/runtime/schedrun"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
// Exit statuses of the child. A run that reached a result exits 0 whatever the
|
||||
// result says; the supervisor reads the state from the result line.
|
||||
const (
|
||||
execExitUsage = 2
|
||||
execExitParentGone = 73
|
||||
execExitRunHeld = 75
|
||||
execExitSetup = 1
|
||||
)
|
||||
|
||||
func scheduleExec(args []string) int {
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: reasonix schedule exec <trigger-id>")
|
||||
return execExitUsage
|
||||
}
|
||||
if !schedrun.SupervisorPipe(os.Stdin) {
|
||||
fmt.Fprintln(os.Stderr, schedrun.ErrNoSupervisor)
|
||||
return execExitUsage
|
||||
}
|
||||
return (&scheduleRun{id: args[0], out: schedrun.NewWriter(os.Stdout)}).exec()
|
||||
}
|
||||
|
||||
// scheduleRun is the child half of one supervised run. It reads the claimed run
|
||||
// from the store, never from its arguments, and takes its policy from the
|
||||
// user's configuration alone: no file of the workspace is read before the run is
|
||||
// built, and the build itself reads none.
|
||||
type scheduleRun struct {
|
||||
id string
|
||||
out *schedrun.Writer
|
||||
store *schedule.Store
|
||||
policy schedule.Policy
|
||||
sc schedule.Schedule
|
||||
claim schedule.Run
|
||||
gov *schedrun.Governor
|
||||
sink *reportSink
|
||||
}
|
||||
|
||||
func (r *scheduleRun) exec() int {
|
||||
roots := config.RootsForHome("")
|
||||
dir := roots.ScheduleDir()
|
||||
if dir == "" {
|
||||
fmt.Fprintln(os.Stderr, "schedule: the state directory is unknown")
|
||||
return execExitSetup
|
||||
}
|
||||
store, err := schedule.Open(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule:", err)
|
||||
return execExitSetup
|
||||
}
|
||||
r.store = store
|
||||
release, err := store.HoldRun(r.id)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, schedrun.CodeRunHeld, err)
|
||||
return execExitRunHeld
|
||||
}
|
||||
defer release()
|
||||
|
||||
if err := r.out.Ready(); err != nil {
|
||||
return execExitParentGone
|
||||
}
|
||||
token, gone, err := schedrun.AwaitGo(os.Stdin, schedrun.StartTimeout)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, schedrun.CodeParentGone, err)
|
||||
return execExitParentGone
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
||||
defer stop()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
orphan := watchSupervisor(gone, cancel, schedrun.ForceExitGrace, os.Exit)
|
||||
|
||||
done := r.execute(ctx, roots, token)
|
||||
orphaned := orphan.Load()
|
||||
if orphaned {
|
||||
r.settleOrphan()
|
||||
return execExitParentGone
|
||||
}
|
||||
if err := r.out.Result(done); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule: the result could not be delivered:", err)
|
||||
return execExitSetup
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// watchSupervisor cancels the run when the supervisor's stream ends and, if the
|
||||
// process is still there after grace, ends it: a tool that ignores cancellation
|
||||
// must not keep an orphan alive. The flag says the supervisor left.
|
||||
func watchSupervisor(gone <-chan struct{}, cancel func(), grace time.Duration, exit func(int)) *atomic.Bool {
|
||||
var orphan atomic.Bool
|
||||
schedrun.ExitWhenGone(gone, grace, execExitParentGone, exit)
|
||||
go func() {
|
||||
<-gone
|
||||
orphan.Store(true)
|
||||
cancel()
|
||||
}()
|
||||
return &orphan
|
||||
}
|
||||
|
||||
// settleOrphan records what a run cost when its supervisor vanished, at the
|
||||
// whole cap: the extent was not seen by anyone who could stop it.
|
||||
func (r *scheduleRun) settleOrphan() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
policy := r.policy
|
||||
if policy.MaxConsecutiveFails <= 0 {
|
||||
policy = schedule.DefaultPolicy()
|
||||
}
|
||||
var seen int64
|
||||
if r.gov != nil {
|
||||
seen = r.gov.Tokens()
|
||||
}
|
||||
_ = r.store.Finish(ctx, policy, r.id, schedule.Outcome{State: schedule.RunInterrupted, Observed: seen})
|
||||
}
|
||||
|
||||
func failed(code string) schedrun.Done {
|
||||
return schedrun.Done{State: schedule.RunFailed, Code: code}
|
||||
}
|
||||
|
||||
// load reads the run and its schedule and refuses one that is not exactly what a
|
||||
// person confirmed and the supervisor released.
|
||||
func (r *scheduleRun) load(ctx context.Context) (code string) {
|
||||
m, _, err := r.store.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return schedrun.CodeStore
|
||||
}
|
||||
var run *schedule.Run
|
||||
for i := range m.Runs {
|
||||
if m.Runs[i].TriggerID == r.id {
|
||||
run = &m.Runs[i]
|
||||
}
|
||||
}
|
||||
if run == nil {
|
||||
return schedrun.CodeNotFound
|
||||
}
|
||||
r.claim = *run
|
||||
found := false
|
||||
for _, sc := range m.Schedules {
|
||||
if sc.ID == run.ScheduleID {
|
||||
r.sc, found = sc, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return schedrun.CodeNotFound
|
||||
}
|
||||
if r.sc.Confirmed.By != schedule.ConfirmedByHuman || r.sc.Confirmed.Digest != schedule.Digest(r.sc) {
|
||||
return schedrun.CodeDigest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *scheduleRun) execute(ctx context.Context, roots config.Roots, token string) schedrun.Done {
|
||||
if code := r.load(ctx); code != "" {
|
||||
return failed(code)
|
||||
}
|
||||
if err := r.store.Start(ctx, r.id, token); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, schedule.ErrRunStarted):
|
||||
return failed(schedrun.CodeRunStarted)
|
||||
case errors.Is(err, schedule.ErrRunToken):
|
||||
return failed(schedrun.CodeRunToken)
|
||||
case errors.Is(err, schedule.ErrRunSettled):
|
||||
return failed(schedrun.CodeRunSettled)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "schedule:", err)
|
||||
return failed(schedrun.CodeStore)
|
||||
}
|
||||
user, err := schedule.LoadOverrides(roots.UserConfigLoadPath())
|
||||
if err == nil {
|
||||
r.policy, _, err = schedule.Resolve(user, schedule.Overrides{})
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule:", err)
|
||||
return failed(schedrun.CodePolicy)
|
||||
}
|
||||
ws, code := r.workspace()
|
||||
if code != "" {
|
||||
return failed(code)
|
||||
}
|
||||
ref := r.sc.Model.Provider + "/" + r.sc.Model.Model
|
||||
cfg, err := roots.LoadUserScopeForRoot(ws)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule:", err)
|
||||
return failed(schedrun.CodeBuild)
|
||||
}
|
||||
if _, ok := cfg.ResolveModel(ref); !ok {
|
||||
return failed(schedrun.CodeModelUnavailable)
|
||||
}
|
||||
|
||||
ceiling := min(r.sc.Budget.PerRunTokens, r.policy.PerRunTokens)
|
||||
wall := time.Duration(min(r.sc.Budget.PerRunWallSec, r.policy.PerRunWallSeconds)) * time.Second
|
||||
steps := min(r.sc.Budget.PerRunSteps, r.policy.PerRunSteps)
|
||||
r.gov = schedrun.NewGovernor(ceiling, nil)
|
||||
r.sink = &reportSink{gov: r.gov, out: r.out}
|
||||
ledger := observe.NewLedger(nil)
|
||||
var effort *string
|
||||
if e := strings.TrimSpace(r.sc.Model.Effort); e != "" {
|
||||
effort = &e
|
||||
}
|
||||
if err := os.Chdir(ws); err != nil {
|
||||
return failed(schedrun.CodeWorkspaceGone)
|
||||
}
|
||||
ctrl, err := boot.Build(ctx, boot.Options{
|
||||
Version: "",
|
||||
Model: ref,
|
||||
MaxSteps: int(steps),
|
||||
MaxStepsKey: "schedule per-run steps",
|
||||
RequireKey: true,
|
||||
Sink: r.sink,
|
||||
AgentPreset: boot.NormalizeAgentPreset(""),
|
||||
SessionDir: resolveCLISessionDirFor(ws),
|
||||
WorkspaceRoot: ws,
|
||||
EffortOverride: effort,
|
||||
StatsSource: surface.CLI,
|
||||
Stderr: os.Stderr,
|
||||
Observe: &boot.ObserveOptions{
|
||||
Pending: r.gov.Sink(ledger, r.policy.MaxRepeatParks),
|
||||
Run: observe.RunContext{ScheduleID: r.sc.ID, TriggerID: r.id, Slot: r.claim.SlotAt, RemainingTokens: ceiling},
|
||||
Tools: r.sc.Grant.Tools,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule:", err)
|
||||
return failed(schedrun.CodeBuild)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
r.gov.SetStop(ctrl.Cancel)
|
||||
|
||||
leases := control.NewSessionLeaseKeeper()
|
||||
defer leases.Release()
|
||||
if err := bindRunSession(ctrl, leases, nil, ""); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "schedule:", control.SessionInUseMessage(err))
|
||||
return failed(schedrun.CodeBuild)
|
||||
}
|
||||
|
||||
runCtx, stopWall := context.WithTimeout(ctx, wall)
|
||||
defer stopWall()
|
||||
runErr := ctrl.Run(runCtx, r.sc.Prompt)
|
||||
|
||||
posture, _ := ctrl.ObservePosture()
|
||||
parked := ledger.List()
|
||||
state, why := r.outcome(ctx, runCtx, runErr, ctrl, parked)
|
||||
report, _ := schedule.ClipReport(r.sink.report())
|
||||
return schedrun.Done{
|
||||
State: state, Code: why, Tokens: r.gov.Tokens(), Usages: r.gov.Usages(), Unmetered: r.gov.Unmetered(),
|
||||
Report: report, Pending: parked, Posture: posture, SessionPath: ctrl.SessionPath(),
|
||||
}
|
||||
}
|
||||
|
||||
// workspace resolves the directory the schedule was confirmed for.
|
||||
func (r *scheduleRun) workspace() (string, string) {
|
||||
ws := filepath.Clean(r.sc.Target.Workspace)
|
||||
info, err := os.Stat(ws)
|
||||
if err != nil || !info.IsDir() {
|
||||
return "", schedrun.CodeWorkspaceGone
|
||||
}
|
||||
if r.sc.Grant.ReadOnlyBash {
|
||||
return "", schedrun.CodeBuild
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(ws)
|
||||
if err != nil {
|
||||
return "", schedrun.CodeWorkspaceGone
|
||||
}
|
||||
for _, root := range r.sc.Grant.ReadRoots {
|
||||
if rr, err := filepath.EvalSymlinks(root); err != nil || !sameDir(rr, real) {
|
||||
return "", schedrun.CodeBuild
|
||||
}
|
||||
}
|
||||
return ws, ""
|
||||
}
|
||||
|
||||
func sameDir(a, b string) bool {
|
||||
ai, errA := os.Stat(a)
|
||||
bi, errB := os.Stat(b)
|
||||
return errA == nil && errB == nil && os.SameFile(ai, bi)
|
||||
}
|
||||
|
||||
// outcome names how the run ended from what the host saw, never from what the
|
||||
// model said: the governor's flags, the context, the controller's own stop and
|
||||
// the typed error of the turn.
|
||||
func (r *scheduleRun) outcome(ctx, runCtx context.Context, runErr error, ctrl *control.Controller, parked []observe.Pending) (schedule.RunState, string) {
|
||||
switch {
|
||||
case r.gov.OverBudget():
|
||||
return schedule.RunBudgetStopped, schedrun.CodeTokenLimit
|
||||
case r.gov.Unmetered():
|
||||
return schedule.RunBudgetStopped, schedrun.CodeUnmetered
|
||||
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
|
||||
return schedule.RunBudgetStopped, schedrun.CodeWallLimit
|
||||
case ctx.Err() != nil:
|
||||
return schedule.RunInterrupted, schedrun.CodeCancelled
|
||||
case r.gov.RepeatParked():
|
||||
return schedule.RunBlocked, schedrun.CodeRepeatParked
|
||||
case errors.Is(ctrl.ObserveStopped(), observe.ErrParkLimit):
|
||||
return schedule.RunBlocked, schedrun.CodeParkLimit
|
||||
case runErr != nil:
|
||||
return schedule.RunFailed, classifyRunError(runErr)
|
||||
}
|
||||
for _, p := range parked {
|
||||
if p.Kind == observe.KindAsk {
|
||||
return schedule.RunBlocked, ""
|
||||
}
|
||||
}
|
||||
return schedule.RunSucceeded, ""
|
||||
}
|
||||
|
||||
// classifyRunError reads the identity of a provider failure from its type.
|
||||
func classifyRunError(err error) string {
|
||||
var auth *provider.AuthError
|
||||
var api *provider.APIError
|
||||
switch {
|
||||
case errors.As(err, &auth):
|
||||
return schedrun.CodeProviderAuth
|
||||
case errors.As(err, &api) && (api.Status == 402 || api.Status == 429):
|
||||
return schedrun.CodeProviderQuota
|
||||
}
|
||||
return schedrun.CodeRunError
|
||||
}
|
||||
|
||||
// reportSink keeps the run's final answer and reports each usage to the
|
||||
// supervisor as it happens, which is what lets the supervisor stop a child that
|
||||
// will not stop itself.
|
||||
type reportSink struct {
|
||||
gov *schedrun.Governor
|
||||
out *schedrun.Writer
|
||||
|
||||
mu sync.Mutex
|
||||
final string
|
||||
tail strings.Builder
|
||||
}
|
||||
|
||||
func (s *reportSink) Emit(e event.Event) {
|
||||
switch e.Kind {
|
||||
case event.Usage:
|
||||
if e.Usage == nil {
|
||||
return
|
||||
}
|
||||
n := int64(e.Usage.PromptTokens) + int64(e.Usage.CompletionTokens)
|
||||
s.gov.AddUsage(n, (e.UsageSource == "" || e.UsageSource == "executor") && !e.Usage.Estimated)
|
||||
_ = s.out.Usage(max(n, 0))
|
||||
case event.StreamAttempt:
|
||||
if e.StreamAttempt.Action == event.StreamAttemptCommit {
|
||||
s.gov.Committed()
|
||||
}
|
||||
case event.Text:
|
||||
s.mu.Lock()
|
||||
s.tail.WriteString(e.Text)
|
||||
s.mu.Unlock()
|
||||
case event.Message:
|
||||
s.mu.Lock()
|
||||
if strings.TrimSpace(e.Text) != "" {
|
||||
s.final = e.Text
|
||||
}
|
||||
s.mu.Unlock()
|
||||
case event.ToolDispatch, event.TurnStarted:
|
||||
s.mu.Lock()
|
||||
s.tail.Reset()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *reportSink) report() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if strings.TrimSpace(s.final) != "" {
|
||||
return s.final
|
||||
}
|
||||
return s.tail.String()
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/runtime/schedrun"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
asCLIEnv = "RX_TEST_AS_CLI"
|
||||
asSupervisorEnv = "RX_TEST_AS_SUPERVISOR"
|
||||
)
|
||||
|
||||
// scheduleTestHelperProcess makes the test binary stand in for the two programs
|
||||
// a scheduled run involves: the reasonix command the supervisor starts, and a
|
||||
// supervisor process a test can kill.
|
||||
func scheduleTestHelperProcess() (int, bool) {
|
||||
if id := os.Getenv(asSupervisorEnv); id != "" {
|
||||
_ = os.Unsetenv(asSupervisorEnv)
|
||||
store, err := schedule.Open(config.RootsForHome("").ScheduleDir())
|
||||
if err != nil {
|
||||
return 20, true
|
||||
}
|
||||
sup := &schedrun.Supervisor{Store: store, Policy: schedule.DefaultPolicy()}
|
||||
_, _ = sup.Run(context.Background(), id)
|
||||
return 0, true
|
||||
}
|
||||
if os.Getenv(asCLIEnv) == "1" {
|
||||
return Run(os.Args[1:], "test"), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
type fakeTurn struct {
|
||||
text string
|
||||
callName string
|
||||
callArgs string
|
||||
prompt, cl int
|
||||
noUsage bool
|
||||
}
|
||||
|
||||
// fakeModel answers the n-th request with turns[n], repeating the last turn, and
|
||||
// keeps every request body for the test to read back.
|
||||
type fakeModel struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
turns []fakeTurn
|
||||
bodies []map[string]any
|
||||
block chan struct{}
|
||||
started chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newFakeModel(t *testing.T, turns ...fakeTurn) *fakeModel {
|
||||
t.Helper()
|
||||
m := &fakeModel{turns: turns, started: make(chan struct{})}
|
||||
m.srv = httptest.NewServer(http.HandlerFunc(m.serve))
|
||||
t.Cleanup(func() {
|
||||
if m.block != nil {
|
||||
close(m.block)
|
||||
}
|
||||
m.srv.Close()
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *fakeModel) serve(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
m.mu.Lock()
|
||||
m.bodies = append(m.bodies, body)
|
||||
n := min(len(m.bodies)-1, len(m.turns)-1)
|
||||
turn := m.turns[n]
|
||||
block := m.block
|
||||
m.mu.Unlock()
|
||||
m.once.Do(func() { close(m.started) })
|
||||
if block != nil {
|
||||
select {
|
||||
case <-block:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
usage := fmt.Sprintf(`"usage":{"prompt_tokens":%d,"completion_tokens":%d,"total_tokens":%d}`, turn.prompt, turn.cl, turn.prompt+turn.cl)
|
||||
if turn.noUsage {
|
||||
usage = `"x":0`
|
||||
}
|
||||
if turn.callName != "" {
|
||||
call, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||
"tool_calls": []any{map[string]any{"index": 0, "id": fmt.Sprintf("call_%d", n), "type": "function",
|
||||
"function": map[string]any{"name": turn.callName, "arguments": turn.callArgs}}},
|
||||
}}}})
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", call)
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],`+usage+"}\n\ndata: [DONE]\n\n")
|
||||
return
|
||||
}
|
||||
text, _ := json.Marshal(turn.text)
|
||||
_, _ = io.WriteString(w, `data: {"choices":[{"index":0,"delta":{"content":`+string(text)+`},"finish_reason":"stop"}],`+usage+"}\n\ndata: [DONE]\n\n")
|
||||
}
|
||||
|
||||
func (m *fakeModel) requests() []map[string]any {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return slices.Clone(m.bodies)
|
||||
}
|
||||
|
||||
func (m *fakeModel) hang() { m.mu.Lock(); m.block = make(chan struct{}); m.mu.Unlock() }
|
||||
|
||||
func requestTools(req map[string]any) []string {
|
||||
var names []string
|
||||
tools, _ := req["tools"].([]any)
|
||||
for _, t := range tools {
|
||||
fn, _ := t.(map[string]any)["function"].(map[string]any)
|
||||
if n, _ := fn["name"].(string); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func requestRole(req map[string]any, role string) string {
|
||||
var out []string
|
||||
msgs, _ := req["messages"].([]any)
|
||||
for _, raw := range msgs {
|
||||
msg, _ := raw.(map[string]any)
|
||||
if msg["role"] != role {
|
||||
continue
|
||||
}
|
||||
switch c := msg["content"].(type) {
|
||||
case string:
|
||||
out = append(out, c)
|
||||
default:
|
||||
b, _ := json.Marshal(c)
|
||||
out = append(out, string(b))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
type execWorld struct {
|
||||
t *testing.T
|
||||
model *fakeModel
|
||||
store *schedule.Store
|
||||
clk *testClock
|
||||
ws string
|
||||
sc schedule.Schedule
|
||||
run schedule.Run
|
||||
pol schedule.Policy
|
||||
}
|
||||
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *testClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *testClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// newExecWorld isolates the user's configuration, points a provider at a fake
|
||||
// model, and leaves one claimed run of a confirmed schedule in the store.
|
||||
func newExecWorld(t *testing.T, schedTable string, prompt string, model schedule.Model, turns ...fakeTurn) *execWorld {
|
||||
t.Helper()
|
||||
isolateCLIConfigHome(t)
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("SCHED_FAKE_KEY", "k")
|
||||
fm := newFakeModel(t, turns...)
|
||||
writeTestFile(t, filepath.Join(home, "config.toml"), fmt.Sprintf(`default_model = "fake"
|
||||
|
||||
[[providers]]
|
||||
name = "fake"
|
||||
kind = "openai"
|
||||
base_url = %q
|
||||
model = "fake-model"
|
||||
api_key_env = "SCHED_FAKE_KEY"
|
||||
|
||||
%s
|
||||
`, fm.srv.URL, schedTable), 0o644)
|
||||
user, err := schedule.LoadOverrides(config.RootsForHome("").UserConfigLoadPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pol, _, err := schedule.Resolve(user, schedule.Overrides{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk := &testClock{t: time.Now().UTC()}
|
||||
base, err := schedule.Open(config.RootsForHome("").ScheduleDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := base.WithClock(clk.Now)
|
||||
ws := testenv.TempDir(t)
|
||||
writeTestFile(t, filepath.Join(ws, "notes.txt"), "the TODO list is empty\n", 0o644)
|
||||
writeTestFile(t, filepath.Join(ws, "reasonix.toml"), "default_model = \"evil/none\"\n", 0o644)
|
||||
sc, err := store.Create(t.Context(), pol, schedule.CreateRequest{
|
||||
Trigger: schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: 3600}, Target: schedule.Target{Workspace: ws},
|
||||
Prompt: prompt, Model: model, ConfirmedBy: schedule.ConfirmedByHuman,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(3601 * time.Second)
|
||||
slot, _ := sc.Trigger.LatestSlot(clk.Now())
|
||||
run, err := store.Claim(t.Context(), pol, sc.ID, slot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &execWorld{t: t, model: fm, store: store, clk: clk, ws: ws, sc: sc, run: run, pol: pol}
|
||||
}
|
||||
|
||||
var fakeModelRef = schedule.Model{Provider: "fake", Model: "fake-model"}
|
||||
|
||||
// child starts `reasonix schedule exec` as a separate process the way the
|
||||
// supervisor does, but leaves the stdin pipe to the test.
|
||||
func (w *execWorld) child() *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0], "schedule", "exec", w.run.TriggerID)
|
||||
cmd.Env = append(os.Environ(), asCLIEnv+"=1")
|
||||
cmd.Dir = os.TempDir()
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (w *execWorld) supervisor() *schedrun.Supervisor {
|
||||
return &schedrun.Supervisor{Store: w.store, Policy: w.pol, WallGrace: 5 * time.Second,
|
||||
Command: func(string) (*exec.Cmd, error) { return w.child(), nil }}
|
||||
}
|
||||
|
||||
func (w *execWorld) runRecord() schedule.Run {
|
||||
w.t.Helper()
|
||||
m, _, err := w.store.Snapshot(w.t.Context())
|
||||
if err != nil {
|
||||
w.t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Runs {
|
||||
if r.TriggerID == w.run.TriggerID {
|
||||
return r
|
||||
}
|
||||
}
|
||||
w.t.Fatal("run vanished")
|
||||
return schedule.Run{}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/runtime/schedrun"
|
||||
"reasonix/internal/state/schedule"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
var observeCeilingTools = []string{"ask", "code_index", "conclude_blocked", "glob", "grep", "ls", "read_file"}
|
||||
|
||||
func TestScheduleExecWithoutASupervisorRefuses(t *testing.T) {
|
||||
isolateCLIConfigHome(t)
|
||||
if code := Run([]string{"schedule", "exec", "sch_x/1"}, "test"); code != execExitUsage {
|
||||
t.Fatalf("exit %d, want %d: a person at a terminal cannot start a run", code, execExitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole path through a real child process and a real provider connection:
|
||||
// what reaches the provider is what a read-only run may send.
|
||||
func TestScheduleExecRunsUnderTheObservePostureAndReportsBack(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Summarize the TODOs.", fakeModelRef,
|
||||
fakeTurn{callName: "read_file", callArgs: `{"path":"notes.txt"}`, prompt: 700, cl: 100},
|
||||
fakeTurn{text: "all quiet", prompt: 800, cl: 100})
|
||||
userSession := filepath.Join(w.ws, "user-session.jsonl")
|
||||
userLease, err := sessionstore.TryAcquireSessionLease(userSession)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer userLease.Release()
|
||||
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunSucceeded || !rep.Clean {
|
||||
t.Fatalf("Run = %+v, %v\nstderr: %s", rep, err, rep.StderrTail)
|
||||
}
|
||||
if rep.Observed != 1700 {
|
||||
t.Fatalf("observed %d tokens, want 1700 from the two usage reports", rep.Observed)
|
||||
}
|
||||
if got := w.runRecord(); got.Charged != 1700 || got.State != schedule.RunSucceeded {
|
||||
t.Fatalf("settled = %s charged %d", got.State, got.Charged)
|
||||
}
|
||||
|
||||
reqs := w.model.requests()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("provider saw %d requests, want 2", len(reqs))
|
||||
}
|
||||
for i, r := range reqs {
|
||||
if got := requestTools(r); !slices.Equal(got, observeCeilingTools) {
|
||||
t.Fatalf("request %d offered %v, want the read-only ceiling %v", i, got, observeCeilingTools)
|
||||
}
|
||||
if r["model"] != "fake-model" {
|
||||
t.Fatalf("request %d used model %v: the workspace's own configuration was read", i, r["model"])
|
||||
}
|
||||
}
|
||||
if requestRole(reqs[0], "system") != requestRole(reqs[1], "system") {
|
||||
t.Fatal("the system prompt changed inside a run: the cache-stable prefix moved")
|
||||
}
|
||||
if strings.Contains(requestRole(reqs[0], "system"), "scheduled-run") || !strings.Contains(requestRole(reqs[0], "user"), "<scheduled-run>") {
|
||||
t.Fatal("the run context must ride the turn tail, not the system prompt")
|
||||
}
|
||||
if !strings.Contains(requestRole(reqs[0], "user"), "Summarize the TODOs.") {
|
||||
t.Fatal("the frozen prompt did not reach the provider")
|
||||
}
|
||||
|
||||
res, err := w.store.GetResult(t.Context(), w.run.TriggerID)
|
||||
if err != nil || res.Report != "all quiet" || res.Posture.RemoteContent {
|
||||
t.Fatalf("result = %+v, %v", res, err)
|
||||
}
|
||||
if res.SessionPath == "" || res.SessionPath == userSession {
|
||||
t.Fatalf("session path %q: the run needs a session of its own", res.SessionPath)
|
||||
}
|
||||
if !sessionstore.SessionLeaseHeldByCurrentRuntime(userSession) {
|
||||
t.Fatal("the user's own session lease was disturbed")
|
||||
}
|
||||
}
|
||||
|
||||
const askCall = `{"questions":[{"header":"Q","question":"Ship it?","options":[{"label":"Yes"},{"label":"No"}]}]}`
|
||||
|
||||
func TestScheduleExecParksAQuestionAndRunsNoWrite(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Fix the notes.", fakeModelRef,
|
||||
fakeTurn{callName: "ask", callArgs: askCall, prompt: 100, cl: 10},
|
||||
fakeTurn{callName: "write_file", callArgs: `{"path":"out.txt","content":"x"}`, prompt: 100, cl: 10},
|
||||
fakeTurn{text: "asked, and could not write", prompt: 100, cl: 10})
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunBlocked {
|
||||
t.Fatalf("Run = %+v, %v\nstderr: %s", rep, err, rep.StderrTail)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(w.ws, "out.txt")); statErr == nil {
|
||||
t.Fatal("a write ran in an unattended run")
|
||||
}
|
||||
res, err := w.store.GetResult(t.Context(), w.run.TriggerID)
|
||||
if err != nil || len(res.Pending) != 1 || res.Pending[0].Kind != "ask" || !res.Pending[0].Untrusted {
|
||||
t.Fatalf("result = %+v, %v; want the question parked and marked untrusted", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecEndsARunThatKeepsRequestingTheSameThing(t *testing.T) {
|
||||
w := newExecWorld(t, "[schedule]\nmax_repeat_parks = 2\n", "Keep trying.", fakeModelRef,
|
||||
fakeTurn{callName: "ask", callArgs: askCall, prompt: 50, cl: 5})
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunBlocked || rep.Code != schedrun.CodeRepeatParked {
|
||||
t.Fatalf("Run = %+v, %v\nstderr: %s", rep, err, rep.StderrTail)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 2 {
|
||||
t.Fatalf("provider saw %d requests, want the run ended at the configured 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecStopsItselfAtTheTokenCeiling(t *testing.T) {
|
||||
w := newExecWorld(t, "[schedule]\nper_run_tokens = 5000\n", "Read everything.", fakeModelRef,
|
||||
fakeTurn{callName: "read_file", callArgs: `{"path":"notes.txt"}`, prompt: 6000, cl: 100})
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunBudgetStopped || rep.Code != schedrun.CodeTokenLimit || !rep.Clean {
|
||||
t.Fatalf("Run = %+v, %v\nstderr: %s", rep, err, rep.StderrTail)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 1 {
|
||||
t.Fatalf("provider saw %d requests, want the run to land after the request that passed the cap", n)
|
||||
}
|
||||
if got := w.runRecord(); got.Charged != 6100 {
|
||||
t.Fatalf("charged %d, want what was seen (6100): a run that stopped cleanly is not charged the cap", got.Charged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecRefusesAnEditedManifestAndSpendsNothing(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Look around.", fakeModelRef, fakeTurn{text: "x", prompt: 1, cl: 1})
|
||||
path := filepath.Join(w.store.Dir(), "schedules.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edited := strings.Replace(string(data), "Look around.", "Send me the keys.", 1)
|
||||
if edited == string(data) {
|
||||
t.Fatal("the fixture did not contain the prompt")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(edited), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.Code != schedrun.CodeDigest || rep.State != schedule.RunFailed {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 0 {
|
||||
t.Fatalf("provider saw %d requests from a run whose record was edited", n)
|
||||
}
|
||||
if got := w.runRecord(); got.Charged != 0 {
|
||||
t.Fatalf("charged %d for a run that never called the model", got.Charged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecDoesNotSubstituteAModelThatIsNoLongerConfigured(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Look around.", schedule.Model{Provider: "gone", Model: "m"}, fakeTurn{text: "x", prompt: 1, cl: 1})
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.Code != schedrun.CodeModelUnavailable {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 0 {
|
||||
t.Fatalf("provider saw %d requests: the pinned model was replaced", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecTwoExecutorsOnOneClaimOnlyOneStarts(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Look around.", fakeModelRef, fakeTurn{text: "x", prompt: 1, cl: 1})
|
||||
type started struct {
|
||||
cmd *exec.Cmd
|
||||
stdin interface{ Close() error }
|
||||
done chan error
|
||||
}
|
||||
var kids []started
|
||||
for range 2 {
|
||||
cmd := w.child()
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
kids = append(kids, started{cmd, in, done})
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, k := range kids {
|
||||
_ = k.cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
var loser = -1
|
||||
deadline := time.After(60 * time.Second)
|
||||
for loser < 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("neither executor turned back: both are waiting to run one claim")
|
||||
default:
|
||||
}
|
||||
for i, k := range kids {
|
||||
select {
|
||||
case err := <-k.done:
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) || ee.ExitCode() != execExitRunHeld {
|
||||
t.Fatalf("executor %d ended with %v, want exit %d", i, err, execExitRunHeld)
|
||||
}
|
||||
loser = i
|
||||
default:
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 0 {
|
||||
t.Fatalf("provider saw %d requests before anyone released a run", n)
|
||||
}
|
||||
winner := kids[1-loser]
|
||||
_ = winner.stdin.Close()
|
||||
select {
|
||||
case err := <-winner.done:
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) || ee.ExitCode() != execExitParentGone {
|
||||
t.Fatalf("the executor that was never released ended with %v, want exit %d", err, execExitParentGone)
|
||||
}
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("the executor did not leave when its supervisor stream ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecLeavesAndSettlesWhenItsSupervisorCrashes(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Look around.", fakeModelRef, fakeTurn{text: "x", prompt: 1, cl: 1})
|
||||
w.model.hang()
|
||||
parent := exec.Command(os.Args[0])
|
||||
parent.Env = append(os.Environ(), asSupervisorEnv+"="+w.run.TriggerID, asCLIEnv+"=1")
|
||||
if err := parent.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = parent.Process.Kill(); _ = parent.Wait() })
|
||||
select {
|
||||
case <-w.model.started:
|
||||
case <-time.After(90 * time.Second):
|
||||
t.Fatal("the child never reached the provider")
|
||||
}
|
||||
if err := parent.Process.Kill(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = parent.Wait()
|
||||
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for w.store.RunHeld(w.run) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the child outlived its supervisor")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
got := w.runRecord()
|
||||
t.Logf("after the supervisor died the child left and the run reads %s (%s)", got.State, runtime.GOOS)
|
||||
if got.State == schedule.RunRunning && runtime.GOOS == "windows" {
|
||||
// The job object ends the child with the supervisor before it can settle
|
||||
// itself; the reaper's read of the lease settles it.
|
||||
w.clk.Advance(schedule.ReapGrace + time.Second)
|
||||
if reaped, err := w.store.ReapDead(t.Context(), w.pol); err != nil || len(reaped) != 1 {
|
||||
t.Fatalf("ReapDead = %v, %v", reaped, err)
|
||||
}
|
||||
got = w.runRecord()
|
||||
}
|
||||
if got.State != schedule.RunInterrupted || got.Charged != got.PerRunCap {
|
||||
t.Fatalf("settled = %s charged %d, want interrupted at the whole cap %d", got.State, got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
// A child started by hand cannot spend a claim: it needs the token only the
|
||||
// supervisor was given, and the run starts once.
|
||||
func TestScheduleExecStartsOnlyWithTheSupervisorsTokenAndOnlyOnce(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Look around.", fakeModelRef, fakeTurn{text: "quiet", prompt: 10, cl: 5})
|
||||
byHand := func(token string) string {
|
||||
cmd := w.child()
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(in, schedrun.GoLine+" "+token+"\n")
|
||||
data, _ := io.ReadAll(out)
|
||||
_ = in.Close()
|
||||
_ = cmd.Wait()
|
||||
return string(data)
|
||||
}
|
||||
if got := byHand("guess"); !strings.Contains(got, schedrun.CodeRunSettled) {
|
||||
t.Fatalf("a claimed run must not start: %s", got)
|
||||
}
|
||||
token, err := w.store.MarkRunning(t.Context(), w.run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := byHand("guess"); !strings.Contains(got, schedrun.CodeRunToken) {
|
||||
t.Fatalf("a guessed token must be refused: %s", got)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 0 {
|
||||
t.Fatalf("provider saw %d requests before the right token", n)
|
||||
}
|
||||
if got := byHand(token); !strings.Contains(got, `"state":"succeeded"`) {
|
||||
t.Fatalf("the token holder did not run: %s", got)
|
||||
}
|
||||
if got := byHand(token); !strings.Contains(got, schedrun.CodeRunStarted) {
|
||||
t.Fatalf("a second start must be refused: %s", got)
|
||||
}
|
||||
if n := len(w.model.requests()); n != 1 {
|
||||
t.Fatalf("provider saw %d requests, want the claim spent once", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleExecStopsARunWhoseProviderReportsNoUsage(t *testing.T) {
|
||||
w := newExecWorld(t, "", "Read.", fakeModelRef,
|
||||
fakeTurn{callName: "read_file", callArgs: `{"path":"notes.txt"}`, noUsage: true})
|
||||
rep, err := w.supervisor().Run(t.Context(), w.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunBudgetStopped || rep.Code != schedrun.CodeUnmetered || rep.Clean {
|
||||
t.Fatalf("Run = %+v, %v\nstderr: %s", rep, err, rep.StderrTail)
|
||||
}
|
||||
if n := len(w.model.requests()); n > 2 {
|
||||
t.Fatalf("provider saw %d requests: a run nothing can count must stop after the second", n)
|
||||
}
|
||||
if got := w.runRecord(); got.Charged != got.PerRunCap {
|
||||
t.Fatalf("charged %d, want the whole cap %d", got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheScheduleChildReadsNoConfigFromItsWorkingDirectory(t *testing.T) {
|
||||
if readsLanguageFromConfig("schedule", false) {
|
||||
t.Fatal("the schedule child would read the working directory's configuration")
|
||||
}
|
||||
if !readsLanguageFromConfig("run", false) || readsLanguageFromConfig("run", true) {
|
||||
t.Fatal("ordinary commands changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchSupervisorCancelsAndForcesTheProcessOut(t *testing.T) {
|
||||
gone := make(chan struct{})
|
||||
cancelled := make(chan struct{})
|
||||
exited := make(chan int, 1)
|
||||
orphan := watchSupervisor(gone, func() { close(cancelled) }, 50*time.Millisecond, func(c int) { exited <- c })
|
||||
if orphan.Load() {
|
||||
t.Fatal("orphaned while the supervisor is alive")
|
||||
}
|
||||
close(gone)
|
||||
select {
|
||||
case <-cancelled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the run was not cancelled")
|
||||
}
|
||||
select {
|
||||
case c := <-exited:
|
||||
if c != execExitParentGone {
|
||||
t.Fatalf("exit code %d", c)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the process was not forced out")
|
||||
}
|
||||
if !orphan.Load() {
|
||||
t.Fatal("the flag did not record the supervisor leaving")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// The acceptance rules of the updater shipped in v1.39.5, kept as they were
|
||||
// there: installed binaries cannot change, so a pointer must satisfy these
|
||||
// whatever the current code accepts.
|
||||
var legacy139StableTag = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||
|
||||
func legacy139IsCLITag(tag string) bool {
|
||||
tag = strings.TrimSpace(tag)
|
||||
return len(tag) >= 2 && tag[0] == 'v' && tag[1] >= '0' && tag[1] <= '9'
|
||||
}
|
||||
|
||||
func legacy139ExpectedAssetURL(raw, tag, name string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
|
||||
return false
|
||||
}
|
||||
want := fmt.Sprintf("/%s/%s/releases/download/%s/%s", ghOwner, ghRepo, tag, name)
|
||||
return strings.EqualFold(parsed.Hostname(), "github.com") &&
|
||||
parsed.Port() == "" &&
|
||||
parsed.EscapedPath() == want &&
|
||||
parsed.RawQuery == "" &&
|
||||
parsed.Fragment == ""
|
||||
}
|
||||
|
||||
func legacy139Accepts(rel ghRelease) error {
|
||||
if !legacy139IsCLITag(rel.TagName) || !legacy139StableTag.MatchString(rel.TagName) || rel.Prerelease {
|
||||
return fmt.Errorf("tag %q prerelease=%v is not a stable CLI release", rel.TagName, rel.Prerelease)
|
||||
}
|
||||
for _, name := range requiredCLIAssets {
|
||||
found := false
|
||||
for _, asset := range rel.Assets {
|
||||
if asset.Name != name {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return fmt.Errorf("duplicate asset %s", name)
|
||||
}
|
||||
found = true
|
||||
if asset.Size <= 0 || asset.Size > 1<<30 || !legacy139ExpectedAssetURL(asset.BrowserDownloadURL, rel.TagName, name) {
|
||||
return fmt.Errorf("asset %s: size %d url %s", name, asset.Size, asset.BrowserDownloadURL)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("missing asset %s", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStudioPointer(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "scripts", "testdata", "cli-pointer-v2.24.0.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestStudioPointerSatisfiesTheV1395UpdaterRules(t *testing.T) {
|
||||
var rel ghRelease
|
||||
if err := json.Unmarshal(loadStudioPointer(t), &rel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := legacy139Accepts(rel); err != nil {
|
||||
t.Fatalf("v1.39.5 would refuse the pointer: %v", err)
|
||||
}
|
||||
if semver.Compare(rel.TagName, "v1.39.5") <= 0 {
|
||||
t.Fatalf("%s does not sort above the 1.x line", rel.TagName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioTagShapesTheV1395UpdaterRefuses(t *testing.T) {
|
||||
var rel ghRelease
|
||||
if err := json.Unmarshal(loadStudioPointer(t), &rel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewrite := func(tag string) ghRelease {
|
||||
out := rel
|
||||
out.TagName = tag
|
||||
out.Assets = append([]ghAsset(nil), rel.Assets...)
|
||||
for i := range out.Assets {
|
||||
out.Assets[i].BrowserDownloadURL = strings.ReplaceAll(rel.Assets[i].BrowserDownloadURL, "/"+rel.TagName+"/", "/"+tag+"/")
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, tag := range []string{"studio-v2.24.0", "v2.24.0-rc.1", "v2.24.0-preview.1"} {
|
||||
if legacy139Accepts(rewrite(tag)) == nil {
|
||||
t.Errorf("v1.39.5 accepts %q as the stable pointer", tag)
|
||||
}
|
||||
}
|
||||
mismatched := rel
|
||||
mismatched.Assets = append([]ghAsset(nil), rel.Assets...)
|
||||
mismatched.Assets[0].BrowserDownloadURL = strings.Replace(rel.Assets[0].BrowserDownloadURL, "/v2.24.0/", "/studio-v2.24.0/", 1)
|
||||
if legacy139Accepts(mismatched) == nil {
|
||||
t.Error("v1.39.5 accepts an asset URL under a tag other than the pointer's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUpdaterReadsTheStudioPointer(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(loadStudioPointer(t))
|
||||
}))
|
||||
defer server.Close()
|
||||
rel, err := fetchCLIReleasePointer(server.Client(), server.URL, cliReleaseStable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range [][2]string{
|
||||
{"darwin", "amd64"}, {"darwin", "arm64"}, {"linux", "amd64"}, {"linux", "arm64"}, {"windows", "amd64"}, {"windows", "arm64"},
|
||||
} {
|
||||
if findCLIPlatformAsset(rel, target[0], target[1]) == nil {
|
||||
t.Errorf("no asset for %s/%s", target[0], target[1])
|
||||
}
|
||||
}
|
||||
if findCLIReleaseAsset(rel, "SHA256SUMS") == nil {
|
||||
t.Error("no SHA256SUMS")
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@ type Renderer interface {
|
||||
// Asker puts structured multiple-choice questions to the user and blocks for the
|
||||
// answers. The agent consults it for the `ask` tool. It is interface-shaped so
|
||||
// the agent stays independent of the frontend; a nil asker means no interactive
|
||||
// user (headless runs), where `ask` returns a "decide for yourself" result. The
|
||||
// interactive frontends wire the controller in as the Asker.
|
||||
// user (headless runs), where `ask` returns an unresolved result and never an
|
||||
// answer. The interactive frontends wire the controller in as the Asker.
|
||||
type Asker interface {
|
||||
Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func (a *Agent) SetTools(tools *tool.Registry) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.svc.tools = tools
|
||||
a.svc.setTools(tools)
|
||||
}
|
||||
|
||||
// SetReasoningLanguage updates the visible reasoning language preference for
|
||||
@@ -405,7 +405,7 @@ func (a *Agent) SetGate(g Gate) {
|
||||
if nilutil.IsNil(g) {
|
||||
g = nil
|
||||
}
|
||||
a.svc.gate = g
|
||||
a.svc.setGate(g)
|
||||
}
|
||||
|
||||
// SetExtensions installs the extension dispatcher after construction. Boot
|
||||
@@ -416,7 +416,7 @@ func (a *Agent) SetExtensions(d *dispatch.Dispatcher) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.svc.extensions = d
|
||||
a.svc.setExtensions(d)
|
||||
}
|
||||
|
||||
// SetRecoveryGate installs Auto Guard. Safe to call before the run loop starts;
|
||||
@@ -493,7 +493,7 @@ func (a *Agent) withTurnPreferences(input string) string {
|
||||
|
||||
// SetAsker installs the asker the `ask` tool uses to question the user.
|
||||
// Interactive frontends wire one in; headless runs leave it nil.
|
||||
func (a *Agent) SetAsker(as Asker) { a.svc.asker = as }
|
||||
func (a *Agent) SetAsker(as Asker) { a.svc.setAsker(as) }
|
||||
|
||||
// SetMemoryQueue installs the sink the remember/forget tools use to apply a
|
||||
// memory change in the current session. The controller wires itself in.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// AskTool obtains what only the user can supply: a decision that is theirs, or a
|
||||
@@ -70,6 +71,8 @@ func (*AskTool) DecisionBarrier() bool { return true }
|
||||
// and stays available in plan mode (clarifying scope while planning is fine).
|
||||
func (*AskTool) ReadOnly() bool { return true }
|
||||
|
||||
func (*AskTool) Reach() tool.Reach { return tool.ReachHostControl }
|
||||
|
||||
func (*AskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var p struct {
|
||||
Questions []struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/safety/evidence"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,8 @@ func (*ConcludeBlockedTool) Schema() json.RawMessage {
|
||||
// evidence it cites was produced by the work that came before it.
|
||||
func (*ConcludeBlockedTool) ReadOnly() bool { return true }
|
||||
|
||||
func (*ConcludeBlockedTool) Reach() tool.Reach { return tool.ReachHostControl }
|
||||
|
||||
type blockedEvidence struct {
|
||||
Command string `json:"command"`
|
||||
Paths []string `json:"paths"`
|
||||
|
||||
@@ -156,6 +156,10 @@ func (a *Agent) parseToolCall(ctx context.Context, plan *toolCallPlan) (toolOutc
|
||||
output: fmt.Sprintf("MCP server %q is connected; its real tools are now available", server),
|
||||
}, true
|
||||
}
|
||||
if a.svc.postureLocked.Load() {
|
||||
msg := fmt.Sprintf("blocked: tool %q is not available: this run is read-only and offers only %s. Nothing else can be enabled from inside the run.", plan.call.Name, strings.Join(a.svc.tools.AllNames(), ", "))
|
||||
return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg), refusalCode: CodePostureToolNotAllowed}, true
|
||||
}
|
||||
return toolOutcome{
|
||||
output: fmt.Sprintf("error: unknown tool %q", plan.call.Name),
|
||||
errMsg: fmt.Sprintf("unknown tool %q", plan.call.Name),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/ext/extension/dispatch"
|
||||
)
|
||||
|
||||
// CodePostureToolNotAllowed identifies a call refused because the run's
|
||||
// posture offers no such tool.
|
||||
const CodePostureToolNotAllowed = "posture.tool_not_allowed"
|
||||
|
||||
// LockPosture fixes the gate and asker installed now: later SetGate and
|
||||
// SetAsker calls change nothing, and a call to a tool the registry does not
|
||||
// hold is refused with CodePostureToolNotAllowed. It cannot be undone.
|
||||
func (a *Agent) LockPosture() { a.svc.postureLocked.Store(true) }
|
||||
|
||||
func (s *agentServices) setGate(g Gate) {
|
||||
if !s.postureLocked.Load() {
|
||||
s.gate = g
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentServices) setAsker(as Asker) {
|
||||
if !s.postureLocked.Load() {
|
||||
s.asker = as
|
||||
}
|
||||
}
|
||||
|
||||
// LockSurface fixes the tool registry and the extension dispatcher installed
|
||||
// now. The host calls it once assembly has installed both; nothing that holds
|
||||
// the agent can then replace either. It cannot be undone.
|
||||
func (a *Agent) LockSurface() { a.svc.surfaceLocked.Store(true) }
|
||||
|
||||
func (s *agentServices) setTools(t *tool.Registry) {
|
||||
if !s.surfaceLocked.Load() {
|
||||
s.tools = t
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentServices) setExtensions(d *dispatch.Dispatcher) {
|
||||
if !s.surfaceLocked.Load() {
|
||||
s.extensions = d
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/ext/extension/dispatch"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type lockProbe struct{ name string }
|
||||
|
||||
func (p lockProbe) Name() string { return p.name }
|
||||
func (p lockProbe) Description() string { return "probe" }
|
||||
func (p lockProbe) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (p lockProbe) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
|
||||
func (p lockProbe) ReadOnly() bool { return true }
|
||||
|
||||
type lockGate struct{}
|
||||
|
||||
func (lockGate) Check(context.Context, string, json.RawMessage, bool) (bool, string, error) {
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
func TestLockPostureAndSurfaceFreezeWhatAssemblyInstalled(t *testing.T) {
|
||||
first := tool.NewRegistry()
|
||||
first.Add(lockProbe{"a"})
|
||||
a := New(&fakeProvider{}, first, sessionstore.NewSession(""), Options{}, event.Discard)
|
||||
a.SetGate(lockGate{})
|
||||
|
||||
a.LockPosture()
|
||||
a.SetGate(nil)
|
||||
a.SetAsker(nil)
|
||||
if a.svc.gate == nil {
|
||||
t.Fatal("SetGate replaced the gate after LockPosture")
|
||||
}
|
||||
|
||||
other := tool.NewRegistry()
|
||||
other.Add(lockProbe{"b"})
|
||||
dispatcher := &dispatch.Dispatcher{}
|
||||
a.SetTools(other)
|
||||
a.SetExtensions(dispatcher)
|
||||
if a.svc.tools == first && a.svc.extensions == dispatcher {
|
||||
t.Fatal("test cannot tell: neither setter ran before the lock")
|
||||
}
|
||||
|
||||
a.LockSurface()
|
||||
a.SetTools(first)
|
||||
a.SetExtensions(nil)
|
||||
if a.svc.tools != other || a.svc.extensions != dispatcher {
|
||||
t.Fatal("SetTools or SetExtensions changed the surface after LockSurface")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/base/diff"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
@@ -62,6 +64,10 @@ type agentServices struct {
|
||||
hooks ToolHooks
|
||||
// asker lets the `ask` tool put questions to the user; nil in headless runs.
|
||||
asker Asker
|
||||
// postureLocked freezes gate and asker; see LockPosture.
|
||||
postureLocked atomic.Bool
|
||||
// surfaceLocked freezes the tool registry and dispatcher; see LockSurface.
|
||||
surfaceLocked atomic.Bool
|
||||
// preEdit is the seam the checkpoint store uses to snapshot pre-edit
|
||||
// content. Only non-ReadOnly tool.Previewer tools fire it, so bash — whose
|
||||
// targets are unknowable — is never tracked. Prefer mutationObserver.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Package schedrun runs one claimed scheduled run in a child process and holds
|
||||
// it to its budget.
|
||||
//
|
||||
// The child is the binary itself under a fixed read-only posture. Its posture
|
||||
// and its secret-protection switches are process-wide state, so a run gets a
|
||||
// process of its own rather than a controller beside an interactive one. The
|
||||
// Supervisor starts it, counts the tokens it reports, and ends it, tree and
|
||||
// all, when the wall clock or the token ceiling passes; a child that ignores
|
||||
// every request to stop is killed, not asked again.
|
||||
//
|
||||
// Parent and child talk over the child's standard streams: one JSON line per
|
||||
// usage report and a last line carrying the result upward, one "go" line
|
||||
// downward. The child keeps reading downward for as long as it runs, and the end
|
||||
// of that stream is the supervisor going away, so no supervisor crash leaves a
|
||||
// run spending. Anything the supervisor cannot account for is charged at the
|
||||
// run's whole cap.
|
||||
package schedrun
|
||||
@@ -0,0 +1,47 @@
|
||||
package schedrun
|
||||
|
||||
import "errors"
|
||||
|
||||
// Callers tell these apart with errors.Is; the wrapping text is display only.
|
||||
var (
|
||||
ErrSpawn = errors.New("schedrun: the child process could not be started")
|
||||
ErrWallLimit = errors.New("schedrun: the run passed its wall-clock limit and was killed")
|
||||
ErrTokenLimit = errors.New("schedrun: the run passed its token ceiling and was killed")
|
||||
ErrCancelled = errors.New("schedrun: the run was cancelled and killed")
|
||||
ErrChildCrashed = errors.New("schedrun: the child ended without delivering a result")
|
||||
ErrProtocol = errors.New("schedrun: the child broke the stream protocol and was killed")
|
||||
ErrParentGone = errors.New("schedrun: the supervisor went away")
|
||||
ErrStartTimeout = errors.New("schedrun: the supervisor did not release the run")
|
||||
ErrRepeatParked = errors.New("schedrun: the run parked the same kind of request too often")
|
||||
ErrExecutorRefused = errors.New("schedrun: the child did not take the run, another executor holds it")
|
||||
ErrNoSupervisor = errors.New("schedrun: this command is started by the supervisor and takes no terminal")
|
||||
)
|
||||
|
||||
// Codes are the typed identities a run's outcome carries in its Report and in
|
||||
// the result line; most are also an error above where a caller wants to branch.
|
||||
const (
|
||||
CodeWallLimit = "schedule.wall_limit"
|
||||
CodeTokenLimit = "schedule.token_limit"
|
||||
CodeCancelled = "schedule.cancelled"
|
||||
CodeCrashed = "schedule.child_crashed"
|
||||
CodeProtocol = "schedule.child_protocol"
|
||||
CodeSpawn = "schedule.spawn_failed"
|
||||
CodeParentGone = "schedule.parent_gone"
|
||||
CodeRepeatParked = "schedule.repeat_parked"
|
||||
CodeParkLimit = "schedule.park_limit"
|
||||
CodeUnmetered = "schedule.unmetered"
|
||||
CodeRunHeld = "schedule.run_held"
|
||||
CodeRunToken = "schedule.run_token"
|
||||
CodeRunStarted = "schedule.run_started"
|
||||
CodeRunSettled = "schedule.run_settled"
|
||||
CodeDigest = "schedule.digest_mismatch"
|
||||
CodeWorkspaceGone = "schedule.workspace_gone"
|
||||
CodeBuild = "schedule.build_failed"
|
||||
CodePolicy = "schedule.policy_invalid"
|
||||
CodeProviderAuth = "provider.auth"
|
||||
CodeProviderQuota = "provider.quota"
|
||||
CodeRunError = "schedule.run_error"
|
||||
CodeStore = "schedule.store_error"
|
||||
CodeNotFound = "schedule.not_found"
|
||||
CodeModelUnavailable = "schedule.model_unavailable"
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
const fakeEnv = "SCHEDRUN_FAKE_CHILD"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if mode := os.Getenv(fakeEnv); mode != "" {
|
||||
os.Exit(runFakeChild(mode))
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func env(k string) string { return os.Getenv("SCHEDRUN_" + k) }
|
||||
|
||||
// runFakeChild is the program a supervised child would be, with a behaviour the
|
||||
// test picks. It holds the run's lease first and waits for the release line,
|
||||
// exactly as the real command does, and it ignores every signal it can.
|
||||
func runFakeChild(mode string) int {
|
||||
signal.Ignore(os.Interrupt)
|
||||
if mode == "grand" {
|
||||
release, err := filelock.TryAcquire(env("GRAND_LOCK"))
|
||||
if err != nil {
|
||||
return 13
|
||||
}
|
||||
defer release()
|
||||
time.Sleep(time.Hour)
|
||||
return 0
|
||||
}
|
||||
store, err := schedule.Open(env("STORE"))
|
||||
if err != nil {
|
||||
return 10
|
||||
}
|
||||
id := env("ID")
|
||||
if mode == "parent" {
|
||||
return runFakeParent(store, id)
|
||||
}
|
||||
release, err := store.HoldRun(id)
|
||||
if err != nil {
|
||||
return 11
|
||||
}
|
||||
defer release()
|
||||
w := NewWriter(os.Stdout)
|
||||
_ = w.Ready()
|
||||
_, gone, err := AwaitGo(os.Stdin, 20*time.Second)
|
||||
if err != nil {
|
||||
return 12
|
||||
}
|
||||
if mark := env("MARK"); mark != "" {
|
||||
f, _ := os.OpenFile(mark, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
fmt.Fprintf(f, "ran %d\n", os.Getpid())
|
||||
f.Close()
|
||||
}
|
||||
switch mode {
|
||||
case "ok":
|
||||
_ = w.Usage(400)
|
||||
_ = w.Usage(600)
|
||||
_ = w.Result(Done{State: schedule.RunSucceeded, Tokens: 1000, Usages: 2,
|
||||
Report: "found 3 TODOs\x00 evil", Posture: observe.New(false),
|
||||
Pending: []observe.Pending{{ID: "p1", Kind: observe.KindAsk, Source: "ask", Detail: "which?\x1b[31m"}}})
|
||||
return 0
|
||||
case "lie":
|
||||
_ = w.Usage(100)
|
||||
_ = w.Result(Done{State: schedule.RunSucceeded, Tokens: 100, Usages: 5})
|
||||
return 0
|
||||
case "unmetered":
|
||||
_ = w.Result(Done{State: schedule.RunSucceeded, Tokens: 0, Usages: 0, Unmetered: true})
|
||||
return 0
|
||||
case "slow-ok":
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
_ = w.Usage(10)
|
||||
_ = w.Result(Done{State: schedule.RunSucceeded, Tokens: 10, Usages: 1})
|
||||
return 0
|
||||
case "noisy":
|
||||
_, _ = os.Stderr.WriteString("boom\x1b[31m\u202e\x00 secret")
|
||||
return 3
|
||||
case "crash":
|
||||
_ = w.Usage(50)
|
||||
return 3
|
||||
case "tree":
|
||||
exe, _ := os.Executable()
|
||||
g := exec.Command(exe)
|
||||
g.Env = append(os.Environ(), fakeEnv+"=grand")
|
||||
if err := g.Start(); err != nil {
|
||||
return 14
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
case "sleep":
|
||||
time.Sleep(time.Hour)
|
||||
case "flood":
|
||||
for range 10 {
|
||||
_ = w.Usage(int64(envInt("CHUNK")))
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
case "huge-line":
|
||||
_, _ = os.Stdout.WriteString(strings.Repeat("x", MaxLineBytes+10) + "\n")
|
||||
time.Sleep(time.Hour)
|
||||
case "hold":
|
||||
<-gone
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func envInt(k string) int {
|
||||
n, _ := strconv.Atoi(env(k))
|
||||
return n
|
||||
}
|
||||
|
||||
// runFakeParent is a supervisor process a test can kill: it runs one child in
|
||||
// the mode SCHEDRUN_CHILD_MODE and never returns on its own.
|
||||
func runFakeParent(store *schedule.Store, id string) int {
|
||||
sup := &Supervisor{Store: store, Policy: testPolicy(), Command: childCommand(os.Getenv("SCHEDRUN_CHILD_MODE"), env("STORE"), id, "")}
|
||||
_, _ = sup.Run(context.Background(), id)
|
||||
return 0
|
||||
}
|
||||
|
||||
func childCommand(mode, store, id, mark string, extra ...string) func(string) (*exec.Cmd, error) {
|
||||
return func(string) (*exec.Cmd, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := exec.Command(exe)
|
||||
c.Env = append(os.Environ(), fakeEnv+"="+mode, "SCHEDRUN_STORE="+store, "SCHEDRUN_ID="+id, "SCHEDRUN_MARK="+mark)
|
||||
c.Env = append(c.Env, extra...)
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/contract/observe"
|
||||
)
|
||||
|
||||
// Governor is the child's own half of the limits: it counts the run's tokens and
|
||||
// asks the run to stop when the ceiling is reached, and it ends a run that keeps
|
||||
// asking for a person for the same kind of thing. The supervisor's kill is the
|
||||
// backstop for a child that does not stop.
|
||||
type Governor struct {
|
||||
tokens atomic.Int64
|
||||
usages atomic.Int64
|
||||
metered atomic.Int64
|
||||
requests atomic.Int64
|
||||
unmetered atomic.Bool
|
||||
ceiling int64
|
||||
stopped atomic.Bool
|
||||
overBudget atomic.Bool
|
||||
repeat atomic.Bool
|
||||
stop func()
|
||||
}
|
||||
|
||||
// NewGovernor returns a governor that calls stop, once, when the run's tokens
|
||||
// reach ceiling. stop may be set later with SetStop: the controller it cancels
|
||||
// does not exist until the run is built.
|
||||
func NewGovernor(ceiling int64, stop func()) *Governor {
|
||||
return &Governor{ceiling: ceiling, stop: stop}
|
||||
}
|
||||
|
||||
func (g *Governor) SetStop(stop func()) { g.stop = stop }
|
||||
|
||||
// AddUsage records one usage report and returns the new total. A report counts
|
||||
// toward the requests' metering only when it is the model's own count for one of
|
||||
// them (metered); an estimate or a side call's usage adds tokens but cannot stand
|
||||
// in for a request that reported nothing.
|
||||
func (g *Governor) AddUsage(tokens int64, metered bool) int64 {
|
||||
g.usages.Add(1)
|
||||
if metered {
|
||||
g.metered.Add(1)
|
||||
}
|
||||
total := g.tokens.Add(max(tokens, 0))
|
||||
if g.ceiling > 0 && total >= g.ceiling {
|
||||
g.overBudget.Store(true)
|
||||
g.halt()
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// Committed records that a model request completed. Each request must be
|
||||
// followed by a usage report; the report may trail the commit by one request, so
|
||||
// two requests without one between them stop the run, because from then on what
|
||||
// it spends is not being counted.
|
||||
func (g *Governor) Committed() {
|
||||
n := g.requests.Add(1)
|
||||
if g.metered.Load() < n-1 {
|
||||
g.unmetered.Store(true)
|
||||
g.halt()
|
||||
}
|
||||
}
|
||||
|
||||
// Unmetered reports whether some request completed with no usage report.
|
||||
func (g *Governor) Unmetered() bool {
|
||||
return g.unmetered.Load() || g.metered.Load() < g.requests.Load()
|
||||
}
|
||||
|
||||
func (g *Governor) halt() {
|
||||
if g.stopped.CompareAndSwap(false, true) && g.stop != nil {
|
||||
g.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Governor) Tokens() int64 { return g.tokens.Load() }
|
||||
func (g *Governor) Usages() int64 { return g.usages.Load() }
|
||||
|
||||
// OverBudget reports whether the ceiling ended the run.
|
||||
func (g *Governor) OverBudget() bool { return g.overBudget.Load() }
|
||||
|
||||
// RepeatParked reports whether the run was ended for parking one kind of
|
||||
// request too often.
|
||||
func (g *Governor) RepeatParked() bool { return g.repeat.Load() }
|
||||
|
||||
// Sink wraps a PendingSink so that the max-th request of one class ends the run.
|
||||
// A class is the kind of thing asked for and what asked for it (an approval for
|
||||
// one tool, a question), read from the record's own fields and never from its
|
||||
// text. max below 1 disables the limit.
|
||||
func (g *Governor) Sink(inner observe.PendingSink, max int64) observe.PendingSink {
|
||||
return &repeatSink{gov: g, inner: inner, limit: max, seen: map[string]int64{}}
|
||||
}
|
||||
|
||||
type repeatSink struct {
|
||||
gov *Governor
|
||||
inner observe.PendingSink
|
||||
limit int64
|
||||
mu sync.Mutex
|
||||
seen map[string]int64
|
||||
}
|
||||
|
||||
func (s *repeatSink) Park(p observe.Pending) (observe.Pending, error) {
|
||||
stored, err := s.inner.Park(p)
|
||||
if err != nil {
|
||||
return stored, err
|
||||
}
|
||||
key := fmt.Sprintf("%s\x00%s", p.Kind, p.Source)
|
||||
s.mu.Lock()
|
||||
s.seen[key]++
|
||||
n := s.seen[key]
|
||||
s.mu.Unlock()
|
||||
if s.limit > 0 && n >= s.limit {
|
||||
s.gov.repeat.Store(true)
|
||||
s.gov.halt()
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/observe"
|
||||
)
|
||||
|
||||
func TestGovernorStopsOnceAtTheCeiling(t *testing.T) {
|
||||
var stops atomic.Int32
|
||||
g := NewGovernor(1000, func() { stops.Add(1) })
|
||||
g.AddUsage(600, true)
|
||||
if stops.Load() != 0 || g.OverBudget() {
|
||||
t.Fatal("stopped below the ceiling")
|
||||
}
|
||||
g.AddUsage(-5000, true)
|
||||
g.AddUsage(400, true)
|
||||
g.AddUsage(400, true)
|
||||
if stops.Load() != 1 || !g.OverBudget() || g.Tokens() != 1400 || g.Usages() != 4 {
|
||||
t.Fatalf("stops=%d over=%v tokens=%d usages=%d", stops.Load(), g.OverBudget(), g.Tokens(), g.Usages())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedParksOfOneClassEndTheRun(t *testing.T) {
|
||||
var stops atomic.Int32
|
||||
g := NewGovernor(0, func() { stops.Add(1) })
|
||||
sink := g.Sink(observe.NewLedger(nil), 3)
|
||||
park := func(kind observe.Kind, source, digest string) {
|
||||
t.Helper()
|
||||
if _, err := sink.Park(observe.Pending{Kind: kind, Source: source, Digest: digest}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
park(observe.KindApproval, "read_file", "a")
|
||||
park(observe.KindApproval, "grep", "b")
|
||||
park(observe.KindAsk, "ask", "c")
|
||||
park(observe.KindApproval, "read_file", "d")
|
||||
if stops.Load() != 0 || g.RepeatParked() {
|
||||
t.Fatal("different classes were counted as one")
|
||||
}
|
||||
park(observe.KindApproval, "read_file", "a")
|
||||
if stops.Load() != 1 || !g.RepeatParked() {
|
||||
t.Fatalf("the third request of one class did not end the run: stops=%d", stops.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatLimitBelowOneIsOff(t *testing.T) {
|
||||
g := NewGovernor(0, func() { t.Fatal("stopped") })
|
||||
sink := g.Sink(observe.NewLedger(nil), 0)
|
||||
for range 10 {
|
||||
_, _ = sink.Park(observe.Pending{Kind: observe.KindAsk, Source: "ask", Digest: "x"})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatSinkPassesTheStoreRefusalOn(t *testing.T) {
|
||||
g := NewGovernor(0, nil)
|
||||
sink := g.Sink(observe.NewLedger(nil), 3)
|
||||
for i := range observe.MaxPending {
|
||||
if _, err := sink.Park(observe.Pending{Kind: observe.KindApproval, Source: "s" + string(rune('a'+i)), Digest: string(rune('a' + i))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := sink.Park(observe.Pending{Kind: observe.KindApproval, Source: "zz", Digest: "zz"}); !errors.Is(err, observe.ErrParkLimit) {
|
||||
t.Fatalf("err = %v, want the store's own park limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitGoReleasesOnTheLineAndReportsTheSupervisorLeaving(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
go func() { _, _ = io.WriteString(w, "go tok123\n") }()
|
||||
token, gone, err := AwaitGo(r, 5*time.Second)
|
||||
if err != nil || token != "tok123" {
|
||||
t.Fatalf("token %q, err %v", token, err)
|
||||
}
|
||||
select {
|
||||
case <-gone:
|
||||
t.Fatal("reported gone while the supervisor still holds the pipe")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
_ = w.Close()
|
||||
select {
|
||||
case <-gone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the end of the stream was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitGoRefusesAnythingButGoAndGivesUp(t *testing.T) {
|
||||
if _, _, err := AwaitGo(strings.NewReader("go\n"), time.Second); !errors.Is(err, ErrParentGone) {
|
||||
t.Fatalf("wrong line: %v", err)
|
||||
}
|
||||
if _, _, err := AwaitGo(strings.NewReader(""), time.Second); !errors.Is(err, ErrParentGone) {
|
||||
t.Fatalf("closed before go: %v", err)
|
||||
}
|
||||
r, w, _ := os.Pipe()
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
if _, _, err := AwaitGo(r, 100*time.Millisecond); !errors.Is(err, ErrStartTimeout) {
|
||||
t.Fatalf("no line: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLinesSkipsStrayOutputAndBoundsLines(t *testing.T) {
|
||||
var got []Line
|
||||
err := readLines(strings.NewReader("warning: hi\n{\"kind\":\"usage\",\"tokens\":7}\n{\"kind\":\"nope\"}\n"), func(l Line) { got = append(got, l) })
|
||||
if err != nil || len(got) != 1 || got[0].Tokens != 7 {
|
||||
t.Fatalf("got %+v, %v", got, err)
|
||||
}
|
||||
if err := readLines(strings.NewReader(strings.Repeat("x", MaxLineBytes+1)+"\n"), func(Line) {}); !errors.Is(err, ErrProtocol) {
|
||||
t.Fatalf("oversize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGovernorStopsARunWhoseRequestsReportNoUsage(t *testing.T) {
|
||||
var stops atomic.Int32
|
||||
g := NewGovernor(0, func() { stops.Add(1) })
|
||||
g.Committed()
|
||||
if stops.Load() != 0 {
|
||||
t.Fatal("stopped on the first request: its usage may still be on the way")
|
||||
}
|
||||
g.Committed()
|
||||
if stops.Load() != 1 || !g.Unmetered() {
|
||||
t.Fatalf("stops=%d unmetered=%v: two requests with no report between them must stop the run", stops.Load(), g.Unmetered())
|
||||
}
|
||||
ok := NewGovernor(0, func() { t.Fatal("stopped") })
|
||||
ok.Committed()
|
||||
ok.AddUsage(10, true)
|
||||
ok.Committed()
|
||||
ok.AddUsage(10, true)
|
||||
if ok.Unmetered() {
|
||||
t.Fatal("a metered run reads as unmetered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitWhenGoneForcesTheProcessOut(t *testing.T) {
|
||||
gone := make(chan struct{})
|
||||
got := make(chan int, 1)
|
||||
ExitWhenGone(gone, 50*time.Millisecond, 73, func(c int) { got <- c })
|
||||
select {
|
||||
case <-got:
|
||||
t.Fatal("exited while the supervisor was alive")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(gone)
|
||||
select {
|
||||
case c := <-got:
|
||||
if c != 73 {
|
||||
t.Fatalf("code %d", c)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("never exited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatesAndSideCallsDoNotStandInForAMeteredRequest(t *testing.T) {
|
||||
var stops atomic.Int32
|
||||
g := NewGovernor(0, func() { stops.Add(1) })
|
||||
for range 5 {
|
||||
g.AddUsage(100, false)
|
||||
}
|
||||
g.Committed()
|
||||
g.Committed()
|
||||
if stops.Load() != 1 || !g.Unmetered() {
|
||||
t.Fatalf("stops=%d unmetered=%v: unmetered requests slipped past on the strength of other usage", stops.Load(), g.Unmetered())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
// Line kinds on the child's standard output.
|
||||
const (
|
||||
KindReady = "ready"
|
||||
KindUsage = "usage"
|
||||
KindResult = "result"
|
||||
)
|
||||
|
||||
// MaxLineBytes bounds one line from the child. A result carries a report and up
|
||||
// to observe.MaxPending records, each cut to its own bound.
|
||||
const MaxLineBytes = 512 << 10
|
||||
|
||||
// Line is one message from the child.
|
||||
type Line struct {
|
||||
Kind string `json:"kind"`
|
||||
Tokens int64 `json:"tokens,omitempty"`
|
||||
Result *Done `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// Done is the child's account of how the run ended. Tokens and Usages are its
|
||||
// own totals; the supervisor compares them with what it counted from the stream
|
||||
// before it believes the usage stream ran to the end.
|
||||
type Done struct {
|
||||
State schedule.RunState `json:"state"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Usages int64 `json:"usages"`
|
||||
// Unmetered is set when the model answered a request with no usage report,
|
||||
// so the tokens the run spent are not known.
|
||||
Unmetered bool `json:"unmetered,omitempty"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Pending []observe.Pending `json:"pending,omitempty"`
|
||||
Posture observe.Posture `json:"posture"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
}
|
||||
|
||||
// Writer serialises lines onto the child's standard output. The supervisor's
|
||||
// reader expects nothing else there, so a child writes through this alone.
|
||||
type Writer struct {
|
||||
mu sync.Mutex
|
||||
enc *json.Encoder
|
||||
}
|
||||
|
||||
func NewWriter(w io.Writer) *Writer { return &Writer{enc: json.NewEncoder(w)} }
|
||||
|
||||
func (w *Writer) Ready() error { return w.write(Line{Kind: KindReady}) }
|
||||
|
||||
func (w *Writer) Usage(tokens int64) error {
|
||||
return w.write(Line{Kind: KindUsage, Tokens: tokens})
|
||||
}
|
||||
|
||||
func (w *Writer) Result(d Done) error {
|
||||
return w.write(Line{Kind: KindResult, Result: &d})
|
||||
}
|
||||
|
||||
func (w *Writer) write(l Line) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.enc.Encode(l)
|
||||
}
|
||||
|
||||
// readLines feeds fn every well-formed line until the stream ends. A line past
|
||||
// MaxLineBytes ends the stream with ErrProtocol; a line that is not a Line is
|
||||
// skipped, because stray output on the stream must not read as a usage report.
|
||||
func readLines(r io.Reader, fn func(Line)) error {
|
||||
br := bufio.NewReaderSize(r, 64<<10)
|
||||
var buf []byte
|
||||
for {
|
||||
chunk, isPrefix, err := br.ReadLine()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(buf)+len(chunk) > MaxLineBytes {
|
||||
return fmt.Errorf("%w: a line passed %d bytes", ErrProtocol, MaxLineBytes)
|
||||
}
|
||||
buf = append(buf, chunk...)
|
||||
if isPrefix {
|
||||
continue
|
||||
}
|
||||
var l Line
|
||||
if json.Unmarshal(buf, &l) == nil && (l.Kind == KindUsage || l.Kind == KindResult || l.Kind == KindReady) {
|
||||
fn(l)
|
||||
}
|
||||
buf = buf[:0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/proc"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultWallGrace is how long past the run's wall clock the child may take
|
||||
// to land on its own before the supervisor kills it.
|
||||
DefaultWallGrace = time.Minute
|
||||
// tokenHardNum/tokenHardDen make the kill line 1.25 times the run's cap: the
|
||||
// child stops itself at the cap, and the kill only meets one that does not.
|
||||
tokenHardNum, tokenHardDen = 5, 4
|
||||
|
||||
settleTimeout = 30 * time.Second
|
||||
killWait = 10 * time.Second
|
||||
stderrTail = 4 << 10
|
||||
)
|
||||
|
||||
// Supervisor runs claimed runs. Store and Policy are required.
|
||||
type Supervisor struct {
|
||||
Store *schedule.Store
|
||||
Policy schedule.Policy
|
||||
// Command builds the child. Nil runs this binary as `schedule exec <id>`.
|
||||
Command func(triggerID string) (*exec.Cmd, error)
|
||||
// WallGrace overrides DefaultWallGrace.
|
||||
WallGrace time.Duration
|
||||
// StartWait overrides StartTimeout, how long the child has to take its lease.
|
||||
StartWait time.Duration
|
||||
}
|
||||
|
||||
// Report is how a supervised run ended. State and Code are always set; Killed
|
||||
// is set when the supervisor, not the child, ended the run.
|
||||
type Report struct {
|
||||
TriggerID string
|
||||
State schedule.RunState
|
||||
Code string
|
||||
Observed int64
|
||||
Clean bool
|
||||
Killed bool
|
||||
StderrTail string
|
||||
}
|
||||
|
||||
// Run executes the claimed run triggerID in a child and settles it. The error is
|
||||
// nil when the child ran to a result of its own, whatever that result says; it
|
||||
// wraps a sentinel when the supervisor had to end the run or could not run it.
|
||||
// schedule.ErrRunSettled means the run was no longer claimed (another executor
|
||||
// has it, or the reaper took it) and nothing was left running.
|
||||
func (s *Supervisor) Run(ctx context.Context, triggerID string) (Report, error) {
|
||||
rep := Report{TriggerID: triggerID}
|
||||
m, _, err := s.Store.Snapshot(ctx)
|
||||
if err != nil {
|
||||
rep.Code = CodeStore
|
||||
return rep, err
|
||||
}
|
||||
var run *schedule.Run
|
||||
for i := range m.Runs {
|
||||
if m.Runs[i].TriggerID == triggerID {
|
||||
run = &m.Runs[i]
|
||||
}
|
||||
}
|
||||
if run == nil {
|
||||
rep.Code = CodeNotFound
|
||||
return rep, schedule.ErrRunNotFound
|
||||
}
|
||||
if run.State != schedule.RunClaimed {
|
||||
rep.Code = CodeRunSettled
|
||||
return rep, schedule.ErrRunSettled
|
||||
}
|
||||
var sc *schedule.Schedule
|
||||
for i := range m.Schedules {
|
||||
if m.Schedules[i].ID == run.ScheduleID {
|
||||
sc = &m.Schedules[i]
|
||||
}
|
||||
}
|
||||
if sc == nil {
|
||||
rep.Code, rep.State, rep.Clean = CodeNotFound, schedule.RunFailed, true
|
||||
return rep, errors.Join(schedule.ErrNotFound, s.settle(triggerID, schedule.Outcome{State: schedule.RunFailed, Clean: true}, nil))
|
||||
}
|
||||
|
||||
release, err := s.Store.HoldSupervision(triggerID)
|
||||
if err != nil {
|
||||
rep.Code = CodeRunHeld
|
||||
return rep, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
perRunCap := run.PerRunCap
|
||||
if s.Policy.PerRunTokens > 0 {
|
||||
perRunCap = min(perRunCap, s.Policy.PerRunTokens)
|
||||
}
|
||||
c, err := s.child(triggerID, perRunCap+perRunCap/tokenHardDen*(tokenHardNum-tokenHardDen))
|
||||
if err != nil {
|
||||
rep.Code, rep.State, rep.Clean = CodeSpawn, schedule.RunFailed, true
|
||||
return rep, errors.Join(fmt.Errorf("%w: %w", ErrSpawn, err), s.settle(triggerID, schedule.Outcome{State: schedule.RunFailed, Clean: true}, nil))
|
||||
}
|
||||
|
||||
if err := s.awaitReady(ctx, c); err != nil {
|
||||
rep.Code = CodeRunHeld
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
rep.Code = CodeCancelled
|
||||
}
|
||||
return rep, err
|
||||
}
|
||||
token, err := s.Store.MarkRunning(ctx, triggerID)
|
||||
if err != nil {
|
||||
c.kill()
|
||||
c.reap(killWait)
|
||||
rep.Code = CodeStore
|
||||
if errors.Is(err, schedule.ErrRunSettled) {
|
||||
rep.Code = CodeRunSettled
|
||||
}
|
||||
return rep, err
|
||||
}
|
||||
if _, err := io.WriteString(c.stdin, GoLine+" "+token+"\n"); err != nil {
|
||||
c.kill()
|
||||
c.reap(killWait)
|
||||
rep = s.end(triggerID, rep, endInfo{killed: true, code: CodeSpawn, state: schedule.RunFailed}, c)
|
||||
return rep, fmt.Errorf("%w: release the child: %w", ErrSpawn, err)
|
||||
}
|
||||
|
||||
wallSec := sc.Budget.PerRunWallSec
|
||||
if s.Policy.PerRunWallSeconds > 0 {
|
||||
wallSec = min(wallSec, s.Policy.PerRunWallSeconds)
|
||||
}
|
||||
cause := s.watch(ctx, c, wallSec)
|
||||
rep = s.end(triggerID, rep, cause, c)
|
||||
return rep, cause.err
|
||||
}
|
||||
|
||||
// awaitReady waits for the child to say it holds the run's lease. A child that
|
||||
// leaves without saying so was refused the run by another executor; the run is
|
||||
// left claimed, unsettled, for that executor's owner or the reaper.
|
||||
func (s *Supervisor) awaitReady(ctx context.Context, c *child) error {
|
||||
wait := s.StartWait
|
||||
if wait <= 0 {
|
||||
wait = StartTimeout
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-c.ready:
|
||||
return nil
|
||||
case <-c.done:
|
||||
c.job.Finish()
|
||||
return ErrExecutorRefused
|
||||
case <-timer.C:
|
||||
c.kill()
|
||||
c.reap(killWait)
|
||||
c.job.Finish()
|
||||
return ErrStartTimeout
|
||||
case <-ctx.Done():
|
||||
c.kill()
|
||||
c.reap(killWait)
|
||||
c.job.Finish()
|
||||
return ErrCancelled
|
||||
}
|
||||
}
|
||||
|
||||
// watch waits for the run to end by itself or be ended, and kills the child when
|
||||
// it is to be ended.
|
||||
func (s *Supervisor) watch(ctx context.Context, c *child, wallSec int64) endInfo {
|
||||
grace := s.WallGrace
|
||||
if grace <= 0 {
|
||||
grace = DefaultWallGrace
|
||||
}
|
||||
deadline := time.NewTimer(time.Duration(wallSec)*time.Second + grace)
|
||||
defer deadline.Stop()
|
||||
select {
|
||||
case <-c.done:
|
||||
return endInfo{}
|
||||
case <-c.overTokens:
|
||||
c.kill()
|
||||
return endInfo{killed: true, err: ErrTokenLimit, code: CodeTokenLimit, state: schedule.RunBudgetStopped}
|
||||
case <-c.protocol:
|
||||
c.kill()
|
||||
return endInfo{killed: true, err: ErrProtocol, code: CodeProtocol, state: schedule.RunFailed}
|
||||
case <-deadline.C:
|
||||
c.kill()
|
||||
return endInfo{killed: true, err: ErrWallLimit, code: CodeWallLimit, state: schedule.RunBudgetStopped}
|
||||
case <-ctx.Done():
|
||||
c.kill()
|
||||
return endInfo{killed: true, err: ErrCancelled, code: CodeCancelled, state: schedule.RunInterrupted}
|
||||
}
|
||||
}
|
||||
|
||||
type endInfo struct {
|
||||
killed bool
|
||||
err error
|
||||
code string
|
||||
state schedule.RunState
|
||||
}
|
||||
|
||||
// end collects the child, works out how it ended and settles the run. Whatever
|
||||
// the supervisor cannot vouch for is charged at the whole cap by the store.
|
||||
func (s *Supervisor) end(triggerID string, rep Report, cause endInfo, c *child) Report {
|
||||
exited := c.reap(killWait)
|
||||
rep.Observed = c.tokens.Load()
|
||||
rep.StderrTail = observe.Sanitize(c.stderr.String())
|
||||
c.job.Finish()
|
||||
rep.Killed = cause.killed
|
||||
done := c.result()
|
||||
var out schedule.Outcome
|
||||
switch {
|
||||
case cause.killed:
|
||||
rep.State, rep.Code = cause.state, cause.code
|
||||
out = schedule.Outcome{State: cause.state, Observed: rep.Observed}
|
||||
case done != nil && done.Unmetered:
|
||||
rep.State, rep.Code = done.State, CodeUnmetered
|
||||
out = schedule.Outcome{State: done.State, Observed: rep.Observed}
|
||||
case done != nil && exited && c.exitOK() && c.usages.Load() == done.Usages && rep.Observed == done.Tokens:
|
||||
rep.State, rep.Code, rep.Clean = done.State, done.Code, true
|
||||
out = schedule.Outcome{State: done.State, Observed: rep.Observed, Clean: true}
|
||||
default:
|
||||
rep.State, rep.Code = schedule.RunFailed, CodeCrashed
|
||||
out = schedule.Outcome{State: schedule.RunFailed, Observed: rep.Observed}
|
||||
}
|
||||
var res *schedule.Result
|
||||
if done != nil && rep.State != schedule.RunInterrupted {
|
||||
res = &schedule.Result{Report: done.Report, Pending: done.Pending, Posture: done.Posture, SessionPath: done.SessionPath}
|
||||
}
|
||||
if err := s.settle(triggerID, out, res); err != nil {
|
||||
rep.Code = CodeStore
|
||||
if errors.Is(err, schedule.ErrRunSettled) {
|
||||
rep.Code = CodeRunSettled
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func (s *Supervisor) settle(triggerID string, o schedule.Outcome, res *schedule.Result) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), settleTimeout)
|
||||
defer cancel()
|
||||
var putErr error
|
||||
if res != nil {
|
||||
putErr = s.Store.PutResult(ctx, triggerID, *res)
|
||||
if errors.Is(putErr, schedule.ErrRunSettled) {
|
||||
return putErr
|
||||
}
|
||||
}
|
||||
return errors.Join(putErr, s.Store.Finish(ctx, s.Policy, triggerID, o))
|
||||
}
|
||||
|
||||
// Reap settles runs whose executor is gone; the ticker calls it before it
|
||||
// claims anything.
|
||||
func (s *Supervisor) Reap(ctx context.Context) ([]string, error) {
|
||||
return s.Store.ReapDead(ctx, s.Policy)
|
||||
}
|
||||
|
||||
func (s *Supervisor) child(triggerID string, hardTokens int64) (*child, error) {
|
||||
build := s.Command
|
||||
if build == nil {
|
||||
build = selfCommand
|
||||
}
|
||||
cmd, err := build(triggerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &child{cmd: cmd, hardTokens: hardTokens, done: make(chan struct{}), ready: make(chan struct{}), overTokens: make(chan struct{}, 1), protocol: make(chan struct{}, 1), stderr: &tail{limit: stderrTail}}
|
||||
cmd.Stderr = c.stderr
|
||||
if c.stdin, err = cmd.StdinPipe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.stdout = out
|
||||
if c.job, err = proc.StartTrackedRequired(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go c.pump()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func selfCommand(triggerID string) (*exec.Cmd, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(exe, "schedule", "exec", triggerID)
|
||||
cmd.Dir = os.TempDir()
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// child is one running child process and what the supervisor has heard from it.
|
||||
type child struct {
|
||||
cmd *exec.Cmd
|
||||
job *proc.TrackedJob
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr *tail
|
||||
|
||||
hardTokens int64
|
||||
tokens atomic.Int64
|
||||
usages atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
last *Done
|
||||
waitErr error
|
||||
waited bool
|
||||
done chan struct{}
|
||||
overOnce sync.Once
|
||||
readyOnce sync.Once
|
||||
ready chan struct{}
|
||||
|
||||
overTokens chan struct{}
|
||||
protocol chan struct{}
|
||||
}
|
||||
|
||||
// pump reads the child's stream until it ends, then collects the exit.
|
||||
func (c *child) pump() {
|
||||
err := readLines(c.stdout, func(l Line) {
|
||||
switch l.Kind {
|
||||
case KindReady:
|
||||
c.readyOnce.Do(func() { close(c.ready) })
|
||||
case KindUsage:
|
||||
c.usages.Add(1)
|
||||
total := saturatingAdd(&c.tokens, max(l.Tokens, 0))
|
||||
if c.hardTokens > 0 && total > c.hardTokens {
|
||||
c.overOnce.Do(func() { c.overTokens <- struct{}{} })
|
||||
}
|
||||
case KindResult:
|
||||
c.mu.Lock()
|
||||
c.last = l.Result
|
||||
c.mu.Unlock()
|
||||
}
|
||||
})
|
||||
if errors.Is(err, ErrProtocol) {
|
||||
select {
|
||||
case c.protocol <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, c.stdout)
|
||||
}
|
||||
err = c.cmd.Wait()
|
||||
c.mu.Lock()
|
||||
c.waitErr, c.waited = err, true
|
||||
c.mu.Unlock()
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
func saturatingAdd(v *atomic.Int64, n int64) int64 {
|
||||
for {
|
||||
cur := v.Load()
|
||||
next := cur + n
|
||||
if next < cur {
|
||||
next = 1<<63 - 1
|
||||
}
|
||||
if v.CompareAndSwap(cur, next) {
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *child) kill() { c.job.Kill(c.cmd) }
|
||||
|
||||
// reap waits for the child to be gone, a bounded time after a kill, and reports
|
||||
// whether it was.
|
||||
func (c *child) reap(limit time.Duration) bool {
|
||||
_ = c.stdin.Close()
|
||||
select {
|
||||
case <-c.done:
|
||||
return true
|
||||
case <-time.After(limit):
|
||||
_ = c.stdout.Close()
|
||||
select {
|
||||
case <-c.done:
|
||||
return true
|
||||
case <-time.After(limit):
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *child) result() *Done {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
func (c *child) exitOK() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.waited && c.waitErr == nil
|
||||
}
|
||||
|
||||
// tail keeps the last bytes written to it.
|
||||
type tail struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
limit int
|
||||
}
|
||||
|
||||
func (t *tail) Write(p []byte) (int, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.buf = append(t.buf, p...)
|
||||
if len(t.buf) > t.limit {
|
||||
t.buf = t.buf[len(t.buf)-t.limit:]
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (t *tail) String() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return string(t.buf)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/state/schedule"
|
||||
)
|
||||
|
||||
var epoch = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *clock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func testPolicy() schedule.Policy {
|
||||
p, _, err := schedule.Resolve(schedule.Overrides{
|
||||
MinIntervalMinutes: new(int64(10)), PerRunTokens: new(int64(10_000)), PerRunWallSeconds: new(int64(1)),
|
||||
}, schedule.Overrides{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
store *schedule.Store
|
||||
clk *clock
|
||||
dir string
|
||||
sc schedule.Schedule
|
||||
run schedule.Run
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "schedules")
|
||||
clk := &clock{t: epoch}
|
||||
base, err := schedule.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := base.WithClock(clk.Now)
|
||||
ws := t.TempDir()
|
||||
sc, err := store.Create(t.Context(), testPolicy(), schedule.CreateRequest{
|
||||
Trigger: schedule.Trigger{Kind: schedule.TriggerEvery, EverySec: 600}, Target: schedule.Target{Workspace: ws},
|
||||
Prompt: "look around", Model: schedule.Model{Provider: "p", Model: "m"}, ConfirmedBy: schedule.ConfirmedByHuman,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(601 * time.Second)
|
||||
slot, _ := sc.Trigger.LatestSlot(clk.Now())
|
||||
run, err := store.Claim(t.Context(), testPolicy(), sc.ID, slot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fixture{store: store, clk: clk, dir: dir, sc: sc, run: run}
|
||||
}
|
||||
|
||||
// ctx bounds a run under test: a supervisor that failed to end a child must fail
|
||||
// the test, not hang it.
|
||||
func (f *fixture) ctx(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 40*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (f *fixture) supervisor(mode, mark string, extra ...string) *Supervisor {
|
||||
return &Supervisor{Store: f.store, Policy: testPolicy(), WallGrace: 300 * time.Millisecond,
|
||||
Command: childCommand(mode, f.dir, f.run.TriggerID, mark, extra...)}
|
||||
}
|
||||
|
||||
func (f *fixture) settled(t *testing.T) schedule.Run {
|
||||
t.Helper()
|
||||
m, _, err := f.store.Snapshot(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Runs {
|
||||
if r.TriggerID == f.run.TriggerID {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatal("run vanished")
|
||||
return schedule.Run{}
|
||||
}
|
||||
|
||||
func (f *fixture) waitFree(t *testing.T) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for f.store.RunHeld(f.run) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the child still holds its lease: it was not ended")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallClockKillsAChildThatIgnoresSignals(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
start := time.Now()
|
||||
rep, err := f.supervisor("sleep", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, ErrWallLimit) || rep.Code != CodeWallLimit || !rep.Killed {
|
||||
t.Fatalf("Run = %+v, %v; want a wall-limit kill", rep, err)
|
||||
}
|
||||
if took := time.Since(start); took > 15*time.Second {
|
||||
t.Fatalf("the kill took %s", took)
|
||||
}
|
||||
f.waitFree(t)
|
||||
got := f.settled(t)
|
||||
if got.State != schedule.RunBudgetStopped || got.Charged != got.PerRunCap {
|
||||
t.Fatalf("settled = %s charged %d, want budget_stopped at the whole cap %d", got.State, got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCeilingKillsAChildThatKeepsSpending(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sup := f.supervisor("flood", "", "SCHEDRUN_CHUNK=4000")
|
||||
sup.WallGrace = 30 * time.Second
|
||||
rep, err := sup.Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, ErrTokenLimit) || rep.Code != CodeTokenLimit || !rep.Killed {
|
||||
t.Fatalf("Run = %+v, %v; want a token-limit kill", rep, err)
|
||||
}
|
||||
f.waitFree(t)
|
||||
got := f.settled(t)
|
||||
want := max(got.PerRunCap, got.Observed)
|
||||
if got.State != schedule.RunBudgetStopped || got.Charged != want {
|
||||
t.Fatalf("settled = %s charged %d observed %d, want the whole cap or what was seen if more (%d)", got.State, got.Charged, got.Observed, want)
|
||||
}
|
||||
if got.Observed <= got.PerRunCap+got.PerRunCap/4 || got.Observed > got.PerRunCap*2 {
|
||||
t.Fatalf("observed %d: the kill line is 1.25 times the cap %d", got.Observed, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACleanRunChargesWhatItUsedAndStoresACleanResult(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rep, err := f.supervisor("ok", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunSucceeded || !rep.Clean || rep.Observed != 1000 {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
got := f.settled(t)
|
||||
if got.State != schedule.RunSucceeded || got.Charged != 1000 {
|
||||
t.Fatalf("settled = %s charged %d, want succeeded at what it used", got.State, got.Charged)
|
||||
}
|
||||
res, err := f.store.GetResult(t.Context(), f.run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(res.Report, "\x00") || !strings.Contains(res.Report, "found 3 TODOs") {
|
||||
t.Fatalf("report was not cleaned: %q", res.Report)
|
||||
}
|
||||
if len(res.Pending) != 1 || strings.ContainsRune(res.Pending[0].Detail, 0x1b) || !res.Pending[0].Untrusted {
|
||||
t.Fatalf("pending = %+v", res.Pending)
|
||||
}
|
||||
if res.Posture.RemoteContent {
|
||||
t.Fatal("a stored report must not claim remote content is rendered")
|
||||
}
|
||||
if !res.ReportUntrusted {
|
||||
t.Fatal("a stored report is model text and must be marked untrusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACrashedChildIsChargedTheWholeCap(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rep, err := f.supervisor("crash", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if err != nil || rep.State != schedule.RunFailed || rep.Code != CodeCrashed || rep.Clean {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
if got := f.settled(t); got.Charged != got.PerRunCap {
|
||||
t.Fatalf("charged %d, want the whole cap %d", got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChildThatBreaksTheProtocolIsKilled(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sup := f.supervisor("huge-line", "")
|
||||
sup.WallGrace = 30 * time.Second
|
||||
rep, err := sup.Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, ErrProtocol) || rep.Code != CodeProtocol {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
f.waitFree(t)
|
||||
if got := f.settled(t); got.State != schedule.RunFailed || got.Charged != got.PerRunCap {
|
||||
t.Fatalf("settled = %s charged %d", got.State, got.Charged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellingTheContextKillsTheRunAndInterruptsIt(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sup := f.supervisor("sleep", "")
|
||||
sup.WallGrace = time.Minute
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
go func() {
|
||||
for {
|
||||
if m, _, err := f.store.Snapshot(t.Context()); err == nil && m.Runs[0].State == schedule.RunRunning {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
rep, err := sup.Run(ctx, f.run.TriggerID)
|
||||
if !errors.Is(err, ErrCancelled) || rep.State != schedule.RunInterrupted {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
f.waitFree(t)
|
||||
if got := f.settled(t); got.Charged != got.PerRunCap {
|
||||
t.Fatalf("charged %d, want the whole cap", got.Charged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunReapedBeforeTheSupervisorStartsIsNotRun(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.clk.Advance(schedule.ReapGrace + time.Second)
|
||||
if reaped, err := f.store.ReapDead(t.Context(), testPolicy()); err != nil || len(reaped) != 1 {
|
||||
t.Fatalf("ReapDead = %v, %v", reaped, err)
|
||||
}
|
||||
mark := filepath.Join(t.TempDir(), "mark")
|
||||
_, err := f.supervisor("ok", mark).Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, schedule.ErrRunSettled) {
|
||||
t.Fatalf("Run = %v, want ErrRunSettled", err)
|
||||
}
|
||||
if _, statErr := os.Stat(mark); statErr == nil {
|
||||
t.Fatal("a settled run was executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunSettledWhileTheChildStartsSpendsNothing(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
mark := filepath.Join(t.TempDir(), "mark")
|
||||
sup := f.supervisor("ok", mark)
|
||||
inner := sup.Command
|
||||
sup.Command = func(id string) (*exec.Cmd, error) {
|
||||
if err := f.store.Finish(t.Context(), testPolicy(), id, schedule.Outcome{State: schedule.RunInterrupted}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inner(id)
|
||||
}
|
||||
rep, err := sup.Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, schedule.ErrRunSettled) || rep.Code != CodeRunSettled {
|
||||
t.Fatalf("Run = %+v, %v; want ErrRunSettled", rep, err)
|
||||
}
|
||||
f.waitFree(t)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if _, statErr := os.Stat(mark); statErr == nil {
|
||||
t.Fatal("the child worked after its run was settled")
|
||||
}
|
||||
if got := f.settled(t); got.State != schedule.RunInterrupted {
|
||||
t.Fatalf("state = %s, want the reaper's verdict to stand", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoSupervisorsOnOneClaimRunItOnce(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
mark := filepath.Join(t.TempDir(), "mark")
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
for i := range errs {
|
||||
wg.Go(func() {
|
||||
sup := f.supervisor("slow-ok", mark)
|
||||
sup.WallGrace = 30 * time.Second
|
||||
_, errs[i] = sup.Run(f.ctx(t), f.run.TriggerID)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
ok, settled := 0, 0
|
||||
for _, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
ok++
|
||||
case errors.Is(err, schedule.ErrRunSettled), errors.Is(err, schedule.ErrRunHeld):
|
||||
settled++
|
||||
default:
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
if ok != 1 || settled != 1 {
|
||||
t.Fatalf("ok=%d settled=%d, want exactly one execution", ok, settled)
|
||||
}
|
||||
data, _ := os.ReadFile(mark)
|
||||
if n := strings.Count(string(data), "ran"); n != 1 {
|
||||
t.Fatalf("the run executed %d times:\n%s", n, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildLeavesWhenTheSupervisorCrashesAndTheRunIsReaped(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := exec.Command(exe)
|
||||
parent.Env = append(os.Environ(), fakeEnv+"=parent", "SCHEDRUN_STORE="+f.dir, "SCHEDRUN_ID="+f.run.TriggerID, "SCHEDRUN_CHILD_MODE=hold")
|
||||
if err := parent.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = parent.Process.Kill(); _ = parent.Wait() })
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for !f.store.RunHeld(f.run) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the child never took its lease")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err := parent.Process.Kill(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = parent.Wait()
|
||||
f.waitFree(t)
|
||||
|
||||
f.clk.Advance(schedule.ReapGrace + time.Second)
|
||||
if reaped, err := f.store.ReapDead(t.Context(), testPolicy()); err != nil || len(reaped) != 1 {
|
||||
t.Fatalf("ReapDead = %v, %v", reaped, err)
|
||||
}
|
||||
got := f.settled(t)
|
||||
if got.State != schedule.RunInterrupted || got.Charged != got.PerRunCap {
|
||||
t.Fatalf("settled = %s charged %d, want interrupted at the whole cap %d", got.State, got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillingTheRunEndsItsWholeProcessTree(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
lock := filepath.Join(t.TempDir(), "grand.lock")
|
||||
sawHeld := make(chan bool, 1)
|
||||
go func() {
|
||||
for end := time.Now().Add(1200 * time.Millisecond); time.Now().Before(end); time.Sleep(20 * time.Millisecond) {
|
||||
if release, err := filelock.TryAcquire(lock); err != nil {
|
||||
sawHeld <- true
|
||||
return
|
||||
} else {
|
||||
release()
|
||||
}
|
||||
}
|
||||
sawHeld <- false
|
||||
}()
|
||||
rep, err := f.supervisor("tree", "", "SCHEDRUN_GRAND_LOCK="+lock).Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, ErrWallLimit) {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
if !<-sawHeld {
|
||||
t.Fatal("the grandchild never took its lock, so the test proves nothing")
|
||||
}
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for {
|
||||
release, err := filelock.TryAcquire(lock)
|
||||
if err == nil {
|
||||
release()
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a grandchild survived the kill of the run")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStreamThatDisagreesWithTheChildIsChargedTheWholeCap(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rep, err := f.supervisor("lie", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if err != nil || rep.Clean {
|
||||
t.Fatalf("Run = %+v, %v; a usage count that does not match must not read as clean", rep, err)
|
||||
}
|
||||
if got := f.settled(t); got.Charged != got.PerRunCap {
|
||||
t.Fatalf("charged %d, want the whole cap %d", got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunWithUnreportedUsageIsChargedTheWholeCap(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rep, err := f.supervisor("unmetered", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if err != nil || rep.Clean || rep.Code != CodeUnmetered {
|
||||
t.Fatalf("Run = %+v, %v", rep, err)
|
||||
}
|
||||
if got := f.settled(t); got.Charged != got.PerRunCap {
|
||||
t.Fatalf("charged %d, want the whole cap %d", got.Charged, got.PerRunCap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChildRefusedTheLeaseLeavesTheRunUnsettled(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
foreign, err := f.store.HoldRun(f.run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer foreign()
|
||||
mark := filepath.Join(t.TempDir(), "mark")
|
||||
_, err = f.supervisor("ok", mark).Run(f.ctx(t), f.run.TriggerID)
|
||||
if !errors.Is(err, ErrExecutorRefused) {
|
||||
t.Fatalf("Run = %v, want ErrExecutorRefused", err)
|
||||
}
|
||||
if got := f.settled(t); got.State != schedule.RunClaimed {
|
||||
t.Fatalf("state = %s: a supervisor whose child never held the run must not release or settle it", got.State)
|
||||
}
|
||||
if _, statErr := os.Stat(mark); statErr == nil {
|
||||
t.Fatal("work ran without the lease")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASupervisedRunIsNeverReapedBeforeItIsSettled(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.clk.Advance(schedule.ReapGrace + time.Minute)
|
||||
sup := f.supervisor("ok", "")
|
||||
inner := sup.Command
|
||||
sup.Command = func(id string) (*exec.Cmd, error) {
|
||||
if reaped, err := f.store.ReapDead(t.Context(), testPolicy()); err != nil || len(reaped) != 0 {
|
||||
t.Errorf("a run under supervision was reaped: %v %v", reaped, err)
|
||||
}
|
||||
return inner(id)
|
||||
}
|
||||
if _, err := sup.Run(f.ctx(t), f.run.TriggerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.settled(t); got.State != schedule.RunSucceeded {
|
||||
t.Fatalf("state = %s", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStderrTailIsSanitized(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rep, _ := f.supervisor("noisy", "").Run(f.ctx(t), f.run.TriggerID)
|
||||
if !strings.Contains(rep.StderrTail, "boom") || strings.ContainsAny(rep.StderrTail, "\x1b\u202e\x00") {
|
||||
t.Fatalf("stderr tail = %q", rep.StderrTail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package schedrun
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GoLine is what the supervisor writes once the run is recorded as running.
|
||||
const GoLine = "go"
|
||||
|
||||
// StartTimeout is how long a child waits to be released before it gives up.
|
||||
const StartTimeout = time.Minute
|
||||
|
||||
// SupervisorPipe reports whether f is the pipe a supervisor keeps open for the
|
||||
// child. A terminal or a file is not one: it would either never end or end at
|
||||
// once, and neither says anything about a supervisor. A socket counts, as it
|
||||
// does for the Studio host's own watchdog.
|
||||
func SupervisorPipe(f *os.File) bool {
|
||||
st, err := f.Stat()
|
||||
return err == nil && st.Mode()&(os.ModeNamedPipe|os.ModeSocket) != 0
|
||||
}
|
||||
|
||||
// ForceExitGrace is how long a child that has lost its supervisor gets to land
|
||||
// before the process ends anyway, so a tool that ignores cancellation cannot
|
||||
// keep an orphan alive.
|
||||
const ForceExitGrace = 30 * time.Second
|
||||
|
||||
// AwaitGo blocks until the supervisor releases the run with "go <token>" and
|
||||
// returns the token. It also returns a channel that closes when the supervisor's
|
||||
// end of the stream does, whether the supervisor exited or crashed; nothing else
|
||||
// says that on every platform. The child does no work before AwaitGo returns, so
|
||||
// a run the store already settled spends nothing.
|
||||
func AwaitGo(stdin io.Reader, timeout time.Duration) (token string, gone <-chan struct{}, err error) {
|
||||
br := bufio.NewReader(stdin)
|
||||
line := make(chan string, 1)
|
||||
ended := make(chan struct{})
|
||||
go func() {
|
||||
defer close(ended)
|
||||
s, rerr := br.ReadString('\n')
|
||||
if rerr != nil {
|
||||
line <- ""
|
||||
return
|
||||
}
|
||||
line <- strings.TrimSpace(s)
|
||||
_, _ = io.Copy(io.Discard, br)
|
||||
}()
|
||||
select {
|
||||
case s := <-line:
|
||||
verb, tok, ok := strings.Cut(s, " ")
|
||||
if !ok || verb != GoLine || tok == "" {
|
||||
return "", nil, ErrParentGone
|
||||
}
|
||||
return tok, ended, nil
|
||||
case <-time.After(timeout):
|
||||
return "", nil, ErrStartTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// ExitWhenGone runs exit(code) once grace has passed after gone closes.
|
||||
func ExitWhenGone(gone <-chan struct{}, grace time.Duration, code int, exit func(int)) {
|
||||
go func() {
|
||||
<-gone
|
||||
time.Sleep(grace)
|
||||
exit(code)
|
||||
}()
|
||||
}
|
||||
@@ -13,12 +13,15 @@ const (
|
||||
RefusalReadOnly = "permission.read_only"
|
||||
RefusalDeclined = "permission.declined"
|
||||
RefusalUnattended = "permission.unattended"
|
||||
// RefusalParked is a call that needed a person, was recorded for one, and
|
||||
// did not run. Unlike RefusalUnattended it names a record the model can cite.
|
||||
RefusalParked = "permission.parked"
|
||||
)
|
||||
|
||||
// IsRefusalCode reports whether code is one a permission gate produces.
|
||||
func IsRefusalCode(code string) bool {
|
||||
switch code {
|
||||
case RefusalDenyRule, RefusalReadOnly, RefusalDeclined, RefusalUnattended:
|
||||
case RefusalDenyRule, RefusalReadOnly, RefusalDeclined, RefusalUnattended, RefusalParked:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -104,6 +104,9 @@ func seatbeltProfile(spec Spec) string {
|
||||
// rule; (allow default) above keeps reads working everywhere else.
|
||||
for _, p := range forbidReadDirs(spec.ForbidReadRoots) {
|
||||
fmt.Fprintf(&b, "(deny file-read* (subpath %s))\n", sbplString(p))
|
||||
// bubblewrap masks these with an empty tmpfs, which takes writes away
|
||||
// too; without this rule a blind overwrite of an unreadable file works here.
|
||||
fmt.Fprintf(&b, "(deny file-write* (subpath %s))\n", sbplString(p))
|
||||
}
|
||||
// After every write allowance: SBPL takes the final match.
|
||||
writeGitMetadataRules(&b, gitMetadataForSpec(spec))
|
||||
|
||||
@@ -338,3 +338,40 @@ func TestAvailableFalseWhenSandboxExecMissing(t *testing.T) {
|
||||
t.Fatal("Available() = true, want false: sandbox-exec not on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
// A path that may not be read may not be overwritten blind either: the schema of
|
||||
// an unreadable file can be public, and inside a write root the write rule alone
|
||||
// would let it through.
|
||||
func TestSandboxForbiddenDirectoryTakesWritesToo(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("sandbox-exec not available")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skipf("no home dir: %v", err)
|
||||
}
|
||||
workRoot, err := os.MkdirTemp(home, ".reasonix-sbtest-forbid-*")
|
||||
if err != nil {
|
||||
t.Skipf("cannot create work dir under home: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(workRoot) })
|
||||
forbidden := filepath.Join(workRoot, "store")
|
||||
if err := os.MkdirAll(forbidden, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := Spec{Mode: "enforce", WriteRoots: []string{workRoot}, ForbidReadRoots: []string{forbidden}, Network: true}
|
||||
run := func(command string) error {
|
||||
argv, _ := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, command)
|
||||
return exec.Command(argv[0], argv[1:]...).Run()
|
||||
}
|
||||
planted := filepath.Join(forbidden, "schedules.json")
|
||||
if err := run("echo forged > " + planted); err == nil {
|
||||
t.Error("a write into a forbidden directory must be refused")
|
||||
}
|
||||
if _, err := os.Stat(planted); !os.IsNotExist(err) {
|
||||
t.Error("the forbidden directory gained a file")
|
||||
}
|
||||
if err := run("echo ok > " + filepath.Join(workRoot, "elsewhere.txt")); err != nil {
|
||||
t.Errorf("the rest of the write root must stay writable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package sandbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -127,3 +128,46 @@ func containsPath(paths []string, want string) bool {
|
||||
}
|
||||
return slices.Contains(paths, absWant)
|
||||
}
|
||||
|
||||
func TestBwrapMasksAForbiddenDirectoryInsideAWriteRootAfterTheBind(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
store := filepath.Join(root, "store")
|
||||
if err := os.Mkdir(store, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
argv := bwrapArgs(Spec{Mode: "enforce", WriteRoots: []string{root}, ForbidReadRoots: []string{store}}, Shell{Kind: ShellBash, Path: "bash"}, "true")
|
||||
bind, mask := indexArgs(argv, "--bind", root, root), indexArgs(argv, "--tmpfs", store)
|
||||
if bind < 0 || mask < 0 || mask < bind {
|
||||
t.Fatalf("the mask must come after the write bind that would expose the directory: %v", argv)
|
||||
}
|
||||
}
|
||||
|
||||
// A blind overwrite of a file in a forbidden directory must fail even though the
|
||||
// directory sits inside a writable root, and must not reach the host's copy.
|
||||
func TestBwrapForbiddenDirectoryDoesNotTakeWrites(t *testing.T) {
|
||||
if !Available() {
|
||||
t.Skip("bubblewrap is not usable here")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skipf("no home dir: %v", err)
|
||||
}
|
||||
root, err := os.MkdirTemp(home, ".reasonix-bwtest-*")
|
||||
if err != nil {
|
||||
t.Skipf("cannot create a work dir under home: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
store := filepath.Join(root, "store")
|
||||
if err := os.Mkdir(store, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := Spec{Mode: "enforce", WriteRoots: []string{root}, ForbidReadRoots: []string{store}, Network: true}
|
||||
argv, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, "echo forged > "+filepath.Join(store, "schedules.json"))
|
||||
if !wrapped {
|
||||
t.Skip("the command was not wrapped")
|
||||
}
|
||||
_ = exec.Command(argv[0], argv[1:]...).Run()
|
||||
if _, err := os.Stat(filepath.Join(store, "schedules.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("the write reached the host's forbidden directory")
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,9 @@ func (c *Controller) recordDecisionReceipt(pending pendingApproval, outcome stri
|
||||
// Interactive frontends (chat, desktop) call this; the headless run keeps the
|
||||
// silent gate and a nil asker from setup.
|
||||
func (c *Controller) EnableInteractiveApproval() {
|
||||
if c.observe != nil {
|
||||
return
|
||||
}
|
||||
escapeApprover := sandboxEscapeApprover{c}
|
||||
configApprover := managedConfigWriteApprover{c}
|
||||
if c.executor != nil {
|
||||
@@ -249,6 +252,9 @@ func (c *Controller) newHeadlessGate(mode string) *freshHumanHeadlessGate {
|
||||
// by the gate for every mode. The only exception is a controller-assessed,
|
||||
// create-only project/reference memory; every other memory write remains denied.
|
||||
func (c *Controller) ApplyHeadlessApprovalMode(mode string) {
|
||||
if c.observe != nil {
|
||||
return
|
||||
}
|
||||
mode = normalizeToolApprovalMode(mode)
|
||||
c.approval.setMode(mode)
|
||||
if c.subagentGate != nil {
|
||||
@@ -260,6 +266,9 @@ func (c *Controller) ApplyHeadlessApprovalMode(mode string) {
|
||||
}
|
||||
|
||||
func (c *Controller) refreshInteractiveGate() {
|
||||
if c.observe != nil {
|
||||
return
|
||||
}
|
||||
if c.executor != nil {
|
||||
c.executor.SetGate(c.newInteractiveGate())
|
||||
}
|
||||
@@ -450,6 +459,9 @@ func (c *Controller) SetToolApprovalMode(mode string) {
|
||||
// not cover — so a frontend must keep showing them instead of assuming the
|
||||
// posture switch resolved everything (#6432).
|
||||
func (c *Controller) ApplyToolApprovalMode(mode string) []string {
|
||||
if c.observe != nil {
|
||||
return nil
|
||||
}
|
||||
mode = normalizeToolApprovalMode(mode)
|
||||
drained, _ := c.switchApprovalMode(mode, func() ([]drainedApproval, bool) { return c.approval.setMode(mode), true })
|
||||
return drained
|
||||
|
||||
@@ -277,6 +277,7 @@ type externalFolderToolRefs interface {
|
||||
type Options struct {
|
||||
Runner agent.Runner
|
||||
Executor *agent.Agent
|
||||
Observe *ObserveRun
|
||||
Guardian *guardian.Session
|
||||
// RecoveryReviewer is the optional independent recovery reviewer (nil =
|
||||
// rule-only path with fail-closed human confirmation for ambiguous cases).
|
||||
@@ -457,7 +458,6 @@ func New(opts Options) *Controller {
|
||||
runtimeGeneration: opts.RuntimeGeneration,
|
||||
}
|
||||
c.adoptSessionResources(opts)
|
||||
|
||||
c.publishPerProjectContext(opts)
|
||||
if opts.Extensions != nil {
|
||||
c.extensions = opts.Extensions
|
||||
@@ -488,8 +488,8 @@ func New(opts Options) *Controller {
|
||||
// Auto Guard is built into Auto. Ask and YOLO bypass it through the mode
|
||||
// provider, so no separate enablement state is needed.
|
||||
c.initRecoveryGate(opts.RecoveryReviewer, opts.RecoveryHeadless)
|
||||
|
||||
c.observeJobs(opts.TaskStore)
|
||||
c.bindObserve()
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ type controllerDeps struct {
|
||||
// one — sub-agents then keep whatever gate they were constructed with.
|
||||
subagentGate *SharedHeadlessGate
|
||||
|
||||
// observe is non-nil for a read-only unattended run; see ObserveRun.
|
||||
observe *ObserveRun
|
||||
|
||||
label string
|
||||
modelRef string
|
||||
modelModes []config.ModelMode // what SetModelMode accepts; see model_modes.go
|
||||
@@ -134,6 +137,7 @@ func newControllerDeps(opts Options, sink event.Sink, usageTee *goalUsageTee, ru
|
||||
goalUsageTee: usageTee,
|
||||
sink: sink,
|
||||
policy: opts.Policy,
|
||||
observe: cloneObserveRun(opts.Observe),
|
||||
subagentGate: opts.SubagentGate,
|
||||
label: opts.Label,
|
||||
modelRef: opts.ModelRef,
|
||||
|
||||
@@ -266,6 +266,9 @@ func (c *Controller) ReplaceExtensions(d *dispatch.Dispatcher) {
|
||||
}
|
||||
|
||||
func (c *Controller) installExtensionsLocked(d *dispatch.Dispatcher) {
|
||||
if c.observeSealed() {
|
||||
return
|
||||
}
|
||||
c.extensions = d
|
||||
// Keep the inbox observer as the outermost sink so Steer/unapplied events
|
||||
// always update durable state, while still installing/updating the
|
||||
|
||||
@@ -31,8 +31,14 @@ type mcpManager struct {
|
||||
// defaultCallTimeout is what a spec gets when it declares none. Read once
|
||||
// while building a spec, so it needs no lock.
|
||||
defaultCallTimeout time.Duration
|
||||
// sealed, once set, refuses every connection. It is written before the
|
||||
// controller is handed to a caller and only read after.
|
||||
sealed error
|
||||
}
|
||||
|
||||
// seal makes the manager refuse to connect or register any server.
|
||||
func (m *mcpManager) seal(reason error) { m.sealed = reason }
|
||||
|
||||
func newMcpManager(host *plugin.Host, reg *tool.Registry, pluginCtx context.Context, defaultCallTimeout time.Duration) mcpManager {
|
||||
return mcpManager{host: host, reg: reg, pluginCtx: pluginCtx, defaultCallTimeout: defaultCallTimeout}
|
||||
}
|
||||
@@ -49,6 +55,9 @@ func (m *mcpManager) hostRef() *plugin.Host {
|
||||
// registers its tools, replacing any prior tools under the same prefix. Returns
|
||||
// the tool count. The host's network/subprocess I/O runs off mu.
|
||||
func (m *mcpManager) connectSpec(s plugin.Spec) (int, error) {
|
||||
if m.sealed != nil {
|
||||
return 0, m.sealed
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.host == nil {
|
||||
m.host = plugin.NewHost()
|
||||
@@ -83,6 +92,9 @@ func (m *mcpManager) connectSpec(s plugin.Spec) (int, error) {
|
||||
// is reused immediately; otherwise cached lazy tools (or one connect stub on a
|
||||
// cache miss) start the server only when the model makes the first real call.
|
||||
func (m *mcpManager) registerSpecOnDemand(s plugin.Spec) (int, error) {
|
||||
if m.sealed != nil {
|
||||
return 0, m.sealed
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.host == nil {
|
||||
m.host = plugin.NewHost()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/safety/permission"
|
||||
)
|
||||
|
||||
// ErrObservePosture is what a controller built under the read-only posture
|
||||
// answers to an operation that would widen it: a live MCP connection, or a
|
||||
// gate or asker that a person could answer through.
|
||||
var ErrObservePosture = errors.New("control: not available under the read-only posture")
|
||||
|
||||
// ObserveRun makes a Controller a read-only, unattended run. It is set once at
|
||||
// construction by the assembly and cannot be changed or lifted afterwards; no
|
||||
// flag or configuration value reaches it.
|
||||
type ObserveRun struct {
|
||||
Posture observe.Posture
|
||||
Pending observe.PendingSink
|
||||
Context observe.RunContext
|
||||
// stopped is set once the run parked more than it may; see ObserveStopped.
|
||||
stopped *atomic.Bool
|
||||
// sealed is set once assembly has installed the tools and extensions.
|
||||
sealed *atomic.Bool
|
||||
}
|
||||
|
||||
// ObserveStopped reports the identity of what ended the run: observe.ErrParkLimit
|
||||
// once it asked for a person too many times, nil otherwise.
|
||||
func (c *Controller) ObserveStopped() error {
|
||||
if c == nil || c.observe == nil || !c.observe.stopped.Load() {
|
||||
return nil
|
||||
}
|
||||
return observe.ErrParkLimit
|
||||
}
|
||||
|
||||
// ObservePosture reports the posture this controller runs under. ok is false
|
||||
// for an ordinary controller.
|
||||
func (c *Controller) ObservePosture() (observe.Posture, bool) {
|
||||
if c == nil || c.observe == nil {
|
||||
return observe.Posture{}, false
|
||||
}
|
||||
return c.observe.Posture, true
|
||||
}
|
||||
|
||||
func cloneObserveRun(o *ObserveRun) *ObserveRun {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *o
|
||||
cp.stopped, cp.sealed = new(atomic.Bool), new(atomic.Bool)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (c *Controller) scheduledRunBlock() string {
|
||||
if c.observe == nil {
|
||||
return ""
|
||||
}
|
||||
return wrapTurnBlock("scheduled-run", c.observe.Context.Block(c.observe.Posture))
|
||||
}
|
||||
|
||||
// bindObserve installs the parking gate and asker on the executor and seals
|
||||
// the paths a frontend could use to replace them.
|
||||
func (c *Controller) bindObserve() {
|
||||
if c.observe == nil {
|
||||
return
|
||||
}
|
||||
c.mcp.seal(ErrObservePosture)
|
||||
c.approval.setMode(ToolApprovalReadOnly)
|
||||
if c.executor == nil {
|
||||
return
|
||||
}
|
||||
stop := func() {
|
||||
c.observe.stopped.Store(true)
|
||||
c.Cancel()
|
||||
}
|
||||
c.executor.SetGate(newObserveGate(c.policy, c.observe.Pending, stop))
|
||||
c.executor.SetAsker(parkingAsker{sink: c.observe.Pending, stop: stop})
|
||||
c.executor.LockPosture()
|
||||
}
|
||||
|
||||
// observeGate answers every call for a run nobody watches. Whatever the inner
|
||||
// gate would have put to a person is recorded for one and refused; nothing is
|
||||
// ever approved here, by a mode, a rule or a session grant.
|
||||
type observeGate struct {
|
||||
inner *permission.Gate
|
||||
sink observe.PendingSink
|
||||
stop func()
|
||||
}
|
||||
|
||||
func newObserveGate(policy permission.Policy, sink observe.PendingSink, stop func()) *observeGate {
|
||||
policy.Mode, policy.ReadOnly, policy.SessionAllow = permission.Deny, true, nil
|
||||
return &observeGate{inner: permission.NewGate(policy, denyPermissionApprover{}), sink: sink, stop: stop}
|
||||
}
|
||||
|
||||
func (g *observeGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
||||
v, err := g.Verdict(ctx, toolName, args, readOnly)
|
||||
return v.Allow, v.Reason, err
|
||||
}
|
||||
|
||||
func (g *observeGate) Verdict(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (permission.Verdict, error) {
|
||||
if RequiresFreshHumanApprovalTool(toolName) {
|
||||
return g.park(toolName, args, observe.RiskHigh), nil
|
||||
}
|
||||
v, err := g.inner.Verdict(ctx, toolName, args, readOnly)
|
||||
if err != nil || v.Allow || v.Code != permission.RefusalUnattended {
|
||||
return v, err
|
||||
}
|
||||
risk := observe.RiskMedium
|
||||
if readOnly {
|
||||
risk = observe.RiskLow
|
||||
}
|
||||
return g.park(toolName, args, risk), nil
|
||||
}
|
||||
|
||||
func (g *observeGate) DeniesWriters() bool { return true }
|
||||
|
||||
func (g *observeGate) ExplicitlyDenies(toolName string, args json.RawMessage) bool {
|
||||
return g.inner.Policy.ExplicitlyDenies(toolName, args)
|
||||
}
|
||||
|
||||
func (g *observeGate) park(toolName string, args json.RawMessage, risk observe.Risk) permission.Verdict {
|
||||
stored, err := g.sink.Park(observe.Pending{
|
||||
Kind: observe.KindApproval,
|
||||
Source: toolName,
|
||||
Summary: observe.Sanitize("The run asked to use " + toolName + "."),
|
||||
Detail: observe.Sanitize(clipUTF8(strings.TrimSpace(toolName+" "+permission.Subject(args)), 400)),
|
||||
Untrusted: true,
|
||||
Risk: risk,
|
||||
Digest: observe.DigestOf(observe.KindApproval, toolName, string(args)),
|
||||
})
|
||||
if errors.Is(err, observe.ErrParkLimit) && g.stop != nil {
|
||||
g.stop()
|
||||
}
|
||||
if err != nil {
|
||||
return permission.Verdict{Reason: "this call needs a person and this run is unattended; recording it for one failed, so it was refused and did not run. Do the part that needs no approval, then conclude and name what was refused.", Code: permission.RefusalUnattended}
|
||||
}
|
||||
return permission.Verdict{
|
||||
Reason: fmt.Sprintf("this call needs a person and this run is unattended, so it was parked as pending decision %s and did not run. Nobody declined it, and retrying or rewriting it cannot change that. Do the part that needs no approval, then conclude and name what is parked.", stored.ID),
|
||||
Code: permission.RefusalParked,
|
||||
}
|
||||
}
|
||||
|
||||
// parkingAsker records a question for a person and reports it unanswered. It
|
||||
// never returns an answer: an empty selection would read to the model as the
|
||||
// user declining, and a chosen option as the user deciding.
|
||||
type parkingAsker struct {
|
||||
sink observe.PendingSink
|
||||
stop func()
|
||||
}
|
||||
|
||||
func (a parkingAsker) Ask(_ context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
|
||||
stored, err := a.sink.Park(observe.Pending{
|
||||
Kind: observe.KindAsk,
|
||||
Source: "ask",
|
||||
Summary: "The run asked a question that only a person can answer.",
|
||||
Detail: observe.Sanitize(clipUTF8(renderQuestions(questions), 4000)),
|
||||
Untrusted: true,
|
||||
Risk: observe.RiskLow,
|
||||
Digest: observe.DigestOf(observe.KindAsk, "ask", renderQuestions(questions)),
|
||||
})
|
||||
if errors.Is(err, observe.ErrParkLimit) && a.stop != nil {
|
||||
a.stop()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.New("this run is unattended and the question could not be recorded for a person, so it is unanswered; do not choose for them. Do the part the task allows without it, then conclude and name the decision that is missing")
|
||||
}
|
||||
return nil, fmt.Errorf("%w as pending decision %s: this run is unattended and the answer is the user's to give, so nothing here can supply it, including you. Do not choose on their behalf. Do whatever the task allows without it, then conclude and name the decision that is parked", observe.ErrParked, stored.ID)
|
||||
}
|
||||
|
||||
func renderQuestions(questions []event.AskQuestion) string {
|
||||
var b strings.Builder
|
||||
for i, q := range questions {
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if h := strings.TrimSpace(q.Header); h != "" {
|
||||
b.WriteString(h + ": ")
|
||||
}
|
||||
b.WriteString(strings.TrimSpace(q.Prompt))
|
||||
for _, o := range q.Options {
|
||||
b.WriteString("\n- " + strings.TrimSpace(o.Label))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SealObserveSurface is called by assembly after it has installed the tool
|
||||
// registry and the extension dispatcher. From then on neither the agent nor the
|
||||
// controller replaces them. It does nothing for an ordinary controller.
|
||||
func (c *Controller) SealObserveSurface() {
|
||||
if c == nil || c.observe == nil {
|
||||
return
|
||||
}
|
||||
c.observe.sealed.Store(true)
|
||||
if c.executor != nil {
|
||||
c.executor.LockSurface()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) observeSealed() bool {
|
||||
return c.observe != nil && c.observe.sealed.Load()
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/observe"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/safety/permission"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// namedReader is a read-only tool under a chosen name that records its runs.
|
||||
type namedReader struct {
|
||||
name string
|
||||
runs *int
|
||||
}
|
||||
|
||||
func (r namedReader) Name() string { return r.name }
|
||||
func (r namedReader) Description() string { return "reads" }
|
||||
func (r namedReader) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
|
||||
}
|
||||
func (r namedReader) ReadOnly() bool { return true }
|
||||
func (r namedReader) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
*r.runs++
|
||||
return "CONTENTS", nil
|
||||
}
|
||||
|
||||
// requestLog is a scripted provider that also keeps what it was sent.
|
||||
type requestLog struct {
|
||||
scriptedTurns
|
||||
reqs []provider.Request
|
||||
}
|
||||
|
||||
func (l *requestLog) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
l.reqs = append(l.reqs, req)
|
||||
return l.scriptedTurns.Stream(ctx, req)
|
||||
}
|
||||
|
||||
func (l *requestLog) toolResult(id string) string {
|
||||
for _, req := range l.reqs {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleTool && m.ToolCallID == id {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// observeController wires the controller the way boot does, around a registry
|
||||
// deliberately wider than the ceiling: the gate and the asker must hold when
|
||||
// the registry does not.
|
||||
func observeController(t *testing.T, policy permission.Policy, turns [][]provider.Chunk) (*Controller, *observe.Ledger, *recordingWriter, *int, *requestLog) {
|
||||
t.Helper()
|
||||
writer := &recordingWriter{}
|
||||
runs := 0
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(writer)
|
||||
reg.Add(namedReader{"read_file", &runs})
|
||||
reg.Add(namedReader{"remember", &runs})
|
||||
reg.Add(agent.NewAskTool())
|
||||
prov := &requestLog{scriptedTurns: scriptedTurns{turns: turns}}
|
||||
ag := agent.New(prov, reg, sessionstore.NewSession(""), agent.Options{}, event.Discard)
|
||||
ledger := observe.NewLedger(nil)
|
||||
c := New(Options{
|
||||
Runner: ag, Executor: ag, Policy: policy, Sink: event.Discard,
|
||||
Observe: &ObserveRun{Posture: observe.New(false), Pending: ledger, Context: observe.RunContext{ScheduleID: "s", TriggerID: "t"}},
|
||||
})
|
||||
t.Cleanup(c.Close)
|
||||
return c, ledger, writer, &runs, prov
|
||||
}
|
||||
|
||||
func TestObserveGateParksWhatNeedsAPersonAndNeverRunsIt(t *testing.T) {
|
||||
policy := permission.New("allow", []string{"write_file", "remember"}, []string{"read_file(secret.txt)"}, nil)
|
||||
c, ledger, writer, runs, _ := observeController(t, policy, [][]provider.Chunk{
|
||||
toolCallTurn("c-read", "read_file", `{"path":"secret.txt"}`),
|
||||
toolCallTurn("c-remember", "remember", `{"note":"x"}`),
|
||||
toolCallTurn("c-write", "write_file", `{"path":"a.txt"}`),
|
||||
textTurn("done"),
|
||||
})
|
||||
c.EnableInteractiveApproval()
|
||||
c.SetToolApprovalMode(ToolApprovalYolo)
|
||||
c.ApplyHeadlessApprovalMode(ToolApprovalYolo)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Run(ctx, "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if *runs != 0 || len(writer.paths) != 0 {
|
||||
t.Fatalf("a refused call ran: reader/remember runs=%d writes=%v", *runs, writer.paths)
|
||||
}
|
||||
parked := ledger.List()
|
||||
if len(parked) != 2 {
|
||||
t.Fatalf("parked = %+v, want the ask-rule read and the fresh-human tool", parked)
|
||||
}
|
||||
if parked[0].Source != "read_file" || parked[0].Risk != observe.RiskLow || parked[1].Source != "remember" || parked[1].Risk != observe.RiskHigh {
|
||||
t.Fatalf("records = %+v", parked)
|
||||
}
|
||||
if mode := c.ToolApprovalMode(); mode != ToolApprovalReadOnly {
|
||||
t.Fatalf("approval mode = %q after a frontend tried to change it", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveAskIsNeverAnswered(t *testing.T) {
|
||||
askArgs := `{"questions":[{"header":"H","question":"Which?","options":[{"label":"A"},{"label":"B"}]}]}`
|
||||
c, ledger, _, _, prov := observeController(t, permission.New("allow", nil, nil, nil), [][]provider.Chunk{
|
||||
toolCallTurn("c-ask", "ask", askArgs),
|
||||
textTurn("done"),
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Run(ctx, "decide"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := ledger.List(); len(got) != 1 || got[0].Kind != observe.KindAsk || !got[0].Untrusted {
|
||||
t.Fatalf("parked = %+v", got)
|
||||
}
|
||||
got := prov.toolResult("c-ask")
|
||||
if !strings.Contains(got, "pending decision p1") || strings.Contains(got, "answered") || strings.Contains(got, "dismissed") {
|
||||
t.Fatalf("the model was told %q, want the question parked and unanswered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParkingAskerReportsParkedByIdentity(t *testing.T) {
|
||||
ledger := observe.NewLedger(nil)
|
||||
answers, err := parkingAsker{sink: ledger}.Ask(context.Background(), []event.AskQuestion{{Header: "H", Prompt: "Which?", Options: []event.AskOption{{Label: "A"}, {Label: "B"}}}})
|
||||
if answers != nil || !errors.Is(err, observe.ErrParked) || !strings.Contains(err.Error(), "p1") {
|
||||
t.Fatalf("Ask = %v, %v, want no answers and ErrParked naming p1", answers, err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingSink struct{}
|
||||
|
||||
func (failingSink) Park(observe.Pending) (observe.Pending, error) {
|
||||
return observe.Pending{}, errors.New("disk full")
|
||||
}
|
||||
|
||||
func TestUnrecordedRequestsAreStillRefused(t *testing.T) {
|
||||
answers, err := parkingAsker{sink: failingSink{}}.Ask(context.Background(), []event.AskQuestion{{Prompt: "Which?"}})
|
||||
if answers != nil || err == nil || errors.Is(err, observe.ErrParked) {
|
||||
t.Fatalf("Ask = %v, %v, want an unanswered error that does not claim to be parked", answers, err)
|
||||
}
|
||||
gate := newObserveGate(permission.New("ask", nil, []string{"read_file"}, nil), failingSink{}, nil)
|
||||
v, verr := gate.Verdict(context.Background(), "read_file", json.RawMessage(`{"path":"a"}`), true)
|
||||
if verr != nil || v.Allow || v.Code != permission.RefusalUnattended {
|
||||
t.Fatalf("verdict = %+v, %v", v, verr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveGateIgnoresSessionAllowAndModes(t *testing.T) {
|
||||
policy := permission.New("allow", []string{"read_file", "write_file"}, []string{"read_file"}, nil).WithSessionAllow([]string{"read_file"})
|
||||
gate := newObserveGate(policy, observe.NewLedger(nil), nil)
|
||||
if v, _ := gate.Verdict(context.Background(), "read_file", json.RawMessage(`{"path":"a"}`), true); v.Allow {
|
||||
t.Fatalf("a session allow answered an ask rule: %+v", v)
|
||||
}
|
||||
if v, _ := gate.Verdict(context.Background(), "write_file", json.RawMessage(`{"path":"a"}`), false); v.Allow || v.Code != permission.RefusalReadOnly {
|
||||
t.Fatalf("a writer under a blanket allow: %+v", v)
|
||||
}
|
||||
if !gate.DeniesWriters() {
|
||||
t.Fatal("the observe gate must report that it denies writers")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink keeps what a store would be handed.
|
||||
type captureSink struct{ got []observe.Pending }
|
||||
|
||||
func (c *captureSink) Park(p observe.Pending) (observe.Pending, error) {
|
||||
c.got = append(c.got, p)
|
||||
p.ID = "p1"
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func TestParkedTextIsCleanedBeforeItReachesAnyStore(t *testing.T) {
|
||||
sink := &captureSink{}
|
||||
dirty := "x\u202ey\u200bz\x00\U000e0041"
|
||||
_, _ = parkingAsker{sink: sink}.Ask(context.Background(), []event.AskQuestion{{Header: "H", Prompt: "Which" + dirty, Options: []event.AskOption{{Label: "A" + dirty}, {Label: "B"}}}})
|
||||
gate := newObserveGate(permission.New("ask", nil, []string{"read_file"}, nil), sink, nil)
|
||||
_, _ = gate.Verdict(context.Background(), "read_file", json.RawMessage(`{"path":"a`+strings.ReplaceAll(dirty, "\x00", "")+`"}`), true)
|
||||
if len(sink.got) != 2 {
|
||||
t.Fatalf("stored %d records", len(sink.got))
|
||||
}
|
||||
for _, p := range sink.got {
|
||||
for _, r := range p.Summary + p.Detail {
|
||||
if r == 0x202e || r == 0x200b || r == 0 || r >= 0xe0000 {
|
||||
t.Fatalf("a hidden character reached the store: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ func (c *Controller) turnBlocksFor(source string, includeOwed bool, notes []stri
|
||||
)
|
||||
}
|
||||
return append(blocks,
|
||||
turnBlock{"scheduled-run", c.scheduledRunBlock()},
|
||||
turnBlock{"background-jobs", c.backgroundJobsBlock()},
|
||||
turnBlock{"memory-update", memoryUpdateBlock(notes)},
|
||||
turnBlock{"reasoning-language", langpref.ReasoningLanguageBlock(langpref.ResolveReasoningLanguage(reasoningLanguage, source))},
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestTurnBlockOrderIsDeclared(t *testing.T) {
|
||||
"hook-context",
|
||||
"available-skills",
|
||||
"project-instructions",
|
||||
"scheduled-run",
|
||||
"background-jobs",
|
||||
"memory-update",
|
||||
"reasoning-language",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
Day = 24 * time.Hour
|
||||
Week = 7 * Day
|
||||
)
|
||||
|
||||
// Outcome is what a supervisor reports when a run ends. Clean means the usage
|
||||
// stream ran to the end, so Observed is the whole extent; anything else (a
|
||||
// kill, a crash, a cut stream) leaves the extent unknown.
|
||||
type Outcome struct {
|
||||
State RunState
|
||||
Observed int64
|
||||
Clean bool
|
||||
}
|
||||
|
||||
// ChargeFor is the tokens a settled run counts against every window. An
|
||||
// unknown extent is charged at the full per-run cap. Observed usage above the
|
||||
// cap is charged as observed, so the window never reads lower than what was seen.
|
||||
func ChargeFor(perRunCap int64, o Outcome) int64 {
|
||||
if !o.Clean || o.State == RunInterrupted {
|
||||
return max(perRunCap, o.Observed)
|
||||
}
|
||||
return max(o.Observed, 0)
|
||||
}
|
||||
|
||||
type usage struct {
|
||||
runs int64
|
||||
tokens int64
|
||||
}
|
||||
|
||||
func windowUsage(runs []Run, now time.Time, span time.Duration, scheduleID string) usage {
|
||||
var u usage
|
||||
from := now.Add(-span)
|
||||
for _, r := range runs {
|
||||
if r.State == RunSkipped || r.ClaimedAt.Before(from) {
|
||||
continue
|
||||
}
|
||||
if scheduleID != "" && r.ScheduleID != scheduleID {
|
||||
continue
|
||||
}
|
||||
u.runs++
|
||||
u.tokens += r.Charged
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// Admit reserves the schedule's full per-run cap and checks that every window
|
||||
// still fits with it counted. The same rule serves the ticker and a manual
|
||||
// tick, so neither can start what the other would refuse.
|
||||
func Admit(p Policy, m Manifest, s Schedule, now time.Time) error {
|
||||
if m.PausedAll {
|
||||
return fmt.Errorf("%w (%s)", ErrPausedAll, m.PausedReason)
|
||||
}
|
||||
switch s.Status {
|
||||
case StatusActive:
|
||||
case StatusExpired:
|
||||
return ErrExpired
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", ErrNotActive, s.Status)
|
||||
}
|
||||
if !now.Before(s.ExpiresAt) {
|
||||
return ErrExpired
|
||||
}
|
||||
if err := p.fits(s.Budget); err != nil {
|
||||
return fmt.Errorf("%w: %w: %w", ErrBudget, ErrPolicyTightened, err)
|
||||
}
|
||||
if s.Trigger.Kind == TriggerEvery && time.Duration(s.Trigger.EverySec)*time.Second < p.MinInterval() {
|
||||
return fmt.Errorf("%w: %w: %ds < %s", ErrIntervalBelowFloor, ErrPolicyTightened, s.Trigger.EverySec, p.MinInterval())
|
||||
}
|
||||
for _, r := range m.Runs {
|
||||
if r.inFlight() {
|
||||
return ErrConcurrency
|
||||
}
|
||||
}
|
||||
reserve := s.Budget.PerRunTokens
|
||||
own := windowUsage(m.Runs, now, Day, s.ID)
|
||||
all := windowUsage(m.Runs, now, Day, "")
|
||||
week := windowUsage(m.Runs, now, Week, "")
|
||||
switch {
|
||||
case own.runs+1 > s.Budget.MaxRunsPerDay:
|
||||
return fmt.Errorf("%w: schedule run count for 24h", ErrBudget)
|
||||
case all.runs+1 > p.MaxRunsGlobalDay:
|
||||
return fmt.Errorf("%w: global run count for 24h", ErrBudget)
|
||||
case all.tokens+reserve > p.GlobalTokensDay:
|
||||
return fmt.Errorf("%w: global tokens for 24h", ErrBudget)
|
||||
case week.tokens+reserve > p.GlobalTokensWeek:
|
||||
return fmt.Errorf("%w: global tokens for 7d", ErrBudget)
|
||||
case s.SpentLifetimeTokens+reserve > s.Budget.LifetimeTokens:
|
||||
return fmt.Errorf("%w: schedule lifetime tokens", ErrBudget)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChargeForIsPessimistic(t *testing.T) {
|
||||
const cap = 250_000
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
o Outcome
|
||||
want int64
|
||||
}{
|
||||
{"clean uses observed", Outcome{State: RunSucceeded, Observed: 10_000, Clean: true}, 10_000},
|
||||
{"killed charges cap", Outcome{State: RunBudgetStopped, Observed: 10_000}, cap},
|
||||
{"crash charges cap", Outcome{State: RunInterrupted}, cap},
|
||||
{"cut stream charges cap", Outcome{State: RunFailed, Observed: 1, Clean: false}, cap},
|
||||
{"observed above cap is not hidden", Outcome{State: RunBudgetStopped, Observed: cap + 5, Clean: true}, cap + 5},
|
||||
{"observed above cap when killed", Outcome{State: RunBudgetStopped, Observed: cap * 2}, cap * 2},
|
||||
} {
|
||||
if got := ChargeFor(cap, tc.o); got != tc.want {
|
||||
t.Errorf("%s: got %d want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func claimAndFinish(t *testing.T, st *Store, p Policy, sc Schedule, n int64, o Outcome) error {
|
||||
t.Helper()
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, n)); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.Finish(t.Context(), p, TriggerID(sc.ID, slotN(sc, n)), o)
|
||||
}
|
||||
|
||||
func TestGlobalDayWindowReservesFullCap(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
p.GlobalTokensDay = 2*p.PerRunTokens + 1
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clean := Outcome{State: RunSucceeded, Observed: 1, Clean: true}
|
||||
for n := int64(1); n <= 2; n++ {
|
||||
clk.Advance(time.Hour)
|
||||
if err := claimAndFinish(t, st, p, sc, n, clean); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); err != nil {
|
||||
t.Fatalf("2 tiny runs settled at 1 token leave room for a full reservation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKilledRunsExhaustTheDailyWindow(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
killed := Outcome{State: RunBudgetStopped, Observed: 5}
|
||||
var refused error
|
||||
for n := int64(1); n <= 6 && refused == nil; n++ {
|
||||
clk.Advance(time.Hour)
|
||||
refused = claimAndFinish(t, st, p, sc, n, killed)
|
||||
}
|
||||
if !errors.Is(refused, ErrBudget) && !errors.Is(refused, ErrNotActive) {
|
||||
t.Fatalf("killed runs charged at the full cap must stop the schedule, got %v", refused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalWindowsRollAndCapTokens(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
p.MaxConsecutiveFails = 100
|
||||
p.GlobalTokensDay = 2 * p.PerRunTokens
|
||||
p.MaxRunsPerScheduleDay = 100
|
||||
p.MaxRunsGlobalDay = 100
|
||||
b := p.DefaultBudget()
|
||||
b.MaxRunsPerDay = 100
|
||||
b.LifetimeTokens = p.LifetimeTokens
|
||||
req := everyReq()
|
||||
req.Budget = &b
|
||||
sc := mustCreate(t, st, p, req)
|
||||
full := Outcome{State: RunSucceeded, Observed: p.PerRunTokens, Clean: true}
|
||||
clk.Advance(time.Hour)
|
||||
if err := claimAndFinish(t, st, p, sc, 1, full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if err := claimAndFinish(t, st, p, sc, 2, full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); !errors.Is(err, ErrBudget) {
|
||||
t.Fatalf("24h token window must refuse: %v", err)
|
||||
}
|
||||
clk.Advance(23 * time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 26)); err != nil {
|
||||
t.Fatalf("the window rolls: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeeklyWindow(t *testing.T) {
|
||||
m := Manifest{}
|
||||
p := DefaultPolicy()
|
||||
p.GlobalTokensWeek = 300_000
|
||||
sc := Schedule{ID: "sch_w", Status: StatusActive, Budget: p.DefaultBudget(), ExpiresAt: epoch.Add(Week * 10),
|
||||
Trigger: Trigger{Kind: TriggerEvery, EverySec: 3600}}
|
||||
m.Runs = []Run{{ScheduleID: "other", ClaimedAt: epoch.Add(-3 * Day), State: RunSucceeded, Charged: 100_000}}
|
||||
if err := Admit(p, m, sc, epoch); !errors.Is(err, ErrBudget) {
|
||||
t.Fatalf("100k+250k reserve > 300k weekly: %v", err)
|
||||
}
|
||||
if err := Admit(p, m, sc, epoch.Add(5*Day)); err != nil {
|
||||
t.Fatalf("run fell out of the 7d window: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifetimeCircuitBreaker(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
p.GlobalTokensDay = 100_000_000
|
||||
p.GlobalTokensWeek = 100_000_000
|
||||
p.MaxRunsGlobalDay = 100
|
||||
p.MaxRunsPerScheduleDay = 100
|
||||
b := p.DefaultBudget()
|
||||
b.MaxRunsPerDay = 100
|
||||
b.LifetimeTokens = 2*p.PerRunTokens + 10
|
||||
req := everyReq()
|
||||
req.Budget = &b
|
||||
sc := mustCreate(t, st, p, req)
|
||||
full := Outcome{State: RunSucceeded, Observed: p.PerRunTokens, Clean: true}
|
||||
for n := int64(1); n <= 2; n++ {
|
||||
clk.Advance(time.Hour)
|
||||
if err := claimAndFinish(t, st, p, sc, n, full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
got := m.Schedules[0]
|
||||
if got.Status != StatusCircuitOpen || got.PausedReason != PauseLifetime {
|
||||
t.Fatalf("breaker did not open: %+v", got)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); !errors.Is(err, ErrNotActive) {
|
||||
t.Fatalf("open circuit must refuse: %v", err)
|
||||
}
|
||||
if err := st.Resume(t.Context(), sc.ID); !errors.Is(err, ErrLifetimeSpent) {
|
||||
t.Fatalf("resume must not reopen a spent schedule: %v", err)
|
||||
}
|
||||
m, _, _ = st.Snapshot(t.Context())
|
||||
if m.Schedules[0].Status != StatusCircuitOpen {
|
||||
t.Fatalf("status must stay circuit-open: %s", m.Schedules[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsecutiveFailureBreaker(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
p.GlobalTokensDay = 100_000_000
|
||||
p.GlobalTokensWeek = 100_000_000
|
||||
p.MaxRunsGlobalDay = 100
|
||||
p.MaxRunsPerScheduleDay = 100
|
||||
b := p.DefaultBudget()
|
||||
b.MaxRunsPerDay = 100
|
||||
b.LifetimeTokens = p.LifetimeTokens
|
||||
req := everyReq()
|
||||
req.Budget = &b
|
||||
sc := mustCreate(t, st, p, req)
|
||||
fail := Outcome{State: RunFailed, Observed: 10, Clean: true}
|
||||
for n := int64(1); n <= 3; n++ {
|
||||
clk.Advance(time.Hour)
|
||||
if err := claimAndFinish(t, st, p, sc, n, fail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if m.Schedules[0].Status != StatusCircuitOpen || m.Schedules[0].PausedReason != PauseFailures {
|
||||
t.Fatalf("got %+v", m.Schedules[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrencyAndSettleErrors(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 2)); !errors.Is(err, ErrConcurrency) {
|
||||
t.Fatalf("one in-flight run machine-wide: %v", err)
|
||||
}
|
||||
id := TriggerID(sc.ID, slotN(sc, 1))
|
||||
if _, err := st.MarkRunning(t.Context(), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok := Outcome{State: RunSucceeded, Observed: 1, Clean: true}
|
||||
if err := st.Finish(t.Context(), p, id, ok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Finish(t.Context(), p, id, ok); !errors.Is(err, ErrRunSettled) {
|
||||
t.Fatalf("double settle: %v", err)
|
||||
}
|
||||
if err := st.Finish(t.Context(), p, "sch_x/1", ok); !errors.Is(err, ErrRunNotFound) {
|
||||
t.Fatalf("unknown run: %v", err)
|
||||
}
|
||||
if err := st.Finish(t.Context(), p, id, Outcome{State: RunClaimed}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("non-settling state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneNeverDropsChargedRecordInWindow(t *testing.T) {
|
||||
m := &Manifest{}
|
||||
m.Runs = append(m.Runs, Run{TriggerID: "s/charged", ScheduleID: "s", ClaimedAt: epoch.Add(-3 * Day), State: RunSucceeded, Charged: 250_000})
|
||||
for i := range 600 {
|
||||
m.Runs = append(m.Runs, Run{TriggerID: fmt.Sprintf("s/skip%d", i), ScheduleID: "s", ClaimedAt: epoch.Add(-time.Duration(i) * time.Minute), State: RunSkipped})
|
||||
}
|
||||
prune(m, epoch)
|
||||
skips := 0
|
||||
for _, r := range m.Runs {
|
||||
if r.State == RunSkipped {
|
||||
skips++
|
||||
}
|
||||
}
|
||||
if skips > MaxSkips {
|
||||
t.Fatalf("skips are limited on their own: %d", skips)
|
||||
}
|
||||
if m.run("s/charged") == nil {
|
||||
t.Fatal("skips crowded a charged record out of the weekly window")
|
||||
}
|
||||
if got := windowUsage(m.Runs, epoch, Week, "").tokens; got != 250_000 {
|
||||
t.Fatalf("weekly usage lost the record: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneOverCapDropsOnlySettledOutsideWindow(t *testing.T) {
|
||||
m := &Manifest{}
|
||||
m.Runs = append(m.Runs, Run{TriggerID: "s/live", State: RunRunning, ClaimedAt: epoch.Add(-10 * Day)})
|
||||
for i := range MaxRuns + 5 {
|
||||
age := 10 * Day
|
||||
if i >= MaxRuns {
|
||||
age = time.Hour
|
||||
}
|
||||
m.Runs = append(m.Runs, Run{TriggerID: fmt.Sprintf("s/%d", i), ClaimedAt: epoch.Add(-age), State: RunSucceeded, Charged: 1})
|
||||
}
|
||||
prune(m, epoch)
|
||||
if m.run("s/live") == nil {
|
||||
t.Fatal("in-flight record dropped")
|
||||
}
|
||||
for i := MaxRuns; i < MaxRuns+5; i++ {
|
||||
if m.run(fmt.Sprintf("s/%d", i)) == nil {
|
||||
t.Fatal("in-window record dropped")
|
||||
}
|
||||
}
|
||||
if len(m.Runs) != MaxRuns {
|
||||
t.Fatalf("len=%d", len(m.Runs))
|
||||
}
|
||||
m.Runs[1].ClaimedAt = epoch.Add(-RunRetention - time.Hour)
|
||||
before := len(m.Runs)
|
||||
prune(m, epoch)
|
||||
if len(m.Runs) != before-1 {
|
||||
t.Fatal("retention should drop the aged record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekWindowBoundary(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
p.GlobalTokensWeek = 300_000
|
||||
sc := Schedule{ID: "sch_w", Status: StatusActive, Budget: p.DefaultBudget(), ExpiresAt: epoch.Add(Week * 10),
|
||||
Trigger: Trigger{Kind: TriggerEvery, EverySec: 3600}}
|
||||
at := func(claimed time.Time) error {
|
||||
m := Manifest{Runs: []Run{{ScheduleID: "other", ClaimedAt: claimed, State: RunSucceeded, Charged: 100_000}}}
|
||||
return Admit(p, m, sc, epoch)
|
||||
}
|
||||
if err := at(epoch.Add(-Week)); !errors.Is(err, ErrBudget) {
|
||||
t.Fatalf("a run exactly 7d old still counts: %v", err)
|
||||
}
|
||||
if err := at(epoch.Add(-Week - time.Second)); err != nil {
|
||||
t.Fatalf("7d+1s old is outside the window: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReapWithBackwardClock(t *testing.T) {
|
||||
r := Run{State: RunClaimed, ClaimedAt: epoch}
|
||||
back := epoch.Add(-time.Hour)
|
||||
if !ShouldReap(r, false, back) {
|
||||
t.Fatal("negative age with a free lease must be reaped, or the global concurrency cap wedges")
|
||||
}
|
||||
if ShouldReap(r, true, back) {
|
||||
t.Fatal("a held lease is alive")
|
||||
}
|
||||
if ShouldReap(r, false, epoch.Add(time.Minute)) {
|
||||
t.Fatal("inside the grace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReapCallbackMayTakeTheStoreLock(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(ReapGrace)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := st.Reap(t.Context(), p, func(Run) bool {
|
||||
_, _, err := st.Snapshot(t.Context())
|
||||
return err != nil
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Reap called back under the transaction lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyTighteningIsIdentifiable(t *testing.T) {
|
||||
sc := Schedule{ID: "s", Status: StatusActive, Budget: DefaultPolicy().DefaultBudget(), ExpiresAt: epoch.Add(Day),
|
||||
Trigger: Trigger{Kind: TriggerEvery, EverySec: 3600}}
|
||||
p := DefaultPolicy()
|
||||
p.PerRunTokens /= 2
|
||||
if err := Admit(p, Manifest{}, sc, epoch); !errors.Is(err, ErrPolicyTightened) || !errors.Is(err, ErrBudget) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimOnlyTheLatestSlot(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(30 * time.Minute)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrNotDue) {
|
||||
t.Fatalf("future slot: %v", err)
|
||||
}
|
||||
clk.Advance(5 * time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 2)); !errors.Is(err, ErrSlotStale) {
|
||||
t.Fatalf("a long-missed slot must not be made up: %v", err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 5)); err != nil {
|
||||
t.Fatalf("latest slot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReapSkipsRunThatStartedDuringCallback(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
run, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(ReapGrace)
|
||||
got, err := st.Reap(t.Context(), p, func(r Run) bool {
|
||||
if _, err := st.MarkRunning(t.Context(), r.TriggerID); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("a run that took its lease during the callback was reaped: %v %v", got, err)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if r := m.run(run.TriggerID); r.State != RunRunning || r.Charged != run.PerRunCap {
|
||||
t.Fatalf("live run disturbed: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReapGrace is how long a claimed run may sit without a live lease before it
|
||||
// is presumed dead.
|
||||
const ReapGrace = 2 * time.Minute
|
||||
|
||||
type SkipReason string
|
||||
|
||||
const (
|
||||
SkipMissed SkipReason = "missed"
|
||||
SkipLeaseHeld SkipReason = "lease_held"
|
||||
SkipBudgetWindow SkipReason = "budget_window"
|
||||
SkipConcurrency SkipReason = "concurrency"
|
||||
)
|
||||
|
||||
// Claim records the slot as started, before anything is spawned. The trigger
|
||||
// id is unique, so of any number of concurrent or restarted claimers exactly
|
||||
// one gets nil and the rest get ErrAlreadyClaimed. The full per-run cap is
|
||||
// reserved as the run's charge until Finish or Reap replaces it.
|
||||
func (s *Store) Claim(ctx context.Context, p Policy, scheduleID string, slot time.Time) (Run, error) {
|
||||
var run Run
|
||||
var refused error
|
||||
err := s.update(ctx, func(m *Manifest) error {
|
||||
sc := m.schedule(scheduleID)
|
||||
if sc == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
slot = slot.UTC().Truncate(time.Second)
|
||||
id := TriggerID(scheduleID, slot)
|
||||
if m.run(id) != nil {
|
||||
return ErrAlreadyClaimed
|
||||
}
|
||||
if !sc.Trigger.IsSlot(slot) {
|
||||
return ErrNotDue
|
||||
}
|
||||
now := s.clock()
|
||||
if slot.After(now) {
|
||||
return fmt.Errorf("%w: slot is in the future", ErrNotDue)
|
||||
}
|
||||
if latest, _ := sc.Trigger.LatestSlot(now); !slot.Equal(latest) {
|
||||
return ErrSlotStale
|
||||
}
|
||||
if sc.Confirmed.By != ConfirmedByHuman || sc.Confirmed.Digest != Digest(*sc) {
|
||||
return ErrDigestMismatch
|
||||
}
|
||||
if err := Admit(p, *m, *sc, now); err != nil {
|
||||
if errors.Is(err, ErrExpired) && sc.Status == StatusActive {
|
||||
sc.Status, sc.UpdatedAt = StatusExpired, now
|
||||
refused = err
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
run = Run{TriggerID: id, ScheduleID: scheduleID, SlotAt: slot, ClaimedAt: now,
|
||||
State: RunClaimed, Charged: sc.Budget.PerRunTokens, PerRunCap: sc.Budget.PerRunTokens}
|
||||
m.Runs = append(m.Runs, run)
|
||||
if sc.Trigger.Kind == TriggerAt {
|
||||
sc.Status, sc.UpdatedAt = StatusExpired, now
|
||||
}
|
||||
prune(m, now)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return run, refused
|
||||
}
|
||||
|
||||
// RecordSkip stores a slot that was deliberately not run, so the same slot is
|
||||
// not reconsidered by another ticker.
|
||||
func (s *Store) RecordSkip(ctx context.Context, scheduleID string, slot time.Time, why SkipReason) error {
|
||||
return s.update(ctx, func(m *Manifest) error {
|
||||
if m.schedule(scheduleID) == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
slot = slot.UTC().Truncate(time.Second)
|
||||
id := TriggerID(scheduleID, slot)
|
||||
if m.run(id) != nil {
|
||||
return ErrAlreadyClaimed
|
||||
}
|
||||
now := s.clock()
|
||||
m.Runs = append(m.Runs, Run{TriggerID: id, ScheduleID: scheduleID, SlotAt: slot,
|
||||
ClaimedAt: now, EndedAt: now, State: RunSkipped, SkipReason: string(why)})
|
||||
prune(m, now)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MarkRunning notes that the supervisor spawned the run and returns the start
|
||||
// token. Only the token's hash is stored: whoever holds the token, which is the
|
||||
// child the supervisor spawned, is the one executor Start will admit.
|
||||
func (s *Store) MarkRunning(ctx context.Context, triggerID string) (string, error) {
|
||||
token := newToken()
|
||||
err := s.update(ctx, func(m *Manifest) error {
|
||||
r := m.run(triggerID)
|
||||
if r == nil {
|
||||
return ErrRunNotFound
|
||||
}
|
||||
if r.State != RunClaimed {
|
||||
return ErrRunSettled
|
||||
}
|
||||
r.State, r.StartHash = RunRunning, tokenHash(token)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Start is the one transition that lets a run's work begin: the run must be
|
||||
// running, the token must be the one MarkRunning issued, and nothing may have
|
||||
// started it before. It commits with the manifest, so a crash or a power loss
|
||||
// cannot leave a run that could start twice.
|
||||
func (s *Store) Start(ctx context.Context, triggerID, token string) error {
|
||||
return s.update(ctx, func(m *Manifest) error {
|
||||
r := m.run(triggerID)
|
||||
if r == nil {
|
||||
return ErrRunNotFound
|
||||
}
|
||||
if !r.inFlight() || r.State != RunRunning {
|
||||
return ErrRunSettled
|
||||
}
|
||||
if r.Started {
|
||||
return ErrRunStarted
|
||||
}
|
||||
if r.StartHash == "" || subtle.ConstantTimeCompare([]byte(r.StartHash), []byte(tokenHash(token))) != 1 {
|
||||
return ErrRunToken
|
||||
}
|
||||
r.Started = true
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic("schedule: crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func tokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Finish settles a claimed or running run: it replaces the reservation with
|
||||
// ChargeFor, moves the schedule's lifetime spend and failure streak, and opens
|
||||
// the circuit when a breaker trips.
|
||||
func (s *Store) Finish(ctx context.Context, p Policy, triggerID string, o Outcome) error {
|
||||
switch o.State {
|
||||
case RunSucceeded, RunFailed, RunBlocked, RunBudgetStopped, RunInterrupted:
|
||||
default:
|
||||
return fmt.Errorf("%w: %q is not a settling state", ErrInvalid, o.State)
|
||||
}
|
||||
return s.update(ctx, func(m *Manifest) error { return s.settle(m, p, triggerID, o) })
|
||||
}
|
||||
|
||||
func (s *Store) settle(m *Manifest, p Policy, triggerID string, o Outcome) error {
|
||||
r := m.run(triggerID)
|
||||
if r == nil {
|
||||
return ErrRunNotFound
|
||||
}
|
||||
if !r.inFlight() {
|
||||
return ErrRunSettled
|
||||
}
|
||||
now := s.clock()
|
||||
r.State, r.EndedAt, r.Observed = o.State, now, max(o.Observed, 0)
|
||||
r.Charged = ChargeFor(r.PerRunCap, o)
|
||||
sc := m.schedule(r.ScheduleID)
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
sc.SpentLifetimeTokens += r.Charged
|
||||
sc.UpdatedAt = now
|
||||
switch o.State {
|
||||
case RunSucceeded:
|
||||
sc.ConsecutiveFailures = 0
|
||||
case RunFailed, RunInterrupted, RunBudgetStopped:
|
||||
sc.ConsecutiveFailures++
|
||||
}
|
||||
if sc.Status != StatusActive {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case sc.SpentLifetimeTokens+sc.Budget.PerRunTokens > sc.Budget.LifetimeTokens:
|
||||
sc.Status, sc.PausedReason = StatusCircuitOpen, PauseLifetime
|
||||
case int64(sc.ConsecutiveFailures) >= p.MaxConsecutiveFails:
|
||||
sc.Status, sc.PausedReason = StatusCircuitOpen, PauseFailures
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldReap is the crash-recovery decision: an in-flight run whose session
|
||||
// lease is not held and which is older than the grace is presumed dead. A held
|
||||
// lease means the run is alive, whatever its age. A negative age (the clock
|
||||
// moved back) leaves the lease as the only evidence, so the run is not pinned
|
||||
// in flight forever.
|
||||
func ShouldReap(r Run, leaseHeld bool, now time.Time) bool {
|
||||
age := now.Sub(r.ClaimedAt)
|
||||
return r.inFlight() && !leaseHeld && (age >= ReapGrace || age < 0)
|
||||
}
|
||||
|
||||
// Reap settles every presumed-dead run as interrupted at the full per-run
|
||||
// cap. It never re-runs anything. leaseHeld asks the OS-level session lease; it
|
||||
// is called outside the transaction lock, so it may take any lock, and the
|
||||
// commit skips any run whose state changed since the snapshot. It returns the reaped ids.
|
||||
func (s *Store) Reap(ctx context.Context, p Policy, leaseHeld func(Run) bool) ([]string, error) {
|
||||
m, _, err := s.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.clock()
|
||||
dead := map[string]RunState{}
|
||||
for _, r := range m.Runs {
|
||||
if ShouldReap(r, leaseHeld(r), now) {
|
||||
dead[r.TriggerID] = r.State
|
||||
}
|
||||
}
|
||||
if len(dead) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var reaped []string
|
||||
err = s.update(ctx, func(m *Manifest) error {
|
||||
for id, seen := range dead {
|
||||
if r := m.run(id); r == nil || r.State != seen {
|
||||
continue
|
||||
}
|
||||
if err := s.settle(m, p, id, Outcome{State: RunInterrupted}); err != nil {
|
||||
return err
|
||||
}
|
||||
reaped = append(reaped, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return reaped, err
|
||||
}
|
||||
|
||||
// MaxSkips bounds skipped-slot records, which count in no budget window and so
|
||||
// must never crowd out a charged record.
|
||||
const MaxSkips = 200
|
||||
|
||||
// prune drops settled records past retention, the oldest skips beyond MaxSkips,
|
||||
// and, over MaxRuns, the oldest settled records already outside the 7-day
|
||||
// window. A charged record inside the window is never dropped.
|
||||
func prune(m *Manifest, now time.Time) {
|
||||
m.Runs = slices.DeleteFunc(m.Runs, func(r Run) bool { return !r.inFlight() && r.ClaimedAt.Before(now.Add(-RunRetention)) })
|
||||
skips := 0
|
||||
for _, r := range m.Runs {
|
||||
if r.State == RunSkipped {
|
||||
skips++
|
||||
}
|
||||
}
|
||||
m.Runs = slices.DeleteFunc(m.Runs, func(r Run) bool {
|
||||
if r.State != RunSkipped || skips <= MaxSkips {
|
||||
return false
|
||||
}
|
||||
skips--
|
||||
return true
|
||||
})
|
||||
windowStart := now.Add(-Week)
|
||||
for len(m.Runs) > MaxRuns {
|
||||
i := slices.IndexFunc(m.Runs, func(r Run) bool { return !r.inFlight() && r.ClaimedAt.Before(windowStart) })
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
m.Runs = slices.Delete(m.Runs, i, i+1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConcurrentClaimHasOneWinner(t *testing.T) {
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
var wins, dupes atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 16 {
|
||||
wg.Go(func() {
|
||||
_, err := reopen(t, dir, clk).Claim(t.Context(), p, sc.ID, slotN(sc, 1))
|
||||
switch {
|
||||
case err == nil:
|
||||
wins.Add(1)
|
||||
case errors.Is(err, ErrAlreadyClaimed):
|
||||
dupes.Add(1)
|
||||
default:
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if wins.Load() != 1 || dupes.Load() != 15 {
|
||||
t.Fatalf("wins=%d dupes=%d", wins.Load(), dupes.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAcrossProcessesHasOneWinner(t *testing.T) {
|
||||
if os.Getenv("SCHEDULE_CLAIM_HELPER") != "" {
|
||||
return
|
||||
}
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
var wg sync.WaitGroup
|
||||
var wins atomic.Int32
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestHelperClaimProcess$")
|
||||
cmd.Env = append(os.Environ(), "SCHEDULE_CLAIM_HELPER="+dir+"|"+sc.ID+"|"+
|
||||
slotN(sc, 1).Format(time.RFC3339)+"|"+clk.Now().Format(time.RFC3339))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("helper: %v\n%s", err, out)
|
||||
return
|
||||
}
|
||||
if strings.Contains(string(out), "CLAIMED") {
|
||||
wins.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if wins.Load() != 1 {
|
||||
t.Fatalf("exactly one process may claim, got %d", wins.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperClaimProcess(t *testing.T) {
|
||||
spec := os.Getenv("SCHEDULE_CLAIM_HELPER")
|
||||
if spec == "" {
|
||||
t.Skip("helper process only")
|
||||
}
|
||||
f := strings.Split(spec, "|")
|
||||
slot, _ := time.Parse(time.RFC3339, f[2])
|
||||
now, _ := time.Parse(time.RFC3339, f[3])
|
||||
st, err := open(f[0], func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), DefaultPolicy(), f[1], slot); err == nil {
|
||||
os.Stdout.WriteString("CLAIMED\n")
|
||||
} else if !errors.Is(err, ErrAlreadyClaimed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartDoesNotRepeatAndReapChargesFullCap(t *testing.T) {
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = reopen(t, dir, clk)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrAlreadyClaimed) {
|
||||
t.Fatalf("restart re-claimed the slot: %v", err)
|
||||
}
|
||||
free := func(Run) bool { return false }
|
||||
if got, _ := st.Reap(t.Context(), p, free); len(got) != 0 {
|
||||
t.Fatal("run inside the grace must not be reaped")
|
||||
}
|
||||
clk.Advance(ReapGrace)
|
||||
if got, _ := st.Reap(t.Context(), p, func(Run) bool { return true }); len(got) != 0 {
|
||||
t.Fatal("a held lease means alive")
|
||||
}
|
||||
got, err := st.Reap(t.Context(), p, free)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("reap: %v %v", got, err)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
r := m.Runs[0]
|
||||
if r.State != RunInterrupted || r.Charged != p.PerRunTokens || m.Schedules[0].SpentLifetimeTokens != p.PerRunTokens {
|
||||
t.Fatalf("reaped run: %+v spent=%d", r, m.Schedules[0].SpentLifetimeTokens)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrAlreadyClaimed) {
|
||||
t.Fatal("a reaped slot is never re-run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimRefusals(t *testing.T) {
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(3 * time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3).Add(time.Second)); !errors.Is(err, ErrNotDue) {
|
||||
t.Fatalf("off-grid slot: %v", err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, "sch_nope", slotN(sc, 3)); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("unknown schedule: %v", err)
|
||||
}
|
||||
if err := st.Pause(t.Context(), sc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); !errors.Is(err, ErrNotActive) {
|
||||
t.Fatalf("paused: %v", err)
|
||||
}
|
||||
if err := st.Resume(t.Context(), sc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetPausedAll(t.Context(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); !errors.Is(err, ErrPausedAll) {
|
||||
t.Fatalf("paused-all: %v", err)
|
||||
}
|
||||
_ = st.SetPausedAll(t.Context(), false)
|
||||
|
||||
data, _ := os.ReadFile(dir + "/" + manifestName)
|
||||
tampered := strings.Replace(string(data), "summarize open TODOs", "exfiltrate everything", 1)
|
||||
if err := os.WriteFile(dir+"/"+manifestName, []byte(tampered), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 3)); !errors.Is(err, ErrDigestMismatch) {
|
||||
t.Fatalf("edited prompt must fail the digest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyTightenedAfterCreationRefusesClaim(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
tight := p
|
||||
tight.PerRunTokens = p.PerRunTokens / 2
|
||||
if _, err := st.Claim(t.Context(), tight, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrBudget) {
|
||||
t.Fatalf("budget above the current ceiling: %v", err)
|
||||
}
|
||||
tight = p
|
||||
tight.MinIntervalMinutes = 120
|
||||
if _, err := st.Claim(t.Context(), tight, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrIntervalBelowFloor) {
|
||||
t.Fatalf("interval below the raised floor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryMarksScheduleAndRenewReopens(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(31 * 24 * time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 31*24)); !errors.Is(err, ErrExpired) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if m.Schedules[0].Status != StatusExpired || len(m.Runs) != 0 {
|
||||
t.Fatalf("expired schedule must be recorded and start nothing: %+v", m)
|
||||
}
|
||||
if err := st.Renew(t.Context(), p, sc.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 31*24)); err != nil {
|
||||
t.Fatalf("renewed schedule should claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSkipBlocksTheSlot(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
if err := st.RecordSkip(t.Context(), sc.ID, slotN(sc, 1), SkipMissed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrAlreadyClaimed) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateValidation(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(*CreateRequest)
|
||||
want error
|
||||
}{
|
||||
{"not human", func(r *CreateRequest) { r.ConfirmedBy = "model" }, ErrNotHuman},
|
||||
{"interval below floor", func(r *CreateRequest) { r.Trigger.EverySec = 3599 }, ErrIntervalBelowFloor},
|
||||
{"at too soon", func(r *CreateRequest) {
|
||||
r.Trigger = Trigger{Kind: TriggerAt, At: epoch.Add(30 * time.Second)}
|
||||
}, ErrInvalid},
|
||||
{"grant above ceiling", func(r *CreateRequest) { r.Grant.Tools = []string{"bash"} }, ErrGrantExceedsCeil},
|
||||
{"read-only bash unavailable", func(r *CreateRequest) { r.Grant.ReadOnlyBash = true }, ErrGrantExceedsCeil},
|
||||
{"budget above policy", func(r *CreateRequest) {
|
||||
b := p.DefaultBudget()
|
||||
b.PerRunTokens++
|
||||
r.Budget = &b
|
||||
}, ErrInvalid},
|
||||
{"empty prompt", func(r *CreateRequest) { r.Prompt = " " }, ErrInvalid},
|
||||
{"relative workspace", func(r *CreateRequest) { r.Target.Workspace = "repo" }, ErrInvalid},
|
||||
{"unpinned model", func(r *CreateRequest) { r.Model.Model = "" }, ErrInvalid},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, _, _ := newTestStore(t)
|
||||
req := everyReq()
|
||||
tc.edit(&req)
|
||||
if _, err := st.Create(t.Context(), p, req); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("got %v, want %v", err, tc.want)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if len(m.Schedules) != 0 {
|
||||
t.Fatal("refused create must not persist")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFillsPolicyDefaultsAndExpiry(t *testing.T) {
|
||||
st, _, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
if sc.Status != StatusActive || !sc.ExpiresAt.Equal(epoch.Add(30*24*time.Hour)) {
|
||||
t.Fatalf("status/expiry: %+v", sc)
|
||||
}
|
||||
if sc.Budget != p.DefaultBudget() || sc.Confirmed.Digest != Digest(sc) {
|
||||
t.Fatalf("budget/digest: %+v", sc)
|
||||
}
|
||||
if len(sc.Grant.ReadRoots) != 1 || sc.Grant.ReadRoots[0] != sc.Target.Workspace {
|
||||
t.Fatalf("read roots default to the workspace: %v", sc.Grant.ReadRoots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEnforcesScheduleLimit(t *testing.T) {
|
||||
st, _, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
p.MaxSchedules = 2
|
||||
mustCreate(t, st, p, everyReq())
|
||||
mustCreate(t, st, p, everyReq())
|
||||
if _, err := st.Create(t.Context(), p, everyReq()); !errors.Is(err, ErrTooMany) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerSlots(t *testing.T) {
|
||||
tr := Trigger{Kind: TriggerEvery, EverySec: 3600, Anchor: epoch}
|
||||
if _, ok := tr.LatestSlot(epoch.Add(59 * time.Minute)); ok {
|
||||
t.Fatal("no slot before the first interval")
|
||||
}
|
||||
got, ok := tr.LatestSlot(epoch.Add(5*time.Hour + 20*time.Minute))
|
||||
if !ok || !got.Equal(epoch.Add(5*time.Hour)) {
|
||||
t.Fatalf("latest slot: %v %v", got, ok)
|
||||
}
|
||||
if !tr.IsSlot(epoch.Add(2*time.Hour)) || tr.IsSlot(epoch.Add(2*time.Hour+time.Second)) || tr.IsSlot(epoch) {
|
||||
t.Fatal("IsSlot must accept exactly anchor + k*every, k>=1")
|
||||
}
|
||||
at := Trigger{Kind: TriggerAt, At: epoch.Add(time.Hour)}
|
||||
if _, ok := at.LatestSlot(epoch); ok {
|
||||
t.Fatal("at trigger not due yet")
|
||||
}
|
||||
if id := TriggerID("sch_x", got); id != "sch_x/"+strconv.FormatInt(got.Unix(), 10) || id != TriggerID("sch_x", got.In(time.FixedZone("x", 3600))) {
|
||||
t.Fatalf("trigger id must be deterministic across zones: %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneShotIsSpentAfterClaim(t *testing.T) {
|
||||
st, clk, _ := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
req := everyReq()
|
||||
req.Trigger = Trigger{Kind: TriggerAt, At: epoch.Add(2 * time.Hour)}
|
||||
sc := mustCreate(t, st, p, req)
|
||||
clk.Advance(2 * time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, sc.Trigger.At); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if m.Schedules[0].Status != StatusExpired {
|
||||
t.Fatalf("status %s", m.Schedules[0].Status)
|
||||
}
|
||||
if err := st.Renew(t.Context(), p, sc.ID); !errors.Is(err, ErrNotActive) {
|
||||
t.Fatalf("spent one-shot must not renew: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAcceptsUserLoweredInterval(t *testing.T) {
|
||||
st, _, _ := newTestStore(t)
|
||||
p, _, _ := Resolve(Overrides{MinIntervalMinutes: new(int64(15))}, Overrides{})
|
||||
req := everyReq()
|
||||
req.Trigger.EverySec = 15 * 60
|
||||
mustCreate(t, st, p, req)
|
||||
req.Trigger.EverySec = 14 * 60
|
||||
if _, err := st.Create(t.Context(), p, req); !errors.Is(err, ErrIntervalBelowFloor) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package schedule is the durable store and budget policy for scheduled runs.
|
||||
//
|
||||
// One machine-wide manifest holds schedules and run records, so a claim, its
|
||||
// dedup key and its budget charge commit in a single atomic write. The package
|
||||
// starts no process: it decides whether a slot may start (Claim), records what
|
||||
// it cost (Finish, Reap) and refuses what the policy forbids. Policy values
|
||||
// come from user-level configuration only; a project layer can tighten them and
|
||||
// never loosen them (Resolve).
|
||||
//
|
||||
// The one number the user layer cannot go under is the 10 minute
|
||||
// AbsoluteMinInterval, a guard against a typo burning quota. It is not the
|
||||
// default (60 minutes), so it locks nothing in.
|
||||
//
|
||||
// Only the latest slot of a schedule can be claimed: a slot missed while
|
||||
// nothing ticked is never made up. A run claimed but not yet holding its
|
||||
// session lease is presumed dead after ReapGrace; if a start is slower than
|
||||
// that it is reaped wrongly, and MarkRunning then returns ErrRunSettled, which
|
||||
// the supervisor must treat as "do not run".
|
||||
//
|
||||
// The confirmed digest is unkeyed, and no key would change that: a writer that is
|
||||
// not confined by the operating-system sandbox can read any key a person's
|
||||
// account can. The store directory is denied to file tools, masked or
|
||||
// write-denied for sandboxed shell commands, and read-protected for every run,
|
||||
// which defends the confirmation against sandboxed writers only. On Windows
|
||||
// (no sandbox), with the sandbox off or under yolo, and for MCP and plugin
|
||||
// processes, anything that can write the directory can forge a confirmed
|
||||
// schedule. A frontend must not present "you confirmed this" as tamper-proof
|
||||
// there.
|
||||
package schedule
|
||||
@@ -0,0 +1,131 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/fileutil"
|
||||
)
|
||||
|
||||
func TestNewerSchemaLoadsReadOnly(t *testing.T) {
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
path := filepath.Join(dir, manifestName)
|
||||
data, _ := os.ReadFile(path)
|
||||
future := strings.Replace(string(data), `"schemaVersion":1`, `"schemaVersion":99`, 1)
|
||||
if err := os.WriteFile(path, []byte(future), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ro, err := st.Snapshot(t.Context())
|
||||
if err != nil || !ro || len(m.Schedules) != 1 {
|
||||
t.Fatalf("snapshot: ro=%v err=%v", ro, err)
|
||||
}
|
||||
clk.Advance(time.Hour)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrSchemaReadonly) {
|
||||
t.Fatalf("claim on newer schema: %v", err)
|
||||
}
|
||||
if _, err := st.Create(t.Context(), p, everyReq()); !errors.Is(err, ErrSchemaReadonly) {
|
||||
t.Fatalf("create on newer schema: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if string(after) != future {
|
||||
t.Fatal("a newer manifest must be left byte-for-byte alone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptManifestIsQuarantinedAndPausesAll(t *testing.T) {
|
||||
for name, content := range map[string]string{
|
||||
"garbage": "{not json",
|
||||
"no version": `{"schedules":[]}`,
|
||||
"truncated": `{"schemaVersion":1,"schedules":[{"id":`,
|
||||
"oversize body": strings.Repeat(" ", MaxManifest+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
st, _, dir := newTestStore(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, manifestName), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ro, err := st.Snapshot(t.Context())
|
||||
if err != nil || ro || !m.PausedAll || m.PausedReason != PauseCorrupt {
|
||||
t.Fatalf("m=%+v ro=%v err=%v", m, ro, err)
|
||||
}
|
||||
q, _ := os.ReadDir(filepath.Join(dir, quarantineName))
|
||||
if len(q) != 1 {
|
||||
t.Fatalf("corrupt file must be kept aside, got %d entries", len(q))
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), DefaultPolicy(), "sch_x", epoch); !errors.Is(err, ErrNotFound) && !errors.Is(err, ErrPausedAll) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if err := st.SetPausedAll(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _, _ = st.Snapshot(t.Context())
|
||||
if m.PausedAll {
|
||||
t.Fatal("a person can lift the corruption pause")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type crashSentinel struct{}
|
||||
|
||||
func TestCrashDuringWriteLeavesPreviousManifest(t *testing.T) {
|
||||
st, clk, dir := newTestStore(t)
|
||||
p := DefaultPolicy()
|
||||
sc := mustCreate(t, st, p, everyReq())
|
||||
clk.Advance(time.Hour)
|
||||
before, _ := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
|
||||
fileutil.CrashPoint = func(op, path string) {
|
||||
if strings.HasPrefix(path, dir) {
|
||||
panic(crashSentinel{})
|
||||
}
|
||||
}
|
||||
func() {
|
||||
defer func() {
|
||||
fileutil.CrashPoint = nil
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected the injected crash")
|
||||
}
|
||||
}()
|
||||
_, _ = st.Claim(t.Context(), p, sc.ID, slotN(sc, 1))
|
||||
}()
|
||||
|
||||
after, _ := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
if string(after) != string(before) {
|
||||
t.Fatal("an interrupted write must leave the previous manifest intact")
|
||||
}
|
||||
// A torn temp file next to the manifest must not confuse the next load.
|
||||
if err := os.WriteFile(filepath.Join(dir, manifestName+".tmp-torn"), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st = reopen(t, dir, clk)
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); err != nil {
|
||||
t.Fatalf("slot never committed, so it must still be claimable: %v", err)
|
||||
}
|
||||
if _, err := st.Claim(t.Context(), p, sc.ID, slotN(sc, 1)); !errors.Is(err, ErrAlreadyClaimed) {
|
||||
t.Fatalf("committed claim must not repeat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestFileIsPrivateAndRevisionsAdvance(t *testing.T) {
|
||||
st, _, dir := newTestStore(t)
|
||||
mustCreate(t, st, DefaultPolicy(), everyReq())
|
||||
mustCreate(t, st, DefaultPolicy(), everyReq())
|
||||
info, err := os.Stat(filepath.Join(dir, manifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 && os.PathSeparator == '/' {
|
||||
t.Fatalf("manifest mode %v", info.Mode())
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if m.Revision != 2 || m.SchemaVersion != SchemaVersion {
|
||||
t.Fatalf("revision=%d schema=%d", m.Revision, m.SchemaVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package schedule
|
||||
|
||||
import "errors"
|
||||
|
||||
// Callers tell these apart with errors.Is; the wrapping text is display only.
|
||||
var (
|
||||
ErrNotFound = errors.New("schedule: not found")
|
||||
ErrSchemaReadonly = errors.New("schedule: manifest schema is newer and read-only")
|
||||
ErrPausedAll = errors.New("schedule: all schedules are paused")
|
||||
ErrNotHuman = errors.New("schedule: creation requires a human confirmation")
|
||||
ErrInvalid = errors.New("schedule: invalid schedule")
|
||||
ErrIntervalBelowFloor = errors.New("schedule: interval is below the minimum")
|
||||
ErrGrantExceedsCeil = errors.New("schedule: grant exceeds the host tool ceiling")
|
||||
ErrPolicyInvalid = errors.New("schedule: invalid policy value")
|
||||
ErrTooMany = errors.New("schedule: schedule limit reached")
|
||||
ErrNotActive = errors.New("schedule: schedule is not active")
|
||||
ErrExpired = errors.New("schedule: schedule has expired")
|
||||
ErrDigestMismatch = errors.New("schedule: confirmed content changed after confirmation")
|
||||
ErrAlreadyClaimed = errors.New("schedule: trigger already claimed")
|
||||
ErrNotDue = errors.New("schedule: slot is not a slot of this schedule")
|
||||
ErrConcurrency = errors.New("schedule: another scheduled run is in flight")
|
||||
ErrBudget = errors.New("schedule: budget does not admit another run")
|
||||
ErrRunNotFound = errors.New("schedule: run not found")
|
||||
ErrSlotStale = errors.New("schedule: slot is not the latest slot of this schedule")
|
||||
ErrPolicyTightened = errors.New("schedule: policy was tightened after this schedule was confirmed")
|
||||
ErrLifetimeSpent = errors.New("schedule: lifetime budget is spent")
|
||||
ErrRunSettled = errors.New("schedule: run already settled")
|
||||
ErrRunStarted = errors.New("schedule: run already started")
|
||||
ErrRunToken = errors.New("schedule: start token does not match this run")
|
||||
ErrRunHeld = errors.New("schedule: run is held by another executor")
|
||||
ErrResultNotFound = errors.New("schedule: no result recorded for this run")
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
var epoch = time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, *fakeClock, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "schedules")
|
||||
clk := &fakeClock{t: epoch}
|
||||
st, err := open(dir, clk.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st, clk, dir
|
||||
}
|
||||
|
||||
func reopen(t *testing.T, dir string, clk *fakeClock) *Store {
|
||||
t.Helper()
|
||||
st, err := open(dir, clk.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func everyReq() CreateRequest {
|
||||
return CreateRequest{
|
||||
Trigger: Trigger{Kind: TriggerEvery, EverySec: 3600},
|
||||
Target: Target{Workspace: filepath.Join(os.TempDir(), "work", "repo")},
|
||||
Prompt: "summarize open TODOs",
|
||||
Model: Model{Provider: "deepseek", Model: "deepseek-chat"},
|
||||
Grant: Grant{Tools: []string{"read_file", "grep"}},
|
||||
ConfirmedBy: ConfirmedByHuman,
|
||||
ToolCeiling: []string{"read_file", "grep", "glob"},
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreate(t *testing.T, st *Store, p Policy, req CreateRequest) Schedule {
|
||||
t.Helper()
|
||||
sc, err := st.Create(t.Context(), p, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
func slotN(sc Schedule, n int64) time.Time {
|
||||
return time.Unix(sc.Trigger.Anchor.Unix()+n*sc.Trigger.EverySec, 0).UTC()
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// CreateRequest is a person's confirmed intent. ToolCeiling and
|
||||
// AllowReadOnlyBash are the host's limits for the observe posture, supplied by
|
||||
// the caller because this package knows no tool registry.
|
||||
type CreateRequest struct {
|
||||
Label string
|
||||
Trigger Trigger
|
||||
Target Target
|
||||
Prompt string
|
||||
Model Model
|
||||
Grant Grant
|
||||
Budget *Budget
|
||||
ConfirmedBy string
|
||||
ToolCeiling []string
|
||||
AllowReadOnlyBash bool
|
||||
}
|
||||
|
||||
// Create validates the request against the policy and stores a new active
|
||||
// schedule. Only a human frontend confirmation is accepted; the model field is
|
||||
// stored as given, so the caller pins it from user-level configuration.
|
||||
func (s *Store) Create(ctx context.Context, p Policy, req CreateRequest) (Schedule, error) {
|
||||
var out Schedule
|
||||
err := s.update(ctx, func(m *Manifest) error {
|
||||
now := s.clock()
|
||||
if req.ConfirmedBy != ConfirmedByHuman {
|
||||
return ErrNotHuman
|
||||
}
|
||||
if int64(len(m.Schedules)) >= p.MaxSchedules {
|
||||
return fmt.Errorf("%w: %d", ErrTooMany, p.MaxSchedules)
|
||||
}
|
||||
sc, err := s.build(p, req, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Schedules = append(m.Schedules, sc)
|
||||
out = sc
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) build(p Policy, req CreateRequest, now time.Time) (Schedule, error) {
|
||||
tr := req.Trigger
|
||||
tr.At, tr.Anchor = tr.At.UTC().Truncate(time.Second), tr.Anchor.UTC().Truncate(time.Second)
|
||||
if err := tr.validate(p, now); err != nil {
|
||||
return Schedule{}, err
|
||||
}
|
||||
if tr.Kind == TriggerEvery {
|
||||
tr.Anchor = now
|
||||
} else {
|
||||
tr.Anchor = time.Time{}
|
||||
}
|
||||
budget := p.DefaultBudget()
|
||||
if req.Budget != nil {
|
||||
budget = *req.Budget
|
||||
}
|
||||
if err := p.fits(budget); err != nil {
|
||||
return Schedule{}, err
|
||||
}
|
||||
if err := checkContent(req); err != nil {
|
||||
return Schedule{}, err
|
||||
}
|
||||
grant, err := checkGrant(req)
|
||||
if err != nil {
|
||||
return Schedule{}, err
|
||||
}
|
||||
sc := Schedule{
|
||||
ID: newID(), Label: req.Label, Trigger: tr, Target: req.Target, Prompt: req.Prompt,
|
||||
Model: req.Model, Grant: grant, Budget: budget, Status: StatusActive,
|
||||
CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(p.Expiry()),
|
||||
}
|
||||
sc.Confirmed = Confirmation{At: now, By: ConfirmedByHuman, Digest: Digest(sc)}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
func checkContent(req CreateRequest) error {
|
||||
switch {
|
||||
case strings.TrimSpace(req.Prompt) == "" || len(req.Prompt) > MaxPromptBytes:
|
||||
return fmt.Errorf("%w: prompt must be 1..%d bytes", ErrInvalid, MaxPromptBytes)
|
||||
case utf8.RuneCountInString(req.Label) > MaxLabelRunes:
|
||||
return fmt.Errorf("%w: label exceeds %d runes", ErrInvalid, MaxLabelRunes)
|
||||
case req.Target.Workspace == "" || !filepath.IsAbs(req.Target.Workspace):
|
||||
return fmt.Errorf("%w: target workspace must be an absolute path", ErrInvalid)
|
||||
case req.Model.Provider == "" || req.Model.Model == "":
|
||||
return fmt.Errorf("%w: model must be pinned", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkGrant(req CreateRequest) (Grant, error) {
|
||||
g := req.Grant
|
||||
g.Tools = slices.Clone(g.Tools)
|
||||
slices.Sort(g.Tools)
|
||||
g.Tools = slices.Compact(g.Tools)
|
||||
for _, t := range g.Tools {
|
||||
if !slices.Contains(req.ToolCeiling, t) {
|
||||
return Grant{}, fmt.Errorf("%w: %q", ErrGrantExceedsCeil, t)
|
||||
}
|
||||
}
|
||||
if g.ReadOnlyBash && !req.AllowReadOnlyBash {
|
||||
return Grant{}, fmt.Errorf("%w: read-only bash is not available on this host", ErrGrantExceedsCeil)
|
||||
}
|
||||
if len(g.ReadRoots) == 0 {
|
||||
g.ReadRoots = []string{req.Target.Workspace}
|
||||
}
|
||||
for _, r := range g.ReadRoots {
|
||||
if !filepath.IsAbs(r) {
|
||||
return Grant{}, fmt.Errorf("%w: read root must be an absolute path", ErrInvalid)
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic("schedule: crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
return "sch_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Pause stops a schedule from being claimed until a person resumes it.
|
||||
func (s *Store) Pause(ctx context.Context, id string) error {
|
||||
return s.setStatus(ctx, id, func(sc *Schedule) error {
|
||||
if sc.Status != StatusActive {
|
||||
return fmt.Errorf("%w: %s", ErrNotActive, sc.Status)
|
||||
}
|
||||
sc.Status, sc.PausedReason = StatusPaused, PauseUser
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Resume reopens a paused or circuit-open schedule and clears its failure
|
||||
// streak. It never resets lifetime spend: a schedule whose next run no longer
|
||||
// fits its lifetime budget is refused with ErrLifetimeSpent and stays as it is.
|
||||
func (s *Store) Resume(ctx context.Context, id string) error {
|
||||
return s.setStatus(ctx, id, func(sc *Schedule) error {
|
||||
if sc.Status != StatusPaused && sc.Status != StatusCircuitOpen {
|
||||
return fmt.Errorf("%w: %s", ErrNotActive, sc.Status)
|
||||
}
|
||||
if sc.SpentLifetimeTokens+sc.Budget.PerRunTokens > sc.Budget.LifetimeTokens {
|
||||
return ErrLifetimeSpent
|
||||
}
|
||||
sc.Status, sc.PausedReason, sc.ConsecutiveFailures = StatusActive, "", 0
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Renew extends expiry from now by the policy lifetime. A one-shot schedule
|
||||
// that already fired cannot be renewed into firing again.
|
||||
func (s *Store) Renew(ctx context.Context, p Policy, id string) error {
|
||||
return s.setStatus(ctx, id, func(sc *Schedule) error {
|
||||
if sc.Trigger.Kind == TriggerAt && sc.Status == StatusExpired {
|
||||
return fmt.Errorf("%w: one-shot schedule is spent", ErrNotActive)
|
||||
}
|
||||
sc.ExpiresAt = s.clock().Add(p.Expiry())
|
||||
if sc.Status == StatusExpired {
|
||||
sc.Status = StatusActive
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) setStatus(ctx context.Context, id string, fn func(*Schedule) error) error {
|
||||
return s.update(ctx, func(m *Manifest) error {
|
||||
sc := m.schedule(id)
|
||||
if sc == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := fn(sc); err != nil {
|
||||
return err
|
||||
}
|
||||
sc.UpdatedAt = s.clock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes a schedule. Its run records stay for the windows they count in.
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
return s.update(ctx, func(m *Manifest) error {
|
||||
i := slices.IndexFunc(m.Schedules, func(sc Schedule) bool { return sc.ID == id })
|
||||
if i < 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
m.Schedules = slices.Delete(m.Schedules, i, i+1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetPausedAll flips the global switch. Resuming clears a corruption pause too:
|
||||
// that is the person's acknowledgement of the quarantine.
|
||||
func (s *Store) SetPausedAll(ctx context.Context, paused bool) error {
|
||||
return s.update(ctx, func(m *Manifest) error {
|
||||
m.PausedAll = paused
|
||||
m.PausedReason = ""
|
||||
if paused {
|
||||
m.PausedReason = PauseUser
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// maxConfigBytes bounds the configuration file read for the [schedule] table.
|
||||
const maxConfigBytes = 1 << 20
|
||||
|
||||
// LoadOverrides reads the [schedule] table of one configuration file. A missing
|
||||
// file or table is no overrides. The caller passes the user-level file: this
|
||||
// package cannot tell a project file from a user one, and a value from a
|
||||
// project layer must go through Resolve's project argument instead.
|
||||
func LoadOverrides(path string) (Overrides, error) {
|
||||
if path == "" {
|
||||
return Overrides{}, nil
|
||||
}
|
||||
data, err := readLimited(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Overrides{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Overrides{}, fmt.Errorf("%w: read %s: %w", ErrPolicyInvalid, path, err)
|
||||
}
|
||||
var doc struct {
|
||||
Schedule Overrides `toml:"schedule"`
|
||||
}
|
||||
if _, err := toml.Decode(string(data), &doc); err != nil {
|
||||
return Overrides{}, fmt.Errorf("%w: parse %s: %w", ErrPolicyInvalid, path, err)
|
||||
}
|
||||
return doc.Schedule, nil
|
||||
}
|
||||
|
||||
func readLimited(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxConfigBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxConfigBytes {
|
||||
return nil, errors.New("configuration file is too large")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AbsoluteMinInterval is a safety net against a mistyped value, not a policy:
|
||||
// the default is 60 minutes and the user layer may lower it to any value at or
|
||||
// above this. It is far below the default so it locks nothing in.
|
||||
const AbsoluteMinInterval = 10 * time.Minute
|
||||
|
||||
// Policy is the resolved set of ceilings every schedule and every claim is held to.
|
||||
type Policy struct {
|
||||
MinIntervalMinutes int64
|
||||
PerRunTokens int64
|
||||
PerRunWallSeconds int64
|
||||
PerRunSteps int64
|
||||
MaxRunsPerScheduleDay int64
|
||||
MaxRunsGlobalDay int64
|
||||
GlobalTokensDay int64
|
||||
GlobalTokensWeek int64
|
||||
LifetimeTokens int64
|
||||
ExpiryDays int64
|
||||
MaxSchedules int64
|
||||
MaxConsecutiveFails int64
|
||||
MaxRepeatParks int64
|
||||
}
|
||||
|
||||
func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
MinIntervalMinutes: 60,
|
||||
PerRunTokens: 250_000,
|
||||
PerRunWallSeconds: 900,
|
||||
PerRunSteps: 40,
|
||||
MaxRunsPerScheduleDay: 4,
|
||||
MaxRunsGlobalDay: 6,
|
||||
GlobalTokensDay: 1_200_000,
|
||||
GlobalTokensWeek: 5_000_000,
|
||||
LifetimeTokens: 3_000_000,
|
||||
ExpiryDays: 30,
|
||||
MaxSchedules: 8,
|
||||
MaxConsecutiveFails: 3,
|
||||
MaxRepeatParks: 3,
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides is one configuration layer's optional values; nil means unset.
|
||||
// The keys belong to a [schedule] table.
|
||||
type Overrides struct {
|
||||
MinIntervalMinutes *int64 `toml:"min_interval_minutes" json:"min_interval_minutes,omitempty"`
|
||||
PerRunTokens *int64 `toml:"per_run_tokens" json:"per_run_tokens,omitempty"`
|
||||
PerRunWallSeconds *int64 `toml:"per_run_wall_seconds" json:"per_run_wall_seconds,omitempty"`
|
||||
PerRunSteps *int64 `toml:"per_run_steps" json:"per_run_steps,omitempty"`
|
||||
MaxRunsPerScheduleDay *int64 `toml:"max_runs_per_schedule_day" json:"max_runs_per_schedule_day,omitempty"`
|
||||
MaxRunsGlobalDay *int64 `toml:"max_runs_global_day" json:"max_runs_global_day,omitempty"`
|
||||
GlobalTokensDay *int64 `toml:"tokens_per_day" json:"tokens_per_day,omitempty"`
|
||||
GlobalTokensWeek *int64 `toml:"tokens_per_week" json:"tokens_per_week,omitempty"`
|
||||
LifetimeTokens *int64 `toml:"lifetime_tokens" json:"lifetime_tokens,omitempty"`
|
||||
ExpiryDays *int64 `toml:"expiry_days" json:"expiry_days,omitempty"`
|
||||
MaxSchedules *int64 `toml:"max_schedules" json:"max_schedules,omitempty"`
|
||||
MaxConsecutiveFails *int64 `toml:"max_consecutive_failures" json:"max_consecutive_failures,omitempty"`
|
||||
MaxRepeatParks *int64 `toml:"max_repeat_parks" json:"max_repeat_parks,omitempty"`
|
||||
}
|
||||
|
||||
// Ignored names a project-layer key that was dropped and why.
|
||||
type Ignored struct {
|
||||
Key string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type field struct {
|
||||
key string
|
||||
raise bool
|
||||
policy *int64
|
||||
user *int64
|
||||
proj *int64
|
||||
}
|
||||
|
||||
func (p *Policy) fields(user, project Overrides) []field {
|
||||
return []field{
|
||||
{"min_interval_minutes", true, &p.MinIntervalMinutes, user.MinIntervalMinutes, project.MinIntervalMinutes},
|
||||
{"per_run_tokens", false, &p.PerRunTokens, user.PerRunTokens, project.PerRunTokens},
|
||||
{"per_run_wall_seconds", false, &p.PerRunWallSeconds, user.PerRunWallSeconds, project.PerRunWallSeconds},
|
||||
{"per_run_steps", false, &p.PerRunSteps, user.PerRunSteps, project.PerRunSteps},
|
||||
{"max_runs_per_schedule_day", false, &p.MaxRunsPerScheduleDay, user.MaxRunsPerScheduleDay, project.MaxRunsPerScheduleDay},
|
||||
{"max_runs_global_day", false, &p.MaxRunsGlobalDay, user.MaxRunsGlobalDay, project.MaxRunsGlobalDay},
|
||||
{"tokens_per_day", false, &p.GlobalTokensDay, user.GlobalTokensDay, project.GlobalTokensDay},
|
||||
{"tokens_per_week", false, &p.GlobalTokensWeek, user.GlobalTokensWeek, project.GlobalTokensWeek},
|
||||
{"lifetime_tokens", false, &p.LifetimeTokens, user.LifetimeTokens, project.LifetimeTokens},
|
||||
{"expiry_days", false, &p.ExpiryDays, user.ExpiryDays, project.ExpiryDays},
|
||||
{"max_schedules", false, &p.MaxSchedules, user.MaxSchedules, project.MaxSchedules},
|
||||
{"max_consecutive_failures", false, &p.MaxConsecutiveFails, user.MaxConsecutiveFails, project.MaxConsecutiveFails},
|
||||
{"max_repeat_parks", false, &p.MaxRepeatParks, user.MaxRepeatParks, project.MaxRepeatParks},
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve applies the user layer over the defaults, then lets the project
|
||||
// layer tighten the result. A project value that would raise a ceiling, lower
|
||||
// the minimum interval or is not positive is dropped and reported. A user
|
||||
// interval under the hard floor is clamped and reported; other bad user values
|
||||
// are errors.
|
||||
func Resolve(user, project Overrides) (Policy, []Ignored, error) {
|
||||
p := DefaultPolicy()
|
||||
for _, f := range p.fields(user, Overrides{}) {
|
||||
if f.user == nil {
|
||||
continue
|
||||
}
|
||||
if *f.user <= 0 {
|
||||
return Policy{}, nil, fmt.Errorf("%w: %s must be positive", ErrPolicyInvalid, f.key)
|
||||
}
|
||||
*f.policy = *f.user
|
||||
}
|
||||
var ignored []Ignored
|
||||
if floor := int64(AbsoluteMinInterval / time.Minute); p.MinIntervalMinutes < floor {
|
||||
p.MinIntervalMinutes = floor
|
||||
ignored = append(ignored, Ignored{"min_interval_minutes", fmt.Sprintf("clamped to the %d minute hard floor", floor)})
|
||||
}
|
||||
for _, f := range p.fields(Overrides{}, project) {
|
||||
if f.proj == nil {
|
||||
continue
|
||||
}
|
||||
switch v := *f.proj; {
|
||||
case v <= 0:
|
||||
ignored = append(ignored, Ignored{f.key, "not positive"})
|
||||
case f.raise && v < *f.policy, !f.raise && v > *f.policy:
|
||||
ignored = append(ignored, Ignored{f.key, "project configuration cannot loosen this limit"})
|
||||
default:
|
||||
*f.policy = v
|
||||
}
|
||||
}
|
||||
return p, ignored, nil
|
||||
}
|
||||
|
||||
func (p Policy) MinInterval() time.Duration {
|
||||
return max(time.Duration(p.MinIntervalMinutes)*time.Minute, AbsoluteMinInterval)
|
||||
}
|
||||
|
||||
func (p Policy) Expiry() time.Duration {
|
||||
return time.Duration(p.ExpiryDays) * 24 * time.Hour
|
||||
}
|
||||
|
||||
// DefaultBudget is what a new schedule gets when its creator states none.
|
||||
func (p Policy) DefaultBudget() Budget {
|
||||
return Budget{
|
||||
PerRunTokens: p.PerRunTokens,
|
||||
PerRunWallSec: p.PerRunWallSeconds,
|
||||
PerRunSteps: p.PerRunSteps,
|
||||
MaxRunsPerDay: p.MaxRunsPerScheduleDay,
|
||||
LifetimeTokens: p.LifetimeTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// fits reports whether a schedule budget stays inside the policy ceilings.
|
||||
func (p Policy) fits(b Budget) error {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
have, cap_ int64
|
||||
}{
|
||||
{"perRunTokens", b.PerRunTokens, p.PerRunTokens},
|
||||
{"perRunWallSec", b.PerRunWallSec, p.PerRunWallSeconds},
|
||||
{"perRunSteps", b.PerRunSteps, p.PerRunSteps},
|
||||
{"maxRunsPerDay", b.MaxRunsPerDay, p.MaxRunsPerScheduleDay},
|
||||
{"lifetimeTokens", b.LifetimeTokens, p.LifetimeTokens},
|
||||
} {
|
||||
if c.have <= 0 || c.have > c.cap_ {
|
||||
return fmt.Errorf("%w: budget %s %d is outside 1..%d", ErrInvalid, c.name, c.have, c.cap_)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultPolicyMatchesOwnerDecision(t *testing.T) {
|
||||
p := DefaultPolicy()
|
||||
if p.MinIntervalMinutes != 60 || p.PerRunTokens != 250_000 || p.GlobalTokensDay != 1_200_000 ||
|
||||
p.GlobalTokensWeek != 5_000_000 || p.LifetimeTokens != 3_000_000 || p.ExpiryDays != 30 {
|
||||
t.Fatalf("defaults drifted: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserOverridesApply(t *testing.T) {
|
||||
p, ign, err := Resolve(Overrides{PerRunTokens: new(int64(100)), MinIntervalMinutes: new(int64(120)), GlobalTokensDay: new(int64(9_000_000))}, Overrides{})
|
||||
if err != nil || len(ign) != 0 {
|
||||
t.Fatalf("err=%v ignored=%v", err, ign)
|
||||
}
|
||||
if p.PerRunTokens != 100 || p.MinIntervalMinutes != 120 || p.GlobalTokensDay != 9_000_000 {
|
||||
t.Fatalf("user layer not applied: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectCannotLoosenAnyLimit(t *testing.T) {
|
||||
d := DefaultPolicy()
|
||||
loose := Overrides{
|
||||
MinIntervalMinutes: new(d.MinIntervalMinutes - 1), PerRunTokens: new(d.PerRunTokens + 1),
|
||||
PerRunWallSeconds: new(d.PerRunWallSeconds + 1), PerRunSteps: new(d.PerRunSteps + 1),
|
||||
MaxRunsPerScheduleDay: new(d.MaxRunsPerScheduleDay + 1), MaxRunsGlobalDay: new(d.MaxRunsGlobalDay + 1),
|
||||
GlobalTokensDay: new(d.GlobalTokensDay + 1), GlobalTokensWeek: new(d.GlobalTokensWeek + 1),
|
||||
LifetimeTokens: new(d.LifetimeTokens + 1), ExpiryDays: new(d.ExpiryDays + 1),
|
||||
MaxSchedules: new(d.MaxSchedules + 1), MaxConsecutiveFails: new(d.MaxConsecutiveFails + 1),
|
||||
MaxRepeatParks: new(d.MaxRepeatParks + 1),
|
||||
}
|
||||
p, ign, err := Resolve(Overrides{}, loose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p != d {
|
||||
t.Fatalf("project layer loosened policy:\n got %+v\nwant %+v", p, d)
|
||||
}
|
||||
if len(ign) != 13 {
|
||||
t.Fatalf("every loosening must be reported, got %d: %v", len(ign), ign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectCanTighten(t *testing.T) {
|
||||
p, ign, err := Resolve(Overrides{PerRunTokens: new(int64(400_000))}, Overrides{PerRunTokens: new(int64(50_000)), MinIntervalMinutes: new(int64(180))})
|
||||
if err != nil || len(ign) != 0 {
|
||||
t.Fatalf("err=%v ignored=%v", err, ign)
|
||||
}
|
||||
if p.PerRunTokens != 50_000 || p.MinIntervalMinutes != 180 {
|
||||
t.Fatalf("tightening not applied: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProjectCannotExceedUserRaisedValue(t *testing.T) {
|
||||
p, ign, _ := Resolve(Overrides{PerRunTokens: new(int64(400_000))}, Overrides{PerRunTokens: new(int64(500_000))})
|
||||
if p.PerRunTokens != 400_000 || len(ign) != 1 || ign[0].Key != "per_run_tokens" {
|
||||
t.Fatalf("project exceeded the user value: %+v %v", p, ign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsBadUserValues(t *testing.T) {
|
||||
for _, o := range []Overrides{{PerRunTokens: new(int64(0))}, {ExpiryDays: new(int64(-1))}} {
|
||||
if _, _, err := Resolve(o, Overrides{}); !errors.Is(err, ErrPolicyInvalid) {
|
||||
t.Fatalf("want ErrPolicyInvalid for %+v, got %v", o, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinIntervalIsUserLowerableAboveHardFloor(t *testing.T) {
|
||||
p, ign, err := Resolve(Overrides{MinIntervalMinutes: new(int64(15))}, Overrides{})
|
||||
if err != nil || len(ign) != 0 || p.MinIntervalMinutes != 15 || p.MinInterval() != 15*time.Minute {
|
||||
t.Fatalf("15 minutes must be accepted: %+v %v %v", p, ign, err)
|
||||
}
|
||||
p, ign, err = Resolve(Overrides{MinIntervalMinutes: new(int64(5))}, Overrides{})
|
||||
if err != nil || p.MinIntervalMinutes != 10 || len(ign) != 1 || ign[0].Key != "min_interval_minutes" {
|
||||
t.Fatalf("5 minutes must clamp to the floor and be reported: %+v %v %v", p, ign, err)
|
||||
}
|
||||
p, ign, _ = Resolve(Overrides{MinIntervalMinutes: new(int64(15))}, Overrides{MinIntervalMinutes: new(int64(10))})
|
||||
if p.MinIntervalMinutes != 15 || len(ign) != 1 {
|
||||
t.Fatalf("project cannot lower the interval: %+v %v", p, ign)
|
||||
}
|
||||
if got := (Policy{MinIntervalMinutes: 1}).MinInterval(); got != AbsoluteMinInterval {
|
||||
t.Fatalf("a hand-built policy is floored too: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIgnoresNonPositiveProjectValue(t *testing.T) {
|
||||
p, ign, err := Resolve(Overrides{}, Overrides{PerRunTokens: new(int64(0))})
|
||||
if err != nil || p != DefaultPolicy() || len(ign) != 1 {
|
||||
t.Fatalf("p=%+v ign=%v err=%v", p, ign, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/base/fileutil"
|
||||
"reasonix/internal/contract/observe"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxReportBytes bounds the stored final report of one run.
|
||||
MaxReportBytes = 16 << 10
|
||||
// MaxResults bounds how many run results are kept; the oldest go first.
|
||||
MaxResults = 100
|
||||
|
||||
maxPendingSummary = 1 << 10
|
||||
maxPendingDetail = 4 << 10
|
||||
maxSessionPath = 1 << 10
|
||||
resultsDirName = "results"
|
||||
)
|
||||
|
||||
// Result is what a finished run left for a person: its final report and what
|
||||
// it parked. Every string in it is sanitized on the way in and Untrusted stays
|
||||
// set on model-authored text, so a reader never has to remember to do either.
|
||||
type Result struct {
|
||||
TriggerID string `json:"triggerId"`
|
||||
Report string `json:"report"`
|
||||
// ReportUntrusted is always true: the report is model text.
|
||||
ReportUntrusted bool `json:"reportUntrusted"`
|
||||
ReportTruncated bool `json:"reportTruncated,omitempty"`
|
||||
Pending []observe.Pending `json:"pending"`
|
||||
PendingDropped int `json:"pendingDropped,omitempty"`
|
||||
Posture observe.Posture `json:"posture"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
RecordedAt time.Time `json:"recordedAt"`
|
||||
}
|
||||
|
||||
func resultName(triggerID string) string {
|
||||
sum := sha256.Sum256([]byte(triggerID))
|
||||
return hex.EncodeToString(sum[:16]) + ".json"
|
||||
}
|
||||
|
||||
func (s *Store) resultsDir() string { return filepath.Join(s.dir, resultsDirName) }
|
||||
|
||||
// PutResult records the result of a run that is still in flight. The report and
|
||||
// the parked records are cleaned and cut to their bounds here, so no writer can
|
||||
// store text the store's readers would have to distrust again. A run that has
|
||||
// already settled (reaped while the child was slow) takes no result.
|
||||
func (s *Store) PutResult(ctx context.Context, triggerID string, r Result) error {
|
||||
r = boundResult(r)
|
||||
r.TriggerID = triggerID
|
||||
r.SessionPath = s.sessionPathOrEmpty(r.SessionPath)
|
||||
return s.locked(ctx, func() error {
|
||||
m, _, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run := m.run(triggerID)
|
||||
if run == nil {
|
||||
return ErrRunNotFound
|
||||
}
|
||||
if !run.inFlight() {
|
||||
return ErrRunSettled
|
||||
}
|
||||
r.RecordedAt = s.clock()
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule: encode result: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(s.resultsDir(), 0o700); err != nil {
|
||||
return fmt.Errorf("schedule: results dir: %w", err)
|
||||
}
|
||||
name := resultName(triggerID)
|
||||
if err := fileutil.AtomicWriteFileStrict(filepath.Join(s.resultsDir(), name), data, 0o600); err != nil {
|
||||
return fmt.Errorf("schedule: write result: %w", err)
|
||||
}
|
||||
s.pruneResults(name)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetResult returns the stored result of a run.
|
||||
func (s *Store) GetResult(ctx context.Context, triggerID string) (Result, error) {
|
||||
var out Result
|
||||
err := s.locked(ctx, func() error {
|
||||
data, err := readCapped(filepath.Join(s.resultsDir(), resultName(triggerID)))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrResultNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule: read result: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.TriggerID != triggerID {
|
||||
return ErrResultNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func boundResult(r Result) Result {
|
||||
r.ReportUntrusted = true
|
||||
r.Report = observe.Sanitize(r.Report)
|
||||
if len(r.Report) > MaxReportBytes {
|
||||
r.Report, r.ReportTruncated = cutUTF8(r.Report, MaxReportBytes), true
|
||||
}
|
||||
r.SessionPath = cutUTF8(observe.Sanitize(r.SessionPath), maxSessionPath)
|
||||
r.Posture.RemoteContent = false
|
||||
if len(r.Pending) > MaxResultPending {
|
||||
r.PendingDropped = len(r.Pending) - MaxResultPending
|
||||
r.Pending = r.Pending[:MaxResultPending]
|
||||
}
|
||||
kept := make([]observe.Pending, len(r.Pending))
|
||||
for i, p := range r.Pending {
|
||||
p.Summary = cutUTF8(observe.Sanitize(p.Summary), maxPendingSummary)
|
||||
p.Detail = cutUTF8(observe.Sanitize(p.Detail), maxPendingDetail)
|
||||
p.Source = cutUTF8(observe.Sanitize(p.Source), maxPendingSummary)
|
||||
if p.Detail != "" {
|
||||
p.Untrusted = true
|
||||
}
|
||||
kept[i] = p
|
||||
}
|
||||
r.Pending = kept
|
||||
return r
|
||||
}
|
||||
|
||||
// MaxResultPending is the most parked records one stored result keeps: the most
|
||||
// a run may park.
|
||||
const MaxResultPending = observe.MaxPending
|
||||
|
||||
// ClipReport cuts a report to MaxReportBytes at a character boundary and says
|
||||
// whether it cut.
|
||||
func ClipReport(s string) (string, bool) {
|
||||
if len(s) <= MaxReportBytes {
|
||||
return s, false
|
||||
}
|
||||
return cutUTF8(s, MaxReportBytes), true
|
||||
}
|
||||
|
||||
func cutUTF8(s string, limit int) string {
|
||||
if len(s) <= limit {
|
||||
return s
|
||||
}
|
||||
s = s[:limit]
|
||||
for len(s) > 0 && !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) pruneResults(keep string) {
|
||||
entries, err := os.ReadDir(s.resultsDir())
|
||||
if err != nil || len(entries) <= MaxResults {
|
||||
return
|
||||
}
|
||||
type aged struct {
|
||||
name string
|
||||
at time.Time
|
||||
}
|
||||
var files []aged
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") || e.Name() == keep {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
files = append(files, aged{e.Name(), info.ModTime()})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(files, func(a, b aged) int { return a.at.Compare(b.at) })
|
||||
for _, f := range files[:max(len(files)+1-MaxResults, 0)] {
|
||||
_ = os.Remove(filepath.Join(s.resultsDir(), f.name))
|
||||
}
|
||||
}
|
||||
|
||||
// sessionPathOrEmpty keeps a session path the child reported only when it names a
|
||||
// transcript file in a sessions directory under the state root, so a reader that
|
||||
// opens it cannot be pointed at credentials, trusted state or anything else.
|
||||
func (s *Store) sessionPathOrEmpty(p string) string {
|
||||
if p == "" || !filepath.IsAbs(p) || filepath.Ext(p) != ".jsonl" {
|
||||
return ""
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(filepath.Dir(s.dir))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
dir, err := filepath.EvalSymlinks(filepath.Dir(p))
|
||||
if err != nil || filepath.Base(dir) != "sessions" {
|
||||
return ""
|
||||
}
|
||||
rel, err := filepath.Rel(root, dir)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/observe"
|
||||
)
|
||||
|
||||
func claimedRun(t *testing.T) (*Store, *fakeClock, string, Run) {
|
||||
t.Helper()
|
||||
st, clk, dir := newTestStore(t)
|
||||
sc := mustCreate(t, st, DefaultPolicy(), everyReq())
|
||||
clk.Advance(time.Duration(sc.Trigger.EverySec)*time.Second + time.Second)
|
||||
run, err := st.Claim(t.Context(), DefaultPolicy(), sc.ID, slotN(sc, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st, clk, dir, run
|
||||
}
|
||||
|
||||
func TestPutResultCleansAndBoundsWhatItStores(t *testing.T) {
|
||||
st, _, _, run := claimedRun(t)
|
||||
pending := make([]observe.Pending, MaxResultPending+5)
|
||||
for i := range pending {
|
||||
pending[i] = observe.Pending{ID: fmt.Sprintf("p%d", i), Kind: observe.KindAsk, Source: "ask", Summary: "s", Detail: "d\x00" + strings.Repeat("y", 10<<10)}
|
||||
}
|
||||
in := Result{Report: "a\x00bc" + strings.Repeat("é", MaxReportBytes), Pending: pending, SessionPath: "p\x1b/",
|
||||
Posture: observe.Posture{Name: "observe", RemoteContent: true}}
|
||||
if err := st.PutResult(t.Context(), run.TriggerID, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetResult(t.Context(), run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(got.Report, "\x00") || len(got.Report) > MaxReportBytes || !got.ReportTruncated {
|
||||
t.Fatalf("report: %d bytes, truncated=%v, %q...", len(got.Report), got.ReportTruncated, got.Report[:8])
|
||||
}
|
||||
if strings.Contains(got.Report, "�") {
|
||||
t.Fatal("the report was cut in the middle of a character")
|
||||
}
|
||||
if len(got.Pending) != MaxResultPending || got.PendingDropped != 5 {
|
||||
t.Fatalf("pending = %d dropped %d", len(got.Pending), got.PendingDropped)
|
||||
}
|
||||
if p := got.Pending[0]; strings.ContainsAny(p.Summary+p.Detail, "\x00") || len(p.Detail) > maxPendingDetail || !p.Untrusted {
|
||||
t.Fatalf("pending not cleaned: %+v", p)
|
||||
}
|
||||
if got.Posture.RemoteContent || got.SessionPath != "" {
|
||||
t.Fatalf("posture/session = %+v %q", got.Posture, got.SessionPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutResultRefusesASettledOrUnknownRun(t *testing.T) {
|
||||
st, _, _, run := claimedRun(t)
|
||||
if err := st.PutResult(t.Context(), "sch_x/1", Result{}); !errors.Is(err, ErrRunNotFound) {
|
||||
t.Fatalf("unknown run: %v", err)
|
||||
}
|
||||
if err := st.Finish(t.Context(), DefaultPolicy(), run.TriggerID, Outcome{State: RunSucceeded, Clean: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.PutResult(t.Context(), run.TriggerID, Result{Report: "late"}); !errors.Is(err, ErrRunSettled) {
|
||||
t.Fatalf("settled run: %v", err)
|
||||
}
|
||||
if _, err := st.GetResult(t.Context(), run.TriggerID); !errors.Is(err, ErrResultNotFound) {
|
||||
t.Fatalf("a refused result must not be stored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultsAreARing(t *testing.T) {
|
||||
st, _, dir, run := claimedRun(t)
|
||||
rdir := filepath.Join(dir, resultsDirName)
|
||||
if err := os.MkdirAll(rdir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-time.Hour)
|
||||
for i := range MaxResults + 20 {
|
||||
p := filepath.Join(rdir, fmt.Sprintf("%032d.json", i))
|
||||
if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(p, old.Add(time.Duration(i)*time.Second), old.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
if err := st.PutResult(t.Context(), run.TriggerID, Result{Report: "new"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(rdir)
|
||||
if len(entries) != MaxResults {
|
||||
t.Fatalf("%d result files kept, want %d", len(entries), MaxResults)
|
||||
}
|
||||
if _, err := st.GetResult(t.Context(), run.TriggerID); err != nil {
|
||||
t.Fatalf("the newest result was pruned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLeaseIsExclusiveAndDiesWithItsHolder(t *testing.T) {
|
||||
st, _, _, run := claimedRun(t)
|
||||
if st.RunHeld(run) {
|
||||
t.Fatal("a fresh run reads as held")
|
||||
}
|
||||
release, err := st.HoldRun(run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.HoldRun(run.TriggerID); !errors.Is(err, ErrRunHeld) {
|
||||
t.Fatalf("second holder: %v", err)
|
||||
}
|
||||
if !st.RunHeld(run) {
|
||||
t.Fatal("a held lease reads as free")
|
||||
}
|
||||
release()
|
||||
if st.RunHeld(run) {
|
||||
t.Fatal("a released lease reads as held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReapDeadSettlesOnlyRunsWhoseLeaseIsFree(t *testing.T) {
|
||||
st, clk, _, run := claimedRun(t)
|
||||
release, err := st.HoldRun(run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(ReapGrace + time.Minute)
|
||||
if reaped, err := st.ReapDead(t.Context(), DefaultPolicy()); err != nil || len(reaped) != 0 {
|
||||
t.Fatalf("a run with a live lease was reaped: %v %v", reaped, err)
|
||||
}
|
||||
release()
|
||||
reaped, err := st.ReapDead(t.Context(), DefaultPolicy())
|
||||
if err != nil || len(reaped) != 1 {
|
||||
t.Fatalf("ReapDead = %v, %v", reaped, err)
|
||||
}
|
||||
m, _, _ := st.Snapshot(t.Context())
|
||||
if r := m.run(run.TriggerID); r.State != RunInterrupted || r.Charged != r.PerRunCap {
|
||||
t.Fatalf("reaped run = %s charged %d", r.State, r.Charged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverridesReadsOnlyTheScheduleTable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
if o, err := LoadOverrides(path); err != nil || o != (Overrides{}) {
|
||||
t.Fatalf("missing file: %+v %v", o, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("default_model = \"x\"\n\n[schedule]\nmax_repeat_parks = 5\nper_run_tokens = 1000\n\n[other]\nmax_repeat_parks = 9\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o, err := LoadOverrides(path)
|
||||
if err != nil || o.MaxRepeatParks == nil || *o.MaxRepeatParks != 5 || *o.PerRunTokens != 1000 {
|
||||
t.Fatalf("overrides = %+v, %v", o, err)
|
||||
}
|
||||
p, _, err := Resolve(o, Overrides{})
|
||||
if err != nil || p.MaxRepeatParks != 5 {
|
||||
t.Fatalf("policy = %+v, %v", p, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[schedule\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadOverrides(path); !errors.Is(err, ErrPolicyInvalid) {
|
||||
t.Fatalf("a file that does not parse must refuse, not fall back: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCannotRaiseTheRepeatParkLimit(t *testing.T) {
|
||||
p, ign, err := Resolve(Overrides{}, Overrides{MaxRepeatParks: new(int64(50))})
|
||||
if err != nil || p.MaxRepeatParks != 3 || len(ign) != 1 {
|
||||
t.Fatalf("policy %+v ignored %v err %v", p, ign, err)
|
||||
}
|
||||
p, _, _ = Resolve(Overrides{}, Overrides{MaxRepeatParks: new(int64(2))})
|
||||
if p.MaxRepeatParks != 2 {
|
||||
t.Fatalf("a project may tighten it: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAdmitsOnlyTheTokenHolderOnce(t *testing.T) {
|
||||
st, _, dir, run := claimedRun(t)
|
||||
if err := st.Start(t.Context(), run.TriggerID, "x"); !errors.Is(err, ErrRunSettled) {
|
||||
t.Fatalf("a claimed run cannot start: %v", err)
|
||||
}
|
||||
token, err := st.MarkRunning(t.Context(), run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
if strings.Contains(string(raw), token) {
|
||||
t.Fatal("the token itself is in the manifest")
|
||||
}
|
||||
if err := st.Start(t.Context(), run.TriggerID, "guess"); !errors.Is(err, ErrRunToken) {
|
||||
t.Fatalf("wrong token: %v", err)
|
||||
}
|
||||
if err := st.Start(t.Context(), run.TriggerID, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Start(t.Context(), run.TriggerID, token); !errors.Is(err, ErrRunStarted) {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupervisionKeepsARunAliveAcrossTheExecutorLeaving(t *testing.T) {
|
||||
st, clk, _, run := claimedRun(t)
|
||||
release, err := st.HoldSupervision(run.TriggerID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.HoldSupervision(run.TriggerID); !errors.Is(err, ErrRunHeld) {
|
||||
t.Fatalf("second supervisor: %v", err)
|
||||
}
|
||||
clk.Advance(ReapGrace + time.Minute)
|
||||
if reaped, _ := st.ReapDead(t.Context(), DefaultPolicy()); len(reaped) != 0 {
|
||||
t.Fatal("a supervised run was reaped")
|
||||
}
|
||||
release()
|
||||
if reaped, _ := st.ReapDead(t.Context(), DefaultPolicy()); len(reaped) != 1 {
|
||||
t.Fatal("a run nobody holds was not reaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPathMustLieUnderTheStateRoot(t *testing.T) {
|
||||
st, _, dir, run := claimedRun(t)
|
||||
inside := filepath.Join(filepath.Dir(dir), "projects", "p", "sessions")
|
||||
if err := os.MkdirAll(inside, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(inside, "a.jsonl"): filepath.Join(inside, "a.jsonl"),
|
||||
filepath.Join(t.TempDir(), "a.jsonl"): "",
|
||||
filepath.Join(filepath.Dir(dir), "..", "x.json"): "",
|
||||
"relative.jsonl": "",
|
||||
} {
|
||||
if got := st.sessionPathOrEmpty(path); got != want {
|
||||
t.Errorf("sessionPathOrEmpty(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
if err := st.PutResult(t.Context(), run.TriggerID, Result{Report: "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := st.GetResult(t.Context(), run.TriggerID); !got.ReportUntrusted {
|
||||
t.Fatal("a report must be marked untrusted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
)
|
||||
|
||||
const leasesDirName = "leases"
|
||||
|
||||
func (s *Store) leasePath(triggerID string) string {
|
||||
return filepath.Join(s.dir, leasesDirName, strings.TrimSuffix(resultName(triggerID), ".json")+".lock")
|
||||
}
|
||||
|
||||
func (s *Store) supervisionPath(triggerID string) string {
|
||||
return filepath.Join(s.dir, leasesDirName, strings.TrimSuffix(resultName(triggerID), ".json")+".sup")
|
||||
}
|
||||
|
||||
// HoldSupervision takes the lock the supervisor keeps from before it spawns the
|
||||
// child until the run is settled. A run counts as alive while either this or the
|
||||
// executor's lease is held, so the reaper never sees a gap between the child
|
||||
// leaving and the supervisor recording what it did.
|
||||
func (s *Store) HoldSupervision(triggerID string) (release func(), err error) {
|
||||
return s.hold(s.supervisionPath(triggerID))
|
||||
}
|
||||
|
||||
// HoldRun takes the exclusive operating-system lock that says a run is alive.
|
||||
// Of any number of executors started for one claim exactly one gets it; the
|
||||
// rest get ErrRunHeld. The lock dies with its holder, so a kill or a crash frees
|
||||
// it without anyone having to clean up, which is why the reaper reads it and no
|
||||
// file content.
|
||||
func (s *Store) HoldRun(triggerID string) (release func(), err error) {
|
||||
return s.hold(s.leasePath(triggerID))
|
||||
}
|
||||
|
||||
func (s *Store) hold(path string) (release func(), err error) {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, leasesDirName), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("schedule: leases dir: %w", err)
|
||||
}
|
||||
release, err = filelock.TryAcquire(path)
|
||||
if errors.Is(err, filelock.ErrHeld) {
|
||||
return nil, ErrRunHeld
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("schedule: run lease: %w", err)
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// RunHeld reports whether some executor holds the run's lease right now. A lease
|
||||
// that cannot be probed counts as held: a run is never reaped on a guess.
|
||||
func (s *Store) RunHeld(r Run) bool {
|
||||
return slices.ContainsFunc([]string{s.leasePath(r.TriggerID), s.supervisionPath(r.TriggerID)}, s.lockHeld)
|
||||
}
|
||||
|
||||
func (s *Store) lockHeld(path string) bool {
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, leasesDirName), 0o700); err != nil {
|
||||
return true
|
||||
}
|
||||
release, err := filelock.TryAcquire(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
release()
|
||||
return false
|
||||
}
|
||||
|
||||
// ReapDead settles every in-flight run whose executor is gone, deciding from the
|
||||
// operating-system lease alone, and drops lease files no record refers to.
|
||||
func (s *Store) ReapDead(ctx context.Context, p Policy) ([]string, error) {
|
||||
reaped, err := s.Reap(ctx, p, s.RunHeld)
|
||||
if m, _, snapErr := s.Snapshot(ctx); snapErr == nil {
|
||||
s.pruneLeases(m)
|
||||
}
|
||||
return reaped, err
|
||||
}
|
||||
|
||||
func (s *Store) pruneLeases(m Manifest) {
|
||||
entries, err := os.ReadDir(filepath.Join(s.dir, leasesDirName))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
live := map[string]bool{}
|
||||
for _, r := range m.Runs {
|
||||
live[filepath.Base(s.leasePath(r.TriggerID))] = true
|
||||
live[filepath.Base(s.supervisionPath(r.TriggerID))] = true
|
||||
}
|
||||
cutoff := s.clock().Add(-ReapGrace)
|
||||
for _, e := range entries {
|
||||
if live[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(s.dir, leasesDirName, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/filelock"
|
||||
"reasonix/internal/base/fileutil"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestName = "schedules.json"
|
||||
lockName = "transaction.lock"
|
||||
quarantineName = "quarantine"
|
||||
)
|
||||
|
||||
// Store is the machine-wide schedule manifest under one directory. It keeps no
|
||||
// mutable state: every operation re-reads the file under the transaction lock,
|
||||
// so any number of Stores, in any number of processes, agree.
|
||||
type Store struct {
|
||||
dir string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Open prepares dir as a private store directory. dir must come from the
|
||||
// user-level state root (never from a project); the wiring that supplies it
|
||||
// asserts that.
|
||||
func Open(dir string) (*Store, error) {
|
||||
return open(dir, time.Now)
|
||||
}
|
||||
|
||||
// WithClock returns a view of the same store that reads time from now, for a
|
||||
// test that has to see a run age past ReapGrace without waiting for it.
|
||||
func (s *Store) WithClock(now func() time.Time) *Store { return &Store{dir: s.dir, now: now} }
|
||||
|
||||
// Dir is the directory the store lives in.
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
func open(dir string, now func() time.Time) (*Store, error) {
|
||||
s := &Store{dir: dir, now: now}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("schedule: create store dir: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) clock() time.Time { return s.now().UTC().Truncate(time.Second) }
|
||||
|
||||
func (s *Store) manifestPath() string { return filepath.Join(s.dir, manifestName) }
|
||||
|
||||
// Snapshot returns a copy of the manifest and whether it is read-only (written
|
||||
// by a newer schema). A corrupt manifest is quarantined and replaced by an
|
||||
// empty one with every schedule paused.
|
||||
func (s *Store) Snapshot(ctx context.Context) (Manifest, bool, error) {
|
||||
var out Manifest
|
||||
var ro bool
|
||||
err := s.locked(ctx, func() error {
|
||||
m, readonly, err := s.loadLocked()
|
||||
out, ro = m, readonly
|
||||
return err
|
||||
})
|
||||
return out, ro, err
|
||||
}
|
||||
|
||||
func (s *Store) locked(ctx context.Context, fn func() error) error {
|
||||
release, err := filelock.Acquire(ctx, filepath.Join(s.dir, lockName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule: transaction lock: %w", err)
|
||||
}
|
||||
defer release()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// update runs fn against a fresh read and commits the result atomically. If fn
|
||||
// returns an error nothing is written.
|
||||
func (s *Store) update(ctx context.Context, fn func(m *Manifest) error) error {
|
||||
return s.locked(ctx, func() error {
|
||||
m, readonly, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if readonly {
|
||||
return ErrSchemaReadonly
|
||||
}
|
||||
if err := fn(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.SchemaVersion = SchemaVersion
|
||||
m.Revision++
|
||||
return s.writeLocked(m)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) writeLocked(m Manifest) error {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule: encode manifest: %w", err)
|
||||
}
|
||||
if len(data) > MaxManifest {
|
||||
return fmt.Errorf("%w: manifest exceeds %d bytes", ErrInvalid, MaxManifest)
|
||||
}
|
||||
if err := fileutil.AtomicWriteFileStrict(s.manifestPath(), data, 0o600); err != nil {
|
||||
return fmt.Errorf("schedule: write manifest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadLocked() (Manifest, bool, error) {
|
||||
data, err := readCapped(s.manifestPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Manifest{SchemaVersion: SchemaVersion}, false, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, errOversize) {
|
||||
return Manifest{}, false, fmt.Errorf("schedule: read manifest: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
var m Manifest
|
||||
if json.Unmarshal(data, &m) == nil && m.SchemaVersion >= 1 {
|
||||
return m, m.SchemaVersion > SchemaVersion, nil
|
||||
}
|
||||
}
|
||||
return s.quarantineLocked()
|
||||
}
|
||||
|
||||
func (s *Store) quarantineLocked() (Manifest, bool, error) {
|
||||
qdir := filepath.Join(s.dir, quarantineName)
|
||||
if err := os.MkdirAll(qdir, 0o700); err != nil {
|
||||
return Manifest{}, false, fmt.Errorf("schedule: quarantine dir: %w", err)
|
||||
}
|
||||
dst := filepath.Join(qdir, manifestName+"."+strconv.FormatInt(s.now().UnixNano(), 10))
|
||||
if err := os.Rename(s.manifestPath(), dst); err != nil {
|
||||
return Manifest{}, false, fmt.Errorf("schedule: quarantine corrupt manifest: %w", err)
|
||||
}
|
||||
m := Manifest{SchemaVersion: SchemaVersion, Revision: 1, PausedAll: true, PausedReason: PauseCorrupt}
|
||||
if err := s.writeLocked(m); err != nil {
|
||||
return Manifest{}, false, err
|
||||
}
|
||||
return m, false, nil
|
||||
}
|
||||
|
||||
var errOversize = errors.New("manifest too large")
|
||||
|
||||
func readCapped(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, MaxManifest+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > MaxManifest {
|
||||
return nil, errOversize
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinAtLead is how far ahead a one-shot trigger must sit when created.
|
||||
const MinAtLead = time.Minute
|
||||
|
||||
// TriggerID is the dedup key of one slot: the schedule and the slot's Unix second.
|
||||
func TriggerID(scheduleID string, slot time.Time) string {
|
||||
return scheduleID + "/" + strconv.FormatInt(slot.Unix(), 10)
|
||||
}
|
||||
|
||||
// IsSlot reports whether t is exactly a slot of the trigger. Slots of an
|
||||
// interval trigger are anchor + k*every for k >= 1, so they never drift.
|
||||
func (t Trigger) IsSlot(slot time.Time) bool {
|
||||
switch t.Kind {
|
||||
case TriggerAt:
|
||||
return slot.Unix() == t.At.Unix()
|
||||
case TriggerEvery:
|
||||
every := t.EverySec
|
||||
d := slot.Unix() - t.Anchor.Unix()
|
||||
return every > 0 && d >= every && d%every == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LatestSlot is the most recent slot at or before now. Only the latest is
|
||||
// returned: a long absence yields one slot, not a backlog.
|
||||
func (t Trigger) LatestSlot(now time.Time) (time.Time, bool) {
|
||||
switch t.Kind {
|
||||
case TriggerAt:
|
||||
if now.Before(t.At) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.At.UTC(), true
|
||||
case TriggerEvery:
|
||||
if t.EverySec <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
d := now.Unix() - t.Anchor.Unix()
|
||||
if d < t.EverySec {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(t.Anchor.Unix()+d/t.EverySec*t.EverySec, 0).UTC(), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (t Trigger) validate(p Policy, now time.Time) error {
|
||||
switch t.Kind {
|
||||
case TriggerAt:
|
||||
if t.At.Before(now.Add(MinAtLead)) {
|
||||
return fmt.Errorf("%w: at must be at least %s ahead", ErrInvalid, MinAtLead)
|
||||
}
|
||||
case TriggerEvery:
|
||||
if time.Duration(t.EverySec)*time.Second < p.MinInterval() {
|
||||
return fmt.Errorf("%w: every %ds, minimum %s", ErrIntervalBelowFloor, t.EverySec, p.MinInterval())
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown trigger kind %q", ErrInvalid, t.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Digest is the sha256 over everything a person confirmed. Expiry and status
|
||||
// are not in it: renewing or pausing is not a change of what was agreed to.
|
||||
func Digest(s Schedule) string {
|
||||
canon := struct {
|
||||
Trigger Trigger `json:"trigger"`
|
||||
Target Target `json:"target"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model Model `json:"model"`
|
||||
Grant Grant `json:"grant"`
|
||||
Budget Budget `json:"budget"`
|
||||
}{s.Trigger, s.Target, s.Prompt, s.Model, s.Grant, s.Budget}
|
||||
b, _ := json.Marshal(canon)
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package schedule
|
||||
|
||||
import "time"
|
||||
|
||||
// SchemaVersion is the manifest layout this build writes. A newer manifest
|
||||
// loads read-only.
|
||||
const SchemaVersion = 1
|
||||
|
||||
const (
|
||||
MaxPromptBytes = 8 << 10
|
||||
MaxLabelRunes = 48
|
||||
MaxRuns = 500
|
||||
RunRetention = 90 * 24 * time.Hour
|
||||
MaxManifest = 8 << 20
|
||||
)
|
||||
|
||||
type TriggerKind string
|
||||
|
||||
const (
|
||||
TriggerAt TriggerKind = "at"
|
||||
TriggerEvery TriggerKind = "every"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusPaused Status = "paused"
|
||||
StatusExpired Status = "expired"
|
||||
StatusCircuitOpen Status = "circuit-open"
|
||||
)
|
||||
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
RunClaimed RunState = "claimed"
|
||||
RunRunning RunState = "running"
|
||||
RunSucceeded RunState = "succeeded"
|
||||
RunFailed RunState = "failed"
|
||||
RunBlocked RunState = "blocked"
|
||||
RunBudgetStopped RunState = "budget_stopped"
|
||||
RunInterrupted RunState = "interrupted"
|
||||
RunSkipped RunState = "skipped"
|
||||
)
|
||||
|
||||
// PauseReason is a typed code, never prose.
|
||||
type PauseReason string
|
||||
|
||||
const (
|
||||
PauseUser PauseReason = "user"
|
||||
PauseCorrupt PauseReason = "corrupt"
|
||||
PauseBreaker PauseReason = "breaker"
|
||||
PauseLifetime PauseReason = "lifetime_budget"
|
||||
PauseFailures PauseReason = "consecutive_failures"
|
||||
)
|
||||
|
||||
const ConfirmedByHuman = "human-frontend"
|
||||
|
||||
type Trigger struct {
|
||||
Kind TriggerKind `json:"kind"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
EverySec int64 `json:"everySec,omitempty"`
|
||||
Anchor time.Time `json:"anchor,omitzero"`
|
||||
}
|
||||
|
||||
type Target struct {
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// Grant carries no network, MCP, subagent or memory-write field: absence is the deny.
|
||||
type Grant struct {
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
ReadOnlyBash bool `json:"readOnlyBash,omitempty"`
|
||||
ReadRoots []string `json:"readRoots,omitempty"`
|
||||
}
|
||||
|
||||
type Budget struct {
|
||||
PerRunTokens int64 `json:"perRunTokens"`
|
||||
PerRunWallSec int64 `json:"perRunWallSec"`
|
||||
PerRunSteps int64 `json:"perRunSteps"`
|
||||
MaxRunsPerDay int64 `json:"maxRunsPerDay"`
|
||||
LifetimeTokens int64 `json:"lifetimeTokens"`
|
||||
}
|
||||
|
||||
type Confirmation struct {
|
||||
At time.Time `json:"at"`
|
||||
By string `json:"by"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type Schedule struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Trigger Trigger `json:"trigger"`
|
||||
Target Target `json:"target"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model Model `json:"model"`
|
||||
Grant Grant `json:"grant"`
|
||||
Budget Budget `json:"budget"`
|
||||
Confirmed Confirmation `json:"confirmed"`
|
||||
Status Status `json:"status"`
|
||||
PausedReason PauseReason `json:"pausedReason,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
||||
SpentLifetimeTokens int64 `json:"spentLifetimeTokens,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Run is one claimed slot. Charged is the reservation (the full per-run cap)
|
||||
// from claim until settlement, then the settled figure.
|
||||
type Run struct {
|
||||
TriggerID string `json:"triggerId"`
|
||||
ScheduleID string `json:"scheduleId"`
|
||||
SlotAt time.Time `json:"slotAt"`
|
||||
ClaimedAt time.Time `json:"claimedAt"`
|
||||
EndedAt time.Time `json:"endedAt,omitzero"`
|
||||
State RunState `json:"state"`
|
||||
SkipReason string `json:"skipReason,omitempty"`
|
||||
Observed int64 `json:"observedTokens,omitempty"`
|
||||
Charged int64 `json:"chargedTokens"`
|
||||
PerRunCap int64 `json:"perRunCap"`
|
||||
// StartHash is the sha256 of the token MarkRunning issued; the token itself
|
||||
// travels only over the supervisor's pipe. Started is set by Start.
|
||||
StartHash string `json:"startHash,omitempty"`
|
||||
Started bool `json:"started,omitempty"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Revision int64 `json:"revision"`
|
||||
PausedAll bool `json:"pausedAll,omitempty"`
|
||||
PausedReason PauseReason `json:"pausedReason,omitempty"`
|
||||
Schedules []Schedule `json:"schedules"`
|
||||
Runs []Run `json:"runs"`
|
||||
}
|
||||
|
||||
func (r Run) inFlight() bool { return r.State == RunClaimed || r.State == RunRunning }
|
||||
|
||||
func (m *Manifest) schedule(id string) *Schedule {
|
||||
for i := range m.Schedules {
|
||||
if m.Schedules[i].ID == id {
|
||||
return &m.Schedules[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manifest) run(triggerID string) *Run {
|
||||
for i := range m.Runs {
|
||||
if m.Runs[i].TriggerID == triggerID {
|
||||
return &m.Runs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -30,6 +30,7 @@ var TransientUserBlockTags = []string{
|
||||
"interrupted-turn-recovery",
|
||||
"execution-policy",
|
||||
"workspace",
|
||||
"scheduled-run",
|
||||
}
|
||||
|
||||
// SupersededUserBlockTags names the transient blocks carrying standing state
|
||||
@@ -43,6 +44,7 @@ var SupersededUserBlockTags = []string{
|
||||
"reasoning-language",
|
||||
"workspace",
|
||||
"project-instructions",
|
||||
"scheduled-run",
|
||||
}
|
||||
|
||||
// SupersededUserBlock indexes SupersededUserBlockTags so the two cannot drift.
|
||||
|
||||
@@ -23,6 +23,7 @@ func init() { tool.RegisterBuiltin(codeIndex{}) }
|
||||
type codeIndex struct {
|
||||
workDir string
|
||||
forbidRoots []string
|
||||
readRoots []string
|
||||
}
|
||||
|
||||
func (codeIndex) Name() string { return "code_index" }
|
||||
@@ -47,6 +48,8 @@ func (codeIndex) Schema() json.RawMessage {
|
||||
|
||||
func (codeIndex) ReadOnly() bool { return true }
|
||||
|
||||
func (codeIndex) Reach() tool.Reach { return tool.ReachLocalRead }
|
||||
|
||||
const (
|
||||
codeIndexDefaultLimit = 100
|
||||
codeIndexMaxLimit = 200
|
||||
@@ -111,6 +114,9 @@ func (c codeIndex) Execute(ctx context.Context, args json.RawMessage) (string, e
|
||||
}
|
||||
|
||||
func (c codeIndex) collect(ctx context.Context, root string, limit int, outline bool) ([]codeSymbol, bool, error) {
|
||||
if err := confineScope(c.readRoots, root); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if confineRead(c.forbidRoots, root) {
|
||||
return nil, false, nil
|
||||
}
|
||||
@@ -124,7 +130,7 @@ func (c codeIndex) collect(ctx context.Context, root string, limit int, outline
|
||||
files = append(files, root)
|
||||
}
|
||||
} else {
|
||||
confine := newWalkConfine(c.forbidRoots, root)
|
||||
confine := newWalkConfine(c.forbidRoots, c.readRoots, root)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user