Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
23042d2
Freeze the dispatcher's interfaces: driver, tasks and attempts, hooks
jorgemanrubia Sep 17, 2026
58ed8a9
Run the connector: tests, the run command, and the worker seam
jorgemanrubia Sep 17, 2026
13d0010
Terminate the leader by pid too; pin --setting-sources in the args test
jorgemanrubia Sep 17, 2026
1fecaf8
Launch on #736's createTask; one live task per event is retired_at's
jorgemanrubia Sep 17, 2026
4904303
Bound the wait on a worker's pipes, so a stray descendant cannot hang…
jorgemanrubia Sep 17, 2026
68fdb10
Fail, not hang, when a per-task workspace session never starts
jorgemanrubia Sep 17, 2026
71a773e
Answer the first review: starvation, stop reasons, recovery, containment
jorgemanrubia Sep 17, 2026
7521b7e
End an attempt through #736's supersedeTask, which returns unexposed …
jorgemanrubia Sep 17, 2026
10fdc10
Answer the second review: scope, authorization, and what a stop means
jorgemanrubia Sep 17, 2026
c7ec041
Preallocate the stranded query's arguments
jorgemanrubia Sep 17, 2026
0b77076
Answer the third review: groups, locations, slots, truncation, the skill
jorgemanrubia Sep 17, 2026
2c44b06
Name the one-owner rule and hold everything to it
jorgemanrubia Sep 17, 2026
547d150
One release point, and nothing may reach around it
jorgemanrubia Sep 17, 2026
06bf1c1
Write the driver contract down, and make the code keep it
jorgemanrubia Sep 17, 2026
125c118
On #736's 67aac1d: settlement cannot meet a moved handed record; desc…
jorgemanrubia Sep 17, 2026
462cac6
The task token's carriage: a one-use socket and the worker-mcp bridge
jorgemanrubia Sep 17, 2026
6eb4666
Withdraw through #736's withdrawExposure, after the supersession it r…
jorgemanrubia Sep 17, 2026
bcaa010
A worker's MCP server may be its descendant in a group of its own: Co…
jorgemanrubia Sep 17, 2026
4a8e52f
The prompt's worst case fits the budget: a URL over 120 characters is…
jorgemanrubia Sep 17, 2026
d58536a
A process group whose members are all zombies is gone
jorgemanrubia Sep 17, 2026
60196d4
drivertest: a secret scan never opens a SQLite database or its journals
jorgemanrubia Sep 17, 2026
5b59bca
Tests start their helper processes with a context
jorgemanrubia Sep 17, 2026
82ee3ec
The redaction rule: one function every text leaving a worker passes t…
jorgemanrubia Sep 17, 2026
58587b6
The refusal rule: a refusal is recorded in the ledger as it happens, …
jorgemanrubia Sep 17, 2026
a00b414
Take no descriptor's range on trust at the syscall boundary
jorgemanrubia Sep 17, 2026
df6ff26
Copilot: a stub that matches its Unix twin, a turn that keeps its ref…
jorgemanrubia Sep 17, 2026
57bfbf3
The token's window is the worker's MCP server's, and starts when the …
jorgemanrubia Sep 17, 2026
9963e3d
The release point ends the MCP server the agent started outside the w…
jorgemanrubia Sep 17, 2026
efeb109
A restart ends the MCP server that took the token, and a clean finish…
jorgemanrubia Sep 17, 2026
8483da8
A token socket always has a path a unix socket can carry
jorgemanrubia Sep 17, 2026
6751c14
Freeze the outbox's interfaces: intents, hooks, sender, poster
jorgemanrubia Sep 17, 2026
ea7f693
Hold the outbox to its invariants with tests, a real kill included
jorgemanrubia Sep 17, 2026
3f56d76
Wire the outbox into basecamp connect, and stop a flush that would cl…
jorgemanrubia Sep 17, 2026
8ac02d4
Let an empty render be the only thing that skips a completion notice
jorgemanrubia Sep 17, 2026
a2a6b75
Pass the context get_dispatch's binding now takes
jorgemanrubia Sep 17, 2026
7b7cdc8
Back off a failing reconciliation, never adopt an unreceipted notice,…
jorgemanrubia Sep 17, 2026
d845fdb
Promise in a holding reply only what happens, give a resend a fresh r…
jorgemanrubia Sep 17, 2026
c550ef8
Recognize an unreceipted notice by its words at its destination, give…
jorgemanrubia Sep 17, 2026
f15aee2
Cut a claimed request off at the flush deadline, with a test
jorgemanrubia Sep 17, 2026
4d11abf
Send in batches so a queue cannot starve reconciliation, and read eve…
jorgemanrubia Sep 17, 2026
0a6df4d
Stand a holding reply down when its route arrives, and never adopt a …
jorgemanrubia Sep 17, 2026
2ba3865
Bound the reconciliation listing, name a refused request, and let no …
jorgemanrubia Sep 17, 2026
6717213
Stand a refused guard down, so the worker acknowledges what nobody did
jorgemanrubia Sep 17, 2026
32e3b5a
List one slow destination per tick, let a person resend a refused not…
jorgemanrubia Sep 17, 2026
c072e1e
State the guard's refusal trade and bounded reconciliation as invaria…
jorgemanrubia Sep 17, 2026
81ee740
Settle what a previous process left, and send what is due, before any…
jorgemanrubia Sep 17, 2026
65e34a1
Stop a start the ledger cannot reconcile, bound it, and stop its send…
jorgemanrubia Sep 17, 2026
e6a8418
Say why a start that shutdown cut short is not a ledger failure
jorgemanrubia Sep 17, 2026
c8e6a50
Make a ledger failure after a send an error, so a start stops on it
jorgemanrubia Sep 17, 2026
29cac8b
Stop the start's reconciliation at the first ledger failure, whatever…
jorgemanrubia Sep 17, 2026
66f4b5d
Mark every ledger failure in reconciliation where it happens, and reo…
jorgemanrubia Sep 17, 2026
ed3035a
Keep a refused guard settled, as #736 now requires, and state what th…
jorgemanrubia Sep 17, 2026
063805d
Name the rule a refused guard follows, and whose refusal it is
jorgemanrubia Sep 17, 2026
f54da90
Retry a busy ledger read, and say what a later task's guard actually …
jorgemanrubia Sep 17, 2026
3e0e5af
Retry the last busy read, scope a worker's message by kind, and bound…
jorgemanrubia Sep 17, 2026
c4846df
Never post a still-running notice after its attempt ended
jorgemanrubia Sep 17, 2026
7e55fc4
Say what the still-running check actually covers, and test its missin…
jorgemanrubia Sep 17, 2026
054a1fb
Stop a start on a ledger failure while sending, and give up on a trun…
jorgemanrubia Sep 17, 2026
4098b87
Let a canceled notice hide nothing, and stop asking the ledger twice …
jorgemanrubia Sep 17, 2026
c5999b4
Test the receipt arm this PR made load-bearing
jorgemanrubia Sep 17, 2026
21bad61
Merge connect-dispatcher: #736 squashed onto main, so the numbers move
jorgemanrubia Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ ready. Show prints what setup recorded. Then run the connector on it:
basecamp connect -P <profile> [--project <id>]... [--shadow]

It runs in the foreground until interrupted. Stdout is a wire of one JSON
object per line (events seen, verdicts, dispatches; never content), and logs
object per line (events seen, verdicts, dispatches, lifecycle messages;
never content), and logs
go to stderr. SIGINT and SIGTERM cancel live workers with stop reason
shutdown, settle them, and exit 130 and 143. --shadow admits and logs in an
isolated state directory and dispatches nothing. macOS and Linux only.`,
Expand Down
92 changes: 75 additions & 17 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ func connectSessionsPath(file setup.File) string {
return filepath.Join(base, "bcc-"+connector.StateDirName(file.AccountID, file.Agent.PersonID))
}

// connectShutdownFlush bounds how long a stopping connector spends posting
// the completion notices of the attempts it stopped. What it cannot post in
// time stays pending in the outbox and goes out on the next start.
const connectShutdownFlush = 15 * time.Second

// connectStartBound bounds how long a starting connector spends settling the
// lifecycle messages a previous process left, before intake and dispatch run.
const connectStartBound = 2 * time.Minute

func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
if !connectSupportedOS(runtime.GOOS) {
return output.ErrUsage("basecamp connect runs on macOS and Linux only: it ends a crashed connector's workers by process group and start time, which only those two can read")
Expand Down Expand Up @@ -259,8 +268,24 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
return output.ErrUsage(err.Error())
}

var dispatcher *connector.Dispatcher
var (
dispatcher *connector.Dispatcher
outbox *connector.Outbox
)
if !f.shadow {
// Lifecycle messages: the hooks write each intent in its transition's
// transaction, so they are installed before anything transitions. A
// shadow run installs none: it posts nothing, and a shadow ledger
// promoted later must carry nothing to send.
ledger.SetHooks(connector.LifecycleHooks(ledger, connector.LifecycleOptions{}))
poster, err := connector.NewBasecampPoster(accountClient, agentID)
if err != nil {
return err
}
outbox, err = connector.NewOutbox(connector.OutboxOptions{Ledger: ledger, Poster: poster, Lines: lines, Logger: logger})
Comment thread
jorgemanrubia marked this conversation as resolved.
if err != nil {
return err
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate this binary for the worker's MCP server: %w", err)
Expand All @@ -277,7 +302,12 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
dispatcher, err = connector.NewDispatcher(connectDispatcherOptions(connectDispatch{
File: file, Buckets: buckets, Ledger: ledger, Driver: worker, Routes: routes.Current,
Profile: name, Executable: exe, StateDir: stateDir, SessionsDir: sessions,
Replies: connector.SDKReplies{Client: accountClient, AgentID: agentID},
// Replies are listed with their words, so the connector's own
// notices are left out even before their receipts are known, and
// no reply is ever adopted from one. That is the whole filter:
// an id-only predicate beside it would ask the ledger again for
// every reply, outside the adoption budget, for nothing.
Replies: connector.LifecycleFilteredReplies{Lister: poster, Ledger: ledger},
Lines: lines, Logger: logger,
}))
if err != nil {
Expand Down Expand Up @@ -341,14 +371,40 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error {
cancel()
})
}
if outbox != nil {
// On start, before anything transitions: settle what a previous
// process left sending and send what is due, so no stale notice
// waits behind new work. Bounded, so a slow Basecamp delays the
// connector's start rather than stopping it; what is left, Run
// carries on with. A ledger that cannot settle an intent stops the
// start.
startCtx, stopStart := context.WithTimeout(runCtx, connectStartBound)
err := outbox.Start(startCtx)
stopStart()
if err != nil && runCtx.Err() == nil {
return err
}
}
runPart("intake", intake.Run)
runPart("admission", func(ctx context.Context) error {
return connector.RunAdmission(ctx, connector.AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Lines: lines, Logger: logger})
})
if dispatcher != nil {
runPart("dispatch", dispatcher.Run)
}
if outbox != nil {
runPart("outbox", outbox.Run)
}
wg.Wait()
if outbox != nil {
// The dispatcher has settled every attempt it stopped; their
// completion notices go out now, within a bound.
flushCtx, stopFlush := context.WithTimeout(context.WithoutCancel(ctx), connectShutdownFlush)
if err := outbox.Flush(flushCtx); err != nil {
logger.Warn("connector: posting lifecycle messages on the way out", "error", err)
}
stopFlush()
}

mu.Lock()
sig := received
Expand Down Expand Up @@ -450,28 +506,30 @@ type connectDispatch struct {
StateDir string
SessionsDir string

Replies connector.ReplyLister
Lines *ndjson.Writer
Logger *slog.Logger
Replies connector.ReplyLister
IsLifecycleMessage func(id int64) bool
Lines *ndjson.Writer
Logger *slog.Logger
}

// connectDispatcherOptions is the dispatcher the run starts: connect.json's
// concurrency and deadline, the projects this run hears, and the worker's own
// MCP server. Built here so what the command wires is what a test can read.
func connectDispatcherOptions(d connectDispatch) connector.DispatcherOptions {
return connector.DispatcherOptions{
Ledger: d.Ledger,
Driver: d.Driver,
Routes: d.Routes,
Concurrency: d.File.Concurrency,
Deadline: time.Duration(d.File.Deadline),
Buckets: d.Buckets,
MCP: connector.WorkerMCP{Command: d.Executable, Profile: d.Profile, StateDir: d.StateDir},
PrivateDir: d.SessionsDir,
Replies: d.Replies,
Lines: d.Lines,
Logger: d.Logger,
StillRunning: connector.DefaultStillRunning,
Ledger: d.Ledger,
Driver: d.Driver,
Routes: d.Routes,
Concurrency: d.File.Concurrency,
Deadline: time.Duration(d.File.Deadline),
Buckets: d.Buckets,
MCP: connector.WorkerMCP{Command: d.Executable, Profile: d.Profile, StateDir: d.StateDir},
PrivateDir: d.SessionsDir,
Replies: d.Replies,
IsLifecycleMessage: d.IsLifecycleMessage,
Lines: d.Lines,
Logger: d.Logger,
StillRunning: connector.DefaultStillRunning,
}
}

Expand Down
9 changes: 9 additions & 0 deletions internal/connector/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,15 @@ END;
// acknowledgement trigger before this branch landed, and a shipped
// migration is never renumbered under a ledger that has applied it.
migrationTasksAndAttempts,
// Migration 8. The outbox every lifecycle message goes through. See
// outbox.go for the invariants it holds.
//
// This was migration 7 while it sat on #736's head: 6 for the tasks and
// attempts it builds on, 7 for the outbox. Main took 6 for the
// acknowledgement trigger, which pushed the dispatcher's tables to 7 and
// this to 8. The numbers move only because nothing has shipped them yet;
// once a ledger has applied one, its number is fixed.
migrationOutbox,
}

func (l *Ledger) migrate(ctx context.Context) error {
Expand Down
Loading
Loading