From 279c985ef2fd5261265d93d8bc0ed2f87392ff01 Mon Sep 17 00:00:00 2001 From: Lilith Date: Mon, 5 Jan 2026 12:19:14 -0800 Subject: [PATCH] =?UTF-8?q?chore(shared):=20=F0=9F=94=A7=20Hello!=20I'm=20?= =?UTF-8?q?a=20mock=20assistant=20responding=20to=20your=20message.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/app.cpython-312.pyc | Bin 13751 -> 16153 bytes .../__pycache__/app.cpython-314.pyc | Bin 11852 -> 18468 bytes .../__pycache__/config.cpython-312.pyc | Bin 3297 -> 4171 bytes .../__pycache__/config.cpython-314.pyc | Bin 2986 -> 4750 bytes .../__pycache__/models.cpython-312.pyc | Bin 3056 -> 3217 bytes src/auto_commit_service/app.py | 91 +++++-- src/auto_commit_service/config.py | 22 ++ src/auto_commit_service/models.py | 3 + .../__pycache__/daemon.cpython-312.pyc | Bin 19794 -> 27710 bytes .../__pycache__/daemon.cpython-314.pyc | Bin 12616 -> 27456 bytes src/auto_commit_service/scheduler/daemon.py | 210 +++++++++++++++- src/auto_commit_service/service/__init__.py | 17 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 438 bytes .../__pycache__/manager.cpython-312.pyc | Bin 0 -> 14460 bytes src/auto_commit_service/service/manager.py | 235 ++++++++++++++++++ 15 files changed, 559 insertions(+), 19 deletions(-) create mode 100644 src/auto_commit_service/service/__init__.py create mode 100644 src/auto_commit_service/service/__pycache__/__init__.cpython-312.pyc create mode 100644 src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc create mode 100644 src/auto_commit_service/service/manager.py diff --git a/src/auto_commit_service/__pycache__/app.cpython-312.pyc b/src/auto_commit_service/__pycache__/app.cpython-312.pyc index 6f8ee0faf718ce86794a0c6b7acc577987205910..0112451522408a9ca5a4b3e0fef7cc7ed28af799 100644 GIT binary patch delta 4563 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 delta 2614 zcma)7ZA_cj6~6cV@E7o6?AJD!51W`TV?F}}!fGn>k^V?XNz#lo>B;~DgAj;blhF^G zK$8>`MeU-qE?v{KS%E*$K z9Vbc)>yI$6cfL-)nxsTIW3LGN8E046lpfy9%!fv6I@dw0Ood^qnjB_YT9|98Q@Q~i z%vo(dF(M2Z(JG@=3`#f>f->DkL=3Z#7}Q0?p?zTlVJ2$yh`R zOTayq;;eB- zPIw(*wd~UOy9JJ$M5IynV{w#wP4HXwpEhCmN(Lv+}ncATvAIoe@@69i8oct!0fM4VsMeH#a%F8IXxME-h;-fW67 zz0tIB*TAfm8zD1Jo~sFz@t;`(US%Q46DWhB%v|O8PN|3_l|12!_@XScP|C<=_AnL{ zg5s<+Bj6&~Uf@RfrNIFeMgy348`H%^K!lCx6Al<42X$eX5Nx`uET|7Nl0+R+!!+j*MEHoZH3oB)3RS+08taxN%(t|(^Hiu-KE_A+D0@bH zl8y4XVv<43_JA)NoTh{V`sA#68BZRQH zFt>YrT+1iZHxQnyghRHxr~V`SI@&`&M|ki#doa#oI%apT+DevfB`dbF)zq?&Qp^5i zaox^x-F8&3%c50@F)>AnnG~aPJGXE(w`MuFW+k_FHM2H);IGzQ@W-4zin;tjOkr7d z8|!9f&H$IIs-+QkIHxxHP-p?4^lBQicdUVO_8||HO1~&UwoojfaiNqGFE=(8i3@eU zMvu7Yk&!PJiH$yZ$@Q7J2UqgSYr2HAv_o*gJ;*9Pub@3o6Lf(g5{rBD=FY-!el`DOemZ}N z^G-NjIAoz!y|Q+m{4Wq3g;?R!{0=-`^s&;6!LNv47O+Zhg@h$ShRdnhwbJt_p6~oG zk)q~i#T_Eup~oqi>Jz-N+aN?3-0|k5Pstthy79KH+igGii&y4YxKgtni5<68E|8wX zeA6>hY=zy$R`X@yl3AikCE~O&V5W)(H;Ns5f`-=(ahbn^GJkBdoQb0h1+Q-H9~Znd zV&57MWYB+0hH*>eob5|(rvwJ>V_9oZG(`6KLD+0E& zxYv%7oyZ{=t!Uv#;lqkHo&!qdTry^j1sxB1x%Lu-_P)#Q;H@uttet|NCWv3QM(hm1 za|H3Fr-)r5xIz%W(JW$l1n~-Hhws(f;Qjgn(+|-WH@!XoS$#D>n<1N~mHQ04dm_`- z#sp-sJxphuY@cY4J~S_ diff --git a/src/auto_commit_service/__pycache__/app.cpython-314.pyc b/src/auto_commit_service/__pycache__/app.cpython-314.pyc index 307d6db07ed307d6ac1e99ff9519598090b9c570..c96b925e5d45dfaabb11f6e452f526eb5df4d287 100644 GIT binary patch delta 5823 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 Au>b%7 delta 510 zcmZ27f$>ZXpEe&a7XuI&@I+?jOK#*_!X)p=%*g+d0Yu*6Q&?bfl~4TxGl=(umFFX? zBlF~oOtO5Fm>C!n84~#t`4a^a1t+LT*jDVy3S!LX4;BazntXsuL`j?>j4zBoOdw1!jKP>Sh+Ui^ha*P-WeXBK{}1tET#F38H3c&?BWe$S7y*u-@JvLU5Y#NJ0k-_Q3B9t&B@0Un;0`E zdn%9sUTw7WNoc`lV3Z{W)zy-?rf%83li)C5&a-yI*3pP5h@@;2SmsKiDDrj xkv5sb#h7~ukR<^Udd;}m&E+NIx?Ve(P;0DeTQ7Ks7X0RY0WZH@o{ diff --git a/src/auto_commit_service/__pycache__/config.cpython-312.pyc b/src/auto_commit_service/__pycache__/config.cpython-312.pyc index 625d03c40d09da66c40f204b04b80efcf3bd92af..6481f0169d6c287b192a85890e20894309cfcf7c 100644 GIT binary patch delta 1085 zcma)5%TE(Q7~gFR6y&jJ(SSjxwnbYDR01kc-dMmG^?->%lg+v_g^}$pneL+9gH8Mo zOk={)qX{NrIC}O(Odvf_dh={zV$^suemit=F)?uu-_FeM_08<;r_LSJy3^9qB*62z z>mj|@_M-K@kNMy2jQEPU0}s9lZ2BBf5SF@$L>ll=8pb|>1aKR^>Os$u7C^7LC@|Xb z^-YmWg~tr`K^p5drmLuCc@TxItx(i|}?6J-r{ zB<7H{hNuops-fe2wcBG*REb=p$mXHkjy&t{17?;k_p3%ON6macSM=5*`_?G7BX<{O zQI=|iryXzDWQw}#w0hc;1VNya9f=)1N+|v#LNnA=J8=u@pAh#v+%2hw!7$a~>i#fN`9-Jwt8p)$a#C$@Cj#h}GnJY+LnIh_H9$iYIF_^#@k#)ifNOkD(YUFtT zhboy9WvZL-eH5*9KloDYDSZik2se?ZYl@C7Xp|)&mR)vA(kZ=8=_|c$d@gQn4>Wxg zi?KvS3ROax?{)rI{chmw#^N`@UswM%C?v0NBn?OsknpP^7m3CXq|k21NLd=)moj@& zrYy}@Lc>6e06w@^ hY;yBcN^?@}ifkuK3CIh@Gcp=okkt9g0AhgE0RZW#GBN-F diff --git a/src/auto_commit_service/__pycache__/config.cpython-314.pyc b/src/auto_commit_service/__pycache__/config.cpython-314.pyc index e6f667245d29a32bad9b3b0b533672200c27c8e1..11348b5b2f266814dcbcb05f64b0702239d40979 100644 GIT binary patch delta 2387 zcmai#O>YxN7{_PVFEI&mHp#}0W8#cs=f%PC1`=99(kjqEfD%ZT7Lg(_%X$*8vfeej zYZJRKuG&jaNN9xwQhVsJy;iDv=m$WmDh1_$>ulmi8CpX5q`|FYYbaIWk2#|dSFS>Di*T8_x;+ar_NU_ zeZHWWvR*|7+)*rOpt5e_RjwGOQblU5zO*6j!2&8lc`+fdW-T`HQUXgl@k50w(qD;7O} zM?>o>WvFPlOxCr6Sxn<% zN!}$}rvI;%s>{3exodds_ImSZvaTBiMZ;IswfdQx^py9z6D=Z1#gBp~D({+bb^$*C zjUmF1S%Y#-R#B$T3s$KtNaUfNOXXC3ryj#|ciywJDW>!!c7Nb5TV-rD@I3BYX2srV zY~`EG9Y*ZS_Hr$)_yLy1*0Z+`iG3hzj>51bZ4{Wu=+ z#1S`Ume~a{>Pd`t;-d$}r~DioF(5O-A%DcEB` zH2!??sp6<=6t1ygkaxgEmh`&T1sfFHWwz5k)ABnjwwBcjJg6vYA(OWM2t8gl64-^t1X&{l_jlE!TvE)gr>*W5(V)_5sY{&?Gmu(O0n!pGXLkE#Bl zCckcP^{!t|PB%He$)Eiz;7bPg{n2&^q-LAkV3WU!Q7ZVw4NQm#SDbT2qg2%5t~L1! zRJ8Bs?QeTG8dGz$*(D68aI-kpWlvGGf>9QuBu44X?;O8Ba_)dze#KpS$z3|&E;}bk zW5pO&OwCcn@&UK5{QlHJW_n@yUeO~4eUD%!{;DMeT{#k zJBfW-Q(fg3(1E&Ngf#`Vv%$`7_DJ-p(>_M6q*IEczC0cNAW?(d5P7hJNwzkk-`YBB z(Wle{R-<USWOu&=C8O$w&6GI6v8C0qvyppi4K%xA8 z>)i{9f13(z=$BcMj`Up`8LjG>V2Ocvxf^~xsD5HnR1qvP`eStCxu15ET=OoSm>cvx zTgkVTV4XMMFHNRf4b@*EX5E@?2$C-DJ65mt`dhs7$QC!lJvmmqUh;%K>Bn zh_IZznbU&NVe)?_$;qr-hm<`)5?UZa5=2OW2x$=E3L~5+zv9wibe$~D?Wy7b6GT&e zi^C>2KczG$)vm~UatpVsIya-<1j{cBKWc^~qt1lPuM9v6Y!v_? CU1IS7 delta 195 zcmbOz`9YlbG%qg~0}$-1i^{Cp$oqkfF=KK9tK4K6c5jwSHcigS3)vf)G TriggerResponse: """Manually trigger a commit cycle.""" - if not await llm_client.is_available(): - raise HTTPException( - status_code=503, - detail="llama-service not available", - ) - try: cycle_result = await daemon.trigger_cycle() + # Check if service failed to start + if cycle_result.repos_processed == 0 and daemon.service_crashed: + raise HTTPException( + status_code=503, + detail="llama-service has crashed", + ) + return TriggerResponse( triggered=True, message=f"Cycle completed: {cycle_result.repos_committed} committed, " f"{cycle_result.repos_failed} failed", cycle_result=cycle_result, ) + except HTTPException: + raise except Exception as e: logger.exception("Error during manual trigger") raise HTTPException( @@ -251,21 +262,22 @@ def create_auto_commit_service( detail="Recursive discovery not enabled", ) - if not await llm_client.is_available(): - raise HTTPException( - status_code=503, - detail="llama-service not available", - ) - try: # Refresh repos old_count = len(daemon.repos) daemon.repos = daemon._discover_and_cache_repos() new_count = len(daemon.repos) - # Run cycle + # Run cycle (will auto-start service if needed) cycle_result = await daemon.trigger_cycle() + # Check if service failed to start + if cycle_result.repos_processed == 0 and daemon.service_crashed: + raise HTTPException( + status_code=503, + detail="llama-service has crashed", + ) + return { "refreshed": True, "old_count": old_count, @@ -276,6 +288,8 @@ def create_auto_commit_service( "repos_failed": cycle_result.repos_failed, "cycle_result": cycle_result, } + except HTTPException: + raise except Exception as e: logger.exception("Error during refresh-and-run") raise HTTPException( @@ -300,4 +314,51 @@ def create_auto_commit_service( "errors": daemon.get_error_history(20), } + @app.get("/report/summary") + async def get_report_summary() -> dict: + """Enhanced report with health checks, success tracking, and error categorization.""" + + # Service health (already implemented) + llm_health = await llm_client.health_check() + + # Last success tracking + last_full_success = daemon.get_last_fully_successful_cycle() + + # Per-repo enhanced data + repos_summary = [ + { + "name": r.name, + "path": str(r.path), + "last_commit": daemon.get_repo_last_commit(r.name), + "last_success": daemon.get_repo_last_success_timestamp(r.name), + "error_count": daemon.get_repo_error_count(r.name), + } + for r in daemon.repos + ] + + # Error categorization + error_categories = daemon.categorize_errors() + + return { + "service_health": { + "llama_service": llm_health, + "daemon_running": daemon._running, + "daemon_enabled": daemon._enabled, + "next_cycle_at": daemon.next_cycle_at, + "cycle_interval_seconds": settings.cycle_interval_seconds, + }, + "success_tracking": { + "last_fully_successful_cycle": ( + last_full_success.model_dump() if last_full_success else None + ), + "total_cycles_run": daemon.total_cycles, + }, + "repos": repos_summary, + "errors": { + "categories": error_categories, + "total_errors": sum(cat["count"] for cat in error_categories.values()), + }, + "last_cycles": daemon.get_history(5), # Recent context + } + return app diff --git a/src/auto_commit_service/config.py b/src/auto_commit_service/config.py index 6250120..ffdfd12 100644 --- a/src/auto_commit_service/config.py +++ b/src/auto_commit_service/config.py @@ -90,6 +90,28 @@ class AutoCommitSettings(BaseServiceSettings): description="Timeout for Claude Code execution in seconds", ) + # Llama service management + llama_service_autostart: bool = Field( + default=True, + description="Automatically start llama service if not running", + ) + llama_service_startup_timeout: float = Field( + default=30.0, + description="Timeout for service startup in seconds", + ) + llama_service_pid_file: Path = Field( + default=Path("~/.config/commits/llama-service.pid").expanduser(), + description="PID file for llama service tracking", + ) + llama_service_lock_file: Path = Field( + default=Path("~/.config/commits/llama-service.lock").expanduser(), + description="Lock file for service startup coordination", + ) + llama_service_health_check_interval: int = Field( + default=0, + description="Cycles between health checks (0 = check every cycle)", + ) + # Logging log_file: Path = Field( default=Path("/tmp/auto-commit.log"), diff --git a/src/auto_commit_service/models.py b/src/auto_commit_service/models.py index ab465e1..5ab515f 100644 --- a/src/auto_commit_service/models.py +++ b/src/auto_commit_service/models.py @@ -49,6 +49,9 @@ class DaemonStatus(BaseModel): repos: list[str] last_cycle: CycleResult | None = None next_cycle_at: datetime | None = None + service_crashed: bool = False + service_health: str | None = None + last_health_check: datetime | None = None class HealthResponse(BaseModel): diff --git a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc index 94dc027551b866618851ec8ee2e83fa1c82e60b9..2b973370a236bfb104a55988ef846353337cdd20 100644 GIT binary patch delta 11218 zcmcIKX>c3ob-TFlg8*?7#PTL6iihr7;wjmpB3;(s)nn^9K((==dhEu8ROMsu3;A~ zo8s;<&#-6AJM4wJnYYA!WBy@3%P1H@F}elmke~K)K?uCT%H`o&1+$mot1$Wlv&~^s%Hlq>vm#@yJ-@s34w>MTG;AMC61ZO6F31pAdCS0|DqfM%GR%mAcR#BduH_wq zm)8o;QmcyhKx+_Me1ez$b0Z2b&{&4N7y9c2D~vD$4&Sq?H%dDkuBWZu3V#2xz6RRo z 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 delta 4895 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 X3-`Qhtv_V?)YNaH%We`xvDkkB;Ke0` diff --git a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc index d30e4396232ce416ec4200116f7ff68f3b94d428..19ef353c04ed810a865d769b8656e5c20dacf99a 100644 GIT binary patch literal 27456 zcmc(I2~=CzndZ}qBm@E^K~W95!{gF&DFYyf3U4)4Txz!w}BE{X*nUtnbXHLu`0>kGOBNfr%i1}2d93|yIE$01# zFfR1D{hm>;fOm^jp`2}87)cBg$7$r3Wi7_Vjafd)1yz`7^(k9WQ^bv9zJprFMkm*) za%hUVQG-JZKW|hO)Uh}ni_=qFP|sqrSd4+iWU&|{i!ren1B<~-pe8emF`hKBxEvOj zOL3dzMg_CwGz&}1OGz`!Y56RzASEqFPP4K!TS{84oL0!vic-=nj!xE=VwPs7H1sr& z#g(wQQi==avzRg#Q_f-v9Gh5N1&gbsIFz)qxGEOMvwSudQ_W&(SWF>{sbw*BET)LX z)U%id7E{b(8d*#ei?OqqW){=JVoF%dIu_H)VoDvGS=@RS*G6$@QyGhEXK@=?zH&!L zu)@g&D;;V;#1_#kEtp;g5}~?e_z0eJ2YF%AACSRWE zs!N;`fWPf`IL?FEa_*?=oXVj(rg{lT13nfOe6>T{O66qNu0d0DKPO-n4FMU5MAPW# zm}`VVhM41}W$rpBt@FWVji5(n!AM>XJV2ZtkPILeb6hCo4g@^Dk)X>Jph{~liR%Kw zNISu~YlMNSE8rDg^No1h10(0Wo=E^tyMz_mCN7F*m&@(XR8S%P|65NP_aOs6QgV8cC z7hqH`aVPTp6wBNR-mi=_cVdHIMra&293MZY+NpAC9ICA{csn_Nyly8ql&#d1cq$-= zob+Yp9BRD9>m1Hk;!k-TnyqpRkb_2?xl@>DKO-mf>d@jX{w1es$S&nd_K}08iaGYR z+>|#<%3EIIjn+o!U-$ccL7#in7xMDKb6y#Rx0y_PgtLJz6Az~>oG$*DG`H~>mYi5k zUHsw0M|f#vG5kLUB(qMm-XZ5Os`Hi21G& zHx{XDa)RiP$K?(R3{rAk3?5zQd;vyga$G?y*-v5 zk>5woOWaZ^S5p0@oLFhymn^Z0mIXuUV?9^e94oGl6;{Mb8y{zBOHEVSFXb-faz&-l z!iGp;!+c>AVoXbVu5;^@cEM1IBAwgr1r|2$qt~)nMI}8;k)Z@3)Nbda_NIuPisi&w z+NgA_y^}K8+4H`kOejNpQg9eS4*f50fcG>UMi{KM6q#h#uBzqksD%Q&ih98toD}?b z)S`A2Qz8&BHLHapJOnlj5-MWjCX`StRkR!b0eWLN#H1^F#KF7axz)8;*~Y?&<{}q! za&yhg5AjL}cSwp)@rUB5A`#sv2R#7qaw?}wq$wcT>=$djmd zIF2@k;s_y;&!JK{xhk%W1MsUuEqe(n7~Ipy^5bWC1{%a(`Md!>IL^y3)E3$^I4&iE zX!+0h&Q1zo>ze#hq|5IHtn;8E$TKo21bnY~T?%)0vFD6?G~jJ+&SFbmv}VX6mc+Az zM0jM#l;G4v59teHaWWvg&IsdUa)N$z{Onn;AZDF+3j~~ZR6+~-9v}=Wfs{~%U^>xB zLJPYoy93@O?qRm&O3&qk@!6aE{}QTqWlOJ_5jO zlK_UTOP*FA@4YZF>KpNapNlV2p21SnsQ0XUX=CF- zuc$vQxcwvNWXKRUqbZ_=)$Tg&4tQNsX7|K|*Y9C?Pc%uQ4lB*Vat&Ix+yx#r2s^17 zBWn;qo8~iP~BhY^_thF?;#V zoEte&`{sy!^F8gNeaBRP%$$Ga@a4k~@=9YR6|s_ z=3aydt;7j4RVom4iyH7#Vz%HN1$PdT4Z6RNiV^-5ddb^Q9#Jb%;~zgSP}ha6^s4pw z&y0&2@ZZfkhI>SbPBV-n;h|lWnUGO{*r5iJB_tF?qb@e2q9BD^NGJacEdLw@E^$9A zY>X9^ULU(Q7A@+G6m_P)R8-$QdE;cXqB~O24VJgaG-X;cbNSXO-H&XQQ@szYMOTB- z;>JjET%u*3oMGCG;c}YcFRga6fnwHz;e_Z|z#xYku zFyvmzzMOsam3L}qyx(Y|l zq%6SWL;!Wu;%Jb7Ml5s%&W%qpA(nkl;*rIgR4~evhb@DXs0wu4V!iUlcKc$%_NlCxr6_8ti&*OBEe*4E z@2v;`+xx%RsLE#z7zO2F2&T37LigLk8s zJreCe2IiImxtV_>#!u^dVSgcFTKR_3046iW|!gOczKWs#%=mU#ghz$VnTasZR` zN~r^wNSH#wKps4KjYTb05ldCrP?c!wuVFw1AHINQ(tNBvkbg*RBE^;rv9&aKwKp!z z*8S0Mv0$QY$i&>Uk7JE8j-+Sf6~!|)ok*?D)7BD=XESPDF`hXosyVSV1#DB&{s6^O zN3|wmsR34vg< ziMEW`kk+81VwT{=b}V##8c7`iDGZUaB0WHZ(x9+2*=N1MxQgAL0(VI%YzkV~7(OoG zti{B_w?v9t<_p$=DKS{CST0)@3?*PsuD&*{oBFM=q4FsUisX*97nDPjLOf_WX(dgx zq*Eb1jzmWQuY=S>E$xY|4ZQClZ-7Kfww|my+anI%oslJa-1Xc${%5bSn3cP)ONQqM7cn`S7^g5x`~bE>wzz;P0rJ^(2i`hpDXc`7?UfSJPb z%$O59Q<@>_N1AhJ5TAhs$Ih2#h&ZJ*H$+Gz&9!SM)m0q**D`J0z#EeE@6ds((}%9c z!DNK(QSv0ZN(KsiV8T1%JL4N+(sK&f#1(p7{2*qW@F6jUOgqQy57NM;7i)k&?+cz& zc6HfTUKHte3X)Z)MFN0Q z7BXxEd#vXYKf%UJtX=`0=~>v)Cbs+%_t?f2RZe@Nl^v1Fj{Aihr}oE6t8T9U`ueN- zn6)Hot&Lb~XD;5iZkTgMHy^~`hno*Rv{%mPqx{AQzcFU7p4k$u-4?0c7PHsRIHUEO zBlVlH{WQ~Xd;P8T(VESXn$1f_U3t-CPFGm8tmDd>9%;Cu&PN5{r&j_$^^XwGGBKoU z1AjXyZe(7O23n#W5}Y#vvNo812gL%!yD~7(B}F1U0&_g>f>h(N62#?pnwP^Uc?tGE ztEtcdw#Q_!O{O>~MGmREk*alSGlBdHpw?yr{oy#EX9CzHw6GOuL48WqnzY+Ap&r;P z0pGy$vg({hR26!AAC`Vl+T#sm0zN<`fNKQyI=oCUekE`mjB_z;TRA}0Gg8^HVS~)r zn40wrv;<=F8E`P55lBZW>h?Pv&pBk>mI67{1%Y^3;R-xStwnk*Y0owRJkk#L2xVUl zc#^WP1tT#EO9fm$Y1#`*qJ>Qh*rZ(@c)oq|?#b_uMGw3fIq>5B%`e7mCDTX0a_Fu7 zafoPMGOVIg|21$}!UYUK3QzSWMXbrd{soO&oCX%rwk*6`fhBs}?fs7J&knJel>kDt zfYHDSMgvK&y_UXRVXFWXUzI_Iz%0!n6}XatOa?mm%)n4bz>rC&&M0CAN(PFk%|y*W z5w$}ZG~%F$+9Bfo)0{(xcu?qc*RE6gU&RfVD5(jZHITu7h*S74QtQ*G=+~0~SRa}q zDk=l03{q?d%so0P5ycd6U6tvSi>}3VvTQu@Re^MtQ#3FN=JI(YLdWQ#juoI?FJ>{~ z^)%-QBnT$~I1xk!+rpPAw2wS23ocQM@HM=${b{l)gh|Nhi)8Zz&AZ@0ih>LnSF4vg zvN|@%CsN8X#o64-6?8NuoxuhM9q1o;pujeze^6MlDkZ(%bFJr{Bhk98k-Dw-ty^QZ zQreu0*=uI@-yXa*m_j_0=xEhU(e3hE<#O__Nd2y*Y<(?=slKrIaV}>so8I#F=V!e4 zi`T`f>c5_|T&U33B|Dc_G?lZG!V-0*|6f8Y!;}cZ%QUuYVve68S3(zKB<>mB-LiYZ z#g2U}oM;-dGAB39yi7RzGx5#qfDuTYoB>~);*v-pamK1lL=?HO%w+x`Q`Z@{4>n1B zPY=IAHpqZotZbb}rno!#4XwP)mO^~fCcs2AC)c2CL`%FSN@9u_7fEB8qM5FumCpFZ zGn1pE7hSB$fdF1)3#ir=>;5UZ**w#(5m-s{3Rco$i&{u?Fmv=iG|`)*>vl)h?S8N0 z{<_{+ars+j>?MN}B+Y7KF@Rh!7pHo^tQaUu=WsJHQ}hzv@7* zj0I>}&gVnmWPym|4O4*&x-N*__M3WPV{3d@l)hBI=G6jwh} zO$w`=R@*7yD7{15t?o`)dZ{w+q-Ii0+Jz+J8ofi8++wBnQ<`mH>^)${VI`WDCzI&l zOfo5PSSGYp+z~Z1N7E|pl*jRZpq*-mDfyefM61(lcuF760zH*u4Gq+1_;O0QwfcoR z4l061Cd0qPDtbQadBdcp9g4mKU_{t@y5PiFn&e)muwAePcVXJHo$L-|xG==4QCcyYp;&Yv-T;23Q8V2uiU z0ao~8oX~upDF<3%KM1WK4u8CK@(mOASeR&$<@W~9j|;De1{l*w_C8<1@)8Y7W>Je1 z7f|~_4}I1P*3N$hHi$vdNVb7QhP+}nS>?+|F+|-scuYYOZny)?KwE|xhK4)wvW(6H z71sdnE^)`X14fQ}HUAi+))%--IFB{}xMC0m73*e=PjyE%cn9*osBYUJX35PIO_Deh z#5SCy?B_2@ni1m;Is$NELf9#Gr0|0BxL{BvQ7O>2V|083=E6JM;ze44Mgt_@e)4lP zE?jgpvSVnjc&6okaqIk!p--84;}`R7s^iT%(S*V9dq?O%ftW?aaeOi;W>5MBFHHT( zSU)5kHRBO4yo)-U^TP1CAY=W8>#xF|G3jy<-7UYK+Kk=7(K!Pylf>QM28^o!i=${5YAM%Mb#^!>NVj%qLP3K9y0+WGqBy# zvdB-Am#1G$6_Hn@GfHg%CHVw0vY9I=ism;)@*8JI!ugH!`5UM7u&T%@ishAlSUw!f zE1u3raC+}jme!uTWa4tHEX9|SQpi#&SjvkjDOD`R&Qe}VNhxJ1r7Y!SN=i9Pu_0x8 zZ%RT@?$YKeGD>^ASH+drObx_J>TXuusG2RFtC=s^94+aNlyuLR^h_Osx_oNjVZ(-K z!$=w+wHn9412TBGUHWe&jr7y<4FZ3PEw^gq;BE)1vRxHLRoJQC1h`ebnd#vOTiOa&jd07%}p%&!)h?3kewUN0@+?FG5Xj+AH z!O*8`Lk0WU;UlI#kWbhh59Lb74HY|_!8;ns4hM>QPB=;)$zRDvOM1!bMzp4&Whs*N zj}R~H#_Z*E@JBXo4BP7N8|o81RQTNA&^Y&|jdLRnaN{&&X*?3GKtq}P^^S8VjBJT; zBn>p*blwN0Cr*b=@a{7p&^#<_1)xM}U!l3}K*%Cb1gu)w5wStxM`+uM(a)08MO(`7 z%dxEA%J^5LjDP)oLqnqHzv1!kPZ|FfUHc;r-t=+yNVGy8el81vFVZ^yL-2?aM*&$# zNlkjjc~}?*m2+!AK+@?YI$@iXo4%LkV5AKWMsoNz(VjG?aO7mTjU0qN(p;v^XXMsx zOj4^ma9TS7!)2?S>{9x~v! z6-v^xkGjL4NX*2gDh{oUA2(QIH5lFHDXxNc1a z=Tq^&rjbzUBdMj?5-K_tbPJN%r9?KFeQ#T+M8?>#Vs(##&GuYW41Nbg<*W>o<0JzT zxg@jp2-&6WuU6Rv3wC@sChZ>W;zd>9c&O4LnTJzp(#XjQM`r%r)&{#eF-x`y4_OAu zeo@*6a0k2ikfFY>tNut={jpG?+&Xr(#t6=gc}Jn-4_T9=8E4+bA4rWQDm4M+eUW6e z%s7E^Wv+ ziK_;J!Wk02#4LB4lBXhyeq=Ol;+?H>3&IaEN z-3fhjSGc8niBs*UidEI#eB;I&(W-rss(siTH)u1NK+ z`RYAad%t`L(n)dIb^kU0d{IL*uOXb*fOFT4?YBq&c=Q|oKfM?$=WialaU@#4J5s*; zu|~bE;O{tfW5HGJLSF4-1D9WXJ^NaAtf+p*6K&WYY1kfZ*c)ls`(D?4!(h1nxv*^r zW%3Iio4Df2>tkez=g1L&uY2fle z*uMV0x$V(9F2C`Soy$L@`s8P1^Ispa)Ia7_#*TQl_WR}y4=i?y+=j@gxjbSn4_9rR z>zJzzS8R!zw?@oc{{iD?-1ZOX?`j`gG`IaC@XxSj-rTdPU#EJ1Yr#P^_gzyy`Q?S= zZ|s5py>``so!akh*jtU4ALOeJa@rphWKp=J9>spJQ-$ykIp(Wd`bsrFY*qJFa6fD_ zPdias6)&Lr5XxX)c313f1%c(o_`8)VKRk{GLc;nylI?Tz+)vF zMexMfsKY2Hu~7tpk{bf?O?Sx$yxBbvabynSL^}607iS`l%M3?`JVS9dDp6Qr0+jM+ zH$J62)JEw}DPP4ghAG2WK^irw>;(xu%0Eb&2_!AfvSnh_K#;)7kQx~T24z!-fHqWi zjCgn2d6hBwS;0NxWxJPcp#n-|T01EbSo*+Vvw06ZH~C7Jg3g z|C79QF-uEmSjpMO`JywLR~V33fr0%49zh+!#RY`x~_ChbxnJ2zI@~5o8vde zqm}I+R<_5?c~=fyJ`^=qM9dWc<0Z8wr;tv>y?pKEXdeGz9{(tdtJ-k63%jBD)t@}D zmPW135o_D+*5>XeTHq}8ds6Tq5UpXP>WCo0V;AeRnUaS-k%U`hIZ z2HC@*ReA|NF)l^WVf<(9-g#@vLjQzOzG zR$#n0*cE`HY|U22rG!LPtd-cqNm+B4sdOjL|LU#GPL{?YKLN7WY)`TfDSwa|^Z>%y zjuu8WX)Uy;w+Ry3T4QHM8`@TuOC3A3_C;w=Ww=Ah>%jeOPTf$a@}77q5S-ZiRMyml z!D*0oy`W$CX4;spKJ&Rw17_KhJbq7sDj3xNMxZKB=|vStHK6M731=o~NZ#R_$XauT zSAh%J?!<>Rk{NaZH|g%gS>>+7Uo-4(N&gbZ%*s5?&pd;xLq&e_tPcG28DN4W!Zeo= zMps`?tGA{gxiwhz=;4|(xE72K{K_zDeMT6)B*Q2uq~x9spUQd$HGfvxZ%l%J+_LD@ zW`J>1QXbs7m@!XwDphtGhkN2}j?-?Z$(ilcCMfWP`NVsEVw{)*TkW=r_k^9g&y zd_JE$scs{?g_nRwbDgMO; zE^-Z^PtM#y^}tC91|}?MyG8km=W-gR%EU8PCe49GSuq&rs$`BXDNjbePANBmJ4A<` zQ`(z&DlLabI9rK*<~sA&SYNy|5ACgX=EJXX<~#F}SB#?;Yp!l%vWDGEde{BB!|%e^ z9{w1<=5VoO(KwOoW405`^`SZWBH4tYuX5)aj;Xl$0JMeJa$?%J6zfGMA9eBlXZYj7 zq_>q+lst}H`ssX`B(3sLV3Hw#d|LtEM0Sv4ZvP}s_67wXU6K}PPP1@iro_-r(t$AP z2%BPmEJp@RU4uV2U~C6NmV_>p9j_h?HPfw+vMB=lwEH!fAk(&2D<62pCmX;}W|$v* z6_to4rnX~OE{f)naWa6BE?`t_j^$hDydL2ldnh{bn%ir zO?Lysa?|ZU>kWh|d!)P#`wBp))zOVZ6bc*EUxN_lk`pV>m@ z489+?j{y+8nxAP;>**3B<1-~Fu7xe2O17;5MN}+YnQe{w=OlsB(c0gop3|Zhjr*QE za-{$G@%_Db)WW}~7?qH$9~J%+#WkmB7~*Q$3|2LFNn$2Oi5b;YoQpK?p4s!mzW4hU zhE7I@&PMRpd-kULtM2*c-JdSg+`q|psBW7&R3WP}0~@>ep~=8G8vc-8(r$-pR~`Qe zWaTGl>OzH5L5zOFZg=z|MLU%98$8Ua_OY`Up*$(KY{EjwDVA;^H8{KAYvJKqTzCK ze{av*=Raj8Hg9(9QLFBm;Qjwz#nKQYEn(@{&Fa1X8|uBXB9}OInbiB+O}Co9H5P5# z6Tx52o-2D;X9YcShx8KW3gwc~w}eILoL@-O)^R4J31ADwESrM^zqIlpqr8R?H1U(T zd5>_0YNmrEpclt_EW^DS~v{3(#RsvJl z&)bT>9K0TSEA;m3^VYf<$D*}q%7Bww*N5L4e*4svDYkC?RF0 z2R28bu612=MGHG3g&p&Sol#5YeM@J2Q}H0Lh<200g(+kdBjwtH^$y)Dsw&qww> zAKvQ>m!G(w_d*PtR~6SP-oXLxu1HPSLVg#OHD5DNzdB=|G0oP@9)I`6J1@=-%)K72 z=#A#>PbxqszN7h@S1TagYxvKt-)X&98LsHV9@nEXuCVGc&y`h1D$dPTN~aYTns?7v zoSQE_H)V-smrfsD%&wF-w5F@4`=-0X_1o@Mg}3#8P!zTgEtrqO-sz_4k4#s!KPfJs zIez=(?UOSn<2!r%7ish=LW*No(m5-qeH`yq2Uj{5UxKJwYwfw!B+2a4c)u(g=leW z1O~UoZSw`~Q(4%ywDZ?rf9v(Pzc6KfP>Kt3@l$oY1*s-*i;bIzw$CovD zE^}(TY069qwRAT%Zm2G)e^6dEll8U3vGR3ziP_4qrYRFH1TDuth&^U7M-62WLs`^N z6EW1x>{u{tU~#1%8cL_#i-xL2c1?9*^`Z@1usb5w9rM4kHTi_M0!=5zucnw?yKcl^Ex@G#>b61VAifZZN!}*GytJ*J{ zXiVdYGLXiK8fT3&pP#FnyEyj>YTB!^(_IVwi}v-ir^Ce^bNSd3D%`}X$Ch_qdDu|) z@d*u=x9Q^+u4u={tz6|H)u$g1tI*C*<(=D&FLbEh-=un>R{Q>DBZYVE*@?&Zc5Z?H zeIzvKzrT4OrF@{>TZhMAHt*5n@mHHF;s14k>V;s0v5*g!iM#@0pa?oYo})GuAZ7NY>!&n!-n>VrTtT8==1PCpYm%5KD}}xTuUYkTlk}iz zZdM&=uA)o$>0lr(;WxQlu(8L_Ss;vGv3M2Iw47pbx!6@mqdvdaKaS)qm&-Fg;&KVp zGj{2?K)PPW4GFY^k7FIeIL;UZFA8o-Ai+|gg(6IlH%T7!?VNCxynjI+8SM-Iio9=< z7bWijc@N3^-{k!rd2B)|u~u8^JFQV9-y~^^#~@e$tP4kPVT`g^4XjQDze? zndJ*)oX9*Hi9idg`{Z{qLmz3|o@nti8z0k;aXfLTaGm}V-Kqb?jvK|7=vwh7mAEmP zZgMDmvVrayU1GP5E){Y5+$U<>(73EYXo*6&nqu`hKAw+{=c|p!)yCt1faUsXo)~Z? z*T-}(*OO9Qro?VkdeVZ6R@kL0Pk3A|@iE;nLG3~+yeCCCcFT_8KH=%C-x53V_vC;Y zCfpFcbPZ2Tur8)h;}a9iU6;t_^~nq9HR^=-#7_IkAG2NMCm{{6!QT@~V7Ne_5p1py z8ZdOsC_v1j>**mU3v{JA@#`?(Vy9FB>^CPQ2>=`=e(eBfi6|qX02mgqyY|_Svf$7L zt}@F)v~;!aUs1@&ag$$tC zq+>fzx^_`IpJCV`E&83pM&!dE7Z`vCI-yd1#I65ZF84?Dm;Ga|;wN0wkGZ1%#x?$g v+wfzq_9xul2)7r3A9D>4jd@XH>4!#~JeW7uUD7|%znZ1mu*{LqhU@_RiZO{e)3RC zKly&&?#%4WezQBXvp)AWeRVjqGBd+O;CaowFZPezBZjGYp)GFEnazF~n zUAj)aqQ~~}vErgmt6~*&eV45>OUZ&Z4H>%Zo!Lru0U4C~%ek%DQ99_XrKp$?#pNW0 zBxH1xkf~N$qPatK0|}V9L@Rels$n#uM3vY?R5c=wM&n8(KvjG5l#*;oPE5pD?r52pIrvwdr{f#jwPK=>gBQj%QrrnFY_Xj%QlpWe8s8I-XfrvkHz4aK@%s1Sw09 z>?o{c2uik~I0PkAP@IC|5)`YTxCO-{C^kXy3QCTkWQBZ+JxrABkOUshQ!QzSwML>7 z$;9E}-bgIL%Qa7$HSonuhhXBDHPg2^p_!@%`1A-_Bz|q}0>bm6`)$j-)pwXAjLW@{3_Y>!kRp2K zbv(;z557smf2Vt9Ll)2&DrRqF*E1-V*a7N%PYiI7%7}>DLl-{`whi9 z@#S~$!-f|AH$x@$@Q*6IyungCd`4nc5LI<7s!Xv&KvFgFSW019AO<9sjYb>*Dcr?H zM;Iz%E(ABgHu$By0K%aeTIT`*cx?3aj<@owMVon^J!h$){5Pgb8sNp5ej83yH8I%G zLy5>lG{t)}bEuXd%FH)!02a$dsN@irV%E&ZpEl>yZTy|g6!o0QZR#xpfiqOq3@4NE z>rb%*q_5!@h$%|iyafXASgUw34?pG}&dw7W8JbUF1f$nvPr0`p+Sf7xj*xjBu{j>S z_n~{|47m)au@$Gm&;i$M?iZ!_*Ksa$RW_J2UxLK>Mb{SBef|l`B6ppwllI= z9##&d;2FyF@V>sioB6xmB3NX!bz@F`Rv{`}XKOsCoc8l$IbN|9da7GdFJ-vM*H<6r z#lFHN^+$ZASw&w_|7%~h?^d9uaIu%1cgb3G!CEwDC_?vn`OoS%RG`%}|AWEX3IDu4 z7c9;+9^g|Ap$uW z8ChGlKmblU#0%DNrtzm){itr8Pq#J|NkfF+-t_+&9bIR%M7wE$0GzxL#dW&l-xSA-?t*kmE>$?<3+ol!*E zag9um#G^y8gc417j924hadf=8BHdls%|8b7>UN&2uef|)h7zoUoCG%TIyZQwuN>&mo| zHrjqCA(I*iBM+Z9=J0d9B~IOHL==ETZ||BQbI6)n%CFq)F42bNe%y6mdcxY((P8b0 zz5+fSY%uG?)Ivgru%^G1p9?y*#;_Jj&T~Nr|1g-L{S_fXlvZ$mpPToFY_tl)>Py31 z_L?=yyZ^I1sv1U>@xhSeOY%;BPoK>Sn_*z3Z0XON8a`oYb$8ftvDbWefvYcZeqpD-hNV)UEX8Wldpy*btUO z+HH79!ZVp?+9@c}qv%>~J z$Jbk0t14_*+k-LA8uRj;3P{+9S+Py(xd)K;yf0Fh>+oPdOO8ZSDSsp$_w${%74lz> zdHMO<4wzKKQj!|woA*_gsa7nNQbQx+PM}0bRZE&M7Kz27r)SaBR9xYY>?@R2x}Trj zSLk)_@P{C?-5F)kQUCC?za<`-8jT*7cJRslKj}UreSjN27|^IXNL9*IiW#7<>i68* z($cnPk7^9Hwe*GCLT#;QBvq>3tWvfv=@}cwqlxISecPR>qq@t=u@UGlur+K%s@#mLk+5Grlw+} zsurs{3nOASTt1aCx!<54z<61W4>X zuv>{@=8@zK-oAzoMN)@UQ?PHSrL#HM(Y8m`jz)*4#@T%+rHwL{WH8IA33dR-79ur6 zNh+(SO28~CN~R!x3gIvcY0y5_+qi5D@&{peBS751x8U0IyNB$}DZCz(`d6NG<$Vx^ zTP_V+;Rc=>C~`7jb%LcmH7bgRPgd-Ls9ejmH}QVsp)!kDKRm z8!p-!Rh#pQui){f$C@trwqEdU{e|+Tui=uj;lcLHmh4A^4+T$dy=W=I>eq$U?-Lo9 z{B;-nbr-F5OKL8=+$Rmk4xh5a$<}qc>s0;I@#nT)s_(r}-#fP>GdSny`$U$q?4ZH! z;boD^# z!CLZJ$^zm?12yy+x><8pTSEgTQQV0A$wZ+pBEpYa#SIceh+%-NmPr8`3^@4Z@tazT zk&YQuH9+)CMwv3r8j#kE(19RsEPJrE7a@j_L^z1>L&A?A+SH8YskrCtOmE{1# zlIO8fUJOzb$^$HVum+eHmB8Xb30CG4TpqD5!fe39PU94?9#QppkHCuYVI*M%AgVqn zT9S#e*f`UmkUSonN#Ba0ri@RB|0<}ui6s2rCY4S%n8g_0_f-Q+C*Ns?2Tt0#7|p72 zJgW4FOAGrEa>ObNU*NqYJv}_=R6_%3o{C4C*jeaO_@&xNE+O+WrSu(A^>>o_F8-O{ ZBjx`jWq&2P-1eP9>wrv4uMtF{>%X@~6m0+i diff --git a/src/auto_commit_service/scheduler/daemon.py b/src/auto_commit_service/scheduler/daemon.py index 7e31a61..1eac7b1 100644 --- a/src/auto_commit_service/scheduler/daemon.py +++ b/src/auto_commit_service/scheduler/daemon.py @@ -5,12 +5,14 @@ import logging import uuid from collections import deque from datetime import datetime, timedelta +from typing import Any from ..config import AutoCommitSettings from ..git import Repository, discover_git_repos from ..llm import LlamaCommitClient from ..models import CycleResult, ProcessStatus, RepoProcessResult from ..recovery import ErrorHandler +from ..service import LlamaServiceManager, ServiceHealth from .processor import CommitProcessor logger = logging.getLogger(__name__) @@ -36,6 +38,20 @@ class CommitDaemon: self.llm_client = llm_client self.error_handler = error_handler + # Initialize service manager + if settings.llama_service_autostart: + logger.info(f"Initializing service manager (autostart enabled)") + self.service_manager = LlamaServiceManager( + service_url=settings.llama_service_url, + pid_file=settings.llama_service_pid_file, + lock_file=settings.llama_service_lock_file, + startup_timeout=settings.llama_service_startup_timeout, + health_check_timeout=5.0, + ) + else: + logger.warning("Service manager disabled (autostart=False)") + self.service_manager = None + # Initialize processor self.processor = CommitProcessor( llm_client=llm_client, @@ -60,6 +76,12 @@ class CommitDaemon: self._last_cycle: CycleResult | None = None self._next_cycle_at: datetime | None = None + # Service health tracking + self._service_crashed = False + self._service_health: ServiceHealth | None = None + self._last_health_check: datetime | None = None + self._last_health_check_cycle = 0 # Track cycles since last check + def _build_repos(self) -> list[Repository]: """Build the list of repositories to process.""" if self.settings.recursive_discovery: @@ -221,6 +243,165 @@ class CommitDaemon: return errors return errors + def get_last_fully_successful_cycle(self) -> CycleResult | None: + """Find the last cycle where all repos succeeded. + + Returns: + CycleResult where repos_failed == 0 and repos_committed > 0, or None + """ + for cycle in reversed(self._cycle_history): + if cycle.repos_failed == 0 and cycle.repos_committed > 0: + return cycle + return None + + def get_repo_last_success_timestamp(self, repo_name: str) -> datetime | None: + """Get timestamp of last successful commit for a repo. + + Args: + repo_name: Repository name to check + + Returns: + Timestamp of last SUCCESS or RECOVERED status, or None + """ + for cycle in reversed(self._cycle_history): + for result in cycle.results: + if result.repo_name == repo_name: + if result.status in (ProcessStatus.SUCCESS, ProcessStatus.RECOVERED): + return result.timestamp + return None + + def categorize_errors(self) -> dict[str, dict[str, Any]]: + """Categorize recent errors by type. + + Returns: + Dict mapping category name to {count, examples[]} + """ + errors = self.get_error_history(limit=100) # Analyze more for patterns + + categories = { + "network": { + "patterns": ["connection", "timeout", "unreachable", "timed out", "network"], + "count": 0, + "examples": [], + }, + "auth": { + "patterns": ["authentication", "permission", "denied", "ssh", "credentials", "publickey"], + "count": 0, + "examples": [], + }, + "merge_conflict": { + "patterns": ["conflict", "merge failed", "divergent", "non-fast-forward"], + "count": 0, + "examples": [], + }, + "git_state": { + "patterns": ["detached head", "not a git", "no such remote", "no upstream"], + "count": 0, + "examples": [], + }, + "llm_service": { + "patterns": ["llama-service", "model not loaded", "generation failed", "llm"], + "count": 0, + "examples": [], + }, + "other": { + "patterns": [], + "count": 0, + "examples": [], + }, + } + + for error_entry in errors: + error_text = error_entry.get("error", "").lower() + categorized = False + + for cat_name, cat_data in categories.items(): + if cat_name == "other": + continue + if any(pattern in error_text for pattern in cat_data["patterns"]): + cat_data["count"] += 1 + if len(cat_data["examples"]) < 3: # Keep up to 3 examples + cat_data["examples"].append(error_entry) + categorized = True + break + + if not categorized: + categories["other"]["count"] += 1 + if len(categories["other"]["examples"]) < 3: + categories["other"]["examples"].append(error_entry) + + # Remove patterns key and empty categories from response + return { + k: {"count": v["count"], "examples": v["examples"]} + for k, v in categories.items() + if v["count"] > 0 + } + + @property + def service_crashed(self) -> bool: + """Check if the llama service has crashed.""" + return self._service_crashed + + @property + def service_health(self) -> str | None: + """Get the current service health status.""" + return self._service_health.value if self._service_health else None + + @property + def last_health_check(self) -> datetime | None: + """Get the last health check timestamp.""" + return self._last_health_check + + async def _ensure_service_ready(self) -> bool: + """Ensure llama service is available, starting if needed. + + Returns: + True if service is available, False if crashed or unavailable + """ + if not self.service_manager: + # Autostart disabled, rely on external service + return True + + # Check if we should perform health check this cycle + should_check = ( + self.settings.llama_service_health_check_interval == 0 or + self._last_health_check_cycle >= self.settings.llama_service_health_check_interval + ) + + if should_check: + self._last_health_check = datetime.now() + self._last_health_check_cycle = 0 + health = await self.service_manager.check_health() + self._service_health = health + + if health == ServiceHealth.CRASHED: + self._service_crashed = True + logger.error("Llama service has crashed - commits disabled") + return False + + if health == ServiceHealth.UNREACHABLE: + logger.info("Llama service unreachable, attempting to start...") + started = await self.service_manager.start_service() + if started: + self._service_crashed = False + self._service_health = ServiceHealth.HEALTHY + return True + else: + logger.error("Failed to start llama service") + return False + + if health == ServiceHealth.DEGRADED: + logger.warning("Llama service is degraded") + return False + + # Service is healthy + self._service_crashed = False + return True + else: + # Skip health check this cycle + self._last_health_check_cycle += 1 + return not self._service_crashed + def enable(self) -> None: """Enable the daemon.""" self._enabled = True @@ -302,10 +483,10 @@ class CommitDaemon: logger.info(f"Starting cycle {cycle_id}") - # Check LLM availability first - if not await self.llm_client.is_available(): - logger.warning("LLM service not available, skipping cycle") - return CycleResult( + # Ensure service is ready (auto-start if needed) + if not await self._ensure_service_ready(): + logger.warning("Llama service not ready, skipping cycle") + cycle_result = CycleResult( cycle_id=cycle_id, started_at=started_at, completed_at=datetime.now(), @@ -314,6 +495,27 @@ class CommitDaemon: repos_failed=0, results=[], ) + self._last_cycle = cycle_result + self._cycle_history.append(cycle_result) + self._total_cycles += 1 + return cycle_result + + # Check LLM availability + if not await self.llm_client.is_available(): + logger.warning("LLM service not available, skipping cycle") + cycle_result = CycleResult( + cycle_id=cycle_id, + started_at=started_at, + completed_at=datetime.now(), + repos_processed=0, + repos_committed=0, + repos_failed=0, + results=[], + ) + self._last_cycle = cycle_result + self._cycle_history.append(cycle_result) + self._total_cycles += 1 + return cycle_result # Phase 1: Commit all repos with changes logger.info(f"[{cycle_id}] Phase 1: Committing changes") diff --git a/src/auto_commit_service/service/__init__.py b/src/auto_commit_service/service/__init__.py new file mode 100644 index 0000000..4c02b17 --- /dev/null +++ b/src/auto_commit_service/service/__init__.py @@ -0,0 +1,17 @@ +"""Service lifecycle management.""" + +from .manager import ( + LlamaServiceManager, + ServiceHealth, + ServiceManagerError, + ServiceStartError, + ServiceCrashError, +) + +__all__ = [ + "LlamaServiceManager", + "ServiceHealth", + "ServiceManagerError", + "ServiceStartError", + "ServiceCrashError", +] diff --git a/src/auto_commit_service/service/__pycache__/__init__.cpython-312.pyc b/src/auto_commit_service/service/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30ee7a91c9cd5a787971cf1e7d0f5b123f08b3a0 GIT binary patch literal 438 zcmY+Aze+e?_e*og9RMQ^7Vbpi(G;gSvE!LkPLCH<&afH!al7r*L=k6?_Gs zz^qQq2u&U3T50Ybe$UCCnzz-u-96{?%XVQB6_4hYC$juh00 zARUoN$0XJXNpwn52u2`B50GT@s1x7r4~3mcF4{_73qI#cv^5*Ek zS4^{d^l~|!IUd}Yu!Gz)>yw=!=Yo=t)qcuWwAt;orcdS%?+p2XRCN?qY!Q5 zN|H*_O0po;DpQKmN3aMZ_~CfQEG~=|SV<*afd?iRxIbb1CLB`YzE+q`o#}F;wRGKb zbuun3Uvj$Q^q<#0rE)ACrM=1AH;%N)r%IgI?T}f$rQ8eh5kUxFz`=K%Li7cKeD0*1 K=q|Hc!Tkl9e}f?a literal 0 HcmV?d00001 diff --git a/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc b/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d00dc0978865254cb35df544f3b58af1ef3994e GIT binary patch literal 14460 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 literal 0 HcmV?d00001 diff --git a/src/auto_commit_service/service/manager.py b/src/auto_commit_service/service/manager.py new file mode 100644 index 0000000..cfbc226 --- /dev/null +++ b/src/auto_commit_service/service/manager.py @@ -0,0 +1,235 @@ +"""Llama service lifecycle manager.""" + +import asyncio +import fcntl +import logging +import os +import signal +import sys +import time +from enum import Enum +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + + +class ServiceManagerError(Exception): + """Base exception for service manager errors.""" + + +class ServiceStartError(ServiceManagerError): + """Failed to start service.""" + + +class ServiceCrashError(ServiceManagerError): + """Service crashed unexpectedly.""" + + +class ServiceHealth(str, Enum): + """Service health status.""" + + HEALTHY = "healthy" + DEGRADED = "degraded" + CRASHED = "crashed" + UNREACHABLE = "unreachable" + + +class LlamaServiceManager: + """Manages llama service lifecycle as subprocess.""" + + def __init__( + self, + service_url: str = "http://localhost:8000", + pid_file: Path | None = None, + lock_file: Path | None = None, + startup_timeout: float = 30.0, + health_check_timeout: float = 5.0, + ): + """Initialize service manager.""" + self.service_url = service_url + self._pid_file = pid_file or Path.home() / ".config/commits/llama-service.pid" + self._lock_file = lock_file or Path.home() / ".config/commits/llama-service.lock" + self._startup_timeout = startup_timeout + self._health_check_timeout = health_check_timeout + self._spawned_pid: int | None = None + self._lock_fd: int | None = None + + async def ensure_service_available(self) -> bool: + """Ensure service is available, starting if necessary.""" + health = await self.check_health() + + if health == ServiceHealth.HEALTHY: + return True + if health in (ServiceHealth.DEGRADED, ServiceHealth.CRASHED): + return False + + logger.info("Llama service unreachable, attempting to start...") + return await self.start_service() + + async def start_service(self) -> bool: + """Start llama service subprocess.""" + pid = self._read_pid_file() + if pid and self._is_process_alive(pid): + logger.info(f"Service already running (PID: {pid})") + return True + + if not self._acquire_lock(): + await asyncio.sleep(2) + pid = self._read_pid_file() + if pid and self._is_process_alive(pid): + return True + return False + + try: + pid = self._read_pid_file() + if pid and self._is_process_alive(pid): + return True + + logger.info("Starting llama service subprocess...") + process = await self._spawn_service() + self._spawned_pid = process.pid + self._write_pid_file(process.pid) + logger.info(f"Llama service started (PID: {process.pid})") + + if await self._wait_for_healthy(self._startup_timeout): + logger.info("✓ Llama service is healthy") + return True + else: + logger.error(f"✗ Service failed to start within {self._startup_timeout}s") + return False + + except Exception as e: + logger.exception(f"Failed to start llama service: {e}") + return False + finally: + self._release_lock() + + async def check_health(self) -> ServiceHealth: + """Check service health and detect crashes.""" + pid = self._read_pid_file() + if pid and not self._is_process_alive(pid): + return ServiceHealth.CRASHED + + try: + async with httpx.AsyncClient(timeout=self._health_check_timeout) as client: + response = await client.get(f"{self.service_url}/health") + if response.status_code == 200: + data = response.json() + return ServiceHealth.HEALTHY if data.get("status") == "ok" else ServiceHealth.DEGRADED + return ServiceHealth.UNREACHABLE + except (httpx.ConnectError, httpx.TimeoutException): + return ServiceHealth.UNREACHABLE + except Exception: + return ServiceHealth.UNREACHABLE + + async def stop_service(self) -> None: + """Gracefully stop service if we own it.""" + if self._spawned_pid is None or not self._is_process_alive(self._spawned_pid): + return + + logger.info(f"Stopping llama service (PID: {self._spawned_pid})...") + try: + os.kill(self._spawned_pid, signal.SIGTERM) + for _ in range(10): + if not self._is_process_alive(self._spawned_pid): + self._cleanup_pid_file() + return + await asyncio.sleep(0.5) + os.kill(self._spawned_pid, signal.SIGKILL) + self._cleanup_pid_file() + except ProcessLookupError: + self._cleanup_pid_file() + except Exception as e: + logger.exception(f"Error stopping service: {e}") + + def _acquire_lock(self) -> bool: + """Acquire exclusive lock.""" + self._lock_file.parent.mkdir(parents=True, exist_ok=True) + try: + self._lock_fd = os.open(self._lock_file, os.O_CREAT | os.O_WRONLY) + fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + + def _release_lock(self) -> None: + """Release lock.""" + if self._lock_fd is not None: + try: + fcntl.flock(self._lock_fd, fcntl.LOCK_UN) + os.close(self._lock_fd) + self._lock_fd = None + except Exception: + pass + + def _read_pid_file(self) -> int | None: + """Read PID from file.""" + if not self._pid_file.exists(): + return None + try: + content = self._pid_file.read_text().strip() + return int(content) if content else None + except Exception: + return None + + def _write_pid_file(self, pid: int) -> None: + """Write PID to file.""" + self._pid_file.parent.mkdir(parents=True, exist_ok=True) + self._pid_file.write_text(str(pid)) + + def _cleanup_pid_file(self) -> None: + """Remove PID file.""" + try: + if self._pid_file.exists(): + self._pid_file.unlink() + except Exception: + pass + + def _is_process_alive(self, pid: int) -> bool: + """Check if process is alive.""" + try: + os.kill(pid, 0) + return True + except OSError: + return False + + async def _spawn_service(self) -> asyncio.subprocess.Process: + """Spawn service as background subprocess.""" + cmd = [sys.executable, "-m", "tqftw_llama_service"] + env = os.environ.copy() + + # Enable mock mode if no real models configured + if "LLAMA_SERVICE_FAST_MODEL_PATH" not in env and "LLAMA_SERVICE_REASONING_MODEL_PATH" not in env: + env["LLAMA_SERVICE_MOCK_MODE"] = "true" + + log_file = self._pid_file.parent / "llama-service.log" + log_file.parent.mkdir(parents=True, exist_ok=True) + + with open(log_file, "a") as log: + log.write(f"\n=== Service started at {time.ctime()} ===\n") + process = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdout=log, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, + ) + return process + + async def _wait_for_healthy(self, timeout: float) -> bool: + """Wait for service to become healthy.""" + start = time.time() + while time.time() - start < timeout: + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.get(f"{self.service_url}/health") + if response.status_code == 200: + data = response.json() + if data.get("status") == "ok": + return True + except Exception: + pass + await asyncio.sleep(1) + return False