fix(runner): report monitor notes length limits (#20604)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use task monitors to resume a future check.
> - The monitor schema limits notes to 500 characters.
> - The field description did not state the limit. Rejection showed only
the schema path.
> - This pull request adds the limit to the description and to safe
length errors.
> - Agents can correct notes before they retry a rejected call.

## Linked Issues or Issue Description

Related prior fix: #16868.

**What happened?**

A native `set_task_monitor` call with 501 characters in `monitor.notes`
failed with a path that ended in `notes/maxLength`. The error did not
state 500. The schema already had `maxLength: 500`, but the field
description did not state it.

**Expected behavior**

State the limit in the field description and in the rejection. Accept
500 characters. Reject 501 characters before dispatch. Preserve a saved
monitor when a replacement is invalid.

**Steps to reproduce**

1. Supply a future `monitor.nextCheckAt` and 501 characters in
`monitor.notes`.
2. Read the native provider error.
3. Before this fix, it shows the constraint path without the maximum.

**Paperclip version or commit**

Reproduced on `7368a0b15c2f02107778a961144d86e2eb59a952` in a Linux
source checkout, with Node 25.9.0 and Rust 1.97.1.

## What Changed

- Use one local value for the published notes limit and its description.
The server test checks agreement with the persisted shared validator.
Keep the standalone Runner package boundary.
- Read the numeric maximum from the authorized JSON Schema validation
error. Keep submitted values private and preserve the 512-byte feedback
bound.
- Test the published schema and real bridge dispatch path at 500 and 501
characters. Test saved contents and rejection without monitor changes
with real PostgreSQL.
- Refresh generated contracts and document the limit.
- Sync two SDK proof lock identities after the reviewed master lock
refresh in #20615. This fixes the remaining CI proof mismatch. Keep
compiler, parser, Zod, and all rejection checks unchanged.

## Verification

- Final head: `bac60234ab3a8fc7043a90c7f9c81b02e250428b`.
- Red: the server regression failed because the field description
omitted `500`. The Rust regression failed because the error omitted
`maximum 500 characters`.
- Local passes after the final base update: 44 PostgreSQL
monitor/scheduler tests, 14 real-source Rust bridge tests, 11
cache/Docker guard tests, and 56 SDK proof tests. Runner TypeScript
build and generated contract checks pass.
- The focused Rust check compiles the actual source in a small Cargo
workspace. The full Rust CI suites also pass on Linux and Windows.
- Final-head [PR CI
run](https://github.com/paperclipai/paperclip/actions/runs/42881633978)
passes. This includes typecheck, build, Runner support, Runner Rust and
Vitest, server tests, serialized tests, and all eight e2e shards.
- Final-head [Windows Rust
run](https://github.com/paperclipai/paperclip/actions/runs/42881633419)
passes.
- Greptile: 5/5 on the final head, with no open findings. There are 61
successful checks and five conditional skips. The separate Paperclip
Review remains queued; its comment says work is paused. No CI or test
check is pending or failed.
- `pnpm verify` returned exit 2 and selected broader CI. The full local
suite was not run. Final-head CI supplies that coverage. Earlier failed
attempts were repaired and are not counted as passes.

## Risks

- Low risk. The existing 500-character bound, scheduling semantics, and
permissions stay in place.
- Length feedback uses only a schema path and numeric bound. It does not
print submitted text.
- The Runner binary and tool contracts must reach an instance for the
new guidance to apply. This PR does not deploy or merge.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The
runtime does not expose a more specific model ID or context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-10 13:59:58 -06:00
1 parent 38944cff1a
commit 76c787fffc
8 files changed
+102 -6

No files matched your search

+2
View File
@@ -987,6 +987,8 @@ results; finalizing accepted work neither renews the deadline nor starts a turn.
Native `set_task_monitor` arguments put scheduling fields inside `monitor`:
`{ "idempotencyKey": "check-ci", "monitor": { "nextCheckAt": "<future UTC timestamp>", "notes": "Check pending CI." } }`.
`monitor.notes` has a maximum of 500 characters. The published field description
and schema expose this limit. Length errors name the field and numeric maximum.
Use `monitor: null` to clear a schedule. A runner schema rejection executes no
Paperclip mutation. It returns bounded schema locations and repair guidance,
without submitted values. Correct the arguments and retry; the rejection is not
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.Line truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.Line truncated
@@ -3874,7 +3874,7 @@
"type": "string"
},
"notes": {
"description": "What to check on the next run; do not include secrets.",
"description": "What to check on the next run. Maximum 500 characters; do not include secrets.",
"maxLength": 500,
"minLength": 1,
"type": "string"
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:56cd92878be1bde5e5fcaa07cf65f6844c812055b692451560166f6a511d44d2",
"catalogSha256": "sha256:bcbe20460b6f3cf3fc814b088d24c81fc2cfb337c42f2907b416c29b4410e76d",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -185,7 +185,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "85be9205c0bfaebb925b5ebf3208b47db6876e33835a1721667c7f4d79581d9b",
"sha256": "b4fd01c42444d812bd3d6335f65a9871a7615a1183313d27a17f9467c0656524",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -287,6 +287,9 @@ impl ProviderBridgeError {
.collect::<String>();
format!("{location} (missing {name})")
}
jsonschema::error::ValidationErrorKind::MaxLength { limit } => {
format!("{location} (maximum {limit} characters)")
}
_ => location,
}
})
@@ -1687,6 +1690,73 @@ mod tests {
}
}
#[test]
fn monitor_notes_length_error_reports_the_authorized_numeric_limit() {
let contract = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../generated/capability/semantic-tool-contracts.json"),
)
.unwrap();
let tools: Value = serde_json::from_str(&contract).unwrap();
let tool = tools
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "set_task_monitor")
.unwrap();
let schema = &tool["inputSchema"];
let limit = schema["properties"]["monitor"]["properties"]["notes"]["maxLength"]
.as_u64()
.unwrap() as usize;
let validator = jsonschema::validator_for(schema).unwrap();
let valid = json!({"idempotencyKey": "check-ci", "monitor": {
"nextCheckAt": "2030-01-01T12:00:00Z", "notes": "x".repeat(limit)
}});
assert!(validator.is_valid(&valid));
let mut invalid = valid.clone();
invalid["monitor"]["notes"] = json!("x".repeat(limit + 1));
assert!(!validator.is_valid(&invalid));
let operation = AuthorizedTool {
operation_id: "set_task_monitor".to_owned(),
version: 1,
description: tool["description"].as_str().unwrap().to_owned(),
input_schema: schema.clone(),
response_schema: json!({"type": "object"}),
};
let mut bridge = ProviderToolBridge::default();
bridge
.prepare(AuthorizedToolSet {
schema: TOOL_SET_SCHEMA.to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(std::slice::from_ref(&operation))
.unwrap(),
operations: vec![operation],
})
.unwrap();
bridge
.begin_call("maximum".to_owned(), "set_task_monitor".to_owned(), valid)
.unwrap();
let error = bridge
.begin_call(
"oversized".to_owned(),
"set_task_monitor".to_owned(),
invalid,
)
.unwrap_err();
assert!(!bridge.has_call_receipt("oversized"));
let message = error.safe_provider_message().unwrap();
assert!(
message.contains("/properties/monitor/properties/notes/maxLength"),
"{message}"
);
assert!(
message.contains(&format!("maximum {limit} characters")),
"{message}"
);
assert!(!message.contains(&"x".repeat(20)));
assert!(message.len() <= 512);
}
#[test]
fn canonical_number_uses_decimal_notation_at_javascript_lower_boundary() {
for encoded in ["1e-6", "0.000001"] {
@@ -1,4 +1,5 @@
/** Production monitor tool; timers and wake delivery belong to Paperclip. */
const notesMaxLength = 500;
const description = 'Schedule, replace, or clear a one-shot task monitor. Supply {"idempotencyKey":"check-ci","monitor":{"nextCheckAt":"<future UTC timestamp>","notes":"Check pending CI."}}. Put nextCheckAt, notes, and all optional scheduling fields inside monitor; use monitor: null to clear. Omit taskId for your current task; other tasks must also be assigned to you and in progress or review. A schema rejection changes nothing: correct the arguments and retry. A successful receipt reports the persisted monitor; a replay reports its current state without re-arming it. After scheduling your current task, end the turn with paperclip_finish: yielded and continuation.kind monitor. Paperclip will wake you with issue_monitor_due; do not sleep, poll, mark done, or use call_api for this wait.';
export const setTaskMonitorInputSchema = {
@@ -14,7 +15,7 @@ export const setTaskMonitorInputSchema = {
required: ["nextCheckAt", "notes"],
properties: {
nextCheckAt: { type: "string", description: "Future UTC timestamp for the next check. This is one shot, not a recurring interval." },
notes: { type: "string", minLength: 1, maxLength: 500, description: "What to check on the next run; do not include secrets." },
notes: { type: "string", minLength: 1, maxLength: notesMaxLength, description: `What to check on the next run. Maximum ${notesMaxLength} characters; do not include secrets.` },
kind: { type: ["string", "null"], enum: ["external_service", null] },
serviceName: { type: ["string", "null"], minLength: 1, maxLength: 120 },
externalRef: { type: ["string", "null"], minLength: 1, maxLength: 500 },
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { activityLog, agents, agentWakeupRequests, authUsers, companyMemberships, companies, completionContracts, createDb, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions } from "@paperclipai/db";
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
import { issueExecutionMonitorPolicySchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { buildIssueMonitorTriggeredPatch, normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
@@ -43,6 +43,29 @@ describeEmbeddedPostgres("native task monitors", () => {
return { ...binding, binding, authority, monitor, call, read };
}
it("advertises the enforced notes limit and rejects oversized notes without writes", async () => {
const value = await fixture();
const tool = value.authority.definitions().find(tool => tool.name === "set_task_monitor")!;
const schema = tool.inputSchema as { properties: { monitor: { properties: { notes: { maxLength: number; description: string } } } } };
const notesSchema = schema.properties.monitor.properties.notes;
const limit = notesSchema.maxLength;
expect(limit).toBe(500);
expect(notesSchema.description).toContain(String(limit));
const notes = "x".repeat(limit);
expect(issueExecutionMonitorPolicySchema.parse({ ...value.monitor, notes }).notes).toBe(notes);
expect(issueExecutionMonitorPolicySchema.safeParse({ ...value.monitor, notes: notes + "x" }).success).toBe(false);
const before = await value.read();
await expect(value.call({ idempotencyKey: "too-long", monitor: { ...value.monitor, notes: notes + "x" } }))
.rejects.toThrow(/500/);
expect(await value.read()).toEqual(before);
await value.call({ idempotencyKey: "maximum", monitor: { ...value.monitor, notes } });
const saved = await value.read();
expect(saved.monitorNotes).toBe(notes);
await expect(value.call({ idempotencyKey: "too-long-replacement", monitor: { ...value.monitor, notes: notes + "x" } }))
.rejects.toThrow(/monitor[\s\S]*notes[\s\S]*500/);
expect(await value.read()).toEqual(saved);
});
it("notifies committed monitor changes for another owned task without replay or authorization wakeups", async () => {
const value = await fixture(), targetId = randomUUID();
await db.insert(issues).values({ id: targetId, companyId: value.companyId,