From 1b8fdd2fc2fba614d0aa04642e492537cc010cc5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 18:05:58 -0400 Subject: [PATCH 1/7] Watch for connector requests with a one-shot background watcher, not Monitor The skill armed the Monitor tool with `persistent: true`. Claude Code 2.1.271 removed that option: every watch now expires within 30 minutes, so a session has to re-arm it. Re-arming and skipping handled ids with `grep -v` loses requests in a different way. In Claude Code's Bash, `grep` is the embedded ugrep, and `ugrep -v` holds each line on a stream until the next one arrives, `--line-buffered` or not. The newest request then waits for the next one. watch-requests.sh reads the connector's output from the start, prints the request lines whose event_id the handled file lacks, and exits. The session records each id, starts the watcher again, and handles the lines. The notification is the exit itself, so no stream filter sits between a line and the session, and reading from the start leaves no gap between watchers. The watcher reads only complete lines, and the handled file lives outside the run, so a line reprinted after a crash is still skipped. --- skills/basecamp-connect/SKILL.md | 38 ++++++++++++----------- skills/basecamp-connect/watch-requests.sh | 33 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 skills/basecamp-connect/watch-requests.sh diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 6206e9f01..521638493 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -75,25 +75,21 @@ see `connector: running` in its log. If it exited instead, explain the error (see *When the connector stops*), and stop. If it says `could not get the agent's token yet`, Basecamp's token endpoint is rate-limiting it or having trouble: it waits as long as it says and tries again on its own. Tell the -person, leave it running, and arm the monitor as usual. Restarting it doesn't +person, leave it running, and start the watcher as usual. Restarting it doesn't help. -Then arm a persistent monitor on that file with the Monitor tool -(`persistent: true`), replaying from the start so nothing that arrived between -the start and the monitor is missed: +Then start the watcher, also with `run_in_background: true`: ```bash -tail -f -n +1 '' | grep --line-buffered -F '"type":"request"' +bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' '' ~/.config/basecamp-connect/'handled-' ``` -Single-quote the path, as with every other value. - -Each notification is one request: handle it with *For each request*. A request -that arrives while you're waiting on the person is not their reply. - -The connector starts from the present. Anything asked more than a minute -before it started is not picked up. If someone says the agent ignored them, that's the first -thing to check. +It exits, notifying you, with the request lines whose `event_id` the handled +file lacks. Record each (`echo >> ~/.config/basecamp-connect/'handled-'`), +start the watcher again, then handle each line with *For each request*. Keep +one watcher running. Not the Monitor tool: its watches expire, and a `grep -v` +in one holds a line back until the next. A request that arrives while you're +waiting on the person is not their reply. ### 2. The request line @@ -549,12 +545,17 @@ output and tell the person what it means: Requests that arrive while it's stopped are not picked up later. +Each start writes a new output file. Stop the watcher (TaskStop), and start it +again on the new file once the connector is running. + ## Checking on it - `basecamp connect doctor -P ''`: the credential, the identity, the event feed and the ledger. Start here when something seems wrong. It posts nothing. -- **"Did that mention reach it?"**: search the connector's output file for the +- **"Did that mention reach it?"**: the connector starts from the present, so + anything asked more than a minute before it started is not picked up. Check + that first. Then search the connector's output file for the recording id. A `"type":"request"` line means it was handed to you. A `"type":"event"` line with a `reason` says why it was turned away (for example `untrusted_performer`). No line means the feed never delivered it. @@ -564,7 +565,8 @@ Requests that arrive while it's stopped are not picked up later. ## Stopping -When the person asks you to stop, or the session is ending, stop the connector -task (TaskStop, or send it SIGINT). There's nothing else to clean up: no -webhooks, nothing public. Subagents already running finish their requests and -reply. Say that new mentions won't be picked up until it's started again. +When the person asks you to stop, or the session is ending, stop the watcher +and the connector task (TaskStop, or send it SIGINT). There's nothing else to +clean up: no webhooks, nothing public. Subagents already running finish their +requests and reply. Say that new mentions won't be picked up until it's +started again. diff --git a/skills/basecamp-connect/watch-requests.sh b/skills/basecamp-connect/watch-requests.sh new file mode 100644 index 000000000..90c62e869 --- /dev/null +++ b/skills/basecamp-connect/watch-requests.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -u + +if [ $# -ne 2 ]; then + echo "usage: watch-requests.sh " >&2 + exit 2 +fi + +out=$1 +handled=$2 + +if [ ! -f "$out" ]; then + echo "watch-requests: no connector output at $out" >&2 + exit 2 +fi +mkdir -p "$(dirname "$handled")" + +unhandled() { + local lines + lines=$(($(wc -l < "$out"))) + [ "$lines" -gt 0 ] || return 1 + head -n "$lines" "$out" | awk -v handled="$handled" ' + BEGIN { while ((getline id < handled) > 0) seen[id] = 1 } + /"type":"request"/ && match($0, /"event_id":[0-9]+/) { + id = substr($0, RSTART + 11, RLENGTH - 11) + if (!(id in seen)) { print; seen[id] = 1; found = 1 } + } + END { exit !found }' +} + +until unhandled; do + sleep 2 +done From 60162162d6f9f557758911e2843665d5c6c1b0db Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 18:24:38 -0400 Subject: [PATCH 2/7] Drain a stopped connector's file, read lines from the watcher's output, and fail closed on the handled file A request printed just before the connector exits could sit in the old output file after the watcher moved to the new one. watch-requests.sh --once checks the old file a last time, without needing timeout(1), which macOS lacks. The watcher's completion notice names its output file rather than carrying the lines, so the skill says to read them from there. awk -v decodes backslashes, so the handled path reaches awk through ENVIRON. A handled file the watcher can't create or read now exits 2 instead of reporting every request as new. The post-compaction notice said the session's monitor kept running. It now names the request watcher. --- internal/commands/agent_hook_connector.go | 2 +- skills/basecamp-connect/SKILL.md | 24 +++++++++++++++-------- skills/basecamp-connect/watch-requests.sh | 21 ++++++++++++++++---- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/internal/commands/agent_hook_connector.go b/internal/commands/agent_hook_connector.go index 841a0213d..03ccb59b9 100644 --- a/internal/commands/agent_hook_connector.go +++ b/internal/commands/agent_hook_connector.go @@ -58,7 +58,7 @@ func connectorReloadNotice(input agentHookInput) string { "finished cut that skill to its first 5,000 tokens, which drops the handoff steps and the subagent's " + "instructions. If this session is still the connector's orchestrator, read " + skill + " in full with " + "the Read tool now, before you go on with anything, a request in hand included, then carry on from the " + - "step you were on. The connector, its monitor and any subagents in flight kept running through the " + + "step you were on. The connector, its request watcher and any subagents in flight kept running through the " + "compaction: don't start them again." } diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 521638493..779603870 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -84,12 +84,13 @@ Then start the watcher, also with `run_in_background: true`: bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' '' ~/.config/basecamp-connect/'handled-' ``` -It exits, notifying you, with the request lines whose `event_id` the handled -file lacks. Record each (`echo >> ~/.config/basecamp-connect/'handled-'`), -start the watcher again, then handle each line with *For each request*. Keep -one watcher running. Not the Monitor tool: its watches expire, and a `grep -v` -in one holds a line back until the next. A request that arrives while you're -waiting on the person is not their reply. +It exits once request lines arrive whose `event_id` the handled file lacks. +Read them in full from its output file, record each +(`echo >> ~/.config/basecamp-connect/'handled-'`), start +the watcher again, then handle each with *For each request*. Keep one running. +Not the Monitor tool: its watches expire, and a `grep -v` in one holds a line +back until the next. A request that arrives while you're waiting on the person +is not their reply. ### 2. The request line @@ -545,8 +546,15 @@ output and tell the person what it means: Requests that arrive while it's stopped are not picked up later. -Each start writes a new output file. Stop the watcher (TaskStop), and start it -again on the new file once the connector is running. +Each start writes a new output file. Stop the watcher (TaskStop), then check +the old file once, so a request printed just before the exit isn't left there: + +```bash +bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' --once '' ~/.config/basecamp-connect/'handled-' +``` + +Handle any line it prints as usual. Start the watcher on the new file once the +connector is running. ## Checking on it diff --git a/skills/basecamp-connect/watch-requests.sh b/skills/basecamp-connect/watch-requests.sh index 90c62e869..ba6a3c6b1 100644 --- a/skills/basecamp-connect/watch-requests.sh +++ b/skills/basecamp-connect/watch-requests.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash set -u +once=false +if [ "${1:-}" = --once ]; then + once=true + shift +fi if [ $# -ne 2 ]; then - echo "usage: watch-requests.sh " >&2 + echo "usage: watch-requests.sh [--once] " >&2 exit 2 fi @@ -13,14 +18,17 @@ if [ ! -f "$out" ]; then echo "watch-requests: no connector output at $out" >&2 exit 2 fi -mkdir -p "$(dirname "$handled")" +if ! { mkdir -p "$(dirname "$handled")" && touch "$handled" && [ -r "$handled" ]; }; then + echo "watch-requests: can't use the handled file $handled" >&2 + exit 2 +fi unhandled() { local lines lines=$(($(wc -l < "$out"))) [ "$lines" -gt 0 ] || return 1 - head -n "$lines" "$out" | awk -v handled="$handled" ' - BEGIN { while ((getline id < handled) > 0) seen[id] = 1 } + head -n "$lines" "$out" | HANDLED=$handled awk ' + BEGIN { while ((getline id < ENVIRON["HANDLED"]) > 0) seen[id] = 1 } /"type":"request"/ && match($0, /"event_id":[0-9]+/) { id = substr($0, RSTART + 11, RLENGTH - 11) if (!(id in seen)) { print; seen[id] = 1; found = 1 } @@ -28,6 +36,11 @@ unhandled() { END { exit !found }' } +if [ "$once" = true ]; then + unhandled + exit 0 +fi + until unhandled; do sleep 2 done From cd6ca164fe0e071308e17855d53fc6189570c9b6 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 18:33:27 -0400 Subject: [PATCH 3/7] Require a writable handled file, and have a compacted session check the watcher A directory or a read-only file passed touch and -r, so the watcher ran against a handled file the session could never record into, and every restart replayed the same requests. It now requires a regular file it can read and write. The post-compaction notice said everything kept running. The watcher exits on each request, so it may have exited during the compaction. The notice now says to check it, and to read its output and start it again if it has exited. --- internal/commands/agent_hook_connector.go | 5 +++-- skills/basecamp-connect/watch-requests.sh | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/commands/agent_hook_connector.go b/internal/commands/agent_hook_connector.go index 03ccb59b9..4e5d2b0da 100644 --- a/internal/commands/agent_hook_connector.go +++ b/internal/commands/agent_hook_connector.go @@ -58,8 +58,9 @@ func connectorReloadNotice(input agentHookInput) string { "finished cut that skill to its first 5,000 tokens, which drops the handoff steps and the subagent's " + "instructions. If this session is still the connector's orchestrator, read " + skill + " in full with " + "the Read tool now, before you go on with anything, a request in hand included, then carry on from the " + - "step you were on. The connector, its request watcher and any subagents in flight kept running through the " + - "compaction: don't start them again." + "step you were on. The connector and any subagents in flight kept running through the compaction: don't " + + "start them again. The request watcher exits on each request, so check whether it is still running; if it " + + "has exited, read its output and start it again as the skill says." } // lastConnectorSkillDir returns the directory of the last basecamp-connect diff --git a/skills/basecamp-connect/watch-requests.sh b/skills/basecamp-connect/watch-requests.sh index ba6a3c6b1..1480158c3 100644 --- a/skills/basecamp-connect/watch-requests.sh +++ b/skills/basecamp-connect/watch-requests.sh @@ -18,7 +18,7 @@ if [ ! -f "$out" ]; then echo "watch-requests: no connector output at $out" >&2 exit 2 fi -if ! { mkdir -p "$(dirname "$handled")" && touch "$handled" && [ -r "$handled" ]; }; then +if ! { mkdir -p "$(dirname "$handled")" && touch "$handled" && [ -f "$handled" ] && [ -r "$handled" ] && [ -w "$handled" ]; }; then echo "watch-requests: can't use the handled file $handled" >&2 exit 2 fi From c5bf320b73de103d069dabb08dae3b5dc7c66835 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 13 Oct 2026 18:46:15 -0400 Subject: [PATCH 4/7] Stop the watcher when the connector output it watches can't be read The output file was checked once, before polling. If it went away later, wc failed, the line count read as zero, and the watcher slept forever while looking alive. Each poll now checks the file and exits 2 if it can't be read. --- skills/basecamp-connect/watch-requests.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skills/basecamp-connect/watch-requests.sh b/skills/basecamp-connect/watch-requests.sh index 1480158c3..8656e77e0 100644 --- a/skills/basecamp-connect/watch-requests.sh +++ b/skills/basecamp-connect/watch-requests.sh @@ -25,6 +25,10 @@ fi unhandled() { local lines + if [ ! -r "$out" ]; then + echo "watch-requests: can't read the connector output at $out" >&2 + exit 2 + fi lines=$(($(wc -l < "$out"))) [ "$lines" -gt 0 ] || return 1 head -n "$lines" "$out" | HANDLED=$handled awk ' From 35a423e73c76a2d1c6452603a1b40ab8ed9017d4 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 20 Oct 2026 13:31:55 -0400 Subject: [PATCH 5/7] Catch up on requests made while no connector ran, and restart it after a memory stop A restarted connector now hands off requests made up to four hours before it started, instead of one minute, so a mention made while it was down is still answered. The skill tells the session to start the connector, and the request watcher, again at once when Claude Code stops them for low memory. --- internal/connector/handoff.go | 17 ++++++------ internal/connector/handoff_test.go | 27 ++++++++++++++++--- internal/connector/skill_layout_test.go | 36 +++++++++++++++++++++++++ skills/basecamp-connect/SKILL.md | 32 +++++++++++++++------- 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/internal/connector/handoff.go b/internal/connector/handoff.go index 52abde89e..210d1a368 100644 --- a/internal/connector/handoff.go +++ b/internal/connector/handoff.go @@ -76,15 +76,16 @@ const ( // ReasonUnreadable: the request's record had no content to hand off, so // nothing was written. ReasonUnreadable = "unreadable" - // ReasonBeforeThisRun: the request was made before this run started. - // Nobody was there to take it, and it is not run late. + // ReasonBeforeThisRun: the request was made more than HandoffGrace + // before this run started, too long ago to answer now. ReasonBeforeThisRun = "before_this_run" ) // HandoffGrace is how long before the run started a request may have been -// made and still be handed off: a mention written a moment before the -// session started is still meant for it. -const HandoffGrace = time.Minute +// made and still be handed off: a mention made while no connector was +// running, because the last one was stopped or the computer slept, is still +// answered when one starts again. +const HandoffGrace = 4 * time.Hour // HandoffOptions configure RunHandoff. type HandoffOptions struct { @@ -178,11 +179,11 @@ func handOffReady(ctx context.Context, opts HandoffOptions) error { } } -// handOff writes one record and closes it. A record made before the run -// started is closed without being written. +// handOff writes one record and closes it. A record made more than +// HandoffGrace before the run started is closed without being written. func handOff(ctx context.Context, opts HandoffOptions, record Record) error { if record.CreatedAt.Before(opts.Started.Add(-HandoffGrace)) { - opts.log().Info("connector: a request from before this run started is not handed off", "event_id", record.ID, "created_at", record.CreatedAt) + opts.log().Info("connector: a request made too long before this run started is not handed off", "event_id", record.ID, "created_at", record.CreatedAt, "catch_up", HandoffGrace) return opts.Ledger.SetState(ctx, record.ID, StateDiscarded, ReasonBeforeThisRun) } line, err := handoffLine(record, opts.AgentID) diff --git a/internal/connector/handoff_test.go b/internal/connector/handoff_test.go index 4ba37cd62..9e9fa6723 100644 --- a/internal/connector/handoff_test.go +++ b/internal/connector/handoff_test.go @@ -76,14 +76,12 @@ func TestEachTrustedRequestIsHandedOffOnce(t *testing.T) { } } -// A request made before this run started is not run late: nobody was there -// to take it. -func TestARequestFromBeforeTheRunIsNotHandedOff(t *testing.T) { +func TestARequestMadeTooLongBeforeTheRunIsNotHandedOff(t *testing.T) { ledger := newTestLedger(t) admitOn(t, ledger, 1, "recording:1") var out bytes.Buffer opts := handoffOptions(ledger, &out) - opts.Started = opts.Started.Add(time.Hour) + opts.Started = testEvent(1).CreatedAt.Add(HandoffGrace + time.Second) require.NoError(t, handOffReady(context.Background(), opts)) @@ -93,6 +91,27 @@ func TestARequestFromBeforeTheRunIsNotHandedOff(t *testing.T) { assert.Equal(t, ReasonBeforeThisRun, r.Reason) } +func TestARequestMadeWhileNoConnectorRanIsHandedOffWhenOneStarts(t *testing.T) { + for _, down := range []time.Duration{2 * time.Minute, time.Hour, HandoffGrace} { + t.Run(down.String(), func(t *testing.T) { + ledger := newTestLedger(t) + admitOn(t, ledger, 1, "recording:1") + var out bytes.Buffer + opts := handoffOptions(ledger, &out) + opts.Started = testEvent(1).CreatedAt.Add(down) + + require.NoError(t, handOffReady(context.Background(), opts)) + + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Equal(t, int64(1), lines[0].EventID) + r := getRecord(t, ledger, 1) + assert.Equal(t, StateDiscarded, r.State) + assert.Equal(t, ReasonHandedOff, r.Reason) + }) + } +} + // Only the projects the agent serves, narrowed to the run's --project. func TestOnlyServedProjectsAreHandedOff(t *testing.T) { ledger := newTestLedger(t) diff --git a/internal/connector/skill_layout_test.go b/internal/connector/skill_layout_test.go index 5257db680..053d467e0 100644 --- a/internal/connector/skill_layout_test.go +++ b/internal/connector/skill_layout_test.go @@ -1,10 +1,12 @@ package connector import ( + "fmt" "os" "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,3 +95,37 @@ func TestTheSubagentReadsItsInstructionsFromTheirOwnFile(t *testing.T) { assert.Contains(t, subagent, step) } } + +func flatText(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func TestTheConnectSkillStartsTheConnectorAgainAfterAMemoryStop(t *testing.T) { + skill := readConnectSkill(t, "SKILL.md") + start := strings.Index(skill, "## When the connector stops") + require.GreaterOrEqual(t, start, 0) + end := strings.Index(skill[start+1:], "\n## ") + require.GreaterOrEqual(t, end, 0) + section := flatText(skill[start : start+1+end]) + + assert.Contains(t, section, "stopped because the system is running low on memory") + assert.Contains(t, section, "So start it again at once, as in step 1") + assert.Contains(t, section, "Do the same for the watcher when the notice names it: start it again on the same file.") +} + +func TestTheConnectSkillStatesHowFarBackARestartCatchesUp(t *testing.T) { + skill := flatText(readConnectSkill(t, "SKILL.md")) + window := fmt.Sprintf("%d hours", int(HandoffGrace/time.Hour)) + + for _, phrase := range []string{ + "restarted within " + window + ".", + "it hands off the requests made while it was stopped, up to " + window + " before it started.", + "hands off requests made up to " + window + " before it started", + "dropped if that's more than " + window + " later.", + } { + assert.Contains(t, skill, phrase) + } + assert.NotContains(t, skill, "within a minute") + assert.NotContains(t, skill, "not picked up later") + assert.NotContains(t, skill, "starts from the present") +} diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 779603870..2d467d062 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -142,7 +142,7 @@ is not their reply. Every line has already passed the trust check: the person may give the agent work, and the project is one it serves. A request arrives once, except in one case: if the connector crashes right after printing a line, it can print it -again when restarted within a minute. Skip an `event_id` you've already handled. Every other +again when restarted within 4 hours. Skip an `event_id` you've already handled. Every other line (no `type`, or `"type":"event"`) is the connector's own log of what it saw and decided, including what it turned away. Never act on those. @@ -532,8 +532,19 @@ first. Every change goes through setup, which keeps whatever you don't pass. ## When the connector stops -The background task notifies you when the connector exits. Read the end of its -output and tell the person what it means: +The background task notifies you when the connector exits. + +If the notice says it was **stopped because the system is running low on +memory**, Claude Code stopped it while the session sat idle. The connector +didn't fail. The notice says not to start it again on your own, but the person +asked you to keep the connector running when they started it. So start it again +at once, as in step 1, check the old output file and start the watcher on the +new one as the end of this section says, and tell the person it happened. A +command started again soon after such a stop isn't stopped again until someone +types in the session. Do the same for the watcher when the notice names it: +start it again on the same file. + +Otherwise, read the end of its output and tell the person what it means: - **"was disconnected in Basecamp, or connected on another computer"**: run `basecamp connect setup -P ''` (it reconnects with one approval), @@ -544,7 +555,8 @@ output and tell the person what it means: - **Anything else**: show the error, and run `basecamp connect doctor -P ''`. -Requests that arrive while it's stopped are not picked up later. +When it starts again, it hands off the requests made while it was stopped, up +to 4 hours before it started. Older ones are closed unanswered. Each start writes a new output file. Stop the watcher (TaskStop), then check the old file once, so a request printed just before the exit isn't left there: @@ -561,10 +573,10 @@ connector is running. - `basecamp connect doctor -P ''`: the credential, the identity, the event feed and the ledger. Start here when something seems wrong. It posts nothing. -- **"Did that mention reach it?"**: the connector starts from the present, so - anything asked more than a minute before it started is not picked up. Check - that first. Then search the connector's output file for the - recording id. A `"type":"request"` line means it was handed to you. A +- **"Did that mention reach it?"**: the connector hands off requests made up + to 4 hours before it started, so anything asked earlier, while it was + stopped, is not picked up. Check that first. Then search the connector's + output file for the recording id. A `"type":"request"` line means it was handed to you. A `"type":"event"` line with a `reason` says why it was turned away (for example `untrusted_performer`). No line means the feed never delivered it. - `basecamp connect status -P ''`: the feed's position, holds and @@ -576,5 +588,5 @@ connector is running. When the person asks you to stop, or the session is ending, stop the watcher and the connector task (TaskStop, or send it SIGINT). There's nothing else to clean up: no webhooks, nothing public. Subagents already running finish their -requests and reply. Say that new mentions won't be picked up until it's -started again. +requests and reply. Say that new mentions wait until it's started again, and +are dropped if that's more than 4 hours later. From 50304039a97bd5c0b404c1a5ee2096c84ff483ab Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 20 Oct 2026 13:42:47 -0400 Subject: [PATCH 6/7] Catch up only on requests this run admitted, and restart only the current watcher A request an earlier run admitted keeps the one-minute grace, so a restart never hands off a decision made under trust that may have changed while the connector was down. The run's start is taken before intake and admission begin, so nothing this run admits is stamped before it. A memory-stop notice for a watcher whose connector was also stopped no longer restarts a watcher on the old output file. --- internal/commands/connect_run.go | 2 +- internal/connector/handoff.go | 28 +++++++----- internal/connector/handoff_test.go | 59 ++++++++++++++++++++----- internal/connector/skill_layout_test.go | 7 ++- skills/basecamp-connect/SKILL.md | 8 ++-- 5 files changed, 74 insertions(+), 30 deletions(-) diff --git a/internal/commands/connect_run.go b/internal/commands/connect_run.go index 345de3318..c6aa0623c 100644 --- a/internal/commands/connect_run.go +++ b/internal/commands/connect_run.go @@ -347,6 +347,7 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { if err := ledger.NoteConnection(ctx, connector.ConnectionRunning, ""); err != nil { logger.Warn("connector: could not record that it runs, for status", "error", err) } + started := time.Now() 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}) @@ -354,7 +355,6 @@ func runConnect(cmd *cobra.Command, f *connectRunFlags) error { if !f.shadow { // Each trusted request goes to stdout for the session that started // the connector, and the connector is done with it. - started := time.Now() runPart("handoff", func(ctx context.Context) error { return connector.RunHandoff(ctx, connector.HandoffOptions{ Ledger: ledger, Served: served.Current, Buckets: buckets, AgentID: agentID, diff --git a/internal/connector/handoff.go b/internal/connector/handoff.go index 210d1a368..9bdbd5e45 100644 --- a/internal/connector/handoff.go +++ b/internal/connector/handoff.go @@ -76,16 +76,17 @@ const ( // ReasonUnreadable: the request's record had no content to hand off, so // nothing was written. ReasonUnreadable = "unreadable" - // ReasonBeforeThisRun: the request was made more than HandoffGrace - // before this run started, too long ago to answer now. + // ReasonBeforeThisRun: the request was made too long before this run + // started to answer now. ReasonBeforeThisRun = "before_this_run" ) // HandoffGrace is how long before the run started a request may have been -// made and still be handed off: a mention made while no connector was -// running, because the last one was stopped or the computer slept, is still -// answered when one starts again. -const HandoffGrace = 4 * time.Hour +// made and still be handed off: a mention written a moment before the +// session started is still meant for it. +const HandoffGrace = time.Minute + +const handoffCatchUp = 4 * time.Hour // HandoffOptions configure RunHandoff. type HandoffOptions struct { @@ -179,11 +180,18 @@ func handOffReady(ctx context.Context, opts HandoffOptions) error { } } -// handOff writes one record and closes it. A record made more than -// HandoffGrace before the run started is closed without being written. +func handoffCutoff(opts HandoffOptions, record Record) time.Time { + if decided := record.Decision.DecidedAt; decided != nil && !decided.Before(opts.Started) { + return opts.Started.Add(-handoffCatchUp) + } + return opts.Started.Add(-HandoffGrace) +} + +// handOff writes one record and closes it. A record made too long before the +// run started is closed without being written. func handOff(ctx context.Context, opts HandoffOptions, record Record) error { - if record.CreatedAt.Before(opts.Started.Add(-HandoffGrace)) { - opts.log().Info("connector: a request made too long before this run started is not handed off", "event_id", record.ID, "created_at", record.CreatedAt, "catch_up", HandoffGrace) + if cutoff := handoffCutoff(opts, record); record.CreatedAt.Before(cutoff) { + opts.log().Info("connector: a request made too long before this run started is not handed off", "event_id", record.ID, "created_at", record.CreatedAt, "cutoff", cutoff) return opts.Ledger.SetState(ctx, record.ID, StateDiscarded, ReasonBeforeThisRun) } line, err := handoffLine(record, opts.AgentID) diff --git a/internal/connector/handoff_test.go b/internal/connector/handoff_test.go index 9e9fa6723..11c863313 100644 --- a/internal/connector/handoff_test.go +++ b/internal/connector/handoff_test.go @@ -76,12 +76,37 @@ func TestEachTrustedRequestIsHandedOffOnce(t *testing.T) { } } -func TestARequestMadeTooLongBeforeTheRunIsNotHandedOff(t *testing.T) { +func admitAt(t *testing.T, ledger *Ledger, id int64, at time.Time) { + t.Helper() + ledger.now = func() time.Time { return at } + admitOn(t, ledger, id, "recording:1") +} + +func TestARequestThisRunAdmittedIsHandedOffIfMadeWithinTheCatchUp(t *testing.T) { + for _, before := range []time.Duration{2 * time.Minute, time.Hour, handoffCatchUp} { + t.Run(before.String(), func(t *testing.T) { + ledger := newTestLedger(t) + var out bytes.Buffer + opts := handoffOptions(ledger, &out) + opts.Started = testEvent(1).CreatedAt.Add(before) + admitAt(t, ledger, 1, opts.Started.Add(time.Second)) + + require.NoError(t, handOffReady(context.Background(), opts)) + + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + assert.Equal(t, int64(1), lines[0].EventID) + assert.Equal(t, ReasonHandedOff, getRecord(t, ledger, 1).Reason) + }) + } +} + +func TestARequestThisRunAdmittedIsClosedIfMadeBeforeTheCatchUp(t *testing.T) { ledger := newTestLedger(t) - admitOn(t, ledger, 1, "recording:1") var out bytes.Buffer opts := handoffOptions(ledger, &out) - opts.Started = testEvent(1).CreatedAt.Add(HandoffGrace + time.Second) + opts.Started = testEvent(1).CreatedAt.Add(handoffCatchUp + time.Second) + admitAt(t, ledger, 1, opts.Started.Add(time.Second)) require.NoError(t, handOffReady(context.Background(), opts)) @@ -91,23 +116,33 @@ func TestARequestMadeTooLongBeforeTheRunIsNotHandedOff(t *testing.T) { assert.Equal(t, ReasonBeforeThisRun, r.Reason) } -func TestARequestMadeWhileNoConnectorRanIsHandedOffWhenOneStarts(t *testing.T) { - for _, down := range []time.Duration{2 * time.Minute, time.Hour, HandoffGrace} { - t.Run(down.String(), func(t *testing.T) { +func TestARequestAnEarlierRunAdmittedIsHandedOffOnlyWithinTheGrace(t *testing.T) { + for _, tc := range []struct { + before time.Duration + handed bool + }{ + {HandoffGrace, true}, + {HandoffGrace + time.Second, false}, + {time.Hour, false}, + } { + t.Run(tc.before.String(), func(t *testing.T) { ledger := newTestLedger(t) - admitOn(t, ledger, 1, "recording:1") var out bytes.Buffer opts := handoffOptions(ledger, &out) - opts.Started = testEvent(1).CreatedAt.Add(down) + opts.Started = testEvent(1).CreatedAt.Add(tc.before) + admitAt(t, ledger, 1, testEvent(1).CreatedAt.Add(time.Second)) require.NoError(t, handOffReady(context.Background(), opts)) - lines := handedOffLines(t, &out) - require.Len(t, lines, 1) - assert.Equal(t, int64(1), lines[0].EventID) r := getRecord(t, ledger, 1) + if tc.handed { + require.Len(t, handedOffLines(t, &out), 1) + assert.Equal(t, ReasonHandedOff, r.Reason) + return + } + assert.Empty(t, out.String()) assert.Equal(t, StateDiscarded, r.State) - assert.Equal(t, ReasonHandedOff, r.Reason) + assert.Equal(t, ReasonBeforeThisRun, r.Reason) }) } } diff --git a/internal/connector/skill_layout_test.go b/internal/connector/skill_layout_test.go index 053d467e0..1c4d51232 100644 --- a/internal/connector/skill_layout_test.go +++ b/internal/connector/skill_layout_test.go @@ -110,22 +110,21 @@ func TestTheConnectSkillStartsTheConnectorAgainAfterAMemoryStop(t *testing.T) { assert.Contains(t, section, "stopped because the system is running low on memory") assert.Contains(t, section, "So start it again at once, as in step 1") - assert.Contains(t, section, "Do the same for the watcher when the notice names it: start it again on the same file.") + assert.Contains(t, section, "When such a notice names the watcher, start it again on the running connector's current output file.") + assert.Contains(t, section, "don't start one on the old file.") } func TestTheConnectSkillStatesHowFarBackARestartCatchesUp(t *testing.T) { skill := flatText(readConnectSkill(t, "SKILL.md")) - window := fmt.Sprintf("%d hours", int(HandoffGrace/time.Hour)) + window := fmt.Sprintf("%d hours", int(handoffCatchUp/time.Hour)) for _, phrase := range []string{ - "restarted within " + window + ".", "it hands off the requests made while it was stopped, up to " + window + " before it started.", "hands off requests made up to " + window + " before it started", "dropped if that's more than " + window + " later.", } { assert.Contains(t, skill, phrase) } - assert.NotContains(t, skill, "within a minute") assert.NotContains(t, skill, "not picked up later") assert.NotContains(t, skill, "starts from the present") } diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 2d467d062..b0a4163a9 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -142,7 +142,7 @@ is not their reply. Every line has already passed the trust check: the person may give the agent work, and the project is one it serves. A request arrives once, except in one case: if the connector crashes right after printing a line, it can print it -again when restarted within 4 hours. Skip an `event_id` you've already handled. Every other +again when restarted within a minute. Skip an `event_id` you've already handled. Every other line (no `type`, or `"type":"event"`) is the connector's own log of what it saw and decided, including what it turned away. Never act on those. @@ -541,8 +541,10 @@ asked you to keep the connector running when they started it. So start it again at once, as in step 1, check the old output file and start the watcher on the new one as the end of this section says, and tell the person it happened. A command started again soon after such a stop isn't stopped again until someone -types in the session. Do the same for the watcher when the notice names it: -start it again on the same file. +types in the session. When such a notice names the watcher, start it again on +the running connector's current output file. If the connector was stopped too, +starting it again already started a new watcher: don't start one on the old +file. Otherwise, read the end of its output and tell the person what it means: From 2f621038465b9f9671a14670017513721024fbcc Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 20 Oct 2026 13:57:42 -0400 Subject: [PATCH 7/7] Say what to do when the watcher's memory-stop notice comes first --- internal/connector/skill_layout_test.go | 4 ++-- skills/basecamp-connect/SKILL.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/connector/skill_layout_test.go b/internal/connector/skill_layout_test.go index 1c4d51232..338018943 100644 --- a/internal/connector/skill_layout_test.go +++ b/internal/connector/skill_layout_test.go @@ -110,8 +110,8 @@ func TestTheConnectSkillStartsTheConnectorAgainAfterAMemoryStop(t *testing.T) { assert.Contains(t, section, "stopped because the system is running low on memory") assert.Contains(t, section, "So start it again at once, as in step 1") - assert.Contains(t, section, "When such a notice names the watcher, start it again on the running connector's current output file.") - assert.Contains(t, section, "don't start one on the old file.") + assert.Contains(t, section, "When such a notice names the watcher, check the connector first. If it was stopped too, start it again as above, which starts a new watcher, and don't start one on the old file.") + assert.Contains(t, section, "If it's still running, start the watcher again on its current output file.") } func TestTheConnectSkillStatesHowFarBackARestartCatchesUp(t *testing.T) { diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index b0a4163a9..dfaa3b971 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -541,10 +541,10 @@ asked you to keep the connector running when they started it. So start it again at once, as in step 1, check the old output file and start the watcher on the new one as the end of this section says, and tell the person it happened. A command started again soon after such a stop isn't stopped again until someone -types in the session. When such a notice names the watcher, start it again on -the running connector's current output file. If the connector was stopped too, -starting it again already started a new watcher: don't start one on the old -file. +types in the session. When such a notice names the watcher, check the +connector first. If it was stopped too, start it again as above, which starts a +new watcher, and don't start one on the old file. If it's still running, start +the watcher again on its current output file. Otherwise, read the end of its output and tell the person what it means: