@openparachute/agent 0.2.2 → 0.2.3-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +48 -27
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
package/src/backends/registry.ts
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
import type { AgentSpec, AgentMode } from "../sandbox/types.ts";
|
|
46
46
|
import { normalizeChannel } from "../sandbox/types.ts";
|
|
47
|
-
import type { AgentBackend, AgentHandle, InterimTurnEvent, TurnSession } from "./types.ts";
|
|
47
|
+
import type { AgentBackend, AgentHandle, InterimTurnEvent, RunContext, TurnSession } from "./types.ts";
|
|
48
48
|
import type { InboundAttachment } from "../transport.ts";
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -90,12 +90,15 @@ export type WriteOutbound = (
|
|
|
90
90
|
reply: string,
|
|
91
91
|
inReplyTo?: string,
|
|
92
92
|
/**
|
|
93
|
-
* The
|
|
94
|
-
* link the outbound note carries (stamped into `metadata.thread`)
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
93
|
+
* The RESOLVABLE, MODE-CORRECT thread id this reply belongs to — the explicit
|
|
94
|
+
* definition→thread→message link the outbound note carries (stamped into `metadata.thread`),
|
|
95
|
+
* mirroring the callback `source_thread` fix (agent#124/#163). For multi-threaded it IS the
|
|
96
|
+
* per-fire thread note's leaf (`Threads/<channel>/<id>`); for single-threaded it is the
|
|
97
|
+
* DETERMINISTIC thread-NOTE id (`Threads/<channel>/<name>`), STABLE across turns — so an
|
|
98
|
+
* observer reading the outbound note's `metadata.thread` resolves the agent's ONE thread
|
|
99
|
+
* with `query-notes { id }`, instead of the per-turn correlation UUID that changed every
|
|
100
|
+
* run (the pre-#163 bug that looked like a fresh session each time). INBOUND-note stamping
|
|
101
|
+
* is deferred (those notes are externally written; see the PR notes).
|
|
99
102
|
*/
|
|
100
103
|
threadId?: string,
|
|
101
104
|
) => Promise<{ id?: string } | void>;
|
|
@@ -174,8 +177,16 @@ export interface ThreadNote {
|
|
|
174
177
|
* per channel, multi-threaded writes one per fire. A write failure is the implementation's
|
|
175
178
|
* to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
|
|
176
179
|
* the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
|
|
180
|
+
*
|
|
181
|
+
* RETURNS the WRITTEN thread-note's id (`{ id }`) so the drain can use it as a RESOLVABLE
|
|
182
|
+
* `source_thread` on the agent-to-agent callback (agent#124) — for BOTH modes, this is the
|
|
183
|
+
* actual note an orchestrator can pull with `query-notes { id }` (single-threaded: the
|
|
184
|
+
* deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
|
|
185
|
+
* `Threads/<safeChannel>/<uuid>` note). `void` is in the union (back-compat) — a transport
|
|
186
|
+
* with no durable store, or one that can't surface an id, returns it and the drain falls
|
|
187
|
+
* back to the per-turn id.
|
|
177
188
|
*/
|
|
178
|
-
export type WriteThread = (thread: ThreadNote) => Promise<void>;
|
|
189
|
+
export type WriteThread = (thread: ThreadNote) => Promise<{ id?: string } | void>;
|
|
179
190
|
|
|
180
191
|
/**
|
|
181
192
|
* A callback delivered back to a SENDER's channel when a turn it requested finishes —
|
|
@@ -216,15 +227,16 @@ export interface CallbackMeta {
|
|
|
216
227
|
/** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
|
|
217
228
|
source_channel: string;
|
|
218
229
|
/**
|
|
219
|
-
* The
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
230
|
+
* The WRITTEN thread-note id — RESOLVABLE for BOTH modes (agent#124): an orchestrator can
|
|
231
|
+
* always pull the recipient's full thread record with `query-notes { id: source_thread }`,
|
|
232
|
+
* even on an error/empty/tool-only turn (the thread note is written BEFORE the outbound
|
|
233
|
+
* reply, so its id exists when there's no `source_message`).
|
|
234
|
+
* - multi-threaded: the per-fire note id (`Threads/<safeChannel>/<uuid>`).
|
|
235
|
+
* - single-threaded: the deterministic note id (`Threads/<safeChannel>/<safeName>`) — NOT
|
|
236
|
+
* the per-turn correlation id (the pre-#124 bug: that correlation id wasn't the note leaf
|
|
237
|
+
* for single-threaded, so it couldn't be resolved).
|
|
238
|
+
* The drain sources this from {@link WriteThread}'s returned id; if the seam can't surface
|
|
239
|
+
* one (no durable store) it falls back to the per-turn id (still a stable provenance token).
|
|
228
240
|
*/
|
|
229
241
|
source_thread: string;
|
|
230
242
|
/**
|
|
@@ -293,6 +305,46 @@ function delay(ms: number): Promise<void> {
|
|
|
293
305
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The thread id to stamp into an OUTBOUND note's `metadata.thread` (agent#163) — the
|
|
310
|
+
* MODE-CORRECT, RESOLVABLE definition→thread→message link, mirroring the callback
|
|
311
|
+
* `source_thread` fix (agent#124):
|
|
312
|
+
*
|
|
313
|
+
* - SINGLE-THREADED — the resolvable thread-NOTE id (the deterministic
|
|
314
|
+
* `Threads/<safeChannel>/<safeName>` note), so an observer reading the outbound note's
|
|
315
|
+
* `metadata.thread` resolves the agent's ONE stable thread with `query-notes { id }`.
|
|
316
|
+
* The pre-#163 bug stamped the per-turn correlation UUID here, which changed every turn
|
|
317
|
+
* and resolved to nothing — misleading an observer into "a fresh session each run" when
|
|
318
|
+
* the single-threaded session is actually stable + resumed across turns.
|
|
319
|
+
* - MULTI-THREADED — the per-fire id (`turnThreadId`), which IS that fire's thread-note leaf
|
|
320
|
+
* (`Threads/<safeChannel>/<turnThreadId>`) — correct as-is: each fire is its own thread.
|
|
321
|
+
*
|
|
322
|
+
* Falls back to `turnThreadId` when no resolvable note id surfaced (no durable thread store
|
|
323
|
+
* wired, or the thread-note write failed) — never undefined, so the link is always stamped.
|
|
324
|
+
*/
|
|
325
|
+
export function outboundThreadId(
|
|
326
|
+
multiThreaded: boolean,
|
|
327
|
+
turnThreadId: string,
|
|
328
|
+
threadNoteId: string | undefined,
|
|
329
|
+
): string {
|
|
330
|
+
if (multiThreaded) return turnThreadId;
|
|
331
|
+
return threadNoteId ?? turnThreadId;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Derive the run-context `fired-by` provenance (agent#162) from an inbound message's
|
|
336
|
+
* `sender` (the note's `metadata.sender`). A SCHEDULED job fire stamps `runner:<jobId>`
|
|
337
|
+
* (the runner's sender provenance) → reported as the job id so the agent knows it's a cron
|
|
338
|
+
* fire; anything else (a human / a delegated agent message) → `interactive`. Absent sender →
|
|
339
|
+
* undefined (the run context omits `fired-by`).
|
|
340
|
+
*/
|
|
341
|
+
export function runFiredBy(sender: string | undefined): string | undefined {
|
|
342
|
+
if (!sender) return undefined;
|
|
343
|
+
const m = /^runner:(.+)$/.exec(sender);
|
|
344
|
+
if (m) return `scheduled-job:${m[1]}`;
|
|
345
|
+
return "interactive";
|
|
346
|
+
}
|
|
347
|
+
|
|
296
348
|
/** A queued inbound message awaiting its serial turn. */
|
|
297
349
|
export interface QueuedMessage {
|
|
298
350
|
/** The inbound text handed to the `claude -p` turn as the prompt. */
|
|
@@ -334,6 +386,13 @@ export interface QueuedMessage {
|
|
|
334
386
|
* `Read` it. Absent/empty → no attachments (today's behavior unchanged).
|
|
335
387
|
*/
|
|
336
388
|
attachments?: InboundAttachment[];
|
|
389
|
+
/**
|
|
390
|
+
* WHO/WHAT sent this inbound (the note's `metadata.sender`) — used ONLY to derive the
|
|
391
|
+
* run-context `fired-by` provenance the daemon injects into the turn (agent#162): a
|
|
392
|
+
* SCHEDULED job fire stamps `runner:<jobId>`, an interactive/delegated message stamps
|
|
393
|
+
* something else. Absent → the run context omits `fired-by`. Carries no routing meaning.
|
|
394
|
+
*/
|
|
395
|
+
sender?: string;
|
|
337
396
|
}
|
|
338
397
|
|
|
339
398
|
/** A registered programmatic agent's live status (surfaced in /health + the list). */
|
|
@@ -797,7 +856,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
797
856
|
// start+end never double-count. Best-effort: a start-ensure write failure is logged
|
|
798
857
|
// (inside recordThread) and the turn STILL runs — a missing/stale working note must
|
|
799
858
|
// never strand the queue or skip the turn.
|
|
800
|
-
|
|
859
|
+
// Capture the start-ensure's WRITTEN thread-note id. For single-threaded this is the
|
|
860
|
+
// DETERMINISTIC `Threads/<safeChannel>/<safeName>` note (the same every turn) — so a
|
|
861
|
+
// resolvable thread id is available BEFORE the turn runs, for the failure-note +
|
|
862
|
+
// outbound `metadata.thread` stamping (agent#163), even on a turn that fails early.
|
|
863
|
+
const startThreadNoteId = await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
|
|
801
864
|
threadId: turnThreadId,
|
|
802
865
|
phase: "start",
|
|
803
866
|
// NO session on the start-ensure: it runs BEFORE claude, so claude may never
|
|
@@ -807,6 +870,26 @@ export class ProgrammaticAgentRegistry {
|
|
|
807
870
|
// the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
|
|
808
871
|
// single-threaded resume turn the prior session is preserved by writeThread anyway.
|
|
809
872
|
});
|
|
873
|
+
// The MODE-CORRECT, RESOLVABLE thread id stamped into outbound + failure notes
|
|
874
|
+
// (agent#163): single-threaded → the deterministic thread-NOTE id (stable across turns);
|
|
875
|
+
// multi-threaded → the per-fire `turnThreadId`. Computed once from the start-ensure id so
|
|
876
|
+
// every path (early failure, ok, outbound-failure) stamps the same resolvable link.
|
|
877
|
+
const outThreadId = outboundThreadId(multiThreaded, turnThreadId, startThreadNoteId);
|
|
878
|
+
|
|
879
|
+
// RUN CONTEXT (agent#162): assemble the runtime facts a headless `claude -p` turn can't
|
|
880
|
+
// know — the REAL wall-clock (`startedAt`), whether this run CONTINUES a prior session
|
|
881
|
+
// (`turnSession.resume`) or starts fresh, and WHY it fired (a scheduled job vs an
|
|
882
|
+
// interactive/delegated message, from the inbound sender). The backend prepends these as
|
|
883
|
+
// a labeled preamble so the agent stamps ACCURATE times instead of fabricating them.
|
|
884
|
+
// (DEFERRED: `priorTurnCount` — the rolling turn_count lives in the transport's thread
|
|
885
|
+
// note and isn't surfaced back to the drain; wiring it is a follow-up. Until then the
|
|
886
|
+
// preamble omits the `turn=N` line — the `now`/session/fired-by trio is the floor.)
|
|
887
|
+
const firedBy = runFiredBy(msg.sender);
|
|
888
|
+
const runContext: RunContext = {
|
|
889
|
+
now: startedAt,
|
|
890
|
+
session: turnSession.resume ? "resumed" : "new",
|
|
891
|
+
...(firedBy ? { firedBy } : {}),
|
|
892
|
+
};
|
|
810
893
|
|
|
811
894
|
let result;
|
|
812
895
|
try {
|
|
@@ -821,6 +904,8 @@ export class ProgrammaticAgentRegistry {
|
|
|
821
904
|
// Phase 1: inbound attachments → the programmatic backend stages them into the
|
|
822
905
|
// agent's private workspace so the turn can Read them. Absent/empty → no staging.
|
|
823
906
|
msg.attachments,
|
|
907
|
+
// agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
|
|
908
|
+
runContext,
|
|
824
909
|
);
|
|
825
910
|
} catch (err) {
|
|
826
911
|
// The backend contract is failure-as-VALUE, never a throw — but defend so a
|
|
@@ -835,7 +920,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
835
920
|
// thread note captures the turn outcome, so a failed turn is still a queryable
|
|
836
921
|
// `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
|
|
837
922
|
// a per-fire note).
|
|
838
|
-
await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
|
|
923
|
+
const threadNoteId = await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
|
|
839
924
|
threadId: turnThreadId,
|
|
840
925
|
phase: "end",
|
|
841
926
|
// No `result` (the backend threw) → NO session to persist. We never write a
|
|
@@ -845,11 +930,13 @@ export class ProgrammaticAgentRegistry {
|
|
|
845
930
|
});
|
|
846
931
|
this.emitTurnEvent(channel, { kind: "error", error: reason });
|
|
847
932
|
// Post a user-facing failure note so the channel shows SOMETHING (not a silent
|
|
848
|
-
// no-reply) — best-effort.
|
|
849
|
-
await this.postFailureNote(channel, msg.inReplyTo,
|
|
933
|
+
// no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
|
|
934
|
+
await this.postFailureNote(channel, msg.inReplyTo, outThreadId, reason);
|
|
850
935
|
// CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
|
|
851
|
-
// hang waiting forever. No outbound note was produced, so no `source_message
|
|
852
|
-
|
|
936
|
+
// hang waiting forever. No outbound note was produced, so no `source_message`; the
|
|
937
|
+
// RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
|
|
938
|
+
// still pull the recipient's thread on a no-reply turn (agent#124).
|
|
939
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
853
940
|
continue;
|
|
854
941
|
}
|
|
855
942
|
|
|
@@ -863,7 +950,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
863
950
|
// BOTH modes record the failed turn (status:error) on the thread note so a failure
|
|
864
951
|
// always leaves a queryable trace (single-threaded upserts the rolling thread,
|
|
865
952
|
// marking it errored; multi-threaded writes a per-fire status:error note).
|
|
866
|
-
await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
|
|
953
|
+
const threadNoteId = await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
|
|
867
954
|
threadId: turnThreadId,
|
|
868
955
|
phase: "end",
|
|
869
956
|
// Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
|
|
@@ -875,11 +962,12 @@ export class ProgrammaticAgentRegistry {
|
|
|
875
962
|
});
|
|
876
963
|
this.emitTurnEvent(channel, { kind: "error", error: result.error });
|
|
877
964
|
// Post a user-facing failure note so the channel shows SOMETHING (not a silent
|
|
878
|
-
// no-reply) — best-effort.
|
|
879
|
-
await this.postFailureNote(channel, msg.inReplyTo,
|
|
965
|
+
// no-reply) — best-effort. Stamp the MODE-CORRECT resolvable thread id (agent#163).
|
|
966
|
+
await this.postFailureNote(channel, msg.inReplyTo, outThreadId, result.error);
|
|
880
967
|
// CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
|
|
881
|
-
// sub-task failed and can react. No delivered reply, so no `source_message
|
|
882
|
-
|
|
968
|
+
// sub-task failed and can react. No delivered reply, so no `source_message`; the
|
|
969
|
+
// RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
|
|
970
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
883
971
|
continue;
|
|
884
972
|
}
|
|
885
973
|
|
|
@@ -891,7 +979,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
891
979
|
// multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
|
|
892
980
|
// logged + the turn still resolves (we never re-run a `claude -p` turn — that would
|
|
893
981
|
// burn quota for a duplicate).
|
|
894
|
-
|
|
982
|
+
// Capture the WRITTEN thread-note id — the RESOLVABLE `source_thread` for the callback
|
|
983
|
+
// (agent#124). The same note id is reused for the outbound-failure re-record below
|
|
984
|
+
// (sameTurn → same note), so a callback on either terminal path points at a pullable
|
|
985
|
+
// thread record.
|
|
986
|
+
let threadNoteId = await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
|
|
895
987
|
threadId: turnThreadId,
|
|
896
988
|
phase: "end",
|
|
897
989
|
// Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
|
|
@@ -915,7 +1007,12 @@ export class ProgrammaticAgentRegistry {
|
|
|
915
1007
|
channel,
|
|
916
1008
|
result.reply,
|
|
917
1009
|
msg.inReplyTo,
|
|
918
|
-
|
|
1010
|
+
// agent#163: stamp the MODE-CORRECT, RESOLVABLE thread id into the outbound note's
|
|
1011
|
+
// `metadata.thread` — single-threaded → the deterministic thread-NOTE id (stable
|
|
1012
|
+
// across turns; an observer resolves the ONE thread), multi-threaded → the per-fire
|
|
1013
|
+
// id. NOT the per-turn correlation UUID (the pre-#163 bug that looked like a fresh
|
|
1014
|
+
// session every run).
|
|
1015
|
+
outThreadId,
|
|
919
1016
|
);
|
|
920
1017
|
if (delivered.ok) sourceMessage = delivered.noteId;
|
|
921
1018
|
if (!delivered.ok) {
|
|
@@ -938,7 +1035,9 @@ export class ProgrammaticAgentRegistry {
|
|
|
938
1035
|
// `sameTurn` so this updates the note the `ok` record above just wrote (one
|
|
939
1036
|
// note, no turn_count double-count) rather than minting a duplicate / advancing
|
|
940
1037
|
// the count (the FIX-1 re-record bug the reviewer caught).
|
|
941
|
-
|
|
1038
|
+
// Re-record returns the SAME note's id (sameTurn upsert / same per-fire note) — use
|
|
1039
|
+
// it as the callback `source_thread` (agent#124), falling back to the ok-record id.
|
|
1040
|
+
threadNoteId = (await this.recordThread(
|
|
942
1041
|
handle,
|
|
943
1042
|
msg,
|
|
944
1043
|
"error",
|
|
@@ -955,7 +1054,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
955
1054
|
// write failed. Only claude's echoed id (FIX 2), never the passed uuid.
|
|
956
1055
|
...(result.sessionId ? { session: result.sessionId } : {}),
|
|
957
1056
|
},
|
|
958
|
-
);
|
|
1057
|
+
)) ?? threadNoteId;
|
|
959
1058
|
this.emitTurnEvent(channel, {
|
|
960
1059
|
kind: "error",
|
|
961
1060
|
error: `reply produced but not saved: ${delivered.error}`,
|
|
@@ -963,8 +1062,8 @@ export class ProgrammaticAgentRegistry {
|
|
|
963
1062
|
// CALLBACK as status:error — the reply was produced but NOT delivered, so the
|
|
964
1063
|
// turn did not truly succeed; the orchestrator must learn that. No `source_message`
|
|
965
1064
|
// (the outbound note never landed); the undelivered text lives in the error thread
|
|
966
|
-
// note for an operator to recover
|
|
967
|
-
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
|
|
1065
|
+
// note for an operator to recover — pull it via the RESOLVABLE `source_thread`.
|
|
1066
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
968
1067
|
continue;
|
|
969
1068
|
}
|
|
970
1069
|
}
|
|
@@ -976,8 +1075,10 @@ export class ProgrammaticAgentRegistry {
|
|
|
976
1075
|
// (empty/tool-only turn → clean resolve, no note expected).
|
|
977
1076
|
this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
|
|
978
1077
|
// CALLBACK on success — the turn finished cleanly (status:ok). `sourceMessage` is the
|
|
979
|
-
// delivered reply note (when there was one) the orchestrator pulls the full result from
|
|
980
|
-
|
|
1078
|
+
// delivered reply note (when there was one) the orchestrator pulls the full result from;
|
|
1079
|
+
// `source_thread` (the WRITTEN thread-note id, agent#124) is the RESOLVABLE pull-link in
|
|
1080
|
+
// both modes, including an empty/tool-only turn where there's no `sourceMessage`.
|
|
1081
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage, threadNoteId);
|
|
981
1082
|
}
|
|
982
1083
|
}
|
|
983
1084
|
|
|
@@ -1013,6 +1114,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
1013
1114
|
turnThreadId: string,
|
|
1014
1115
|
status: "ok" | "error",
|
|
1015
1116
|
sourceMessage?: string,
|
|
1117
|
+
sourceThreadId?: string,
|
|
1016
1118
|
): Promise<void> {
|
|
1017
1119
|
// Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
|
|
1018
1120
|
if (!this.writeCallback) return;
|
|
@@ -1041,11 +1143,17 @@ export class ProgrammaticAgentRegistry {
|
|
|
1041
1143
|
// source_message are echoed/included only when present. The daemon's WriteCallback
|
|
1042
1144
|
// wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
|
|
1043
1145
|
// — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
|
|
1146
|
+
//
|
|
1147
|
+
// `source_thread` is the WRITTEN thread-note id (agent#124) — RESOLVABLE for BOTH modes
|
|
1148
|
+
// (`query-notes { id: source_thread }`), available even on an error/empty/tool-only turn
|
|
1149
|
+
// (the thread note is written before the outbound). Fall back to the per-turn id only when
|
|
1150
|
+
// the thread seam surfaced none (no durable store / a write failure) — still a stable
|
|
1151
|
+
// provenance token, just not a pullable note.
|
|
1044
1152
|
const meta: CallbackMeta = {
|
|
1045
1153
|
callback: "true",
|
|
1046
1154
|
status,
|
|
1047
1155
|
source_channel: handle.channel,
|
|
1048
|
-
source_thread: turnThreadId,
|
|
1156
|
+
source_thread: sourceThreadId ?? turnThreadId,
|
|
1049
1157
|
...(sourceMessage ? { source_message: sourceMessage } : {}),
|
|
1050
1158
|
...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
|
|
1051
1159
|
delegation_depth: String(incomingDepth + 1),
|
|
@@ -1071,6 +1179,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
1071
1179
|
* agent name (single-threaded's thread is "named after the definition"). Best-effort: a
|
|
1072
1180
|
* write failure is LOGGED, never thrown out — a missing thread note must not strand the
|
|
1073
1181
|
* queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
|
|
1182
|
+
*
|
|
1183
|
+
* RETURNS the WRITTEN thread-note id so the drain can use it as a RESOLVABLE
|
|
1184
|
+
* `source_thread` on the agent-to-agent callback (agent#124), for BOTH modes. `undefined`
|
|
1185
|
+
* when no sink is wired, the write failed, or the seam surfaced no id — the drain then
|
|
1186
|
+
* falls back to the per-turn id.
|
|
1074
1187
|
*/
|
|
1075
1188
|
private async recordThread(
|
|
1076
1189
|
handle: ProgrammaticAgentHandle,
|
|
@@ -1080,8 +1193,8 @@ export class ProgrammaticAgentRegistry {
|
|
|
1080
1193
|
startedAt: string,
|
|
1081
1194
|
usage: ThreadNote["usage"],
|
|
1082
1195
|
opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
|
|
1083
|
-
): Promise<
|
|
1084
|
-
if (!this.writeThread) return;
|
|
1196
|
+
): Promise<string | undefined> {
|
|
1197
|
+
if (!this.writeThread) return undefined;
|
|
1085
1198
|
const thread: ThreadNote = {
|
|
1086
1199
|
channel: handle.channel,
|
|
1087
1200
|
name: handle.spec.name,
|
|
@@ -1107,12 +1220,18 @@ export class ProgrammaticAgentRegistry {
|
|
|
1107
1220
|
...(opts.phase ? { phase: opts.phase } : {}),
|
|
1108
1221
|
};
|
|
1109
1222
|
try {
|
|
1110
|
-
|
|
1223
|
+
// The seam returns the WRITTEN note id (`{ id }`) for a durable transport; `void` for
|
|
1224
|
+
// one with no store. Surface it so the drain can set a RESOLVABLE callback
|
|
1225
|
+
// `source_thread` (agent#124). A missing id → undefined → the drain falls back to the
|
|
1226
|
+
// per-turn id.
|
|
1227
|
+
const written = await this.writeThread(thread);
|
|
1228
|
+
return written?.id;
|
|
1111
1229
|
} catch (err) {
|
|
1112
1230
|
console.error(
|
|
1113
1231
|
`parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
|
|
1114
1232
|
`(continuing): ${(err as Error).message}`,
|
|
1115
1233
|
);
|
|
1234
|
+
return undefined;
|
|
1116
1235
|
}
|
|
1117
1236
|
}
|
|
1118
1237
|
|
package/src/backends/types.ts
CHANGED
|
@@ -84,6 +84,43 @@ export interface TurnSession {
|
|
|
84
84
|
resume: boolean;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* RUN CONTEXT for one turn (agent#162) — the runtime facts a programmatic `claude -p` turn
|
|
89
|
+
* otherwise has NO way to know, so an agent stops FABRICATING them. A headless `-p` turn has
|
|
90
|
+
* no clock and no notion of "which run this is": uni-weaver was openly inventing report
|
|
91
|
+
* timestamps (a fixed `10:05` slot, the date "derived from context") because it couldn't read
|
|
92
|
+
* a real clock mid-run. The daemon KNOWS these facts at dispatch time, so it injects them into
|
|
93
|
+
* the turn (a concise, clearly-labeled preamble the agent reads) rather than letting the agent
|
|
94
|
+
* guess. Cheap, and it removes a whole class of fabricated-time confusion.
|
|
95
|
+
*
|
|
96
|
+
* The backend renders this as a SHORT preamble prepended to the turn message — it never
|
|
97
|
+
* mangles the agent's own system-prompt semantics. ADDITIVE: a caller that omits it leaves the
|
|
98
|
+
* turn message exactly as before.
|
|
99
|
+
*/
|
|
100
|
+
export interface RunContext {
|
|
101
|
+
/** The REAL wall-clock at dispatch (ISO 8601) — the authoritative clock the turn lacks. */
|
|
102
|
+
now: string;
|
|
103
|
+
/**
|
|
104
|
+
* Whether this turn CONTINUES a prior conversation (`resumed`, single-threaded turn 2+) or
|
|
105
|
+
* STARTS a fresh one (`new`, the first turn / every multi-threaded fire) — the cheap "which
|
|
106
|
+
* run is this" signal the daemon already resolved (`TurnSession.resume`).
|
|
107
|
+
*/
|
|
108
|
+
session: "new" | "resumed";
|
|
109
|
+
/**
|
|
110
|
+
* WHY this turn is running (provenance): a SCHEDULED job fire stamps `runner:<jobId>` (the
|
|
111
|
+
* runner's sender provenance) → reported as the job id; anything else is an interactive /
|
|
112
|
+
* delegated message → `interactive`. Lets a scheduled agent know it's a cron fire vs a live
|
|
113
|
+
* reply. Absent when the inbound carried no sender.
|
|
114
|
+
*/
|
|
115
|
+
firedBy?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The thread's COMPLETED turn count BEFORE this turn (single-threaded's rolling counter;
|
|
118
|
+
* 0 on the first turn). Best-effort — omitted when the daemon can't cheaply resolve it
|
|
119
|
+
* (no durable thread store). So the agent can stamp "turn N" accurately.
|
|
120
|
+
*/
|
|
121
|
+
priorTurnCount?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
/**
|
|
88
125
|
* An opaque handle to a started agent, returned by {@link AgentBackend.start} and
|
|
89
126
|
* passed back to `deliver`/`stop`/`status`. The only field the seam itself depends
|
|
@@ -201,6 +238,12 @@ export interface AgentBackend {
|
|
|
201
238
|
* a safe basename) before the turn and appends a workspace-relative pointer to the
|
|
202
239
|
* prompt, so the `claude -p` turn can `Read` them. ADDITIVE — absent/empty → no
|
|
203
240
|
* staging, the turn behaves exactly as before.
|
|
241
|
+
*
|
|
242
|
+
* `runContext` (optional, agent#162) is the runtime context the daemon knows but a headless
|
|
243
|
+
* `-p` turn can't (the real wall-clock, whether this run is new vs resumed, why it fired).
|
|
244
|
+
* The programmatic backend prepends it as a concise, clearly-labeled preamble to the turn
|
|
245
|
+
* message so the agent stamps ACCURATE times instead of fabricating them. ADDITIVE — omitted
|
|
246
|
+
* → the turn message is exactly as before.
|
|
204
247
|
*/
|
|
205
248
|
deliver(
|
|
206
249
|
handle: AgentHandle,
|
|
@@ -208,6 +251,7 @@ export interface AgentBackend {
|
|
|
208
251
|
session: TurnSession,
|
|
209
252
|
onInterim?: InterimSink,
|
|
210
253
|
attachments?: InboundAttachment[],
|
|
254
|
+
runContext?: RunContext,
|
|
211
255
|
): Promise<DeliverResult>;
|
|
212
256
|
|
|
213
257
|
/**
|