Skip to content

Commit bcdbd24

Browse files
fix(agent): refuse completion submit and cancellation signal at Load and generation
Core admits both values, and the generated harness honours neither. A submit package owes an acceptance, a status route and callbacks, and the harness answers a bare output in the reply. A signal package promises an explicit interrupted outcome, and a handler runs on a thread Python cannot interrupt. Each is now refused by contract.Supported, called from Builder.Load and from generation, before any package exists. The generated contract also carries the declared concurrency, which the harness now enforces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5ab63ba commit bcdbd24

7 files changed

Lines changed: 160 additions & 2 deletions

File tree

‎pkg/agent/builder.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"google.golang.org/protobuf/encoding/protojson"
2020
"google.golang.org/protobuf/proto"
2121

22+
"github.com/codefly-dev/runnable-python/pkg/contract"
2223
"github.com/codefly-dev/runnable-python/pkg/generate"
2324
"github.com/codefly-dev/runnable-python/pkg/pack"
2425
"github.com/codefly-dev/runnable-python/pkg/prepare"
@@ -89,6 +90,9 @@ func (b *Builder) Load(ctx context.Context, request *builderv0.LoadRequest) (*bu
8990
if _, err := generate.HandlerModule(r.Entrypoint.Handler); err != nil {
9091
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
9192
}
93+
if err := contract.Supported(r); err != nil {
94+
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
95+
}
9296
b.runnable, b.identity = r, proto.Clone(id).(*basev0.RunnableIdentity)
9397
return &builderv0.LoadResponse{State: &builderv0.LoadStatus{State: builderv0.LoadStatus_READY}}, nil
9498
}

‎pkg/agent/builder_load_test.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
11+
"github.com/codefly-dev/core/resources"
12+
"google.golang.org/grpc/codes"
13+
"google.golang.org/grpc/status"
14+
)
15+
16+
// TestLoadRefusesWhatTheHarnessCannotHonour holds both refusals at the first
17+
// call that sees a declaration. Core admits each value, and a package built
18+
// for either would claim what its harness never does when called: a submit
19+
// package would answer a bare output where an acceptance is owed, and a signal
20+
// package would promise an interrupted outcome no Python thread can report.
21+
func TestLoadRefusesWhatTheHarnessCannotHonour(t *testing.T) {
22+
for _, test := range []struct {
23+
name string
24+
declare func(*resources.RunnableExecution)
25+
refused string
26+
}{
27+
{"call completion and no cancellation", func(*resources.RunnableExecution) {}, ""},
28+
{"submit completion", func(e *resources.RunnableExecution) {
29+
e.Recovery, e.Completion = resources.RunnableRecoveryReceipt, resources.RunnableCompletionSubmit
30+
}, `completion "submit"`},
31+
{"signal cancellation", func(e *resources.RunnableExecution) {
32+
e.Cancellation = resources.RunnableCancellationSignal
33+
}, `cancellation "signal"`},
34+
} {
35+
t.Run(test.name, func(t *testing.T) {
36+
ctx := context.Background()
37+
builder, request := declared(t, test.declare)
38+
39+
loaded, err := builder.Load(ctx, request)
40+
41+
if test.refused == "" {
42+
if err != nil {
43+
t.Fatalf("a declaration the harness honours was refused: %v", err)
44+
}
45+
if loaded.GetState().GetState() != builderv0.LoadStatus_READY {
46+
t.Fatalf("state = %v", loaded.GetState().GetState())
47+
}
48+
return
49+
}
50+
if status.Code(err) != codes.InvalidArgument || !strings.Contains(err.Error(), test.refused) {
51+
t.Fatalf("Load = %v, want InvalidArgument naming %s", err, test.refused)
52+
}
53+
if builder.runnable != nil {
54+
t.Fatal("a refused declaration stayed loaded")
55+
}
56+
})
57+
}
58+
}
59+
60+
func declared(t *testing.T, declare func(*resources.RunnableExecution)) (*Builder, *builderv0.LoadRequest) {
61+
t.Helper()
62+
ctx := context.Background()
63+
manifest, err := resources.LoadFromPath[resources.Agent](ctx, filepath.Join("..", "..", "agent.codefly.yaml"))
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
dir := t.TempDir()
68+
if err := os.WriteFile(filepath.Join(dir, "workspace.codefly.yaml"),
69+
[]byte("kind: workspace\nname: proof\nlayout: flat\nservices: []\n"), 0o600); err != nil {
70+
t.Fatal(err)
71+
}
72+
workspace, err := resources.LoadWorkspaceFromDir(ctx, dir)
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
module, err := workspace.LoadModuleFromName(ctx, "proof")
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
r, err := module.NewRunnable(ctx, "word-count", manifest, "handler.py")
81+
if err != nil {
82+
t.Fatal(err)
83+
}
84+
declare(r.Execution)
85+
if err := r.Save(ctx); err != nil {
86+
t.Fatal(err)
87+
}
88+
location, err := workspace.RunnableLocationOf(r)
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
wire, err := location.Proto()
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
return NewBuilder(manifest), &builderv0.LoadRequest{Runnable: wire}
97+
}

