Skip to content

Commit d989655

Browse files
feat(harness)!: the generated harness serves call mode instead of running one invocation and exiting
The native placement is removed (core#681), and with it the launcher framing this harness implemented: two document paths in the environment, a request read off disk, a result renamed into place, and an exit code a launcher interpreted. A generated Python package now serves, exactly as runnable-go#20 made the Go one serve. One call is `POST` to the procedure core pins, with the bounded input document as the whole body and no envelope around it. The Work Context, the effect identity and the deadline arrive as core's own header spellings, and a success answers 200 with the bounded output document the same way. Every constant — the listen address variable, both procedures, all four headers — is read from core, because runnable-go renders the same ones and neither repository owns the other: a harness serving one spelling while its binding names another is an unreachable owner, which reads as an outage rather than as a mistake in a string. `TestTheFramingIsOneContract` holds the Python literals equal to core's constants, and caught exactly that: the Work Context header is `X-Codefly-Work-Context`, not the spelling core#678 first pinned. **The failure-code header is set in exactly one place, and a test holds it there.** It is the only thing that proves to a caller that no effect committed, so a handler's own HandlerFailure sets it and nothing else does. An untyped exception, a deadline and a payload the contract refuses all answer without it and leave the effect unproven — the harness cannot know which side of its effect a handler stopped on, and a caller that believed otherwise would stop looking for a receipt that exists. A call with no Work Context is refused rather than run under whatever identity this process happens to have, which is the state core's required identity slot exists to remove. The handler reads the capability from the context and forwards it; the harness neither parses nor reconstructs one. The receipt route is the one place the two shapes of the harness differ. A package declaring receipt recovery serves it and its handler module defines `receipt_of` beside `handle`; returning None answers 404, which is not an error, because absent is "not yet known" and never "no". The function is required at startup rather than at the call that needed it — an owner that cannot say what an effect committed leaves every uncertain outcome uncertain forever, which is not something to discover during a recovery. A recompute package serves no such route at all rather than one answering "never". The handler runs on its own thread because Python cannot raise into one: when the budget passes the harness answers and the handler is still running, which is what runnable-go does with its goroutine and for the same reason. SIGALRM and the swallowed-signal detector are gone with the one-invocation process they bounded. Log bounding goes too. Per-call stream bounds are not something a server can enforce — concurrent calls share the process's descriptors — and `max_log_bytes` describes what the placement capturing those streams enforces, which is where it belongs. The generated contract drops it, and gains the declared timeout, which bounds a call carrying no deadline header. The Go tests start the real built package on a port the operating system chose, call it over HTTP exactly as the durable-work runtime's invoker does, and classify every answer with core's own ClassifyServed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 90d1097 commit d989655

25 files changed

Lines changed: 1288 additions & 1211 deletions

‎AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
`github.com/codefly-dev/runnable-python` (Go 1.27) is the Codefly Runnable agent
44
for Python. It owns everything Python about a runnable: the handler scaffold and
5-
the typed bindings of a contract, the `codefly.runnable/v1` harness generated
6-
into every package, uv-locked dependency and interpreter preparation, the native
5+
the typed bindings of a contract, the `codefly.runnable.served/v1` harness
6+
served by every package, uv-locked dependency and interpreter preparation, the native
77
package and the build evidence identifying it, and the Linux image recipe.
88

9-
It does **not** own the invocation and result framing, `RunnableLocation`,
9+
It does **not** own the call framing, `RunnableLocation`,
1010
`RunnableBuild` or `PackageArtifact.command` — those belong to
1111
[`codefly-dev/core`](https://github.com/codefly-dev/core), and `pkg/contract`
1212
here is agent-*private* generated configuration, not wire framing. It does not

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The Codefly language agent for typed, finite Python operations: generate a handler, prepare its dependencies, and produce identified native packages and container build recipes.
44

5-
**Status: native authoring and packaging are implemented through the real Builder gRPC lifecycle on core v0.3.29.** Agent 0.0.2 includes the shared invocation/result framing and signal-cancellation contract; published binaries appear on the [releases page](https://github.com/codefly-dev/runnable-python/releases). [Milestone 3](docs/milestone_3_builder_grpc.md) records the Builder checkpoint; [issue #1](https://github.com/codefly-dev/runnable-python/issues/1) still tracks durable native and Kubernetes execution.
5+
**Status: the generated harness serves call mode on core v0.6.0, and authoring, preparation and packaging run through the real Builder gRPC lifecycle.** A package is a server: one call is a POST carrying the bounded input document, with the caller's Work Context required on it ([docs/protocol.md](docs/protocol.md)). The launcher framing — two document paths in the environment, a result file, an exit code a launcher read — went with the native placement in core#681. Published binaries appear on the [releases page](https://github.com/codefly-dev/runnable-python/releases). [Milestone 3](docs/milestone_3_builder_grpc.md) records the Builder checkpoint and predates the served harness; [issue #1](https://github.com/codefly-dev/runnable-python/issues/1) still tracks durable Kubernetes execution.
66

77
The review corrections and current validation are recorded in [milestone 2](docs/milestone_2_review_fixes.md). Earlier image/k3d observations are historical and do not qualify the Codefly/Orchestration path.
88

@@ -12,8 +12,8 @@ Python handler templates, generated input/output types, the invocation harness,
1212

1313
| Package | What it owns |
1414
|---|---|
15-
| `pkg/contract` | the `codefly.runnable/v1` framing, in Go ([docs/protocol.md](docs/protocol.md)) |
16-
| `pkg/harness` | the Python harness generated into every runnable: the same framing, and the bounded schema profile it enforces |
15+
| `pkg/contract` | the `codefly.runnable.served/v1` framing, in Go ([docs/protocol.md](docs/protocol.md)) |
16+
| `pkg/harness` | the Python harness generated into every runnable: it serves the operation, and enforces the bounded schema profile on both payloads |
1717
| `pkg/generate` | the handler scaffold, the typed bindings of a contract, the generated contract |
1818
| `pkg/prepare` | uv-locked dependencies and the pinned interpreter |
1919
| `pkg/pack` | the native package and the build evidence that identifies it |

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
publisher: codefly.dev
22
kind: codefly:runnable
33
name: python
4-
version: 0.0.6
4+
version: 0.1.0-dev.0
55
source:
66
directory: .
77
agent: codefly.dev/go:0.0.56

‎builder_grpc_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
106106
require.NoError(t, err)
107107
emitted := response.GetArtifacts()[0]
108108
pkg := preparedPackage(t, r, wire.GetIdentity(), prepared.GetBuild(), []*basev0.RunnableArtifact{{
109-
Kind: basev0.RunnableArtifact_NATIVE, Platform: runtime.GOOS + "/" + runtime.GOARCH,
109+
Kind: basev0.RunnableArtifact_ARCHIVE, Platform: runtime.GOOS + "/" + runtime.GOARCH,
110110
Reference: filepath.Base(emitted.GetPath()), Digest: "sha256:" + emitted.GetSha256(), Command: emitted.GetCommand(),
111111
}})
112112
require.NoError(t, corerunnable.VerifyPackage(pkg))
@@ -116,10 +116,10 @@ func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
116116
require.NotEmpty(t, pkg.GetArtifacts()[0].GetCommand())
117117
installed := filepath.Join(root, "installed")
118118
unpack(t, response.GetArtifacts()[0].GetPath(), installed)
119-
result := invoke(t, installed, pkg.GetArtifacts()[0].GetCommand(), pkg, map[string]any{"text": "one two three"})
120-
require.Equal(t, 0, result.exit, result.stderr)
121-
require.Equal(t, 3, result.count(t),
122-
"the package must execute the prepared handler, so the refused retry discarded no snapshot")
119+
harness := serve(t, installed, pkg.GetArtifacts()[0].GetCommand(), pkg)
120+
completion := harness.call(t, corerunnable.ServedInvokeProcedure, map[string]any{"text": "one two three"})
121+
require.Equal(t, 3, count(t, completion),
122+
"the package must serve the prepared handler, so the refused retry discarded no snapshot")
123123

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

‎docs/protocol.md‎

Lines changed: 107 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,128 @@
1-
# `codefly.runnable/v1` — launcher and Python harness
2-
3-
Core defines the public protocol in
4-
[`runnable_invocation.proto`](https://github.com/codefly-dev/core/blob/b3470f0096cd818d6a90c67dc31e9ad09824d822/proto/codefly/base/v0/runnable_invocation.proto)
5-
and supplies `PrepareInvocation`, `EncodeInvocation`, `InvocationEnvironment`,
6-
`ParseResult` and `Complete`. The Python process implements this wire format;
7-
Go qualification tests encode real requests with core and parse the actual
8-
Python result with core. The earlier agent-specific framing is obsolete.
1+
# `codefly.runnable.served/v1` — the harness a Python runnable serves
2+
3+
Core defines the contract in
4+
[`runnable/served.go`](https://github.com/codefly-dev/core/blob/main/runnable/served.go)
5+
and [`runnable_invocation.proto`](https://github.com/codefly-dev/core/blob/main/proto/codefly/base/v0/runnable_invocation.proto),
6+
and supplies `PrepareInvocation`, `ParseResult`, `ClassifyServed` and
7+
`ServedOutcomeIsCertain`. This harness implements the owner's half; the Go
8+
qualification tests call the real package over HTTP the way the durable-work
9+
runtime's invoker calls it and classify every answer with core's own
10+
`ClassifyServed`, so a harness that drifted from the contract fails there rather
11+
than in a live composition.
12+
13+
**A runnable is called, not launched.** The launcher framing this replaced —
14+
two document paths in the environment, a request read off disk, a result renamed
15+
into place, and an exit code a launcher interpreted — went with the native
16+
placement (core#681). A package naming `codefly.runnable/v1` is refused by name.
917

1018
## Transport
1119

12-
One invocation is one process, with package-relative argv and the unpacked
13-
artifact root as its working directory. No shell expansion is involved.
20+
The package's command starts a server. The one fact it cannot derive is where to
21+
listen: the port is allocated by whatever placed it, and a harness choosing its
22+
own would be right on one machine for ten minutes.
1423

1524
| Environment variable | Value |
1625
| --- | --- |
17-
| `CODEFLY__RUNNABLE_PROTOCOL` | `codefly.runnable/v1` |
18-
| `CODEFLY__RUNNABLE_INVOCATION` | absolute path to the launcher's request |
19-
| `CODEFLY__RUNNABLE_RESULT` | absolute path for the harness's atomic result |
20-
21-
The documents use proto3 JSON with snake_case field names. `input` and `output`
22-
are base64 encodings of exact UTF-8 JSON objects. Bounds apply to decoded bytes,
23-
including whitespace, and preserve signed 64-bit integers. The harness caps its
24-
request read at base64-expanded input capacity plus 64 KiB of framing.
25-
26-
```json
27-
{
28-
"protocol": "codefly.runnable/v1",
29-
"runnable": {"name":"word-count","module":"proof","workspace":"proof","version":"0.0.1"},
30-
"invocation_id": "inv-1",
31-
"intent_id": "intent-1",
32-
"issued_at": "2026-09-13T10:00:00Z",
33-
"deadline": "2026-09-13T10:01:00Z",
34-
"input": "eyJ0ZXh0Ijoib25lIHR3byB0aHJlZSJ9"
35-
}
36-
```
26+
| `CODEFLY__RUNNABLE_ADDRESS` | the allocated `host:port`, required |
3727

38-
This input decodes to `{"text":"one two three"}`. An invocation must match the
39-
release embedded by the Builder. Standalone generation, which has no owning
40-
workspace, checks the declaration's name and version. `invocation_id` and
41-
`intent_id` are required and at most 128 characters. `effect_id` is required for
42-
`receipt` recovery and optional for `recompute`.
28+
One call is `POST` to a procedure core pins, with the bounded input document as
29+
the whole body and no Codefly envelope around it — a field Codefly added to an
30+
owner's body would be a field the owner never described.
4331

44-
## Results and certainty
32+
| Route | Answers |
33+
| --- | --- |
34+
| `/codefly.runnable.v0.Runnable/Invoke` | one call: the bounded output document, 200 |
35+
| `/codefly.runnable.v0.Runnable/Lookup` | what an effect committed; served only by a `receipt` package |
4536

46-
A successful result for the example is:
37+
The per-call facts that are not the body travel as headers, spelled exactly as
38+
core spells them. `pkg/contract`'s `TestTheFramingIsOneContract` holds the Python
39+
literals equal to core's constants, because nothing at build time connects them.
4740

48-
```json
49-
{"protocol":"codefly.runnable/v1","invocation_id":"inv-1","status":"SUCCEEDED","output":"eyJjb3VudCI6M30="}
50-
```
41+
| Header | Meaning |
42+
| --- | --- |
43+
| `X-Codefly-Work-Context` | the caller's minted capability, carried verbatim — **required** |
44+
| `Codefly-Runnable-Effect-Id` | the effect identity a receipt is keyed by; required for `receipt` recovery |
45+
| `Codefly-Runnable-Deadline` | when the caller stops waiting, RFC 3339 with nanoseconds |
46+
| `Codefly-Runnable-Failure-Code` | on the answer: the operation's own typed failure code |
47+
48+
A call carrying no Work Context is **refused**, not run under whatever identity
49+
the process happens to have: that is the state core's required identity slot
50+
exists to remove. The harness never parses or reconstructs the capability — the
51+
handler reads it from `context.invocation.work_context` and forwards it to
52+
whatever it calls in turn, and that is the whole of what either does with it.
5153

52-
The output decodes to `{"count":3}`. The harness validates the output schema and
53-
byte limit before atomically renaming the result into place. It reports a certain
54-
operation failure only when the handler explicitly raises:
54+
## What an answer proves
55+
56+
Only the handler's own `HandlerFailure` proves that no effect committed, and it
57+
is the only thing that sets the failure-code header:
5558

5659
```python
5760
from codefly_runnable import HandlerFailure
58-
raise HandlerFailure("unavailable", "The operation was refused")
61+
raise HandlerFailure("card_declined", "the issuer declined")
5962
```
6063

61-
This writes `status: FAILED` with `error: {code, message}` and no output. Use it
62-
only when the operation knows its effect's disposition. An unexpected exception,
63-
invalid payload, invalid return, import failure or timeout writes no result.
64-
When the generated contract declares signal cancellation, a handled interruption
65-
writes `status: INTERRUPTED` without output. Core classifies it as `CANCELED`,
66-
which leaves the effect uncertain; it is never a certain operation failure.
67-
A contract declaring no cancellation does not install interruption handlers.
68-
69-
To prevent an earlier result from surviving an outcome that writes nothing, the harness removes any document at the
70-
result path before it reads the request, and refuses to start if it cannot. A
71-
document at that path is always this invocation's, so a launcher reusing the
72-
path cannot read an earlier attempt's result as this one's. The result is
73-
written 0644: which accounts may read it is the facility's choice. Exit codes
74-
are diagnostics, not portable completion outcomes:
75-
76-
| Exit | Diagnostic |
77-
| --- | --- |
78-
| 0 | validated success, unless author code bypassed the harness |
79-
| 64 | input schema rejection before author import |
80-
| 65 | invalid or oversized output, or result write failure |
81-
| 66 | explicit failure or unexpected handler/import exception |
82-
| 67 | harness deadline timer |
83-
| 68 | harness interruption |
84-
| 69 | invalid framing or environment |
85-
86-
The launcher calls core `Complete`. A valid result wins; otherwise a launcher
87-
termination reason takes precedence, then an invalid result, then a nonzero
88-
exit/signal, then exit zero with no result. A harness exit 67 by itself is
89-
`CRASHED`; the launcher must record its own deadline termination to classify
90-
`TIMED_OUT`. Only `SUCCEEDED` and explicit `FAILED` are certain.
91-
92-
## Deadlines and logs
93-
94-
For the native checkpoint, the harness preserves the original absolute deadline
95-
on the same host. Expired requests never import author code. The timer covers
96-
imports and the handler, and a swallowed signal cannot produce success. The
97-
launcher remains responsible for terminating the process group and descendants.
98-
Core #474 describes a clock-offset budget based on `deadline - issued_at`; the
99-
cross-host clock policy needs reconciliation before Kubernetes qualification.
100-
No distributed deadline or cancellation guarantee is claimed here.
101-
102-
`stdout` and `stderr` carry diagnostics only. The harness's own terminal
103-
diagnostic is written after the handler's bound is released, so a handler that
104-
exhausts the budget cannot erase the single record of why an uncertain
105-
invocation ended. The generated harness uses the
106-
release's `max_log_bytes` (4 MiB by default), bounding Python, native-library and
107-
inherited subprocess writes. It may append one truncation marker. The launcher
108-
must independently enforce its exact transport bound and record truncation.
64+
Everything else leaves the effect **unproven**, because the harness cannot know
65+
which side of its effect a handler stopped on. A caller that read a bare status
66+
as proof would stop looking for a receipt that exists.
67+
68+
| The handler | Answer | What the caller concludes |
69+
| --- | --- | --- |
70+
| returned output the contract admits | `200`, the output document | proven committed |
71+
| raised `HandlerFailure` | `422` + the failure-code header | proven not committed |
72+
| raised anything else | `500`, no failure code | unproven |
73+
| was still running at the deadline | `504`, no failure code | unproven |
74+
| returned an answer the contract refuses | `500`, no failure code | unproven |
75+
| was never reached (payload, identity, effect id) | `400`, no failure code | unproven |
76+
77+
The statuses are for humans and proxies: core classifies on the failure-code
78+
header and the answer document, never on a status class.
79+
80+
## Deadlines
81+
82+
`Codefly-Runnable-Deadline` bounds the call, measured from when the harness reads
83+
it rather than from an instant the caller stamped, so a clock offset neither
84+
shortens nor extends the work the caller asked for. A caller may shorten the
85+
declared timeout and never overrule it: the budget is the smaller of the two, and
86+
the declared one applies when no header is sent. A deadline already in the past
87+
is refused before the handler runs.
88+
89+
The handler runs on its own thread, because Python cannot raise into one. When
90+
the budget passes the harness answers and the handler is still running; what it
91+
would have returned is no longer that call's outcome.
92+
93+
## Signals and logs
94+
95+
A signal stops the harness taking new calls and gives the calls in flight
96+
`SHUTDOWN_GRACE_SECONDS` to finish. Cutting them off would turn every one of them
97+
into an unproven effect at once.
98+
99+
`stdout` and `stderr` carry diagnostics only, never completion data: a handler
100+
that printed its output would be indistinguishable from a library that printed a
101+
warning. The harness does **not** bound them. Per-call stream bounding is not
102+
something a server can do — concurrent calls share the process's descriptors —
103+
and `max_log_bytes` describes what the placement capturing those streams
104+
enforces, which is where it belongs.
105+
106+
## The receipt route
107+
108+
A package declaring `recovery: receipt` serves `Lookup`, and its handler module
109+
defines `receipt_of(context, input)` beside `handle`. Returning the output
110+
document answers 200; returning `None` answers 404, which is **not** an error:
111+
absent is "not yet known", never "no", and a caller reads it as inconclusive
112+
rather than as permission to invoke again.
113+
114+
The function is required at startup rather than at the call that needed it: an
115+
owner that cannot answer what an effect committed leaves every uncertain outcome
116+
uncertain forever, and that is not something to discover during a recovery. A
117+
`recompute` package has nothing to look up, so the route is absent rather than
118+
present and answering "never".
109119

110120
## Payload schema
111121

112122
`object`, `array`, `string`, signed 64-bit `integer` and `boolean` are supported.
113123
`optional` and `nullable` remain distinct. There is no coercion: `3.0` is not an
114-
integer, `1` is not a boolean, and undeclared keys are errors. Empty schemas accept
115-
exactly `{}`. Validation errors identify the offending input or output path.
124+
integer, `1` is not a boolean, undeclared keys are errors, and a repeated key is
125+
refused rather than resolved to its last value. Empty schemas accept exactly
126+
`{}`. Validation errors identify the offending input or output path. Both
127+
payloads are bounded by the declared `max-input-bytes` / `max-output-bytes`,
128+
measured on the document that is actually sent.

‎go.mod‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/codefly-dev/runnable-python
33
go 1.27.1
44

55
require (
6-
github.com/codefly-dev/core v0.5.1
6+
github.com/codefly-dev/core v0.6.0
77
github.com/stretchr/testify v1.12.1
88
google.golang.org/grpc v1.83.2
99
google.golang.org/protobuf v1.36.12
@@ -85,8 +85,8 @@ require (
8585
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
8686
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
8787
github.com/shoenig/go-m1cpu v0.2.1 // indirect
88-
github.com/tklauser/go-sysconf v0.3.16 // indirect
89-
github.com/tklauser/numcpus v0.11.0 // indirect
88+
github.com/tklauser/go-sysconf v0.4.0 // indirect
89+
github.com/tklauser/numcpus v0.12.0 // indirect
9090
github.com/xlab/treeprint v1.2.0 // indirect
9191
github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect
9292
github.com/yusufpapurcu/wmi v1.2.4 // indirect

0 commit comments

Comments
 (0)