Skip to content

Commit 0cf1a61

Browse files
fix: make an uncertain invocation diagnosable and a failed prepare recoverable
Review of the Builder-over-gRPC change found three defects in the new "only a certain outcome writes a result" design and several smaller ones. An uncertain outcome (timeout, interruption, invalid payload, invalid return, import failure) deliberately writes no result document, which makes stderr the only carrier of the reason. The harness emitted that line from inside bounded_logs, so it competed with the handler's own max_log_bytes budget and lost: a handler writing 4000 bytes to stderr before returning a wrong-typed output left exit 65, no result, and no reason at all. The diagnostic is now written after the handler's bound is released, which is where the completion write used to happen. The harness also never cleared the result path. Verified: a timed-out attempt leaves an earlier attempt's SUCCEEDED document in place, and core's Complete ranks a valid result above the launcher's own deadline termination, matching it to the run by invocation_id alone. A launcher reusing the path therefore reads a stale success as this run's proven result, or at best misreports a crash as INVALID_OUTPUT. The harness now removes the document before reading the request and refuses to start if it cannot: a document at that path is always this run's. The test fixture was pre-clearing the path and hiding this, so it no longer does. write_completion also renamed a NamedTemporaryFile into place, narrowing the result to 0600; it is now explicitly 0644, since which accounts may read it is the facility's choice, not the harness's. RunnableBuildInputs cleared the prepared snapshot before doing anything fallible, and prepare refuses a non-empty destination. Retrying into the caller's own output directory therefore failed with codes.Unknown *and* discarded the good snapshot, leaving a prepared tree on disk that Package then refused. It now rejects an occupied destination by name with AlreadyExists before touching state, and installs the new snapshot only once the replacement is complete. Unexpected failures across the Builder are wrapped as codes.Internal instead of reaching callers as codes.Unknown, and an empty artifact list is reported rather than panicking on artifacts[0]. outputDirectory had copied Confine's symlink-resolution walk verbatim and already drifted from it; both now share generate.Resolve/Within. pkg/agent had no tests, so the containment guard that keeps a build from copying its own output is now covered directly. The deleted contract_test.go was the only thing comparing this repo's Go constants against the Python literals; restored against core's own symbols, so a core rename fails the build instead of exiting 69 in the fleet. The generated harness bytes and the configuration digest both changed while the manifest still said 0.0.1, so the same declaration produced different package bytes under one agent version. Bumped to 0.0.2. Every behavior change has a test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 29bcbc6 commit 0cf1a61

16 files changed

Lines changed: 444 additions & 89 deletions

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:runnable
33
name: python
4-
version: 0.0.1
4+
version: 0.0.2

‎builder_grpc_test.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
2828
root := t.TempDir()
2929
t.Setenv(resources.CodeflyHomeEnv, filepath.Join(root, "home"))
3030
t.Setenv(manager.AgentSourceEnv, "local")
31-
agent, err := resources.ParseAgent(ctx, resources.RunnableAgent, "codefly.dev/python:0.0.1")
31+
agent, err := resources.ParseAgent(ctx, resources.RunnableAgent, "codefly.dev/python:0.0.2")
3232
require.NoError(t, err)
3333
binary, err := agent.Path(ctx)
3434
require.NoError(t, err)
@@ -83,6 +83,13 @@ func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
8383
prepared, err := client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: filepath.Join(root, "prepared")})
8484
require.NoError(t, err)
8585
require.Equal(t, builderv0.RunnableBuildInputsStatus_SUCCESS, prepared.GetState().GetState())
86+
87+
// Preparing again into the caller's populated directory is refused by name
88+
// rather than from inside preparation, and the snapshot already prepared
89+
// survives the refusal: the Package below still emits it.
90+
_, err = client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: filepath.Join(root, "prepared")})
91+
require.Equal(t, codes.AlreadyExists, status.Code(err))
92+
8693
write(t, filepath.Join(r.Dir(), "handler.py"), "def handle(context, input):\n return {\"count\": 999}\n")
8794
_, err = client.Package(ctx, &builderv0.PackageRequest{Targets: []*builderv0.PackageTarget{{Os: "other", Architecture: runtime.GOARCH}}, OutputDirectory: filepath.Join(root, "unsupported")})
8895
require.Equal(t, codes.Unimplemented, status.Code(err))
@@ -106,7 +113,8 @@ func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
106113
unpack(t, response.GetArtifacts()[0].GetPath(), installed)
107114
result := invoke(t, installed, pkg.GetArtifacts()[0].GetCommand(), pkg, map[string]any{"text": "one two three"})
108115
require.Equal(t, 0, result.exit, result.stderr)
109-
require.Equal(t, 3, result.count(t), "the package must execute the prepared handler")
116+
require.Equal(t, 3, result.count(t),
117+
"the package must execute the prepared handler, so the refused retry discarded no snapshot")
110118

111119
write(t, filepath.Join(root, "prepared", "runnable", "handler.py"), "def handle(context, input): return {\"count\": 888}\n")
112120
_, err = client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "tampered")})

‎docs/milestone_3_builder_grpc.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ published by this work. Core #474 must settle before coordinated fleet release.
2323
- [gRPC regression](../builder_grpc_test.go): builds and starts the real agent,
2424
verifies Builder capability, creates and packages over gRPC, edits live source,
2525
relocates and invokes the prepared archive. It also checks invalid target,
26-
source/symlink overlap, snapshot tampering and failed-Load state clearing.
26+
source/symlink overlap, snapshot tampering, failed-Load state clearing, and
27+
that preparing again into an occupied output directory is refused without
28+
discarding the snapshot already prepared.
2729
- [Native qualification](../qualification_test.go): core encodes the invocation
2830
and validates/classifies the real Python result; locked third-party dependencies
2931
and the bundled interpreter survive removal of the preparation environment.
@@ -41,7 +43,7 @@ GOWORK=off go test -race ./...
4143
uv run --with pytest --python 3.12 pytest -q pkg/harness/tests
4244
```
4345

44-
The native Go and gRPC tests pass on macOS/arm64. The harness has 57 passing real
46+
The native Go and gRPC tests pass on macOS/arm64. The harness has 61 passing real
4547
process/schema cases. The CLI companion integration creates through the actual
4648
CLI, packages through this agent, removes source/prepared files, and proves:
4749

‎docs/protocol.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,14 @@ This writes `status: FAILED` with `error: {code, message}` and no output. Use it
6262
only when the operation knows its effect's disposition. An unexpected exception,
6363
invalid payload, invalid return, import failure, timeout or interruption writes
6464
no result. Core therefore cannot mistake an unknown external effect for a known
65-
failure. Exit codes are diagnostics, not portable completion outcomes:
65+
failure.
66+
67+
Because those outcomes write nothing, the harness removes any document at the
68+
result path before it reads the request, and refuses to start if it cannot. A
69+
document at that path is always this invocation's, so a launcher reusing the
70+
path cannot read an earlier attempt's result as this one's. The result is
71+
written 0644: which accounts may read it is the facility's choice. Exit codes
72+
are diagnostics, not portable completion outcomes:
6673

6774
| Exit | Diagnostic |
6875
| --- | --- |
@@ -90,7 +97,10 @@ Core #474 describes a clock-offset budget based on `deadline - issued_at`; the
9097
cross-host clock policy needs reconciliation before Kubernetes qualification.
9198
No distributed deadline or cancellation guarantee is claimed here.
9299

93-
`stdout` and `stderr` carry diagnostics only. The generated harness uses the
100+
`stdout` and `stderr` carry diagnostics only. The harness's own terminal
101+
diagnostic is written after the handler's bound is released, so a handler that
102+
exhausts the budget cannot erase the single record of why an uncertain
103+
invocation ended. The generated harness uses the
94104
release's `max_log_bytes` (4 MiB by default), bounding Python, native-library and
95105
inherited subprocess writes. It may append one truncation marker. The launcher
96106
must independently enforce its exact transport bound and record truncation.

‎pkg/agent/agent.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ func (a *Agent) GetAgentInformation(_ context.Context, _ *agentv0.AgentInformati
3636
info := services.Advertisement{
3737
CapabilityOnly: true,
3838
Backends: runners.BackendSupport{
39+
// Deliberately not core's runners/python.HasUVRuntime, which is
40+
// identical: importing that package pulls a TOML dependency in for
41+
// service runtime selection this agent never does. What is
42+
// advertised here is exactly what prepare.Prepare requires.
3943
Local: func() bool { _, err := exec.LookPath("uv"); return err == nil },
4044
Docker: false,
4145
},

‎pkg/agent/builder.go‎

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ import (
2424
"github.com/codefly-dev/runnable-python/pkg/prepare"
2525
)
2626

27-
// Builder owns one loaded declaration and its prepared snapshot. Calls are
28-
// serialized so packaging cannot accidentally use another load's build state.
27+
// Builder owns one loaded declaration and its prepared snapshot.
28+
//
29+
// That state is per-process, not per-caller: the lock serializes calls but
30+
// cannot tell two callers apart, and Load replaces the state it finds. One
31+
// connection therefore drives one Runnable at a time — interleaving Load and
32+
// Package for two Runnables over the same agent packages whichever was loaded
33+
// last. A caller that builds Runnables concurrently needs one agent process
34+
// each, which is what manager.Load already gives it.
2935
type Builder struct {
3036
builderv0.UnimplementedBuilderServer
3137
mu sync.Mutex
@@ -60,18 +66,18 @@ func (b *Builder) Load(ctx context.Context, request *builderv0.LoadRequest) (*bu
6066
}
6167
actual, err := workspace.RunnableLocationOf(r)
6268
if err != nil {
63-
return nil, err
69+
return nil, status.Errorf(codes.Internal, "locate the Runnable in its workspace: %v", err)
6470
}
6571
actualWire, err := actual.Proto()
6672
if err != nil {
67-
return nil, err
73+
return nil, status.Errorf(codes.Internal, "encode the Runnable location: %v", err)
6874
}
6975
if !proto.Equal(actualWire.GetIdentity(), id) {
7076
return nil, status.Error(codes.InvalidArgument, "Runnable release identity does not match the workspace declaration")
7177
}
7278
actualDir, err := filepath.EvalSymlinks(r.Dir())
7379
if err != nil {
74-
return nil, err
80+
return nil, status.Errorf(codes.Internal, "resolve the declared Runnable directory: %v", err)
7581
}
7682
requestedDir, err := filepath.EvalSymlinks(resources.RunnableLocationFromProto(location).Dir())
7783
if err != nil || actualDir != requestedDir {
@@ -98,18 +104,18 @@ func (b *Builder) Create(ctx context.Context, _ *builderv0.CreateRequest) (*buil
98104
}
99105
r := b.runnable
100106
if err := generate.Scaffold(r, r.Dir()); err != nil {
101-
return nil, err
107+
return nil, status.Errorf(codes.Internal, "scaffold the Runnable: %v", err)
102108
}
103109
for _, input := range []string{generate.ProjectFile, generate.LockFile} {
104110
if !slices.Contains(r.Entrypoint.Inputs, input) {
105111
r.Entrypoint.Inputs = append(r.Entrypoint.Inputs, input)
106112
}
107113
}
108114
if err := r.Save(ctx); err != nil {
109-
return nil, err
115+
return nil, status.Errorf(codes.Internal, "save the declaration: %v", err)
110116
}
111117
if err := generate.GenerateForRelease(r, r.Dir(), b.identity); err != nil {
112-
return nil, err
118+
return nil, status.Errorf(codes.Internal, "generate the harness: %v", err)
113119
}
114120
return &builderv0.CreateResponse{State: &builderv0.CreateStatus{State: builderv0.CreateStatus_CREATED}}, nil
115121
}
@@ -120,7 +126,6 @@ func (b *Builder) RunnableBuildInputs(ctx context.Context, request *builderv0.Ru
120126
if b.runnable == nil {
121127
return nil, status.Error(codes.FailedPrecondition, "load a Runnable before building")
122128
}
123-
b.prepared, b.build = nil, nil
124129
r := b.runnable
125130
if len(r.LibraryDependencies) != 0 {
126131
return nil, status.Error(codes.Unimplemented, "internal library dependency preparation is not implemented")
@@ -134,17 +139,26 @@ func (b *Builder) RunnableBuildInputs(ctx context.Context, request *builderv0.Ru
134139
if err != nil {
135140
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
136141
}
137-
if err := generate.GenerateForRelease(r, r.Dir(), b.identity); err != nil {
142+
// Preparation populates the destination and refuses a directory that is
143+
// already populated, so an occupied one is rejected here rather than
144+
// surfacing from inside preparation as an untyped error.
145+
if err := requireEmpty(root); err != nil {
138146
return nil, err
139147
}
148+
if err := generate.GenerateForRelease(r, r.Dir(), b.identity); err != nil {
149+
return nil, status.Errorf(codes.Internal, "generate the harness: %v", err)
150+
}
140151
prepared, err := prepare.Prepare(ctx, r, r.Dir(), root)
141152
if err != nil {
142-
return nil, err
153+
return nil, status.Errorf(codes.Internal, "prepare the Runnable: %v", err)
143154
}
144155
build, err := pack.Build(r, filepath.Join(prepared.Root, prepare.SourceDirectory), prepared.Toolchain)
145156
if err != nil {
146-
return nil, err
157+
return nil, status.Errorf(codes.Internal, "measure the build inputs: %v", err)
147158
}
159+
// Only now, with a complete replacement in hand. A failed call leaves the
160+
// previous snapshot loaded: it still describes a whole tree on disk, and
161+
// Package re-measures it before using it, so it stays packageable.
148162
b.prepared, b.build = prepared, build
149163
return &builderv0.RunnableBuildInputsResponse{
150164
State: &builderv0.RunnableBuildInputsStatus{State: builderv0.RunnableBuildInputsStatus_SUCCESS},
@@ -189,18 +203,18 @@ func (b *Builder) Package(_ context.Context, request *builderv0.PackageRequest)
189203
}
190204
currentBuild, err := pack.Build(b.runnable, filepath.Join(b.prepared.Root, prepare.SourceDirectory), b.prepared.Toolchain)
191205
if err != nil {
192-
return nil, err
206+
return nil, status.Errorf(codes.Internal, "re-measure the prepared snapshot: %v", err)
193207
}
194208
if !proto.Equal(currentBuild, b.build) {
195209
return nil, status.Error(codes.FailedPrecondition, "prepared build inputs changed before packaging")
196210
}
197211
evidence, err := pack.Native(b.runnable, b.agent, b.prepared, archive)
198212
if err != nil {
199-
return nil, err
213+
return nil, status.Errorf(codes.Internal, "emit the native archive: %v", err)
200214
}
201215
build := &basev0.RunnableBuild{}
202216
if err := protojson.Unmarshal(evidence.Build, build); err != nil {
203-
return nil, err
217+
return nil, status.Errorf(codes.Internal, "decode the emitted build: %v", err)
204218
}
205219
if !proto.Equal(build, b.build) {
206220
return nil, status.Error(codes.FailedPrecondition, "prepared build inputs changed before packaging")
@@ -209,7 +223,10 @@ func (b *Builder) Package(_ context.Context, request *builderv0.PackageRequest)
209223
// metadata travel in the shared response, never in a language-specific CLI file.
210224
var artifacts []*basev0.RunnableArtifact
211225
if err := decodeArtifacts(evidence.Artifacts, &artifacts); err != nil {
212-
return nil, err
226+
return nil, status.Errorf(codes.Internal, "decode the emitted artifacts: %v", err)
227+
}
228+
if len(artifacts) != 1 {
229+
return nil, status.Errorf(codes.Internal, "native packaging emitted %d artifacts, expected exactly one", len(artifacts))
213230
}
214231
artifact := artifacts[0]
215232
return &builderv0.PackageResponse{
@@ -237,43 +254,41 @@ func decodeArtifacts(data []byte, artifacts *[]*basev0.RunnableArtifact) error {
237254

238255
// outputDirectory never allows preparing inside the source, or writing through
239256
// a symlink into it. This prevents a build from recursively copying its output.
257+
//
258+
// generate.Resolve does the resolution so this guard and generate.Confine
259+
// cannot drift apart: nothing is created until the resolved path is accepted,
260+
// so a rejected output leaves no directory inside the source or the snapshot.
240261
func outputDirectory(directory, source string) (string, error) {
241262
if !filepath.IsAbs(directory) {
242263
return "", fmt.Errorf("output_directory must be absolute")
243264
}
244-
// Resolve the nearest existing ancestor before creating anything. Rejected
245-
// outputs must not leave directories inside the source or prepared snapshot.
246-
ancestor := filepath.Clean(directory)
247-
var missing []string
248-
var root string
249-
var err error
250-
for {
251-
root, err = filepath.EvalSymlinks(ancestor)
252-
if err == nil {
253-
break
254-
}
255-
if !os.IsNotExist(err) {
256-
return "", err
257-
}
258-
if _, statErr := os.Lstat(ancestor); statErr == nil {
259-
return "", fmt.Errorf("output contains a dangling symlink")
260-
}
261-
missing = append(missing, filepath.Base(ancestor))
262-
parent := filepath.Dir(ancestor)
263-
if parent == ancestor {
264-
return "", err
265-
}
266-
ancestor = parent
267-
}
268-
for i := len(missing) - 1; i >= 0; i-- {
269-
root = filepath.Join(root, missing[i])
265+
root, err := generate.Resolve(directory)
266+
if err != nil {
267+
return "", err
270268
}
271269
source, err = filepath.EvalSymlinks(source)
272270
if err != nil {
273271
return "", err
274272
}
275-
if root == source || strings.HasPrefix(root, strings.TrimSuffix(source, string(filepath.Separator))+string(filepath.Separator)) || strings.HasPrefix(source, strings.TrimSuffix(root, string(filepath.Separator))+string(filepath.Separator)) {
273+
if root == source || generate.Within(root, source) || generate.Within(source, root) {
276274
return "", fmt.Errorf("output_directory must be separate from the Runnable source")
277275
}
278276
return root, nil
279277
}
278+
279+
// requireEmpty refuses a destination that already holds something. Preparation
280+
// populates the whole directory, so overwriting one is how a caller loses an
281+
// earlier build, and the caller owns this path.
282+
func requireEmpty(directory string) error {
283+
entries, err := os.ReadDir(directory)
284+
if os.IsNotExist(err) {
285+
return nil
286+
}
287+
if err != nil {
288+
return status.Errorf(codes.InvalidArgument, "inspect output_directory: %v", err)
289+
}
290+
if len(entries) != 0 {
291+
return status.Errorf(codes.AlreadyExists, "output_directory %s is not empty", directory)
292+
}
293+
return nil
294+
}

0 commit comments

Comments
 (0)