‎pkg/contract/contract.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ type Generated struct {
4040
// a call carrying no deadline header is bounded by the author's own
4141
// statement of how long the operation may run.
4242
TimeoutNanoseconds int64 `json:"timeout-nanoseconds"`
43+
// Concurrency is the declared bound on simultaneous calls, which the
44+
// harness enforces before a handler runs; 0 is the facility default.
45+
Concurrency uint32 `json:"concurrency"`
46+
}
47+
48+
// Supported refuses a declaration the generated harness cannot honour. Both
49+
// are values core admits, and a package built for either would claim in its
50+
// configuration what it never does when called.
51+
func Supported(runnable *resources.Runnable) error {
52+
if completion := runnable.Execution.GetCompletion(); completion != resources.RunnableCompletionCall {
53+
return fmt.Errorf("completion %q is not served by the Python harness: it answers in the reply to the call (%q) and serves no acceptance, status route or callback",
54+
completion, resources.RunnableCompletionCall)
55+
}
56+
if runnable.Execution.Cancellation == resources.RunnableCancellationSignal {
57+
return fmt.Errorf("cancellation %q is not honoured by the Python harness: a handler runs on a thread Python cannot interrupt, so the interrupted outcome it promises could never be reported; declare %q",
58+
resources.RunnableCancellationSignal, resources.RunnableCancellationNone)
59+
}
60+
return nil
4361
}
4462

4563
// Handler locates the author entrypoint inside the generated package.
@@ -77,6 +95,7 @@ func Generate(runnable *resources.Runnable, handlerModule string) *Generated {
7795
MaxInputBytes: runnable.Execution.MaxInputBytes(),
7896
MaxOutputBytes: runnable.Execution.MaxOutputBytes(),
7997
TimeoutNanoseconds: runnable.Execution.GetTimeout().Nanoseconds(),
98+
Concurrency: runnable.Execution.Concurrency,
8099
// The whole release identity, not the half a declaration states: the
81100
// harness hands it to the handler, and a field missing from the
82101
// document is a field the handler cannot read. Standalone generation

‎pkg/generate/generate.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func Generate(runnable *resources.Runnable, dir string) error {
6161
// Standalone generation checks the declaration's name and version; the Builder
6262
// always supplies the complete identity resolved during Load.
6363
func GenerateForRelease(runnable *resources.Runnable, dir string, identity *basev0.RunnableIdentity) error {
64+
if err := contract.Supported(runnable); err != nil {
65+
return err
66+
}
6467
module, err := HandlerModule(runnable.Entrypoint.Handler)
6568
if err != nil {
6669
return err
@@ -205,6 +208,10 @@ def %s(context: Context, input: Input) -> %s | None:
205208
None is not an error and never means the effect did not happen: it is the
206209
answer a caller reads as inconclusive, which is what sends it looking again
207210
rather than invoking a second time.
211+
212+
Authorize the lookup here: answer only for an effect the caller's
213+
context.invocation.work_context may read. The harness requires a Work
214+
Context but neither verifies it nor binds the effect id to it.
208215
"""
209216
raise NotImplementedError("implement the receipt lookup of %s")
210217
`, contract.ReceiptAttribute, OutputType, runnable.Name)

‎pkg/generate/generate_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ execution:
5757
timeout: 1m
5858
cancellation: none
5959
recovery: recompute
60+
concurrency: 3
6061
payload:
6162
max-input-bytes: 4096
6263
max-output-bytes: 2048
@@ -127,6 +128,9 @@ func TestTheGeneratedContractCarriesTheDeclaredBounds(t *testing.T) {
127128
if document["recovery"] != "recompute" {
128129
t.Errorf("recovery = %v", document["recovery"])
129130
}
131+
if document["concurrency"] != 3.0 {
132+
t.Errorf("the declared concurrency, which the harness enforces, was not generated: %v", document["concurrency"])
133+
}
130134
handler, _ := document["handler"].(map[string]any)
131135
if handler["module"] != "handler" || handler["attribute"] != "handle" {
132136
t.Errorf("handler = %v", handler)
@@ -136,6 +140,33 @@ func TestTheGeneratedContractCarriesTheDeclaredBounds(t *testing.T) {
136140
}
137141
}
138142

143+
// TestGenerationRefusesWhatTheHarnessCannotHonour: core admits both values,
144+
// and a package generated for either would claim what its harness never does.
145+
func TestGenerationRefusesWhatTheHarnessCannotHonour(t *testing.T) {
146+
for name, change := range map[string][2]string{
147+
`completion "submit"`: {"recovery: recompute", "recovery: receipt\n completion: submit"},
148+
`cancellation "signal"`: {"cancellation: none", "cancellation: signal"},
149+
} {
150+
t.Run(name, func(t *testing.T) {
151+
dir := t.TempDir()
152+
write(t, filepath.Join(dir, resources.RunnableConfigurationName), strings.Replace(declaration, change[0], change[1], 1))
153+
runnable, err := resources.LoadRunnableFromDir(context.Background(), dir)
154+
if err != nil {
155+
t.Fatal(err)
156+
}
157+
158+
err = generate.Generate(runnable, dir)
159+
160+
if err == nil || !strings.Contains(err.Error(), name) {
161+
t.Fatalf("Generate = %v, want a refusal naming %s", err, name)
162+
}
163+
if _, err := os.Stat(filepath.Join(dir, generate.GeneratedDirectory)); !os.IsNotExist(err) {
164+
t.Errorf("a refused declaration still generated a harness: %v", err)
165+
}
166+
})
167+
}
168+
}
169+
139170
func TestRegeneratingProducesTheSameBytes(t *testing.T) {
140171
dir := t.TempDir()
141172
runnable := load(t, dir)

‎pkg/pack/pack_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ entrypoint:
4141
execution:
4242
facilities: [generated-service]
4343
timeout: 30s
44-
cancellation: signal
44+
cancellation: none
4545
recovery: receipt
4646
workspace-configuration-dependencies: [stripe]
4747
`

‎qualification_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ entrypoint:
7272
execution:
7373
facilities: [generated-service, kubernetes]
7474
timeout: 2m
75-
cancellation: signal
75+
cancellation: none
7676
recovery: recompute
7777
payload:
7878
max-input-bytes: 65536

0 commit comments

Comments
 (0)