Skip to content

Commit bb643d8

Browse files
author
SqlRush
committed
Add remote poll message pump
1 parent 26ba025 commit bb643d8

8 files changed

Lines changed: 675 additions & 11 deletions

File tree

cmd/claude/main.go

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"ccgo/internal/model"
3131
"ccgo/internal/permissions"
3232
pluginpkg "ccgo/internal/plugins"
33+
remotepkg "ccgo/internal/remote"
3334
"ccgo/internal/session"
3435
"ccgo/internal/tool"
3536
filetools "ccgo/internal/tools/file"
@@ -698,7 +699,7 @@ func runDaemon(ctx context.Context, state *bootstrap.State, options daemonOption
698699
if err := writeHeartbeat(now); err != nil {
699700
return contracts.ToolResult{}, err
700701
}
701-
return runDaemonDueSchedules(ctx, runner, now)
702+
return runDaemonTick(ctx, runner, now)
702703
}
703704
if !options.Once {
704705
daemonServer, err = daemonpkg.StartServer(daemonpkg.ServerOptions{
@@ -774,6 +775,172 @@ func runDaemonDueSchedules(ctx context.Context, runner conversation.Runner, now
774775
}, "", now, tool.NopProgressSink())
775776
}
776777

778+
func runDaemonTick(ctx context.Context, runner conversation.Runner, now time.Time) (contracts.ToolResult, error) {
779+
scheduleResult, err := runDaemonDueSchedules(ctx, runner, now)
780+
if err != nil {
781+
return contracts.ToolResult{}, err
782+
}
783+
remoteResult := runDaemonRemotePoll(ctx, runner, now)
784+
schedule := scheduleResult.StructuredContent
785+
remotePoll := remoteResult.StructuredContent
786+
scheduleTriggered := intMapValue(schedule, "triggered_count")
787+
scheduleErrors := intMapValue(schedule, "error_count")
788+
remoteDelivered := intMapValue(remotePoll, "delivered_count")
789+
remoteErrors := intMapValue(remotePoll, "error_count")
790+
structured := map[string]any{
791+
"type": "daemon_tick",
792+
"checked_at": now.UTC().Format(time.RFC3339Nano),
793+
"due_count": intMapValue(schedule, "due_count"),
794+
"triggered_count": scheduleTriggered + remoteDelivered,
795+
"error_count": scheduleErrors + remoteErrors,
796+
"schedule_triggered_count": scheduleTriggered,
797+
"schedule_error_count": scheduleErrors,
798+
"remote_delivered_count": remoteDelivered,
799+
"remote_poll_error_count": remoteErrors,
800+
"remote_poll_duplicate_count": intMapValue(remotePoll, "duplicate_count"),
801+
"schedule": schedule,
802+
"remote_poll": remotePoll,
803+
}
804+
return contracts.ToolResult{
805+
Content: fmt.Sprintf("Triggered %d due schedule(s), delivered %d remote event(s); %d error(s).", scheduleTriggered, remoteDelivered, scheduleErrors+remoteErrors),
806+
StructuredContent: structured,
807+
}, nil
808+
}
809+
810+
func runDaemonRemotePoll(ctx context.Context, runner conversation.Runner, now time.Time) contracts.ToolResult {
811+
structured := map[string]any{
812+
"type": "remote_poll",
813+
"checked_at": now.UTC().Format(time.RFC3339Nano),
814+
"runtime_state": remotepkg.PumpDisabled,
815+
"event_count": 0,
816+
"delivered_count": 0,
817+
"duplicate_count": 0,
818+
"error_count": 0,
819+
}
820+
registrationPath := remotepkg.SessionRegistrationPath(runner.SessionPath, runner.SessionID)
821+
pumpPath := remotepkg.SessionPumpPath(runner.SessionPath, runner.SessionID)
822+
if registrationPath == "" || pumpPath == "" {
823+
structured["error_count"] = 1
824+
structured["error"] = "remote registration path is unavailable"
825+
return contracts.ToolResult{Content: "Remote poll is not configured.", StructuredContent: structured}
826+
}
827+
registration, err := remotepkg.LoadRegistrationState(registrationPath)
828+
if err != nil {
829+
structured["runtime_state"] = remotepkg.PumpFailed
830+
structured["error_count"] = 1
831+
structured["error"] = err.Error()
832+
_ = remotepkg.WritePumpState(pumpPath, remotepkg.PumpState{
833+
SessionID: runner.SessionID,
834+
RuntimeState: remotepkg.PumpFailed,
835+
LastPollAt: now.UTC().Format(time.RFC3339Nano),
836+
ErrorCount: 1,
837+
LastError: err.Error(),
838+
})
839+
return contracts.ToolResult{Content: "Remote poll failed.", StructuredContent: structured}
840+
}
841+
if registration.RuntimeState != remotepkg.RegistrationRegistered || strings.TrimSpace(registration.PollURL) == "" {
842+
_ = remotepkg.WritePumpState(pumpPath, remotepkg.PumpState{
843+
SessionID: runner.SessionID,
844+
RuntimeState: remotepkg.PumpDisabled,
845+
PollURL: remotepkg.DisplayEndpoint(registration.PollURL),
846+
LastPollAt: now.UTC().Format(time.RFC3339Nano),
847+
})
848+
return contracts.ToolResult{Content: "Remote poll is disabled.", StructuredContent: structured}
849+
}
850+
previous, err := remotepkg.LoadPumpState(pumpPath)
851+
if err != nil {
852+
previous.LastError = err.Error()
853+
}
854+
settings := runner.MergedSettings()
855+
authToken := ""
856+
if settings.Remote != nil {
857+
authToken = settings.Remote.AuthToken
858+
}
859+
poll := remotepkg.FetchPollEvents(ctx, remotepkg.PollOptions{
860+
PollURL: registration.PollURL,
861+
Cursor: previous.LastCursor,
862+
AuthToken: authToken,
863+
})
864+
pumpState := remotepkg.PumpState{
865+
SessionID: runner.SessionID,
866+
RuntimeState: remotepkg.PumpRunning,
867+
PollURL: remotepkg.DisplayEndpoint(registration.PollURL),
868+
LastCursor: previous.LastCursor,
869+
LastPollAt: now.UTC().Format(time.RFC3339Nano),
870+
StatusCode: poll.StatusCode,
871+
EventCount: len(poll.Events),
872+
}
873+
if poll.NextCursor != "" {
874+
pumpState.LastCursor = poll.NextCursor
875+
}
876+
if poll.Error != "" {
877+
pumpState.RuntimeState = remotepkg.PumpFailed
878+
pumpState.ErrorCount = 1
879+
pumpState.LastError = poll.Error
880+
structured["runtime_state"] = pumpState.RuntimeState
881+
structured["status_code"] = poll.StatusCode
882+
structured["event_count"] = len(poll.Events)
883+
structured["error_count"] = 1
884+
structured["error"] = poll.Error
885+
_ = remotepkg.WritePumpState(pumpPath, pumpState)
886+
return contracts.ToolResult{Content: "Remote poll failed.", StructuredContent: structured}
887+
}
888+
errorsOut := make([]map[string]any, 0)
889+
delivered := 0
890+
duplicates := 0
891+
for _, event := range poll.Events {
892+
input, err := json.Marshal(map[string]any{
893+
"team_id": event.TeamID,
894+
"target": event.Target,
895+
"event_id": event.EventID,
896+
"source": event.Source,
897+
"event": event.Event,
898+
"message": event.Message,
899+
})
900+
if err != nil {
901+
errorsOut = append(errorsOut, map[string]any{"event_id": event.EventID, "error": err.Error()})
902+
continue
903+
}
904+
result, err := tasktools.RunRemoteTrigger(tool.Context{
905+
Context: ctx,
906+
WorkingDirectory: runner.WorkingDirectory,
907+
SessionID: runner.SessionID,
908+
Metadata: map[string]any{
909+
tool.MetadataSessionPathKey: runner.SessionPath,
910+
},
911+
}, input, tool.NopProgressSink())
912+
if err != nil {
913+
errorsOut = append(errorsOut, map[string]any{"event_id": event.EventID, "team_id": event.TeamID, "error": err.Error()})
914+
continue
915+
}
916+
if duplicate, _ := result.StructuredContent["duplicate"].(bool); duplicate {
917+
duplicates++
918+
continue
919+
}
920+
delivered += intMapValue(result.StructuredContent, "sent_count")
921+
}
922+
pumpState.DeliveredCount = delivered
923+
pumpState.DuplicateCount = duplicates
924+
pumpState.ErrorCount = len(errorsOut)
925+
if len(errorsOut) > 0 {
926+
pumpState.LastError = fmt.Sprint(errorsOut[0]["error"])
927+
}
928+
_ = remotepkg.WritePumpState(pumpPath, pumpState)
929+
structured["runtime_state"] = pumpState.RuntimeState
930+
structured["poll_url"] = pumpState.PollURL
931+
structured["last_cursor"] = pumpState.LastCursor
932+
structured["status_code"] = pumpState.StatusCode
933+
structured["event_count"] = pumpState.EventCount
934+
structured["delivered_count"] = pumpState.DeliveredCount
935+
structured["duplicate_count"] = pumpState.DuplicateCount
936+
structured["error_count"] = pumpState.ErrorCount
937+
structured["errors"] = errorsOut
938+
return contracts.ToolResult{
939+
Content: fmt.Sprintf("Remote poll delivered %d event(s); %d duplicate(s); %d error(s).", delivered, duplicates, len(errorsOut)),
940+
StructuredContent: structured,
941+
}
942+
}
943+
777944
func daemonTickResponse(result contracts.ToolResult) daemonpkg.TickResponse {
778945
structured := result.StructuredContent
779946
return daemonpkg.TickResponse{

cmd/claude/main_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
daemonpkg "ccgo/internal/daemon"
2222
integrationspkg "ccgo/internal/integrations"
2323
"ccgo/internal/messages"
24+
remotepkg "ccgo/internal/remote"
2425
"ccgo/internal/session"
2526
"ccgo/internal/tool"
2627
bashtools "ccgo/internal/tools/bash"
@@ -301,6 +302,87 @@ func TestRunDaemonDueSchedulesNoopsWithoutSchedules(t *testing.T) {
301302
}
302303
}
303304

305+
func TestRunDaemonRemotePollInjectsRemoteTriggers(t *testing.T) {
306+
dir := t.TempDir()
307+
transcriptPath := filepath.Join(dir, "session.jsonl")
308+
sessionID := contracts.ID("sess_daemon_remote")
309+
manager := session.NewSidechainManager(transcriptPath, sessionID)
310+
if _, err := manager.Start(session.SidechainOptions{ID: "agent/remote-lead", StartedAt: time.Unix(100, 0).UTC()}); err != nil {
311+
t.Fatal(err)
312+
}
313+
if _, err := manager.Start(session.SidechainOptions{ID: "agent/remote-member", StartedAt: time.Unix(101, 0).UTC()}); err != nil {
314+
t.Fatal(err)
315+
}
316+
if _, _, err := manager.CreateTeam(session.TeamOptions{
317+
ID: "remote/team",
318+
CoordinatorTaskID: "agent/remote-lead",
319+
TaskIDs: []string{"agent/remote-member"},
320+
Timestamp: time.Unix(102, 0).UTC(),
321+
}); err != nil {
322+
t.Fatal(err)
323+
}
324+
var cursors []string
325+
var auths []string
326+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
327+
cursors = append(cursors, r.URL.Query().Get("cursor"))
328+
auths = append(auths, r.Header.Get("Authorization"))
329+
w.Header().Set("content-type", "application/json")
330+
_, _ = w.Write([]byte(`{"next_cursor":"cursor-2","events":[{"id":"delivery-1","team":"remote/team","source":"webhook","event":"deploy","message":"Deploy now."}]}`))
331+
}))
332+
defer server.Close()
333+
if err := remotepkg.WriteRegistrationState(remotepkg.SessionRegistrationPath(transcriptPath, sessionID), remotepkg.RegistrationState{
334+
SessionID: sessionID,
335+
RuntimeState: remotepkg.RegistrationRegistered,
336+
PollURL: server.URL + "/poll?token=secret",
337+
RemoteSessionID: "remote-session",
338+
}); err != nil {
339+
t.Fatal(err)
340+
}
341+
runner := conversation.Runner{
342+
SessionID: sessionID,
343+
SessionPath: transcriptPath,
344+
WorkingDirectory: dir,
345+
MCP: &conversation.MCPConfig{UserSettings: contracts.Settings{
346+
Remote: &contracts.RemoteSetting{AuthToken: "poll-token"},
347+
}},
348+
}
349+
first := runDaemonRemotePoll(context.Background(), runner, time.Unix(200, 0).UTC())
350+
if first.StructuredContent["runtime_state"] != remotepkg.PumpRunning || first.StructuredContent["delivered_count"] != 1 || first.StructuredContent["duplicate_count"] != 0 || first.StructuredContent["error_count"] != 0 {
351+
t.Fatalf("first poll = %#v", first.StructuredContent)
352+
}
353+
if len(cursors) != 1 || cursors[0] != "" || auths[0] != "Bearer poll-token" {
354+
t.Fatalf("first cursor/auth = %#v %#v", cursors, auths)
355+
}
356+
pump, err := remotepkg.LoadPumpState(remotepkg.SessionPumpPath(transcriptPath, sessionID))
357+
if err != nil {
358+
t.Fatal(err)
359+
}
360+
if pump.LastCursor != "cursor-2" || pump.PollURL != server.URL+"/poll" || pump.DeliveredCount != 1 {
361+
t.Fatalf("pump = %#v", pump)
362+
}
363+
resume, err := manager.ResumeContext("agent/remote-lead", 3)
364+
if err != nil {
365+
t.Fatal(err)
366+
}
367+
if len(resume.Messages) != 2 || !strings.Contains(messages.TextContent(resume.Messages[1]), "Remote trigger received.") || !strings.Contains(messages.TextContent(resume.Messages[1]), "Deploy now.") {
368+
t.Fatalf("resume messages = %#v", resume.Messages)
369+
}
370+
second := runDaemonRemotePoll(context.Background(), runner, time.Unix(201, 0).UTC())
371+
if second.StructuredContent["delivered_count"] != 0 || second.StructuredContent["duplicate_count"] != 1 || second.StructuredContent["error_count"] != 0 {
372+
t.Fatalf("second poll = %#v", second.StructuredContent)
373+
}
374+
if len(cursors) != 2 || cursors[1] != "cursor-2" {
375+
t.Fatalf("cursors = %#v", cursors)
376+
}
377+
resume, err = manager.ResumeContext("agent/remote-lead", 5)
378+
if err != nil {
379+
t.Fatal(err)
380+
}
381+
if len(resume.Messages) != 2 {
382+
t.Fatalf("duplicate should not append, messages = %#v", resume.Messages)
383+
}
384+
}
385+
304386
func TestRunDaemonServesHealthEndpoint(t *testing.T) {
305387
t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir())
306388
cwd := t.TempDir()

0 commit comments

Comments
 (0)