Skip to content

Commit ee872d1

Browse files
authored
Agents file created using expert-guided corrective grounding technique (#1072)
1 parent 0bb9399 commit ee872d1

File tree

7 files changed

+433
-1162
lines changed

7 files changed

+433
-1162
lines changed

AGENTS.md

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
# AGENTS.md — Effection agent contract
2+
3+
This file is the behavioral contract for AI agents working with the Effection
4+
codebase.
5+
6+
Agents must not invent APIs, must not infer semantics from other ecosystems, and
7+
must ground claims in the public API and repository code.
8+
9+
If you are unsure whether something exists, consult the API reference:
10+
https://frontside.com/effection/api/
11+
12+
## Core invariants (do not violate)
13+
14+
### Operations vs Promises
15+
16+
- **Operations** are lazy. They execute only when interpreted (e.g. `yield*`,
17+
`run()`, `Scope.run()`, `spawn()`).
18+
- **Promises** are eager. Creating a promise (or calling an `async` function)
19+
starts work; `await` only observes completion.
20+
- You must not claim that a promise is "inert until awaited".
21+
- You must not use `await` inside a generator function (`function*`). Use
22+
`yield*` with an operation instead (e.g. `yield* until(promise)`).
23+
24+
### Structured concurrency is scope-owned
25+
26+
- Scope hierarchy is created automatically by the interpreter; application code
27+
should not manage scopes manually.
28+
- "Lexical" in Effection: scope hierarchy follows the lexical structure of
29+
operation invocation sites (e.g. `yield*`, `spawn`, `Scope.run`), not where
30+
references are stored or later used.
31+
- Work is owned by **Scopes**.
32+
- When a scope exits, all work created in that scope is halted.
33+
- References do not extend lifetimes. Returning a `Task`, `Scope`, `Stream`, or
34+
`AbortSignal` does not keep it alive.
35+
36+
### Effects do not escape scopes
37+
38+
- Values may escape scopes.
39+
- Ongoing effects must not escape: tasks, resources, streams/subscriptions, and
40+
context mutations must remain scope-bound.
41+
42+
## Operations, Futures, Tasks
43+
44+
### Operation
45+
46+
- An `Operation<T>` is a recipe for work. It does nothing by itself.
47+
- Operations are typically created by invoking a generator function
48+
(`function*`).
49+
50+
### Future
51+
52+
- A `Future<T>` is both:
53+
- an Effection operation (`yield* future`)
54+
- a Promise (`await future`)
55+
56+
### Task
57+
58+
- A `Task<T>` is a `Future<T>` representing a concurrently running operation.
59+
- A task does not own lifetime or context; its scope does.
60+
61+
## Entry points and scope creation
62+
63+
### `main()`
64+
65+
- You should prefer `main()` when writing an entire program in Effection.
66+
- Inside `main()`, prefer `yield* exit(status, message?)` for termination; do
67+
not call `process.exit()` / `Deno.exit()` directly (it bypasses orderly
68+
shutdown).
69+
70+
### `exit()`
71+
72+
- `exit()` is an operation intended to be used from within `main()` to initiate
73+
shutdown.
74+
75+
### `run()`
76+
77+
- You may use `run()` to embed Effection into existing async code.
78+
- `run()` starts execution immediately; awaiting the returned task only observes
79+
completion.
80+
81+
### `createScope()`
82+
83+
- You must not use `createScope()` for normal Effection application code.
84+
- You may use `createScope()` only for **integration** between Effection and
85+
non-Effection lifecycle management (frameworks/hosts/embedders).
86+
- You must observe `destroy()` (`await` / `yield*`) to complete teardown.
87+
Calling `destroy()` without observation does not guarantee shutdown
88+
completion.
89+
90+
### `useScope()`
91+
92+
- Use `yield* useScope()` to capture the current `Scope` for integration (e.g.
93+
callbacks) and re-enter Effection with `scope.run(() => operation)`.
94+
95+
## `spawn()`
96+
97+
**Shape (canonical)**
98+
99+
```ts
100+
const op = spawn(myOperation); // returns an OPERATION
101+
const task = yield * op; // returns a TASK (Future) and starts it
102+
```
103+
104+
**Rules**
105+
106+
- `spawn()` does not start work by itself. Yielding the spawn operation starts
107+
work.
108+
- A spawned task must not outlive its parent scope.
109+
110+
## `Task.halt()`
111+
112+
**Rules**
113+
114+
- `task.halt()` returns a `Future<void>`. You must observe it (`await` /
115+
`yield*` / `.then()`), or shutdown is not guaranteed to complete.
116+
- `halt()` represents teardown. It can succeed even if the task failed.
117+
- If a task is halted before completion, consuming its value (`yield* task` /
118+
`await task`) fails with `Error("halted")`.
119+
120+
## Scope vs Task (ownership)
121+
122+
| Concept | Owns lifetime | Owns context |
123+
| ------- | ------------: | -----------: |
124+
| `Scope` |||
125+
| `Task` |||
126+
127+
## Context API (strict)
128+
129+
**Valid APIs**
130+
131+
- `createContext<T>(name, defaultValue?)`
132+
- `yield* Context.get()`
133+
- `yield* Context.expect()`
134+
- `yield* Context.set(value)`
135+
- `yield* Context.delete()`
136+
- `yield* Context.with(value, operation)`
137+
138+
**Rules**
139+
140+
- You must treat context as scope-local. Children inherit from parents; children
141+
may override without mutating ancestors.
142+
- You must not treat context as global mutable state.
143+
144+
## `race()`
145+
146+
**Rules**
147+
148+
- `race()` accepts an array of operations.
149+
- It returns the value of the first operation to complete.
150+
- It halts all losing operations.
151+
152+
## `all()`
153+
154+
**Rules**
155+
156+
- `all()` accepts an array of operations and evaluates them concurrently.
157+
- It returns an array of results in input order.
158+
- If any member errors, `all()` errors and halts the other members.
159+
- If you need "all operations either complete or error" (no fail-fast), wrap
160+
each member to return a railway-style result (e.g. `{ ok: true, value }` /
161+
`{ ok: false, error }`) instead of letting errors escape.
162+
163+
## `call()`
164+
165+
**Rules**
166+
167+
- `call()` invokes a function that returns a value, promise, or operation.
168+
- `call()` does not create a scope boundary and does not delimit concurrency.
169+
- If you need to report failures without throwing (e.g. so other work can
170+
continue), catch errors and return a railway-style result object instead of
171+
letting the error escape.
172+
173+
## `lift()`
174+
175+
**Rules**
176+
177+
- `lift(fn)` returns a function that produces an `Operation` which calls `fn`
178+
when interpreted (`yield*`), not when created.
179+
180+
## `action()`
181+
182+
**Rules**
183+
184+
- Use `action()` to wrap callback-style APIs when you can provide a cleanup
185+
function.
186+
- You must not claim `action()` creates an error or concurrency boundary; it
187+
does not.
188+
189+
## `until()`
190+
191+
**Rules**
192+
193+
- `until(promise)` adapts an already-created `Promise` into an `Operation`.
194+
- Prefer `until(promise)` over `call(() => promise)` when you have a promise—it
195+
is shorter and clearer.
196+
- It does not make the promise cancellable; for cancellable interop, prefer
197+
`useAbortSignal()` with APIs that accept `AbortSignal`.
198+
199+
## `scoped()`
200+
201+
**Rules**
202+
203+
- Use `scoped()` to create a boundary such that effects created inside do not
204+
persist after it returns.
205+
- You must use `scoped()` (not `call()`/`action()`) when you need boundary
206+
semantics.
207+
208+
## `resource()`
209+
210+
**Shape (ordering matters)**
211+
212+
```ts
213+
resource(function* (provide) {
214+
try {
215+
yield* provide(value);
216+
} finally {
217+
cleanup();
218+
}
219+
});
220+
```
221+
222+
**Rules**
223+
224+
- Setup happens before `provide()`.
225+
- Cleanup must be in `finally` (or after `provide()` guarded by `finally`) so it
226+
runs on return/error/halt.
227+
- Teardown can be asynchronous. If cleanup needs async work, express it as an
228+
`Operation` and `yield*` it inside `finally` (wait for teardown to finish)—do
229+
not fire-and-forget cleanup.
230+
231+
## `ensure()`
232+
233+
**Rules**
234+
235+
- `ensure(fn)` registers cleanup to run when the current operation shuts down.
236+
- `fn` may return `void` (sync cleanup) or an `Operation` (async cleanup).
237+
- You should wrap sync cleanup bodies in braces so the function returns `void`.
238+
239+
## `useAbortSignal()`
240+
241+
**Rules**
242+
243+
- `useAbortSignal()` is an interop escape hatch for non-Effection APIs that
244+
accept `AbortSignal`.
245+
- The returned signal is bound to the current scope and aborts when that scope
246+
exits (return, error, or halt).
247+
- You should pass the signal to a **leaf** async API call, not thread it through
248+
a nested async stack.
249+
- If the choice is "thread an AbortSignal through a nested async stack" vs
250+
"rewrite in Effection", you should prefer rewriting in Effection.
251+
252+
**Gotchas**
253+
254+
- You must not assume AbortController provides structured-concurrency
255+
guarantees. See:
256+
https://frontside.com/blog/2025-08-04-the-heartbreaking-inadequacy-of-abort-controller/
257+
258+
## Streams, Subscriptions, Channels, Signals, Queues
259+
260+
### Stream and Subscription
261+
262+
- A `Stream<T, TClose>` is an operation that yields a `Subscription<T, TClose>`.
263+
- A `Subscription` is stateful; values are observed via
264+
`yield* subscription.next()`.
265+
266+
### `on(target, name)` and `once(target, name)` (EventTarget adapters)
267+
268+
**Rules**
269+
270+
- `on()` creates a `Stream` of events from an `EventTarget`; listeners are
271+
removed on scope exit.
272+
- `once()` yields the next matching event as an `Operation` (it is equivalent to
273+
subscribing to `on()` and taking one value).
274+
275+
### `sleep()`, `interval()`, `suspend()`
276+
277+
**Rules**
278+
279+
- `sleep(ms)` is cancellable: if the surrounding scope exits, the timer is
280+
cleared.
281+
- `interval(ms)` is a `Stream` that ticks until the surrounding scope exits
282+
(cleanup clears the interval).
283+
- `suspend()` pauses indefinitely and only resumes when its enclosing scope is
284+
destroyed.
285+
286+
### `each(stream)` (loop consumption)
287+
288+
**Rules**
289+
290+
- You must call `yield* each.next()` exactly once at the end of every loop
291+
iteration.
292+
- You must call `yield* each.next()` even if the iteration ends with `continue`.
293+
294+
**Gotchas**
295+
296+
- If you do not call `each.next()`, the loop throws `IterationError` on the next
297+
iteration.
298+
299+
**Shape (ordering matters)**
300+
301+
```ts
302+
for (let value of yield * each(stream)) {
303+
// ...
304+
yield * each.next();
305+
}
306+
```
307+
308+
### Channel vs Signal vs Queue
309+
310+
| Concept | Send from | Send API | Requires subscribers | Buffering |
311+
| --------- | ------------------------------ | ------------------------- | ----------------------- | ------------------------------- |
312+
| `Channel` | inside operations | `send(): Operation<void>` | yes (otherwise dropped) | per-subscriber while subscribed |
313+
| `Signal` | outside operations (callbacks) | `send(): void` | yes (otherwise no-op) | per-subscriber while subscribed |
314+
| `Queue` | anywhere (single consumer) | `add(): void` | no | buffered (single subscription) |
315+
316+
### `Channel`
317+
318+
**Rules**
319+
320+
- Use `createChannel()` to construct a `Channel`.
321+
- Use `Channel` for communication between operations.
322+
- You must `yield* channel.send(...)` / `yield* channel.close(...)`.
323+
- You must assume sends are dropped when there are no active subscribers.
324+
325+
### `Signal`
326+
327+
**Rules**
328+
329+
- Use `createSignal()` to construct a `Signal`.
330+
- Use `Signal` only as a bridge from synchronous callbacks into an Effection
331+
stream.
332+
- You must not use `Signal` for in-operation messaging; use `Channel` instead.
333+
- You must assume `signal.send(...)` is a no-op if nothing is subscribed.
334+
335+
### `Queue`
336+
337+
**Rules**
338+
339+
- Use `createQueue()` to construct a `Queue`.
340+
- You may use `Queue` when you need buffering independent of subscriber timing
341+
(single consumer).
342+
- You must consume via `yield* queue.next()`.
343+
344+
## `subscribe()` and `stream()` (async iterable adapters)
345+
346+
**Rules**
347+
348+
- Use `subscribe(asyncIterator)` to adapt an `AsyncIterator` to an Effection
349+
`Subscription`.
350+
- Use `stream(asyncIterable)` to adapt an `AsyncIterable` to an Effection
351+
`Stream`.
352+
- You must not treat JavaScript async iterables as Effection streams without
353+
wrapping.
354+
- You must not use `for await` inside a generator function. Use `stream()` to
355+
adapt the async iterable, then `each()` to iterate.
356+
357+
**Shape (async iterable consumption)**
358+
359+
```ts
360+
for (const item of yield * each(stream(asyncIterable))) {
361+
// ...
362+
yield * each.next();
363+
}
364+
```
365+
366+
## `withResolvers()`
367+
368+
**Rules**
369+
370+
- `withResolvers()` creates an `operation` plus synchronous `resolve(value)` /
371+
`reject(error)` functions.
372+
- After resolve/reject, yielding the `operation` always produces the same
373+
outcome; calling resolve/reject again has no effect.

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"rules": { "exclude": ["prefer-const", "require-yield"] },
1616
"exclude": ["build", "www", "docs", "tasks"]
1717
},
18-
"fmt": { "exclude": ["build", "www", "CODE_OF_CONDUCT.md", "README.md"] },
18+
"fmt": { "exclude": ["build", "www"] },
1919
"test": { "exclude": ["build"] },
2020
"compilerOptions": { "lib": ["deno.ns", "esnext", "dom", "dom.iterable"] },
2121
"imports": {

0 commit comments

Comments
 (0)