Commit 3ef51f4
fix(copilot): say whether a withheld tool call changed anything (#7178)
* fix(copilot): disclose how far a withheld run got instead of one opaque sentinel
A tool result the egress projection cannot vouch for is reduced to a bare
success or to `TOOL_RESULT_UNAVAILABLE_ERROR`. Both drop the execution id
with the payload, and the sentinel also overwrites the real error text, so
a call rejected on its own arguments and a run that already executed come
back byte-identical. Those need opposite retry decisions.
Reproduced against real code by latching a registry the way production
latches one — a child run that returned no provenance envelope — and
driving the real handler and the real projection: a pre-dispatch rejection
and a post-dispatch failure were identical, and a completed run arrived as
`{"success":true}` with nothing to look it up by. Two distinct shapes for
three outcomes.
The registry is right to fail closed; the boundary was discarding facts it
never needed to redact. A tool may now declare a `ToolCallEffect` — a phase
and server-minted ids — which the projection preserves when it withholds
content, because neither is derived from that content. The exemption is
enforced rather than asserted: ids must match the identifier shape this
system mints, and one that does not voids the whole disclosure.
The phase is attached in the application layer from dispatch onward and
nowhere earlier, which is what makes the id's absence the positive
statement that nothing was created rather than an admission of not
knowing. Withholding also now reports its cause — a latched registry names
the guard that tripped, an absent one means no catalog was built, and a
content refusal means the registry was fine — so the next occurrence is
diagnosable from the logs it already writes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): keep the withheld-result tests out of the secret scanners
The withheld-run fixture was shaped like a live provider key, which is
exactly what a secret scanner is built to catch — it flagged the test file
itself. The value only has to clear the eight-character substitution floor,
so it says what it is instead. The id-shape guard likewise no longer needs
a credential-looking string to prove it refuses one.
Also routes the test's error-message mock through getErrorMessage rather
than reimplementing it inline, which check:utils bans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): name the dispatched run from the boundary that owns it
Review found the attempted-run id attached around the whole of
executeWorkflow, which validates workspace and billing attribution before
it can create anything. A preflight refusal therefore reported a run that
never existed, telling a caller to resolve an id with nothing behind it and
to skip a retry that was safe — the mirror of the defect this branch fixes.
Move the attachment inside executeWorkflow, at the point it enters the
execution core, which is the first moment a row may exist. Everything above
it now correctly carries nothing, and the copilot layer keeps only the
window executeWorkflow cannot see: a failure after the run already
returned, where the crossing import threw and an execution certainly
exists.
Also from review:
- Attach to any thrown object rather than only an Error, and normalize a
thrown primitive past the dispatch boundary. Restricting to Error made
the invariant silently invert for a thrown plain object — the id would
not attach, its absence would read as "nothing started", and the caller
would duplicate a real run.
- Void the disclosure when an id would take one of the record's own field
names. A valid uuid under `effect` overwrote the phase the retry decision
reads, on the same all-or-nothing terms as an unvouchable id.
- Drop `effect` from the provider model response. That path spreads every
non-output field through verbatim, so the type's claim that the
disclosure reaches the model only through the withheld-result projection
was true by accident rather than by construction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): refuse an in-band tool call whose egress catalog is unavailable
Production shows 88 of these in fourteen days, every one from this route
and every one caused by a workspace id that no longer exists reaching the
in-band lane. The handler ran anyway, which is the worst pair of outcomes
available: the side effect happened, and because the projection can vouch
for nothing without a catalog, the caller got a bare success or an opaque
sentinel naming neither the cause nor whether anything had changed. It is
also where the reported "cannot tell whether the mutation occurred" came
from — of the tools affected, read and grep dominate, and the runs were
bursts inside single sessions.
Refuse before dispatch instead. Nothing runs, so there is nothing to be
uncertain about, and the caller is told which workspace and why.
A missing workspace also reported itself as an access denial, which sent
every deleted-workspace call down a permissions path nobody could
reproduce. `checkWorkspaceAccess` already distinguishes the two, so say
which one it was. The refusal log now carries the user and workspace it
refused; without them the only way to find the cause was to join by
timestamp.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): put the dispatch boundary at the logging session, not the core
Review was right that entering the execution core is too early. The core
loads custom blocks, workflow state, and the environment before
`safeStart` writes a row, so a setup failure — which ran nothing and is
safely retryable — still reported a dispatched run and sent the caller
looking for it.
Move the marker to `loggingStarted`, read before the catch's own recovery
`safeStart` writes a row for the failure itself. That is the first point
blocks may have executed, so it is the honest line, and it lets
executeWorkflow go back to a plain rethrow.
The thrown value stays exactly as received, including a non-Error one:
the core's finalization guard identifies it, and three existing tests pin
that. A thrown primitive therefore carries no id, which costs nothing
today because every throw site past `safeStart` raises an Error — noted in
the code rather than papered over.
Also read the marker with `Object.hasOwn` rather than `in`, so an id
reached through a prototype chain can never disclose an unrelated run, and
cover the reserved-key branch of the disclosure guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): log a withheld in-band result even when it withheld a success
The cause was written only on the failure branch, but a withheld success
keeps `projected.success` true — so the one case that leaves no other
trace, where the model reads a bare success and nothing says why, was also
the only one whose cause was never recorded. Report it on its own, as the
resume driver already does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): name the run from the executor, not the logging session
Review found the logging session wrong in both directions, and it is: the
result of `safeStart` is never checked, so blocks execute even when it
fails — reporting that nothing started for a run that did, which is the
direction that duplicates work — and it flips before trigger resolution
and serialization, reporting a run for failures that never reached a
block. A resume whose conditional update matches no row returns true and
named a run that does not exist.
Entering the executor is the only honest answer to "could a side effect
have occurred", because side effects come from blocks rather than from log
rows. Moving the marker there settles all three at once.
Also from review:
- Stop returning the thrown environment or database error to the model
when the egress catalog is unavailable. Nothing there can project it —
the catalog it would need is the very thing that is missing — so the
reason stays in the log and the response carries fixed text plus the
workspace id the caller itself supplied.
- Guard the attach against a frozen failure, which would otherwise throw
and replace the original error partway through cleanup, making a
diagnostic aid the thing that loses the diagnosis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): key the dispatched-run id off the failure instead of writing to it
Guarding the write was trading one failure for the worse one: a frozen or
sealed error kept the process alive but dropped the marker, which turns
"this run exists" into "nothing started" — the single direction that
duplicates work.
Record the id in a WeakMap keyed by the thrown value, the same shape
markExecutionFinalizedByCore already keeps for the same reason. Nothing is
written to the error, so a non-extensible one is recorded like any other
and there is no throw to guard. Identity keying also retires the
prototype-chain concern, and the error's own surface stays clean, so a
serialized failure no longer carries a stray field.
A thrown primitive still cannot be keyed, which costs nothing today
because every throw site past the dispatch boundary raises an Error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): let the executor say when a block could first run
Review was right that entering `execute` is still too early: DAG
construction, snapshot restoration and pipeline assembly all happen inside
it and reject a malformed graph having changed nothing, so a validation
failure reported a run to resolve.
Only the executor knows where that line falls, so it reports it. A
`onBlocksMayRun` context extension fires immediately before `engine.run`
on both entry points, and execution-core records the run from there rather
than guessing at it from outside. A rejected graph now correctly says
nothing started.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): report the run from the engine, and never let recovery erase it
Two findings, both real.
Firing before `engine.run` was still one step early: the engine's
cancellation subscription is fallible and rejects having run nothing, so
that failure claimed a run. The signal now fires inside the engine,
immediately before the loop that processes blocks and past every startup
step that can refuse a request — DAG construction, pipeline assembly and
the subscription. The executor no longer guesses at the line from outside;
the engine states it.
Separately, the copilot catch path could throw while recording the failed
crossing or releasing the execution slot. Either one propagated a
different error — one the dispatched-run id was never recorded against —
so an existing run reported itself as never started and invited the
duplicate the id exists to prevent. Both are recovery work and neither may
replace the failure it is describing, so both are contained and logged.
Contained with try/catch rather than a rejection handler, since a
synchronous throw has to be caught too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): mark a run that failed after the core returned
Once `executeWorkflowCore` returns, the run happened. Everything after it
in `executeWorkflow` — analytics, pause persistence, post-execution
settling — is bookkeeping that can still throw, and the core's own catch
no longer runs, so those failures named no run. The copilot handler then
reported `not_attempted` for an execution that had already produced side
effects, which is the one direction that duplicates work.
Mark it as soon as the core settles, so any later failure carries it.
The `finally` had the same shape and is now contained: a throw there
replaces whatever the function was about to do, turning a successful run
into an error or an error that names its run into one that does not.
Settling post-execution work is bookkeeping and must not be able to do
either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): stop calling a cancelled run performed
`performed` claims the run reached the end of its work, so a caller reads
it as "never retry, just read the outcome". Every returned result carried
it, including a cancelled or paused one — which stopped partway and may
have run every block, one, or none.
Those are `attempted`: an execution exists under this id, resolve it
before deciding anything. That is true whether the cancellation landed
before the first block or after the last.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): derive the run phase from what the executor saw, not the result's shape
Nine review rounds found the same class of defect, which makes it a design
problem rather than nine bugs. "Did a side effect occur" had two sources
that disagreed: a precise marker on the thrown path, and on the returned
path an inference from whatever the outcome happened to look like. Every
property used for that inference is a proxy that breaks on the paths that
matter — an engine failing before its first block still carries an
ExecutionResult, and a run that ends without one still ran every block it
had — so each round found another path where the proxy lied.
There is now one source. The engine reports the moment a block handler is
first about to run, which is terminal: no fallible step remains between it
and the handler, so there is nothing left for a later reviewer to find in
front of it. The signal is threaded to the caller and recorded against the
outcome, and the copilot adapter reads it on every exit path instead of
inspecting status or the presence of an attached result.
The phase then follows from two stated facts rather than a guess: nothing
dispatched is not_attempted whatever the result looks like, a run that
stopped partway is attempted, and one that reached the end is performed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(copilot): report dispatch from the block handler itself
I claimed last round that nothing could precede the signal. That was
wrong: `executeNode` returns early on a cache hit, initializes loop and
parallel scopes, and handles a sentinel that never reaches a handler — all
after the point it fired. Both reviewers found the same thing.
Move it to the line before `blockExecutor.execute`, which is the handler
call. Nothing separates the two, so unlike every previous position this
one cannot have something in front of it. Fired per block rather than
once, since observers record a boolean and repeats cost nothing.
Also accept functions as carriers of the run markers. They key a WeakMap
exactly as objects do, so excluding them dropped the record for a thrown
function and lost the distinction the markers exist to make.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(copilot): decide the run phase where the caller lives, not in the executor
Ten review rounds chased the same question — when exactly may a side effect
have occurred — through six positions in the executor, ending with a
callback on every block of every execution in the product. Against 543k
executions a week, serving a disclosure read about fifty times a week. The
precision was never the point: `attempted` and `performed` both mean an
execution exists under this id, and the caller was already handed the id
that resolves it.
Revert all of it. The engine, the orchestrator, both context types and the
callback threading through execute-workflow and execution-core go back to
staging untouched; the executor's only remaining change is the id carrier
in utils/errors.ts.
The phase now comes from what the copilot layer already holds. Its `try`
opens on the executor call, so everything it catches is post-dispatch by
construction while authorization, admission and provenance export throw
past it having created nothing — no id means nothing exists, an id means
resolve it. A result in hand says how the run ended, which separates
cancelled and paused from completed.
The harness that motivated this is now in the diff: every outcome the run
path can produce, driven through the real handler and the real projection,
asserted on the retry decision a caller can reach and on no run content
crossing. Six mutations were used to confirm it fails for the right
reasons; one of them found the dispatch flag this refactor introduced was
already dead, and it is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(copilot): state that a named run may resolve to nothing
Both reviewers read an id on a preflight failure as a defect. It is the
one place this contract is deliberately coarse, so say so where each of
them was looking rather than leave it to be rediscovered.
`attempted` already means "zero or one executions exist under this id" —
the id is a correlation key, not a promise that a row exists. A caller
resolves it, finds nothing, and retries, which is the right outcome at the
cost of one lookup.
Buying that lookup back means an executor-side dispatch marker: a callback
on every block of every execution in the product, which this branch just
reverted for that reason. It would also gain nothing, since all four
preflight throws are invariant violations — no workspace id, no billing
attribution, no principal, attribution mismatch — and a retry fails
identically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent 46703e3 commit 3ef51f4
14 files changed
Lines changed: 934 additions & 50 deletions
File tree
- apps/sim
- app/api/copilot/tools/execute
- executor/utils
- lib
- copilot
- request/tools
- tool-executor
- tools/handlers/workflow
- environment
- workflows/application
- providers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
99 | | - | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
100 | 105 | | |
| 106 | + | |
101 | 107 | | |
102 | 108 | | |
103 | | - | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
104 | 118 | | |
105 | 119 | | |
106 | 120 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
11 | 12 | | |
| 13 | + | |
12 | 14 | | |
13 | 15 | | |
14 | 16 | | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
| 21 | + | |
19 | 22 | | |
20 | 23 | | |
21 | 24 | | |
| |||
115 | 118 | | |
116 | 119 | | |
117 | 120 | | |
118 | | - | |
119 | | - | |
| 121 | + | |
| 122 | + | |
120 | 123 | | |
121 | 124 | | |
122 | 125 | | |
123 | 126 | | |
124 | | - | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
125 | 138 | | |
126 | 139 | | |
| 140 | + | |
| 141 | + | |
127 | 142 | | |
128 | 143 | | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
129 | 158 | | |
130 | 159 | | |
131 | 160 | | |
| |||
148 | 177 | | |
149 | 178 | | |
150 | 179 | | |
151 | | - | |
| 180 | + | |
152 | 181 | | |
153 | 182 | | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
154 | 197 | | |
155 | 198 | | |
156 | 199 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
39 | 88 | | |
40 | 89 | | |
41 | 90 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
64 | | - | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
65 | 68 | | |
66 | 69 | | |
67 | 70 | | |
| |||
737 | 740 | | |
738 | 741 | | |
739 | 742 | | |
740 | | - | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
741 | 746 | | |
742 | 747 | | |
743 | 748 | | |
744 | 749 | | |
| 750 | + | |
| 751 | + | |
745 | 752 | | |
746 | 753 | | |
747 | 754 | | |
748 | 755 | | |
| 756 | + | |
749 | 757 | | |
750 | 758 | | |
751 | 759 | | |
| |||
Lines changed: 104 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
| 8 | + | |
7 | 9 | | |
8 | 10 | | |
9 | 11 | | |
| |||
457 | 459 | | |
458 | 460 | | |
459 | 461 | | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
0 commit comments