88 "path"
99 "sort"
1010 "strings"
11+ "sync"
1112 "time"
1213
1314 "github.com/google/uuid"
@@ -63,6 +64,12 @@ func (s *TenantSkillService) InstallSkill(
6364 if err != nil {
6465 return "" , err
6566 }
67+ if s .canSkipInstall (ctx , existing , bundle ) {
68+ if err := s .refreshSkippedBundle (ctx , existing , archive ); err != nil {
69+ return "" , fmt .Errorf ("store bundle for skill %s: %w" , existing .ID , err )
70+ }
71+ return existing .ID , nil
72+ }
6673
6774 skillID := uuid .NewString ()
6875 now := s .now ()
@@ -201,6 +208,14 @@ func (s *TenantSkillService) runInstall(
201208 return nil
202209 }
203210
211+ // From here on this run is the row's owner, and everything below can take
212+ // minutes. The heartbeat is what tells a second upload of the same archive
213+ // (and the reaper) that those minutes are work rather than a dead process.
214+ // It is deferred before it is stopped explicitly below, so a failure path
215+ // still stops it ahead of the deferred failSkill.
216+ stopHeartbeat := s .startInstallHeartbeat (ctx , tenantID , configID , skillID )
217+ defer stopHeartbeat ()
218+
204219 // The name comes from SKILL.md and is already validated on parse, so a
205220 // rejection here means the bundle was accepted by a looser rule than the
206221 // one the image path enforces. Failing before any sandbox work keeps that
@@ -349,6 +364,10 @@ func (s *TenantSkillService) runInstall(
349364 return err
350365 }
351366 pointerSwitched = true
367+ // The heartbeat writes the whole row, so it must be gone before the
368+ // terminal "ready" write below: a beat landing after it would put the row
369+ // back to installing and have the reaper fail a skill that is serving.
370+ stopHeartbeat ()
352371 s .markPreviousSnapshotsSuperseded (ctx , tenantID , configID , installRowID )
353372
354373 // The terminal write is the one that must not be best-effort: the pointer
@@ -873,9 +892,10 @@ func (s *TenantSkillService) failSkill(
873892// installStillOwnsTheRow is the lock-side counterpart of InstallSkill's
874893// optimistic row write. A remove that ran first deleted the row; a newer
875894// upload of the same name replaced BundleSHA256; a queued remove flipped the
876- // status. Any of those means this run must not snapshot — failSkill would
877- // stamp the newer owner's row, and a snapshot with no matching row is an
878- // orphan the ledger cannot name.
895+ // status; a sibling retry of the same archive found the first run had already
896+ // landed in the live image. Any of those means this run must not snapshot —
897+ // failSkill would stamp the newer owner's row, and a snapshot with no matching
898+ // row is an orphan the ledger cannot name.
879899func (s * TenantSkillService ) installStillOwnsTheRow (
880900 ctx context.Context , tenantID uint64 , configID , skillID string , bundle * SkillBundle ,
881901) (bool , error ) {
@@ -892,9 +912,147 @@ func (s *TenantSkillService) installStillOwnsTheRow(
892912 if bundle != nil && current .BundleSHA256 != "" && current .BundleSHA256 != bundle .SHA256 {
893913 return false , nil
894914 }
915+ if current .Status == types .SkillStatusReady {
916+ _ , inImage , ok := s .skillFilesInLiveImage (ctx , current )
917+ if ok && inImage {
918+ return false , nil
919+ }
920+ }
895921 return true , nil
896922}
897923
924+ // canSkipInstall reports whether this upload is a no-op. Re-uploading the
925+ // exact archive of a skill that is already ready (and still in the live image)
926+ // must not boot a billed sandbox or grow a new snapshot. An install of the
927+ // same bytes that is still beating is the same situation: the first run owns
928+ // the work.
929+ //
930+ // Only a ready row is answered from the image. An installing row is answered
931+ // from the heartbeat alone, deliberately: the ledger records which skill an
932+ // install snapshotted, not which archive, so a row that is installing bundle
933+ // B while the image still carries the earlier bundle A would look "already
934+ // installed" and this upload would report a success that never happened.
935+ //
936+ // A failed skill with the same digest is a retry: the previous attempt never
937+ // made it into the image. A removal in flight is not a skip either — taking
938+ // the row back to installing is how an upload cancels it.
939+ func (s * TenantSkillService ) canSkipInstall (
940+ ctx context.Context , existing * types.TenantSkillEntity , bundle * SkillBundle ,
941+ ) bool {
942+ if existing == nil || bundle == nil {
943+ return false
944+ }
945+ if existing .BundleSHA256 == "" || existing .BundleSHA256 != bundle .SHA256 {
946+ return false
947+ }
948+ switch existing .Status {
949+ case types .SkillStatusInstalling :
950+ return s .installIsInFlight (existing )
951+ case types .SkillStatusReady :
952+ _ , inImage , ok := s .skillFilesInLiveImage (ctx , existing )
953+ return ok && inImage
954+ default :
955+ return false
956+ }
957+ }
958+
959+ // installIsInFlight reports whether an installing row still belongs to a live
960+ // process. The answer is the heartbeat: a running install restamps
961+ // InstallingSince every skillInstallHeartbeatInterval, so silence past
962+ // skillInstallInFlightSkip means the process is gone and the next upload must
963+ // be allowed to start a new run rather than wait for the stuck-run reaper.
964+ //
965+ // Reading the submission time instead would force a choice between calling a
966+ // slow install dead — a single agent command may take installCommandTimeout,
967+ // and an install runs several — and leaving a dead one unrecoverable.
968+ func (s * TenantSkillService ) installIsInFlight (existing * types.TenantSkillEntity ) bool {
969+ if existing == nil || existing .InstallingSince == nil {
970+ return false
971+ }
972+ return ! existing .InstallingSince .Before (s .clock ()().Add (- skillInstallInFlightSkip ))
973+ }
974+
975+ // startInstallHeartbeat keeps this run's liveness visible while it works, and
976+ // returns the stop function that must be called before any terminal write.
977+ //
978+ // The heartbeat writes the whole row, so it would otherwise race the "ready"
979+ // write past the pointer switch and revive an installing status. Both callers
980+ // stop it before that point: runInstall stops it the moment the pointer moves,
981+ // and the deferred stop runs before the deferred failSkill.
982+ func (s * TenantSkillService ) startInstallHeartbeat (
983+ ctx context.Context , tenantID uint64 , configID , skillID string ,
984+ ) func () {
985+ interval := s .installHeartbeat
986+ if interval <= 0 {
987+ interval = skillInstallHeartbeatInterval
988+ }
989+ beatCtx , stop := context .WithCancel (ctx )
990+ done := make (chan struct {})
991+ go func () {
992+ defer close (done )
993+ ticker := time .NewTicker (interval )
994+ defer ticker .Stop ()
995+ for {
996+ select {
997+ case <- beatCtx .Done ():
998+ return
999+ case <- ticker .C :
1000+ s .beatInstallHeartbeat (beatCtx , tenantID , configID , skillID )
1001+ }
1002+ }
1003+ }()
1004+ var once sync.Once
1005+ return func () {
1006+ once .Do (func () {
1007+ stop ()
1008+ <- done
1009+ })
1010+ }
1011+ }
1012+
1013+ // beatInstallHeartbeat stamps InstallingSince for a row this run still owns.
1014+ // A row that has left the installing status belongs to a newer upload, a
1015+ // queued removal, or a finished run, and reviving its timestamp would hide
1016+ // one of those from the reaper.
1017+ func (s * TenantSkillService ) beatInstallHeartbeat (
1018+ ctx context.Context , tenantID uint64 , configID , skillID string ,
1019+ ) {
1020+ current , err := s .skills .GetSkill (ctx , tenantID , configID , skillID )
1021+ if err != nil {
1022+ logger .Warnf (ctx , "[skill] load %s for install heartbeat failed: %v" , skillID , err )
1023+ return
1024+ }
1025+ if current == nil || current .Status != types .SkillStatusInstalling {
1026+ return
1027+ }
1028+ at := s .clock ()()
1029+ current .InstallingSince = & at
1030+ if err := s .skills .UpdateSkill (ctx , current ); err != nil {
1031+ logger .Warnf (ctx , "[skill] install heartbeat for %s failed: %v" , skillID , err )
1032+ }
1033+ }
1034+
1035+ // refreshSkippedBundle stores the uploaded archive even when the image work
1036+ // is skipped. read_skill serves file contents from it, so a re-upload of a
1037+ // ready skill is how a missing object-store blob gets repaired without
1038+ // growing a new snapshot. A failure here is returned to the caller rather
1039+ // than turning the ready row into a failed install.
1040+ func (s * TenantSkillService ) refreshSkippedBundle (
1041+ ctx context.Context , existing * types.TenantSkillEntity , archive []byte ,
1042+ ) error {
1043+ if existing == nil {
1044+ return nil
1045+ }
1046+ ref , err := s .saveBundle (ctx , existing .TenantID , existing .ID , archive )
1047+ if err != nil {
1048+ return err
1049+ }
1050+ return s .updateSkillFields (ctx , existing .TenantID , existing .SandboxConfigID , existing .ID ,
1051+ func (e * types.TenantSkillEntity ) {
1052+ e .BundleRef = ref
1053+ })
1054+ }
1055+
8981056// startMaintenanceSession opens the session one image operation runs in. The
8991057// operation name is carried into the session because the transcript is kept
9001058// deliberately, for troubleshooting: filing a removal's under "Skill install"
0 commit comments