Merge remote-tracking branch 'origin/master' into session-surface

Reconciles the session-surface work (surfaceOp/sourceEventSeqs provenance as
the sole derivation path) with master's worktree-subagent series (fork-seed
boundary + out-of-process subagent backends).

Semantic reconciliations beyond the textual auto-merge:
- SQLite SCHEMA_VERSION: both sides bumped 2->3. Merged to a single v3 carrying
  BOTH column families — master's seed_length on `sessions` and surface's
  source_event_seqs/surface_op on `events`. writeRow + both INSERT sites bind
  the full set; the schema doc lists all three added columns as the v2->v3 gap.
- agent-loop runStep request: master's `sessionId: session.id` and surface's
  per-append surfaceOp/sourceEventSeqs coexist (different regions).
- Fork seed + surface: a fork seeds the child from the parent's LIVE events,
  which now carry surfaceOp, so the child's surface rebuilds correctly. Verified
  end-to-end — the subagent-fork replay recalls the inherited "SAFFRON" codeword
  through the seeded prefix.
- Subagent snapshot fixtures (recorded pre-surface) re-enriched via KEYLESS
  deterministic replay: only surfaceOp/sourceEventSeqs added onto existing
  recorded lines (matched by seq), no recorded value changed. Not re-recorded
  against the live API.

Gates: typecheck, test (1112), test:snapshot (14), doc-sync, lint, build,
hygiene all green.
This commit is contained in:
Hypatia May
2026-06-24 10:48:01 +08:00
203 changed files with 8630 additions and 484 deletions

View File

@@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory {
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id — revisit when the
* UI/ACP path owns session selection.
*
* TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
* fork seeds the new Session with the parent's event log, spawn starts
* fresh; the child is returned as a regular Agent handle.
*/
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
@@ -140,16 +136,22 @@ export class AgentLoop extends Service implements AgentFactory {
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
* client-generated session id becomes the live/persisted session id. Returns
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
* ACP bridge uses this so the client-generated session id becomes the
* live/persisted session id; the in-process FORK subagent backend passes a
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
const session = this.ctx.sessions.prepare(options.sessionId, {
...options.seed !== undefined ? { seed: options.seed } : {},
meta: options.meta ?? {},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
}
@@ -217,6 +219,9 @@ export class AgentLoop extends Service implements AgentFactory {
createdAt: meta.createdAt,
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
// Reconstruct the seed boundary from the persisted header, NOT from
// `events.length` (the resume seeds the WHOLE stored log).
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)

View File

@@ -570,6 +570,7 @@ async function runStep(
messages: session.deriveMessages(),
...system ? { system } : {},
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
sessionId: session.id,
signal,
}
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))